diff --git a/.changeset/banned-user-credentials-refused.md b/.changeset/banned-user-credentials-refused.md new file mode 100644 index 00000000000..b9c24b53046 --- /dev/null +++ b/.changeset/banned-user-credentials-refused.md @@ -0,0 +1,21 @@ +--- +'@objectstack/core': patch +'@objectstack/platform-objects': patch +'@objectstack/plugin-auth': patch +--- + +fix(core)!: a banned user's credentials are refused while the ban holds, at every door that accepts an API key + +Clause-②: yes (narrowing) — while a user's ban holds, a request authenticated by an API key that user owns is answered as a key that does not exist (the generic `401 UNAUTHENTICATED`), at every door that accepts a key: the REST data door, the MCP door, and every other door that resolves a key through the shared key-admission seam. The widening half: `@objectstack/core` exports the one ban predicate, `isUserBanInForce` (with `USER_BAN_FIELDS` and the `UserBanFields` type), which the key-admission seam and plugin-auth's session-shaped refusals now share. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at every key-admitting door (`@objectstack/core`): a banned user's API keys are refused while the ban holds. + +- **The rule.** A ban holds every credential of the user. Banning a user already ends their sessions and refuses their sign-in; their API keys now answer as a key that does not exist for as long as the ban holds. A ban with an expiry stops binding once the expiry has passed, exactly as sign-in does — no sign-in is needed to lapse it. +- **Not a revocation.** No key row is revoked or rewritten. Lifting the ban (or letting it expire) admits the same keys again, with nothing reissued, each still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive (`API_KEY_INACTIVE`), and its remedy is a new key. +- **One anonymous entry.** A key refused for its owner (a ban) or for the tenancy posture (no organization under `isolated`) resolves to the same entry as a key that does not exist, on every composition: the guest's grants on a face that serves the guest, nothing elsewhere. It never falls through to a session. +- **Who moves.** An integration running on a key whose owner is banned gets `401` until the ban is lifted or expires. + +**The remedy.** Lift the ban, or mint a replacement key under a user who is not banned. diff --git a/content/docs/permissions/administrator-guide.mdx b/content/docs/permissions/administrator-guide.mdx index 702ebe4b64c..25518b8da58 100644 --- a/content/docs/permissions/administrator-guide.mdx +++ b/content/docs/permissions/administrator-guide.mdx @@ -81,7 +81,8 @@ only on reorgs. Why the tree matters: | **Import** (toolbar) | Bulk onboarding from CSV/Excel | Wizard with column mapping and dry-run preview; up to 500 rows per batch. Pick the sign-in policy: no password (first sign-in via OTP/magic/reset link), send invitations, or one-time temporary passwords | Day-to-day lifecycle actions live on each user's row menu and record header: -**Ban / Unban** (blocks sign-in), **Unlock Account** (clears a brute-force +**Ban / Unban** (blocks sign-in, ends the user's sessions, and refuses their API keys +while the ban holds), **Unlock Account** (clears a brute-force lockout early, at both the password and two-factor stages), **Set Password** (also mints one-time temporary passwords), and **Impersonate User** (see [Step 4](#step-4--verify)). *(Rough edge today: @@ -180,7 +181,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets). | I need to… | Do this | |:--|:--| | Onboard a new employee | Invite/Create the user → Business Unit Member row → assign their position ([2](#step-2--add-people-users)→[3](#step-3--assign-positions)) | -| Offboard someone | **Ban User** (blocks sign-in immediately) — then remove assignments at leisure | +| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then remove assignments at leisure | | "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) | | "Why can't I see X?" | Studio → Access → **Explain access** for that user + object | | Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows | diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 5fa09d18ba4..97b324a1d06 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -1253,8 +1253,8 @@ All endpoints are available under `/api/v1/auth/*`: - `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users) - `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported) - `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early -- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions) -- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in +- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions; the user's API keys are refused at every door while the ban holds, and a ban with an expiry stops binding once it expires. The keys are not revoked) +- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in and its API keys (each key is still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive, and needs replacing) #### Organization Membership (requires `plugins.organization`; platform-admin gated) diff --git a/packages/core/src/security/api-key-banned-owner.test.ts b/packages/core/src/security/api-key-banned-owner.test.ts new file mode 100644 index 00000000000..c90e02b5cb1 --- /dev/null +++ b/packages/core/src/security/api-key-banned-owner.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A banned user's credentials are refused while the ban holds — pinned at the + * shared key-admission seam (`resolveApiKeyAdmission`, reached by every door + * through `resolveAuthzContext`), where an API key becomes its owner. + * + * - an owner whose ban holds → the key is `refused` (`owner_banned`): no + * principal, no fall-through to the session path, and the resolved context + * is the one a key that does not exist resolves to; + * - a ban with an expiry binds until it expires and not after — the one + * predicate (`isUserBanInForce`) the session refusal shares; + * - not a revocation: nothing is written while the ban holds, and the same + * key is admitted again once it is lifted; + * - the key's own lifecycle still answers first; + * - an owner read that throws is the branded store outage; a store with no + * `sys_user` table holds no ban. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveAuthzContext } from './resolve-authz-context.js'; +import { hashApiKey, resolveApiKeyAdmission, resolveApiKeyPrincipal } from './api-key.js'; +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { isUserBanInForce } from './user-ban.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const KEY = 'osk_banned_owner_pin_key'; +const MADE_UP_KEY = 'osk_no_row_holds_this_key'; +const OWNER = 'u_key_owner'; +const COOKIE_USER = 'u_cookie_holder'; +const NOW = Date.parse('2026-11-05T12:00:00.000Z'); + +function makeQl(owner: Record | null = { id: OWNER }, keyRow: Record = {}) { + const tables: Record = { + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }], + sys_member: [], + sys_user: [...(owner ? [owner] : []), { id: COOKIE_USER }], + }; + const reads: Array<{ object: string; opts: any }> = []; + const update = vi.fn(async () => ({})); + return { + tables, + reads, + update, + ql: { + update, + async find(object: string, opts: any) { + reads.push({ object, opts }); + const where = opts?.where ?? {}; + const rows = (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; + }), + ); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + }, + }; +} + +const headers = (key = KEY, extra: Record = {}) => new Headers({ 'x-api-key': key, ...extra }); +const admit = (ql: any, key = KEY, nowMs = NOW) => + resolveApiKeyAdmission(ql, headers(key), nowMs, undefined, undefined, undefined); + +describe('isUserBanInForce — the one ban predicate', () => { + it('a set ban flag with no expiry holds', () => { + expect(isUserBanInForce({ banned: true }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: 1, ban_expires: null }, NOW)).toBe(true); + }); + + it('a row without the ban flag is not banned, whatever its expiry says', () => { + expect(isUserBanInForce({ banned: false, ban_expires: new Date(NOW + 60_000) }, NOW)).toBe(false); + expect(isUserBanInForce({ banned: null }, NOW)).toBe(false); + expect(isUserBanInForce({}, NOW)).toBe(false); + expect(isUserBanInForce(undefined, NOW)).toBe(false); + }); + + it('a ban with an expiry holds until the expiry has passed, in either column spelling', () => { + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1).toISOString() }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, banExpires: new Date(NOW + 1) }, NOW)).toBe(true); + // At the instant itself the ban still holds — the sign-in check lifts it only strictly after. + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW) }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW - 1).toISOString() }, NOW)).toBe(false); + expect(isUserBanInForce({ banned: true, banExpires: NOW - 1 }, NOW)).toBe(false); + }); + + it('an expiry that is not an instant does not lift the ban', () => { + expect(isUserBanInForce({ banned: true, ban_expires: 'not-a-date' }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, ban_expires: { at: NOW - 1 } }, NOW)).toBe(true); + }); +}); + +describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the ban holds', () => { + it('refuses the key of an owner whose ban holds, with no principal', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const admission = await admit(ql); + expect(admission).toMatchObject({ outcome: 'refused', reason: 'owner_banned', keyId: 'key_1', userId: OWNER }); + expect(admission).not.toHaveProperty('principal'); + expect(await resolveApiKeyPrincipal(ql, headers(), NOW, undefined, undefined, undefined)).toBeUndefined(); + }); + + it('admits the key of an owner who is not banned', async () => { + const { ql } = makeQl({ id: OWNER, banned: false }); + expect((await admit(ql)).outcome).toBe('admitted'); + }); + + it('reads the owner\'s ban columns by id, as the system', async () => { + const { ql, reads } = makeQl({ id: OWNER, banned: false }); + await admit(ql); + const ownerReads = reads.filter((r) => r.object === 'sys_user'); + expect(ownerReads).toHaveLength(1); + expect(ownerReads[0].opts).toMatchObject({ + where: { id: OWNER }, + fields: ['id', 'banned', 'ban_expires'], + limit: 1, + context: { isSystem: true }, + }); + }); + + it('a ban with an expiry binds until it expires, and not after', async () => { + const expires = new Date(NOW + 60_000).toISOString(); + const { ql } = makeQl({ id: OWNER, banned: true, ban_expires: expires }); + expect((await admit(ql, KEY, NOW)).outcome).toBe('refused'); + expect((await admit(ql, KEY, NOW + 60_001)).outcome).toBe('admitted'); + }); + + it('is not a revocation: nothing is written while the ban holds, and lifting it admits the same key', async () => { + const { ql, tables, update } = makeQl({ id: OWNER, banned: true }); + const keyRowBefore = { ...tables.sys_api_key[0] }; + expect((await admit(ql)).outcome).toBe('refused'); + expect(update).not.toHaveBeenCalled(); + expect(tables.sys_api_key[0]).toEqual(keyRowBefore); + + tables.sys_user[0].banned = false; + expect((await admit(ql)).outcome).toBe('admitted'); + }); + + it('the key\'s own lifecycle answers first: a revoked key of a banned owner is refused as revoked', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }, { revoked: true }); + expect((await admit(ql)).outcome).toBe('lifecycle'); + }); + + it('a key whose owner row is absent keeps today\'s answer — no row holds no ban', async () => { + const { ql } = makeQl(null); + expect((await admit(ql)).outcome).toBe('admitted'); + }); + + it('an owner read that throws is the branded store outage, never an admission', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const find = ql.find.bind(ql); + ql.find = async (object: string, opts: any) => { + if (object === 'sys_user') throw new Error('connection reset'); + return find(object, opts); + }; + const err = await admit(ql).catch((e) => e); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err.status).toBe(503); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('a store with no sys_user table holds no ban', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const find = ql.find.bind(ql); + ql.find = async (object: string, opts: any) => { + if (object === 'sys_user') throw new Error('no such table: sys_user'); + return find(object, opts); + }; + expect((await admit(ql)).outcome).toBe('admitted'); + }); +}); + +describe('resolveAuthzContext — a banned owner\'s key resolves as a key that does not exist', () => { + const resolve = (ql: any, key: string, getSession?: (h: any) => Promise) => + resolveAuthzContext({ + ql, + headers: headers(key), + nowMs: NOW, + userAuthGates: undefined, + apiKeyLifecycle: undefined, + ...(getSession ? { getSession } : {}), + }); + + it('resolves no principal, and the same context a made-up key resolves', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const banned = await resolve(ql, KEY); + const madeUp = await resolve(ql, MADE_UP_KEY); + expect(banned.userId).toBeUndefined(); + expect(banned.credential).toBeUndefined(); + expect(banned).toEqual(madeUp); + // The reason is said server-side only. + expect(warn.mock.calls.flat().join('\n')).toContain('owner_banned'); + } finally { + warn.mockRestore(); + } + }); + + it('never falls through to a session sent beside the key', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const authz = await resolve(ql, KEY, getSession); + expect(authz.userId).toBeUndefined(); + expect(getSession).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('control: a non-banned owner\'s key resolves the owner, credential api_key', async () => { + const { ql } = makeQl({ id: OWNER, banned: false }); + const authz = await resolve(ql, KEY); + expect(authz.userId).toBe(OWNER); + expect(authz.credential).toBe('api_key'); + }); + + it('control: a session is not judged by this seam — a cookie with no key resolves as before', async () => { + const { ql } = makeQl({ id: OWNER, banned: true }); + const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } })); + const authz = await resolveAuthzContext({ + ql, + headers: new Headers(), + nowMs: NOW, + userAuthGates: undefined, + apiKeyLifecycle: undefined, + getSession, + }); + expect(authz.userId).toBe(COOKIE_USER); + expect(authz.credential).toBe('session'); + }); +}); + +describe('a refused key gets the anonymous entry an unknown key gets, on a face that serves the guest', () => { + const GUEST_SET = 'public_catalog_reader'; + const ORG = 'org_only'; + + /** A guest anchor that grants something, in the one organization the guest entry derives. */ + function guestServingQl(keyRow: Record, owner: Record) { + const tables: Record = { + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }], + sys_user: [owner], + sys_member: [], + sys_organization: [{ id: ORG }], + sys_metadata_activation: [], + }; + const registered = new Set(Object.keys(tables)); + const ql = { + registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) }, + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + const rows = (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; + }), + ); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + }; + bindStaticSecurityCatalog(ql, { + positions: [{ name: 'guest', permissionSets: [GUEST_SET] }], + permissions: [{ name: GUEST_SET, tabPermissions: { storefront: 'visible' } }], + }); + return ql; + } + + const onGuestFace = (ql: any, key: string) => + resolveAuthzContext({ + ql, + headers: headers(key), + nowMs: NOW, + guestEntry: true, + userAuthGates: undefined, + apiKeyLifecycle: undefined, + getSession: async () => undefined, + }); + + const quietly = async (fn: () => Promise): Promise => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { return await fn(); } finally { warn.mockRestore(); } + }; + + it('a banned owner\'s key and an unknown key get the same guest entry', async () => { + const ql = guestServingQl({}, { id: OWNER, banned: true }); + const banned = await quietly(() => onGuestFace(ql, KEY)); + const unknown = await onGuestFace(ql, MADE_UP_KEY); + // Non-vacuous: the guest anchor really grants something here. + expect(unknown.permissions).toEqual([GUEST_SET]); + expect(unknown.tenantId).toBe(ORG); + expect(banned.userId).toBeUndefined(); + expect(banned).toEqual(unknown); + }); +}); diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index 13e1cdc2035..37adafdc53a 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -30,6 +30,7 @@ import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts'; import { accountScopeAuthGate, type AuthGate, type UserAuthGateSource } from './auth-gate.js'; import { isMissingTableError } from '@objectstack/types'; import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { isUserBanInForce, USER_BAN_FIELDS } from './user-ban.js'; /** Default visible prefix for generated keys (helps users identify a key). */ export const API_KEY_PREFIX = 'osk_'; @@ -492,7 +493,7 @@ export interface ApiKeyPrincipal { * key is unknown/revoked/expired": a refusal means the credential is real and * intact but cannot be admitted under this deployment's tenancy posture. */ -export type ApiKeyRefusalReason = 'organization_required' | 'organization_membership_ended'; +export type ApiKeyRefusalReason = 'organization_required' | 'organization_membership_ended' | 'owner_banned'; /** * The verdict on an inbound API key. Five outcomes, deliberately distinct: @@ -518,7 +519,8 @@ export type ApiKeyRefusalReason = 'organization_required' | 'organization_member * caller must NOT fall through to the session path, for the reason the * `refused` arm gives. ⛔ Not a revocation: nothing is written, and the same * key is admitted again the moment the owner remediates. - * - `refused` — a real, intact key the posture cannot admit. The caller must + * - `refused` — a real, intact key the posture cannot admit, or whose owner's + * ban holds (`owner_banned`, {@link isUserBanInForce}). The caller must * NOT fall through to the session path: falling through would be more * permissive than today's behaviour (an API key already outranks a session), * and the whole point of the refusal is that it is LOUD at call time. @@ -748,6 +750,47 @@ export async function resolveApiKeyAdmission( const lifecycle = lifecycleRefusalOf(row, nowMs, policy, { keyId, userId }); if (lifecycle) return { outcome: 'lifecycle', refusal: lifecycle }; + // A banned user's credentials are refused while the ban holds. The key is a + // credential of its owner, so the owner's ban is read HERE, where the key + // becomes that owner, with the one ban predicate (`isUserBanInForce`) the + // session refusal shares: the ban flag, lifted once its expiry has passed. + // + // The answer is the `refused` arm: no principal, no fall-through to the + // session path, and on the wire the generic 401 a key that does not exist + // gets — nothing tells the holder whether the key, or its owner, is the + // reason. ⛔ Not a revocation: nothing is written (the key row is not + // revoked and the use is not stamped), so lifting the ban admits the same + // key again. Asked after the key's own lifecycle, as every owner question + // is: a dead key answers for its own state first. + // + // One read of the owner's row per key resolution: nothing earlier on this + // path has the row (the grants read comes after admission and may be served + // from a cache that would not see a fresh ban). A read that THROWS is the + // branded store outage, as for `sys_api_key`; a `sys_user` table that was + // never provisioned holds no ban. + let owner: Record | undefined; + try { + let users: any = await ql.find('sys_user', { + where: { id: userId }, + fields: [...USER_BAN_FIELDS], + limit: 1, + context: { isSystem: true }, + }); + if (users && (users as any).value) users = (users as any).value; + owner = Array.isArray(users) ? users[0] : undefined; + } catch (err) { + if (!isMissingTableError(err, 'sys_user')) throw new AuthzStoreUnavailableError('sys_user', err); + } + if (isUserBanInForce(owner, nowMs)) { + return { + outcome: 'refused', + reason: 'owner_banned', + keyId, + userId, + message: 'The owner of this API key is banned; the key is refused while the ban holds.', + }; + } + // [#8287 / PD #12] ONE spelling. The producer (`runtime` `/keys`) writes // `active_organization_id` and nothing else, so this reads that column and // nothing else. The `row.organization_id ?? row.organizationId` chain that diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 7db4e52e3d5..92c641cc9fb 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -177,6 +177,10 @@ export { type TenancyPostureSource, } from './api-key.js'; +// The one "does this user's ban hold" predicate: the key-admission seam and +// plugin-auth's session-shaped refusals all ask it. +export { isUserBanInForce, USER_BAN_FIELDS, type UserBanFields } from './user-ban.js'; + // [#16013] The ONE try/catch CLASSIFICATION every admission seam performs on // the `tenancy` read that feeds `effectiveTenancyPosture` above -- branded // "never registered" stays quiet, every other rejection is the ADR-0112 outage diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index c3eddd26215..90c589104c0 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -515,6 +515,26 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise resolveAuthzContextInScope(input)); } +/** + * The anonymous entry: what a request that resolved no principal is served — + * the guest's grants on a face that takes the guest entry + * ({@link ResolveAuthzInput.guestEntry}), the empty envelope otherwise. ONE + * helper for both ways to get here (no credential or an unknown key, and a + * refused key), so the two answers cannot drift apart. + */ +async function anonymousEntry(ctx: ResolvedAuthzContext, input: ResolveAuthzInput): Promise { + if (input.guestEntry) { + const guest = await resolveGuestAnchorGrants(input.ql, input.tenancyPosture); + if (guest) { + ctx.tenantId = guest.organizationId; + ctx.permissions = guest.permissions; + ctx.systemPermissions = guest.systemPermissions; + if (guest.tabPermissions) ctx.tabPermissions = guest.tabPermissions; + } + } + return ctx; +} + async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { const { ql, headers } = input; const ctx: ResolvedAuthzContext = { @@ -568,7 +588,11 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise = { }, ban_user: { label: "Ban User", - description: "Ban this user? They will be signed out and unable to sign in until unbanned.", + description: "Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.", successMessage: "User banned", params: { banReason: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 429a91c5ed0..1905af99ec3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -159,7 +159,7 @@ export const esESObjects: NonNullable = { }, ban_user: { label: "Bloquear usuario", - description: "¿Bloquear a este usuario? Cerrará sesión y no podrá volver a iniciarla hasta que se desbloquee.", + description: "¿Bloquear a este usuario? Cerrará sesión, no podrá volver a iniciarla y sus claves de API se rechazarán hasta que se desbloquee. Las claves vuelven a funcionar tras el desbloqueo.", successMessage: "Usuario bloqueado", params: { banReason: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index d63dbecd380..6be7f3fd0da 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -159,7 +159,7 @@ export const jaJPObjects: NonNullable = { }, ban_user: { label: "利用停止", - description: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできなくなります。", + description: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできず、API キーも拒否されます。解除後はキーが再び使えるようになります。", successMessage: "ユーザーを利用停止にしました", params: { banReason: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ff6344f3a60..80d1826a2d5 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -159,7 +159,7 @@ export const zhCNObjects: NonNullable = { }, ban_user: { label: "封禁用户", - description: "要封禁该用户吗?封禁后会立即登出,且在解除封禁前无法再次登录。", + description: "要封禁该用户吗?封禁后会立即登出,在解除封禁前无法再次登录,其 API 密钥也会被拒绝。解除封禁后这些密钥恢复可用。", successMessage: "用户已封禁", params: { banReason: { diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts index b405e8378e4..e2393728242 100644 --- a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -147,10 +147,11 @@ describe('#7309 — the destructive warnings survived the move, word for word', it('sys_user.ban_user still says the user is signed out and locked out', () => { const a = byName(SysUser, 'ban_user'); expect(a.description).toBe( - 'Ban this user? They will be signed out and unable to sign in until unbanned.', + 'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.', ); expect(a.description).toContain('signed out'); expect(a.description).toContain('unable to sign in'); + expect(a.description).toContain('API keys refused'); }); it('sys_user.delete_my_account still says it cannot be undone', () => { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index fa8b00956f4..09da1ff1e0d 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -207,7 +207,7 @@ export const SysUser = ObjectSchema.create({ // and nothing is POSTed until that one dialog's own Confirm. // NB: the top-level `description` (#7367), never `ai.description` — that // one is the LLM-facing tool contract and is shown to no user. - description: 'Ban this user? They will be signed out and unable to sign in until unbanned.', + description: 'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.', params: [ { name: 'banReason', label: 'Ban Reason', type: 'text', required: false }, ], @@ -216,7 +216,7 @@ export const SysUser = ObjectSchema.create({ ai: { exposed: true, description: - 'Ban a user (the record; optionally banReason): they are signed out and cannot sign in until unbanned. Platform administrators only. Ask the human to confirm before calling.', + 'Ban a user (the record; optionally banReason): they are signed out, cannot sign in, and their API keys are refused until unbanned; the keys work again after the unban. Platform administrators only. Ask the human to confirm before calling.', requiresConfirmation: true, }, }, diff --git a/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts b/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts index ecc9496439d..99527f7b3ba 100644 --- a/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts +++ b/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts @@ -41,6 +41,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { randomBytes } from 'node:crypto'; +import { isUserBanInForce } from '@objectstack/core'; import { FORWARD_CREDENTIAL_HEADER, redeemForwardCredential, @@ -140,13 +141,6 @@ function soleTokenOf(where: unknown): string | undefined { return typeof w.value === 'string' ? w.value : undefined; } -/** A user better-auth would refuse a session to: banned, and the ban not lapsed. */ -function isBanned(user: any): boolean { - if (!user?.banned) return false; - const until = user.banExpires ? new Date(user.banExpires).getTime() : undefined; - return until === undefined || Number.isNaN(until) || until > Date.now(); -} - /** * The better-auth plugin that answers the view's session lookup from memory. * It wraps the database adapter better-auth builds its session store on @@ -172,7 +166,8 @@ export function agentForwardIdentityViewPlugin() { return inner.findOne(args); } const user = await inner.findOne({ model: 'user', where: [{ field: 'id', value: view.agent.userId }] }); - if (!user || isBanned(user)) return null; + // A user better-auth would refuse a session to: the one ban predicate. + if (!user || isUserBanInForce(user)) return null; const session = { id: view.sessionId, token: view.token, diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.ts b/packages/plugins/plugin-auth/src/principal-session-mint.ts index 83d366a652f..6173d17805e 100644 --- a/packages/plugins/plugin-auth/src/principal-session-mint.ts +++ b/packages/plugins/plugin-auth/src/principal-session-mint.ts @@ -89,6 +89,7 @@ import type { PrincipalSessionMintOptions, } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isUserBanInForce } from '@objectstack/core'; import { isHumanUserRow } from './audience-posture.js'; import { judgeHeldSession } from './platform-admin-gate.js'; @@ -225,14 +226,6 @@ function refuseUpFront(context: ExecutionContext | undefined, options: Principal return ttl; } -function isBanned(user: Record): boolean { - if (user.banned !== true && user.banned !== 1) return false; - const until = user.banExpires ?? user.ban_expires; - if (until == null || until === '') return true; - const ms = new Date(until as string | Date).getTime(); - return !Number.isFinite(ms) || ms > Date.now(); -} - /** better-call's signed-cookie value (`encodeURIComponent(value + '.' + HMAC-SHA256)`), what `setSignedCookie` writes. */ async function signedValue(token: string, secret: string): Promise { const { makeSignature } = await import('better-auth/crypto'); @@ -270,7 +263,7 @@ export async function mintPrincipalSession( const user = await ctx.internalAdapter.findUserById(userId); if (!user) throw deny('A principal session cannot be minted: the requesting user does not exist.'); if (!isHumanUserRow(user)) throw deny('A principal session is minted for human users only, and this user is a system identity.'); - if (isBanned(user)) throw deny('A principal session cannot be minted for a banned user.'); + if (isUserBanInForce(user)) throw deny('A principal session cannot be minted for a banned user.'); const live = await deps.resolveUserAuthGates(userId, organizationId ? { organizationId } : {}); const held = heldRefusal(live[0]); diff --git a/packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts b/packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts new file mode 100644 index 00000000000..3aa38d4b71f --- /dev/null +++ b/packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 20 — spawns the real producer once per single-cause fixture: one showcase boot for the whole file, its REST and MCP doors driven in process, and one timed ban waited out to its expiry; 12.25 s of tests, the slower of two local runs on a shared agent box, x 1.25, rounded up, no CI reading yet (the dogfood lane did not run on this PR), re-measure on CI + +/** + * A banned user's credentials are refused while the ban holds, at every door + * that accepts an API key. + * + * A ban already ends the user's sessions and refuses their sign-in. An API + * key the user minted is a credential of that same user, so while the ban + * holds the key answers exactly as a key that does not exist: the same status, + * the same error code and message, on the REST data door (read and write) and + * on the MCP door. Nothing distinguishes a banned owner's key from a made-up + * one, so the answer says nothing about the key or its owner. + * + * The ban is read where the key is resolved to its owner (the shared + * key-admission seam in `@objectstack/core`), with the one ban predicate the + * session refusal uses: the ban flag, and its expiry. A ban with an expiry + * stops binding once it expires, as sign-in does. + * + * ⛔ Not a revocation: the key row is not written. The same key is admitted + * again the moment the ban is lifted (or lapses). + * + * Controls, which keep today's answer: + * - before the ban, the key reads (200), writes (201) and opens an MCP + * session (200); + * - another member's key keeps working while the first member is banned; + * - the banned member's sign-in is still refused `403 BANNED_USER`; + * - after the unban, the key works again with nothing reissued. + * + * Refusal cases assert status AND code AND message (ADR-0112) against a + * made-up key's answer on the same door, and the write case reads the store + * afterwards: a refusal that still wrote is the defect wearing a refusal. + */ + +import { randomBytes } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import { bootShowcase } from './showcase-boot.js'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const OBJECT = 'showcase_private_note'; +const SYS = { context: { isSystem: true } }; +const PASSWORD = 'Member-Pass-123'; + +/** One door's answer, reduced to what a caller can tell apart. */ +interface DoorAnswer { + status: number; + code: unknown; + message: unknown; + wwwAuthenticate: string | null; +} + +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.value) ? r.value : []); + +describe('a banned user\'s credentials are refused while the ban holds, at every door', () => { + let stack: VerifyStack; + let adminToken: string; + let ql: any; + + const BANNED_EMAIL = 'apikey.banned.owner@verify.test'; + const PEER_EMAIL = 'apikey.banned.peer@verify.test'; + let bannedUserId: string; + let bannedSession: string; + let bannedKey: string; + let peerKey: string; + /** A well-formed key no row holds — the answer every refusal is compared to. */ + const madeUpKey = `osk_${randomBytes(32).toString('base64url')}`; + + const mintKey = async (token: string, name: string): Promise => { + const res = await stack.apiAs(token, 'POST', '/keys', { name }); + expect(res.status, await res.clone().text()).toBe(201); + const body: any = await res.json(); + expect(body?.data?.key).toBeTruthy(); + return String(body.data.key); + }; + + const answerOf = async (res: Response): Promise => { + const text = await res.text(); + let body: any; + try { body = JSON.parse(text); } catch { body = undefined; } + return { + status: res.status, + code: body?.error?.code ?? body?.code, + message: body?.error?.message ?? body?.message, + wwwAuthenticate: res.headers.get('www-authenticate'), + }; + }; + + /** The three doors, each authenticated by the key alone (no cookie, no bearer session). */ + const readAs = (key: string) => + stack.api(`/data/${OBJECT}`, { headers: { 'x-api-key': key } }); + const writeAs = (key: string, title: string) => + stack.api(`/data/${OBJECT}`, { + method: 'POST', + headers: { 'x-api-key': key, 'content-type': 'application/json' }, + body: JSON.stringify({ title }), + }); + const mcpInitializeAs = (key: string) => + stack.api('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + // The form remote MCP clients send a key in. + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'banned-owner-pin', version: '0.0.0' } }, + }), + }); + + const notesTitled = async (title: string): Promise => + rowsOf(await ql.find(OBJECT, { where: { title }, limit: 5, ...SYS })).length; + + const ban = async (body: Record) => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/ban-user', { userId: bannedUserId, ...body }); + expect(res.status, await res.clone().text()).toBe(200); + }; + const unban = async () => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/unban-user', { userId: bannedUserId }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + /** Every door answers the banned owner's key exactly as it answers the made-up key. */ + const expectAnsweredAsMadeUpKey = async (label: string) => { + const read = await answerOf(await readAs(bannedKey)); + const readControl = await answerOf(await readAs(madeUpKey)); + expect(readControl.status, `${label}: the made-up key's read must be refused`).toBe(401); + expect(read, `${label}: data read`).toEqual(readControl); + + const title = `banned-owner-write-${randomBytes(4).toString('hex')}`; + const write = await answerOf(await writeAs(bannedKey, title)); + const writeControl = await answerOf(await writeAs(madeUpKey, `${title}-control`)); + expect(writeControl.status, `${label}: the made-up key's write must be refused`).toBe(401); + expect(write, `${label}: data write`).toEqual(writeControl); + expect(await notesTitled(title), `${label}: the refused write must not land`).toBe(0); + + const init = await answerOf(await mcpInitializeAs(bannedKey)); + const initControl = await answerOf(await mcpInitializeAs(madeUpKey)); + expect(initControl.status, `${label}: the made-up key's MCP initialize must be refused`).toBe(401); + expect(init, `${label}: MCP initialize`).toEqual(initControl); + }; + + const expectKeyServed = async (key: string, label: string) => { + const read = await readAs(key); + expect(read.status, `${label}: read ${await read.clone().text()}`).toBe(200); + const title = `served-write-${randomBytes(4).toString('hex')}`; + const write = await writeAs(key, title); + expect(write.status, `${label}: write ${await write.clone().text()}`).toBe(201); + expect(await notesTitled(title), `${label}: the write landed`).toBe(1); + const init = await mcpInitializeAs(key); + expect(init.status, `${label}: MCP initialize ${await init.clone().text()}`).toBe(200); + }; + + let priorScim: string | undefined; + + beforeAll(async () => { + // The ban routes answer 501 unless better-auth's admin plugin is on, and + // `bootStack` exposes no auth-plugin override. `OS_SCIM_ENABLED` is the one + // env knob that reaches it (SCIM forces the admin plugin on); it is read + // when the auth manager is constructed, so it must precede the boot. + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootShowcase({ + extraPlugins: [new MCPServerPlugin()], + security: showcaseAppDefaultSecurity(), + }); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + bannedSession = await stack.signUp(BANNED_EMAIL, PASSWORD); + const me = await stack.apiAs(bannedSession, 'GET', '/auth/get-session'); + expect(me.status).toBe(200); + bannedUserId = String(((await me.json()) as any)?.user?.id ?? ''); + expect(bannedUserId).not.toBe(''); + bannedKey = await mintKey(bannedSession, 'banned-owner-key'); + + const peerSession = await stack.signUp(PEER_EMAIL, PASSWORD); + peerKey = await mintKey(peerSession, 'peer-key'); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + it('control: before the ban, the key reads, writes and opens an MCP session', async () => { + await expectKeyServed(bannedKey, 'before the ban'); + }); + + it('while the ban holds, the key is answered as a key that does not exist, on every door', async () => { + await ban({ banReason: 'credential-pin' }); + try { + // The session and sign-in refusals are unchanged. + const oldSession = await stack.apiAs(bannedSession, 'GET', `/data/${OBJECT}`); + expect(oldSession.status, 'the ended session').toBe(401); + const signIn = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: BANNED_EMAIL, password: PASSWORD }), + }); + const signInBody: any = await signIn.json(); + expect(signIn.status).toBe(403); + expect(signInBody?.code ?? signInBody?.error?.code).toBe('BANNED_USER'); + + await expectAnsweredAsMadeUpKey('banned'); + + // Another member's key is not touched by this member's ban. + await expectKeyServed(peerKey, 'peer during the ban'); + } finally { + await unban(); + } + }); + + it('the ban does not revoke the key: once lifted, the same key is served again', async () => { + await expectKeyServed(bannedKey, 'after the unban'); + const [row] = rowsOf(await ql.find('sys_api_key', { where: { name: 'banned-owner-key' }, limit: 1, ...SYS })); + expect(row?.revoked === true || row?.revoked === 1, 'the key row was not revoked').toBe(false); + }); + + it('a ban with an expiry binds until it expires, and not after', async () => { + await ban({ banReason: 'timed-credential-pin', banExpiresIn: 3 }); + try { + await expectAnsweredAsMadeUpKey('timed ban, before expiry'); + await new Promise((r) => setTimeout(r, 3_500)); + // No sign-in lapses the ban on the row first: the expiry alone decides. + await expectKeyServed(bannedKey, 'timed ban, after expiry'); + } finally { + await unban(); + } + }, 60_000); +});