Skip to content

Commit f4cd4ec

Browse files
fix(core)!: a banned user's credentials are refused while the ban holds, at every door (#30287)
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 widening half: `@objectstack/core` exports the one ban predicate, `isUserBanInForce` (with `USER_BAN_FIELDS` and the `UserBanFields` type). Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/authentication.mdx` and `content/docs/permissions/administrator-guide.mdx` state what a ban holds; the dogfood pin `api-key-banned-owner.dogfood.test.ts` exercises it. ## 维护者速读 - 改了什么:用户被封禁期间,其名下的 API key 在所有接受 key 的入口(REST 数据读写、MCP 等)一律按"不存在的 key"拒绝(401);封禁带到期时间的,到期即恢复;key 本身不吊销。 - 为什么改:封禁本应管住该用户的全部凭据,登录与会话已被拒,key 要与之一致;同时把封禁判定收敛为一个共享谓词。 - 风险与回滚:每次 key 鉴权多一次按主键读 `sys_user`(实测约 0.65 ms);被封禁用户的集成会收到 401,属预期;回滚即还原本 PR。 - 先看:`packages/core/src/security/api-key.ts`(准入处新增的封禁判定段)、`packages/core/src/security/user-ban.ts`、`packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/api-key-banned-owner.dogfood.test.ts`,预期 4/4 通过。 - 需要你做的:无,席位落地。另有一项不在本 PR 内、由你定夺:名下用户记录已不存在的 API key 是否仍予放行,本 PR 不作决定,该项由安全席位记在其待决清单上。 ## What changes - **The rule.** A ban holds every credential of the user. While a user's ban holds, an API key that user owns is refused where the key is resolved to its owner (the shared key-admission seam in `@objectstack/core`, which every key-accepting door uses), with the same answer a key that does not exist gets: the generic `401 UNAUTHENTICATED`, same code, same message, on the REST data door (read and write) and on the MCP door. A ban with an expiry stops binding once it expires, as sign-in does. - **Not a revocation.** No key row is revoked or rewritten, and a refused key is not stamped as used. Lifting the ban (or its expiry passing) admits the same key again, still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive. - **One ban predicate.** `isUserBanInForce` (`packages/core/src/security/user-ban.ts`) mirrors the sign-in check (the ban flag; an expiry lifts it only once strictly past; an unreadable expiry does not lift it). plugin-auth's principal-session mint and agent-forward identity view now ask it instead of their own copies. - **Order.** The key's own lifecycle (revoked / expired / inactive) still answers first; the ban is asked before the tenancy and account-posture questions. - **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, never a session. Both exits share one helper (`anonymousEntry`, `packages/core/src/security/resolve-authz-context.ts`). - **Failure mode.** An owner read that throws is the branded store outage (`503`), as a `sys_api_key` read that throws already is; a store with no `sys_user` table holds no ban. - **Cost.** One primary-key read of `sys_user` (three columns) per key resolution. Nothing earlier on the admission path loads the user row. Measured on the showcase stack (shared box): 0.650 ms per read (N=1000) against 7.738 ms per key-authenticated `GET /data` (M=300). - Docs: the ban entries in `authentication.mdx` and `administrator-guide.mdx` say what a ban holds. The **Ban User** confirmation and its agent-facing description (`sys_user.ban_user`) say the user's API keys are refused until the unban and work again after it; zh-CN / ja-JP / es-ES carried, en bundle regenerated (`node scripts/check-i18n-bundles.mjs --write`). - Changeset: `.changeset/banned-user-credentials-refused.md` (`@objectstack/core`, `@objectstack/platform-objects`, `@objectstack/plugin-auth`, patch, BREAKING narrowing banner, ADR-0087 `not-required (no-migration-prescription)`). ## Tests - `packages/core/src/security/api-key-banned-owner.test.ts` (18): the predicate table; the seam refuses a banned owner's key with no principal; reads the ban columns by id as the system; a timed ban binds until it expires and not after; nothing is written during the ban and lifting it admits the same key; lifecycle answers first; absent owner row keeps today's answer; owner read outage is the branded `503`; missing table holds no ban; `resolveAuthzContext` resolves the same context for a banned owner's key as for a made-up key, never falls through to a session sent beside the key; controls for a non-banned owner and for a session with no key; on a face that serves the guest, with a guest anchor that grants a set, a banned owner's key and an unknown key resolve the same entry (the guest's set, in the one organization). - `packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts`: the Ban User confirmation pin now carries the new sentence and asserts it names the API keys. - `packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts` (4, showcase + MCP): before the ban the key reads (200), writes (201) and opens an MCP session (200); while the ban holds every door answers the key exactly as it answers a made-up key (status, code, message, `WWW-Authenticate`), the refused write does not land, the ended session is 401, sign-in is `403 BANNED_USER`, and another member's key keeps working; after the unban the same key is served and the key row is not revoked; a 3-second timed ban binds before expiry and not after, with no sign-in in between. ## Acceptance notes - A revoked key answers `403 API_KEY_REVOKED` by ruling (located lifecycle refusal), so it is distinguishable from a made-up key today; this PR matches the banned owner's key to the made-up key's `401`, not to the revoked key's answer. - The refused-key path does not stamp `last_used_at`, so the inactivity window keeps running through a long ban; a key idle past the window when the ban is lifted answers `API_KEY_INACTIVE` (remedy: a new key). - The ban flag is read whether or not better-auth's admin plugin is enabled; with the plugin off nothing writes the flag except a direct row write. ## Verification Head `bd20d79afb` (findings commit `bd20d79afb` on the platform merge of `main`, `1c3929139a`; the earlier findings commits `12c342a23d`, `f064a0e418` sit on `15b303e0bf`). The ablation, package-suite and first dogfood readings were taken on `15b303e0bf`, the second round's are under "Patch round", and this round's under "Findings round". <details><summary>Ablation (one-shot, not committed)</summary> Mutation `if (isUserBanInForce(owner, nowMs) && (owner as any)?.__ablation_marker_r4 === 'never') {` through `scripts/ablation-replace.mjs` (anchor 1 to 0); `pnpm --filter @objectstack/core build`; `ablation-dist-preflight.mjs @objectstack/core __ablation_marker_r4`: marker present in 2 built files. - unit: `Tests 5 failed | 12 passed (17)` - dogfood door pin: `Tests 2 failed | 2 passed (4)` Restore: `git checkout HEAD -- packages/core/src/security/api-key.ts`, blob `37adafdc53a5` equals HEAD, `git diff HEAD` empty; rebuilt; `--absent` preflight: marker absent from all 14 built files, tree clean. A first attempt spelled the mutation `false && …`; the bundler folded it away, the dist preflight read it absent, and that run's readings were discarded. </details> <details><summary>Package suites and gates</summary> - `pnpm --filter @objectstack/core typecheck && vitest run`: Test Files 141 passed, Tests 3100 passed. - `pnpm --filter @objectstack/plugin-auth typecheck && vitest run`: Test Files 220 passed, Tests 3876 passed, 10 skipped. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 93 derived families run, each exit code recorded before any pipe; `--ran` reconciliation: 93 run, 0 NOT-MEASURED (derived zero). Two first answered `PREREQUISITE NOT MET` (exit 3: `check:skill-examples`, `check:dual-build-cjs-loads`) and exit 0 after building the unbuilt packages they read. - Full dogfood battery: `pnpm --filter @objectstack/dogfood test` at `15b303e0bf`: Test Files 1 failed | 419 passed | 3 skipped (423); Tests 1 failed | 3495 passed | 36 skipped (3532). The one red, `permission-set-clone-door.dogfood.test.ts` (cloning `member_default` answers 422 INVALID_METADATA for grants on `sys_comment` / `sys_activity`), fails identically with this diff's `packages/core/src` and `packages/plugins/plugin-auth/src` reverted to base and rebuilt (Tests 1 failed | 10 passed), so it is not this change; reported to the seat. </details> <details><summary>Patch round (12c342a, f064a0e)</summary> - Ablation of the guest-face pin (source-resolved test, no dist leg): `return anonymousEntry(ctx, input);` in the refused exit replaced by `return ctx;` through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob 90c5891 to 01c893387b0b): Tests 1 failed | 17 passed (18), the failure being the new pin. Restore: `git checkout HEAD --`, hash equals HEAD, `git diff HEAD` empty; Tests 18 passed (18). - `pnpm --filter @objectstack/core build && typecheck && vitest run` at 12c342a: Test Files 141 passed, Tests 3101 passed. - `pnpm --filter @objectstack/dogfood exec vitest run test/api-key-banned-owner.dogfood.test.ts` at 12c342a: Tests 4 passed (4). - `pnpm --filter @objectstack/platform-objects typecheck && vitest run` at f064a0e (uncommitted tree identical): Test Files 110 passed, Tests 1514 passed. - `pnpm check:i18n`: OK (10 packages, all bundles in sync). - Derived gates at f064a0e: 96 derived families, each exit code recorded before any pipe; `--ran`: 93 run (exit 0, including the three new for this head: `check:authz-resolver`, `check:i18n`, `check:i18n-stale-fill`), 3 NOT-MEASURED (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3, their build prerequisite could not take the shared verify lock, held 40+ minutes by another session; all three were exit 0 at `15b303e0bf`, and CI runs them). </details> <details><summary>Findings round (bd20d79)</summary> - Changeset: `'@objectstack/platform-objects': patch` joins core and plugin-auth in the frontmatter; the diff moves that package's published source (the Ban User confirmation and its bundles). All three are in the lockstep `fixed` group, so the published level is unchanged. `pnpm exec changeset status --since=origin/main --verbose`: exit 0, the release plan reads this changeset and bumps `@objectstack/platform-objects` to 18.3.1 (patch). `pnpm check:changeset-fixed`: exit 0 (the group is in sync with 110 public packages). - Test budget: `api-key-banned-owner.dogfood.test.ts` declares `// test-budget: 20 — spawns the real producer once per single-cause fixture: …` on line 2, and `readTestBudget` (`scripts/measure-test-shard-timings.mjs`) reads it as 20 s at line 2. No CI reading exists for this file: the dogfood matrix was filtered out on every head of this PR (the `Dogfood Regression Gate` aggregate reads "no shard was expected to run"), and the file is not on `main` yet. Two local runs of `pnpm --filter @objectstack/dogfood exec vitest run test/api-key-banned-owner.dogfood.test.ts` on a shared box: Tests 4 passed (4) each time, tests 12.25 s and 12.12 s (Duration 39.86 s and 39.92 s, the rest is transform and import); 12.25 x 1.25, rounded up to the next 10 s, is 20. - Changeset families (`dispatch-gates --commands` for `.changeset/banned-user-credentials-refused.md`) at bd20d79, each exit 0: `check-adr-0087-registration` (scan and self-test), `check-changeset-no-major`, `check-empty-changeset` (scan and self-test), `check:changeset-gate-self-tests`, `check-closing-keyword-parity` (scan and self-test), `check-comment-mask-corpus`, `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:closed-ceremony-text`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:published-files`, `check:refd-timer-probe`, `check:watch-hint-literal`. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at bd20d79 derives 96 families. The 59 that read the two files this commit changes ran locally, each exit code recorded before any pipe, all 59 exit 0 (`check:dual-build-cjs-loads` first answered exit 3, PREREQUISITE NOT MET, then exit 0 after building the seven packages it reads; `check:type-check-debt`: 0 raw tsc errors, none above its record). `--ran`: 96 derived, 59 run, 0 NOT-MEASURED, 37 unrun; the 37 read only files this commit does not change, and this head's CI judges them. - CI at bd20d79: 65 check-runs, 57 success, 8 skipped, none red. Required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI and Live E2E UI success; Lint UI skipped. Check Changeset success. - Full dogfood battery at bd20d79 (`pnpm --filter @objectstack/dogfood test` under the verify lock, shared box): Test Files 3 failed | 417 passed | 3 skipped (423); Tests 4 failed | 3492 passed | 36 skipped (3532). All four reds are `Test timed out in 5000ms` (`flow-provenance-server-held`, `package-commit-revert-path-package`, and `wall-package-refusal` twice), taken while the box carried other sessions' work; the three files re-run at the same head: Test Files 3 passed (3), Tests 45 passed (45). The previous round's one red (`permission-set-clone-door`) passes here, and `api-key-banned-owner.dogfood.test.ts` passes in the battery. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ <!-- signed: security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg --> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 70981e6 commit f4cd4ec

17 files changed

Lines changed: 709 additions & 44 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/platform-objects': patch
4+
'@objectstack/plugin-auth': patch
5+
---
6+
7+
fix(core)!: a banned user's credentials are refused while the ban holds, at every door that accepts an API key
8+
9+
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.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: the ban columns are read where a key becomes its owner, and no key row is revoked or rewritten. -->
13+
14+
**BREAKING** accept-set narrowing at every key-admitting door (`@objectstack/core`): a banned user's API keys are refused while the ban holds.
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **Who moves.** An integration running on a key whose owner is banned gets `401` until the ban is lifted or expires.
20+
21+
**The remedy.** Lift the ban, or mint a replacement key under a user who is not banned.

‎content/docs/permissions/administrator-guide.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ only on reorgs. Why the tree matters:
8181
| **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 |
8282

8383
Day-to-day lifecycle actions live on each user's row menu and record header:
84-
**Ban / Unban** (blocks sign-in), **Unlock Account** (clears a brute-force
84+
**Ban / Unban** (blocks sign-in, ends the user's sessions, and refuses their API keys
85+
while the ban holds), **Unlock Account** (clears a brute-force
8586
lockout early, at both the password and two-factor stages), **Set Password**
8687
(also mints one-time temporary passwords),
8788
and **Impersonate User** (see [Step 4](#step-4--verify)). *(Rough edge today:
@@ -180,7 +181,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets).
180181
| I need to… | Do this |
181182
|:--|:--|
182183
| 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)) |
183-
| Offboard someone | **Ban User** (blocks sign-in immediately) — then remove assignments at leisure |
184+
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then remove assignments at leisure |
184185
| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) |
185186
| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object |
186187
| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows |

‎content/docs/permissions/authentication.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,8 +1253,8 @@ All endpoints are available under `/api/v1/auth/*`:
12531253
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
12541254
- `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)
12551255
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
1256-
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions)
1257-
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in
1256+
- `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)
1257+
- `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)
12581258

12591259
#### Organization Membership (requires `plugins.organization`; platform-admin gated)
12601260

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A banned user's credentials are refused while the ban holds — pinned at the
5+
* shared key-admission seam (`resolveApiKeyAdmission`, reached by every door
6+
* through `resolveAuthzContext`), where an API key becomes its owner.
7+
*
8+
* - an owner whose ban holds → the key is `refused` (`owner_banned`): no
9+
* principal, no fall-through to the session path, and the resolved context
10+
* is the one a key that does not exist resolves to;
11+
* - a ban with an expiry binds until it expires and not after — the one
12+
* predicate (`isUserBanInForce`) the session refusal shares;
13+
* - not a revocation: nothing is written while the ban holds, and the same
14+
* key is admitted again once it is lifted;
15+
* - the key's own lifecycle still answers first;
16+
* - an owner read that throws is the branded store outage; a store with no
17+
* `sys_user` table holds no ban.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { resolveAuthzContext } from './resolve-authz-context.js';
22+
import { hashApiKey, resolveApiKeyAdmission, resolveApiKeyPrincipal } from './api-key.js';
23+
import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
24+
import { isUserBanInForce } from './user-ban.js';
25+
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
26+
27+
const KEY = 'osk_banned_owner_pin_key';
28+
const MADE_UP_KEY = 'osk_no_row_holds_this_key';
29+
const OWNER = 'u_key_owner';
30+
const COOKIE_USER = 'u_cookie_holder';
31+
const NOW = Date.parse('2026-11-05T12:00:00.000Z');
32+
33+
function makeQl(owner: Record<string, unknown> | null = { id: OWNER }, keyRow: Record<string, unknown> = {}) {
34+
const tables: Record<string, any[]> = {
35+
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }],
36+
sys_member: [],
37+
sys_user: [...(owner ? [owner] : []), { id: COOKIE_USER }],
38+
};
39+
const reads: Array<{ object: string; opts: any }> = [];
40+
const update = vi.fn(async () => ({}));
41+
return {
42+
tables,
43+
reads,
44+
update,
45+
ql: {
46+
update,
47+
async find(object: string, opts: any) {
48+
reads.push({ object, opts });
49+
const where = opts?.where ?? {};
50+
const rows = (tables[object] ?? []).filter((r) =>
51+
Object.entries(where).every(([k, v]) => {
52+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`);
53+
return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v;
54+
}),
55+
);
56+
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
57+
},
58+
},
59+
};
60+
}
61+
62+
const headers = (key = KEY, extra: Record<string, string> = {}) => new Headers({ 'x-api-key': key, ...extra });
63+
const admit = (ql: any, key = KEY, nowMs = NOW) =>
64+
resolveApiKeyAdmission(ql, headers(key), nowMs, undefined, undefined, undefined);
65+
66+
describe('isUserBanInForce — the one ban predicate', () => {
67+
it('a set ban flag with no expiry holds', () => {
68+
expect(isUserBanInForce({ banned: true }, NOW)).toBe(true);
69+
expect(isUserBanInForce({ banned: 1, ban_expires: null }, NOW)).toBe(true);
70+
});
71+
72+
it('a row without the ban flag is not banned, whatever its expiry says', () => {
73+
expect(isUserBanInForce({ banned: false, ban_expires: new Date(NOW + 60_000) }, NOW)).toBe(false);
74+
expect(isUserBanInForce({ banned: null }, NOW)).toBe(false);
75+
expect(isUserBanInForce({}, NOW)).toBe(false);
76+
expect(isUserBanInForce(undefined, NOW)).toBe(false);
77+
});
78+
79+
it('a ban with an expiry holds until the expiry has passed, in either column spelling', () => {
80+
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1).toISOString() }, NOW)).toBe(true);
81+
expect(isUserBanInForce({ banned: true, banExpires: new Date(NOW + 1) }, NOW)).toBe(true);
82+
// At the instant itself the ban still holds — the sign-in check lifts it only strictly after.
83+
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW) }, NOW)).toBe(true);
84+
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW - 1).toISOString() }, NOW)).toBe(false);
85+
expect(isUserBanInForce({ banned: true, banExpires: NOW - 1 }, NOW)).toBe(false);
86+
});
87+
88+
it('an expiry that is not an instant does not lift the ban', () => {
89+
expect(isUserBanInForce({ banned: true, ban_expires: 'not-a-date' }, NOW)).toBe(true);
90+
expect(isUserBanInForce({ banned: true, ban_expires: { at: NOW - 1 } }, NOW)).toBe(true);
91+
});
92+
});
93+
94+
describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the ban holds', () => {
95+
it('refuses the key of an owner whose ban holds, with no principal', async () => {
96+
const { ql } = makeQl({ id: OWNER, banned: true });
97+
const admission = await admit(ql);
98+
expect(admission).toMatchObject({ outcome: 'refused', reason: 'owner_banned', keyId: 'key_1', userId: OWNER });
99+
expect(admission).not.toHaveProperty('principal');
100+
expect(await resolveApiKeyPrincipal(ql, headers(), NOW, undefined, undefined, undefined)).toBeUndefined();
101+
});
102+
103+
it('admits the key of an owner who is not banned', async () => {
104+
const { ql } = makeQl({ id: OWNER, banned: false });
105+
expect((await admit(ql)).outcome).toBe('admitted');
106+
});
107+
108+
it('reads the owner\'s ban columns by id, as the system', async () => {
109+
const { ql, reads } = makeQl({ id: OWNER, banned: false });
110+
await admit(ql);
111+
const ownerReads = reads.filter((r) => r.object === 'sys_user');
112+
expect(ownerReads).toHaveLength(1);
113+
expect(ownerReads[0].opts).toMatchObject({
114+
where: { id: OWNER },
115+
fields: ['id', 'banned', 'ban_expires'],
116+
limit: 1,
117+
context: { isSystem: true },
118+
});
119+
});
120+
121+
it('a ban with an expiry binds until it expires, and not after', async () => {
122+
const expires = new Date(NOW + 60_000).toISOString();
123+
const { ql } = makeQl({ id: OWNER, banned: true, ban_expires: expires });
124+
expect((await admit(ql, KEY, NOW)).outcome).toBe('refused');
125+
expect((await admit(ql, KEY, NOW + 60_001)).outcome).toBe('admitted');
126+
});
127+
128+
it('is not a revocation: nothing is written while the ban holds, and lifting it admits the same key', async () => {
129+
const { ql, tables, update } = makeQl({ id: OWNER, banned: true });
130+
const keyRowBefore = { ...tables.sys_api_key[0] };
131+
expect((await admit(ql)).outcome).toBe('refused');
132+
expect(update).not.toHaveBeenCalled();
133+
expect(tables.sys_api_key[0]).toEqual(keyRowBefore);
134+
135+
tables.sys_user[0].banned = false;
136+
expect((await admit(ql)).outcome).toBe('admitted');
137+
});
138+
139+
it('the key\'s own lifecycle answers first: a revoked key of a banned owner is refused as revoked', async () => {
140+
const { ql } = makeQl({ id: OWNER, banned: true }, { revoked: true });
141+
expect((await admit(ql)).outcome).toBe('lifecycle');
142+
});
143+
144+
it('a key whose owner row is absent keeps today\'s answer — no row holds no ban', async () => {
145+
const { ql } = makeQl(null);
146+
expect((await admit(ql)).outcome).toBe('admitted');
147+
});
148+
149+
it('an owner read that throws is the branded store outage, never an admission', async () => {
150+
const { ql } = makeQl({ id: OWNER, banned: true });
151+
const find = ql.find.bind(ql);
152+
ql.find = async (object: string, opts: any) => {
153+
if (object === 'sys_user') throw new Error('connection reset');
154+
return find(object, opts);
155+
};
156+
const err = await admit(ql).catch((e) => e);
157+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
158+
expect(err.status).toBe(503);
159+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
160+
});
161+
162+
it('a store with no sys_user table holds no ban', async () => {
163+
const { ql } = makeQl({ id: OWNER, banned: true });
164+
const find = ql.find.bind(ql);
165+
ql.find = async (object: string, opts: any) => {
166+
if (object === 'sys_user') throw new Error('no such table: sys_user');
167+
return find(object, opts);
168+
};
169+
expect((await admit(ql)).outcome).toBe('admitted');
170+
});
171+
});
172+
173+
describe('resolveAuthzContext — a banned owner\'s key resolves as a key that does not exist', () => {
174+
const resolve = (ql: any, key: string, getSession?: (h: any) => Promise<any>) =>
175+
resolveAuthzContext({
176+
ql,
177+
headers: headers(key),
178+
nowMs: NOW,
179+
userAuthGates: undefined,
180+
apiKeyLifecycle: undefined,
181+
...(getSession ? { getSession } : {}),
182+
});
183+
184+
it('resolves no principal, and the same context a made-up key resolves', async () => {
185+
const { ql } = makeQl({ id: OWNER, banned: true });
186+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
187+
try {
188+
const banned = await resolve(ql, KEY);
189+
const madeUp = await resolve(ql, MADE_UP_KEY);
190+
expect(banned.userId).toBeUndefined();
191+
expect(banned.credential).toBeUndefined();
192+
expect(banned).toEqual(madeUp);
193+
// The reason is said server-side only.
194+
expect(warn.mock.calls.flat().join('\n')).toContain('owner_banned');
195+
} finally {
196+
warn.mockRestore();
197+
}
198+
});
199+
200+
it('never falls through to a session sent beside the key', async () => {
201+
const { ql } = makeQl({ id: OWNER, banned: true });
202+
const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } }));
203+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
204+
try {
205+
const authz = await resolve(ql, KEY, getSession);
206+
expect(authz.userId).toBeUndefined();
207+
expect(getSession).not.toHaveBeenCalled();
208+
} finally {
209+
warn.mockRestore();
210+
}
211+
});
212+
213+
it('control: a non-banned owner\'s key resolves the owner, credential api_key', async () => {
214+
const { ql } = makeQl({ id: OWNER, banned: false });
215+
const authz = await resolve(ql, KEY);
216+
expect(authz.userId).toBe(OWNER);
217+
expect(authz.credential).toBe('api_key');
218+
});
219+
220+
it('control: a session is not judged by this seam — a cookie with no key resolves as before', async () => {
221+
const { ql } = makeQl({ id: OWNER, banned: true });
222+
const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } }));
223+
const authz = await resolveAuthzContext({
224+
ql,
225+
headers: new Headers(),
226+
nowMs: NOW,
227+
userAuthGates: undefined,
228+
apiKeyLifecycle: undefined,
229+
getSession,
230+
});
231+
expect(authz.userId).toBe(COOKIE_USER);
232+
expect(authz.credential).toBe('session');
233+
});
234+
});
235+
236+
describe('a refused key gets the anonymous entry an unknown key gets, on a face that serves the guest', () => {
237+
const GUEST_SET = 'public_catalog_reader';
238+
const ORG = 'org_only';
239+
240+
/** A guest anchor that grants something, in the one organization the guest entry derives. */
241+
function guestServingQl(keyRow: Record<string, unknown>, owner: Record<string, unknown>) {
242+
const tables: Record<string, any[]> = {
243+
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }],
244+
sys_user: [owner],
245+
sys_member: [],
246+
sys_organization: [{ id: ORG }],
247+
sys_metadata_activation: [],
248+
};
249+
const registered = new Set(Object.keys(tables));
250+
const ql = {
251+
registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) },
252+
async find(object: string, opts: any) {
253+
const where = opts?.where ?? {};
254+
const rows = (tables[object] ?? []).filter((r) =>
255+
Object.entries(where).every(([k, v]) => {
256+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`);
257+
return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v;
258+
}),
259+
);
260+
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
261+
},
262+
};
263+
bindStaticSecurityCatalog(ql, {
264+
positions: [{ name: 'guest', permissionSets: [GUEST_SET] }],
265+
permissions: [{ name: GUEST_SET, tabPermissions: { storefront: 'visible' } }],
266+
});
267+
return ql;
268+
}
269+
270+
const onGuestFace = (ql: any, key: string) =>
271+
resolveAuthzContext({
272+
ql,
273+
headers: headers(key),
274+
nowMs: NOW,
275+
guestEntry: true,
276+
userAuthGates: undefined,
277+
apiKeyLifecycle: undefined,
278+
getSession: async () => undefined,
279+
});
280+
281+
const quietly = async <T>(fn: () => Promise<T>): Promise<T> => {
282+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
283+
try { return await fn(); } finally { warn.mockRestore(); }
284+
};
285+
286+
it('a banned owner\'s key and an unknown key get the same guest entry', async () => {
287+
const ql = guestServingQl({}, { id: OWNER, banned: true });
288+
const banned = await quietly(() => onGuestFace(ql, KEY));
289+
const unknown = await onGuestFace(ql, MADE_UP_KEY);
290+
// Non-vacuous: the guest anchor really grants something here.
291+
expect(unknown.permissions).toEqual([GUEST_SET]);
292+
expect(unknown.tenantId).toBe(ORG);
293+
expect(banned.userId).toBeUndefined();
294+
expect(banned).toEqual(unknown);
295+
});
296+
});

0 commit comments

Comments
 (0)