diff --git a/.changeset/23367-api-key-lifecycle.md b/.changeset/23367-api-key-lifecycle.md new file mode 100644 index 00000000000..e7f491833aa --- /dev/null +++ b/.changeset/23367-api-key-lifecycle.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/runtime': minor +'@objectstack/rest': minor +'@objectstack/mcp': minor +'@objectstack/plugin-auth': minor +'@objectstack/plugin-sharing': minor +'@objectstack/service-settings': minor +'@objectstack/service-storage': minor +'@objectstack/service-datasource': minor +'@objectstack/cloud-connection': minor +'@objectstack/platform-objects': minor +--- + +feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + +Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + +**BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + +Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + +- **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. +- **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. +- **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. +- **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. +- **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. +- **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. +- **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + +What changes for you: + +- **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. +- **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. +- **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. +- **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. +- **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. +- **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index f32b99730f8..2dda31aedd3 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -121,10 +121,59 @@ Or mint one over REST: ```bash curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys -# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key" } } +# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key", +# "expires_at": "2027-01-12T09:30:00.000Z" } } # the raw key is shown once — store it in your secret manager ``` +### Key lifetime, inactivity and rotation + +Personal API keys do not live forever. The policy is part of the +**Authentication** settings (the `auth` namespace, group **API keys**), with +these defaults: + +| Rule | Default | Setting | +|:---|:---|:---| +| A key created without `expires_at` expires after | 90 days | `api_key_default_expiry_days` | +| The furthest `expires_at` a new key may ask for | 1 year from creation | `api_key_max_expiry_days` | +| A key with no recorded use is refused after | 90 days | `api_key_inactivity_days` (`0` turns it off) | + +- **Creating a key.** Pass `{ "expires_at": "" }` to choose the + expiry. A date further away than the maximum is refused with + `400 API_KEY_EXPIRY_TOO_LONG`, whose message names the latest date allowed; + exactly the maximum is accepted. Without `expires_at` the key gets the + default lifetime, and the response echoes the date it got. +- **Use is recorded.** Each key's `last_used_at` is stamped when the key is + used (at most once an hour) and shows on the API Keys page. +- **Keys created before the policy.** A key that was created with no expiry + date gets one year of grace from the moment the deployment started enforcing + the policy (recorded once, as `api_key_policy_started_at`). The date is + written onto the key's `expires_at` the first time it is used, so the API + Keys page shows it, and the refusal after it names it. A key with no recorded + use counts its inactivity from that same moment (or from its own creation, + when that is later). + +A key that stops working is refused with `403` and a code that says why — the +message names the key's prefix, the date, and the remedy: + +| Code | Meaning | +|:---|:---| +| `API_KEY_EXPIRED` | Past its `expires_at` (or past the grace of a key created with none). | +| `API_KEY_INACTIVE` | No recorded use for longer than the inactivity window. The key is **not** revoked. | +| `API_KEY_REVOKED` | Revoked by its owner or an administrator. | + +The remedy is the same for all three: **create a new key and replace the old +one wherever it is configured** — keys do not renew. To rotate without a gap, +create the new key first, switch the integration over, then revoke the old one. +An unknown key (a typo, a key that never existed) still gets the generic +`401 UNAUTHENTICATED`. + +These codes reach the caller on every endpoint that accepts a key: the MCP +HTTP endpoint (`/api/v1/mcp`) and the other endpoints the runtime serves, the +REST data API (`/api/v1/data/…`), the settings, file-download, datasource +admin and share-link routes, the marketplace install routes, and the MCP +**stdio** transport. + Send it on every request in any of three equivalent forms: | Header | Example | @@ -159,8 +208,8 @@ security, field-level security, and tenant isolation apply to the stdio session exactly as they would to that identity in the Console — and revoking the key stops the session on its next call. -The transport is **fail-closed**: a missing, unknown, revoked, expired, or -owner-less key makes stdio **refuse to start** rather than fall back to an +The transport is **fail-closed**: a missing, unknown, revoked, expired, +inactive, or owner-less key makes stdio **refuse to start** rather than fall back to an unscoped or `system` session — there is deliberately no bypass. For a full-authority local agent, mint the key on a platform-admin or dedicated **service** identity; for a scoped one, mint it on a user with exactly the diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index f8fd37ef2c8..dffeef87690 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -4,7 +4,7 @@ navTitle: Error Code Catalog description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies --- -ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance. +ObjectStack uses a structured error system with **9 error categories** and **56 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance. This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the `StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the @@ -81,6 +81,11 @@ settings' `max_upload_mb`, and an import over its route's row ceiling, answer `4 } ``` +### `API_KEY_EXPIRY_TOO_LONG` +**Cause:** `POST /api/v1/keys` asked for an `expires_at` further away than the deployment's maximum key lifetime (`api_key_max_expiry_days`, one year by default). The message names the latest date allowed; exactly the maximum is accepted. +**Fix:** Ask for that date or an earlier one, and rotate the key before it expires. +**Retry:** `no_retry` + ### `VALIDATION_FAILED` **Cause:** Record-level validation refused the write. This is the code a *producer* names — `ValidationError` from the record and rule validators, and @@ -429,6 +434,23 @@ result set — a response indistinguishable from a successful query. **Fix:** Retry during the allowed time window. **Retry:** `retry_after` +### `API_KEY_EXPIRED` +**Cause:** The API key is past its `expires_at`, or it was created with no expiry and the one-year grace the API-key lifecycle policy gave it has ended. The message names the key's prefix and the date. +**Fix:** Keys do not renew: create a new key and replace this one wherever it is configured. See [key lifetime, inactivity and rotation](/docs/ai/connect-mcp#key-lifetime-inactivity-and-rotation). +**Retry:** `no_retry` + +### `API_KEY_INACTIVE` +**Cause:** The API key has no recorded use for longer than the deployment's inactivity window (`api_key_inactivity_days`, 90 by default). The key is not revoked; its own call does not revive it. The message names the date the idle period started. +**Fix:** Create a new key and replace this one wherever it is configured. +**Retry:** `no_retry` + +### `API_KEY_REVOKED` +**Cause:** The API key was revoked by its owner or an administrator. +**Fix:** Create a new key and replace this one wherever it is configured. A revoked key never works again. +**Retry:** `no_retry` + +These three are answered on every endpoint that accepts an API key: the endpoints the runtime serves (the MCP HTTP endpoint among them), the REST data API, the settings, file-download, datasource admin and share-link routes, the marketplace install routes and the MCP stdio transport. An unknown key is always the generic 401. + --- ## Not Found Errors (404) @@ -942,7 +964,7 @@ async function handleApiCall() { |:---:|:---|:---| | 400 | `validation` | `VALIDATION_FAILED`, `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` | | 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` | -| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` | +| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED`, `API_KEY_EXPIRED`, `API_KEY_INACTIVE`, `API_KEY_REVOKED` | | 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` | | 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` | | 413 | `validation` | `PAYLOAD_TOO_LARGE` — an upload over the storage settings' `max_upload_mb`, or an import over its row ceiling (see [above](#payload_too_large)) | diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 85a2629a085..1af1c8cc1a5 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -69,7 +69,11 @@ permission model grants anonymous users. Two ways to authenticate: (the key is shown once), or from the **Connect an Agent** page in the Console — **Account → Developer** for any signed-in user, **Setup → Connect an Agent** for platform admins. Send it as `x-api-key: osk_…` or - `Authorization: Bearer osk_…`. + `Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a + year) and stop working after 90 days without use; a refused key answers + `403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the + remedy is a new key — see + [key lifetime, inactivity and rotation](/docs/ai/connect-mcp#key-lifetime-inactivity-and-rotation). See [Authentication](/docs/permissions/authentication) for the full identity surface (OAuth flows, sessions, providers) and diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 3ca1f958403..8ac3e96f3d7 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +327 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +331 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -96,6 +96,10 @@ const result = ApiErrorSchema.parse(data); * `AMBIGUOUS_MATCH` * `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` +* `API_KEY_EXPIRED` +* `API_KEY_EXPIRY_TOO_LONG` +* `API_KEY_INACTIVE` +* `API_KEY_REVOKED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED` * `APPROVAL_COMMENT_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 84b3112112d..81184b6a4cf 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -263,6 +263,10 @@ const result = ErrorCode.parse(data); * `AMBIGUOUS_MATCH` * `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` +* `API_KEY_EXPIRED` +* `API_KEY_EXPIRY_TOO_LONG` +* `API_KEY_INACTIVE` +* `API_KEY_REVOKED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED` * `APPROVAL_COMMENT_FAILED` diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts index b544ea7c4e9..b676b038756 100644 --- a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -99,6 +99,7 @@ async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Prom async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> { const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }), diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 57fb34f24f1..7770ed0fd59 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -120,6 +120,9 @@ import { // [#23455] The auth service's per-user posture computation, bound for the // shared key-admission seam. userAuthGateSourceOf, + // [#23367] The API-key lifecycle policy source, and its branded refusal. + apiKeyLifecyclePolicySourceOf, + isApiKeyLifecycleRefusalError, // [#16013] The ADMISSION seam's posture derivation, in ONE call — the same // symbol `packages/rest`'s seam now imports, for the same reason. It reads // the posture IN FORCE off the kernel's `tenancy` service and carries @@ -3366,6 +3369,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { getSession, tenancyPosture, userAuthGates: userAuthGateSourceOf(authService), + // [#23367] The API-key lifecycle policy, from the same service. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); if (!authz.userId) return null; // ADR-0069: the ONE consult of the authentication-policy gate for @@ -3391,6 +3396,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // [commit 6a180e42d] `null` here means "nobody is authenticated", which is // not what a permission-store outage established. Re-raised. if (isAuthzStoreUnavailableError(err)) throw err; + // [#23367, ruling A] A key its own lifecycle refused (revoked, + // expired, inactive) is re-raised too: the five doors mount on the + // host's raw app, whose thrown-envelope render answers its declared + // `403` and registered code with the located message. + if (isApiKeyLifecycleRefusalError(err)) throw err; return null; } }; diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index f23bb23581a..de4aeb276f2 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -320,6 +320,7 @@ async function observe(variant: keyof typeof VARIANTS): Promise { await withPlatformAdminEmails(platformAdminEmails, () => resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeRecordingQl(tables, seen), headers: headers(), getSession: sessionFor('usr_1', org), @@ -368,6 +369,7 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re const ctx = await withPlatformAdminEmails(undefined, () => resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()), headers: headers(), getSession: sessionFor('usr_1', 'org_1'), @@ -402,6 +404,7 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re const ctx = await withPlatformAdminEmails(v.platformAdminEmails, () => resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeRecordingQl(v.tables, new Map()), headers: headers(), getSession: sessionFor('usr_1', v.org), diff --git a/packages/core/src/security/api-key-account-posture.test.ts b/packages/core/src/security/api-key-account-posture.test.ts index ec4380244fb..347e8f41f11 100644 --- a/packages/core/src/security/api-key-account-posture.test.ts +++ b/packages/core/src/security/api-key-account-posture.test.ts @@ -136,7 +136,7 @@ describe('resolveApiKeyAdmission — the held arm (ruling C′ item 3)', () => { it('holds a key whose owner holds an account-scope posture, carrying the principal and the posture', async () => { const { ql } = makeQl(); const { source } = postureSource({ [OWNER]: [MFA_REQUIRED] }); - const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source); + const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source, undefined); expect(admission.outcome).toBe('held'); if (admission.outcome !== 'held') return; expect(admission.principal.userId).toBe(OWNER); @@ -146,21 +146,21 @@ describe('resolveApiKeyAdmission — the held arm (ruling C′ item 3)', () => { it('admits a key whose owner holds only credential-scope postures', async () => { const { ql } = makeQl(); const { source } = postureSource({ [OWNER]: [PASSWORD_RESET_REQUIRED, PASSWORD_EXPIRED] }); - const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source); + const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source, undefined); expect(admission.outcome).toBe('admitted'); }); it('asks the owner about the KEY\'s own organization', async () => { const { ql } = makeQl({ active_organization_id: 'org_key' }); const { source, calls } = postureSource({}); - await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source); + await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source, undefined); expect(calls).toEqual([{ userId: OWNER, organizationId: 'org_key' }]); }); it('answers the tenancy refusal first, without asking the owner', async () => { const { ql } = makeQl(); const { source, calls } = postureSource({ [OWNER]: [MFA_REQUIRED] }); - const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), 'isolated', source); + const admission = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), 'isolated', source, undefined); expect(admission.outcome).toBe('refused'); expect(calls).toEqual([]); }); @@ -168,7 +168,7 @@ describe('resolveApiKeyAdmission — the held arm (ruling C′ item 3)', () => { it('a posture computation that rejects is the branded store outage, never an admission', async () => { const { ql } = makeQl(); const source = async () => { throw new Error('auth service fault'); }; - const err = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source).catch((e) => e); + const err = await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, source, undefined).catch((e) => e); expect(isAuthzStoreUnavailableError(err)).toBe(true); expect(err.status).toBe(503); expect(err.code).toBe('SERVICE_UNAVAILABLE'); @@ -176,7 +176,7 @@ describe('resolveApiKeyAdmission — the held arm (ruling C′ item 3)', () => { it('an explicit `undefined` source (a kernel with no auth service) leaves the owner unasked', async () => { const { ql } = makeQl(); - expect((await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, undefined)).outcome).toBe('admitted'); + expect((await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, undefined, undefined)).outcome).toBe('admitted'); }); }); @@ -184,13 +184,13 @@ describe('[#23455] resolveApiKeyPrincipal — never a principal for a held owner it('collapses `held` to no principal, and still admits a key whose owner is not held', async () => { const { ql } = makeQl(); const { table, source } = postureSource({ [OWNER]: [MFA_REQUIRED] }); - expect(await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source, undefined)).toBeUndefined(); // A credential-scope posture holds password sign-in alone. table[OWNER] = [PASSWORD_RESET_REQUIRED]; - expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source))?.userId).toBe(OWNER); + expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source, undefined))?.userId).toBe(OWNER); // Not a revocation: the posture clears and the same key answers again. table[OWNER] = []; - expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source))?.userId).toBe(OWNER); + expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source, undefined))?.userId).toBe(OWNER); }); }); @@ -212,9 +212,16 @@ describe('[#23455] the posture source is a REQUIRED input at every key-admitting void resolveApiKeyAdmission(ql, headers, Date.now(), undefined); // @ts-expect-error — `userAuthGates` is a required parameter. void resolveApiKeyPrincipal(ql, headers, Date.now(), undefined); + // [#23367] The lifecycle policy source is required beside it, the same way. + // @ts-expect-error — `apiKeyLifecycle` is a required key of `ResolveAuthzInput`. void resolveAuthzContext({ ql, headers, userAuthGates: undefined }); + // @ts-expect-error — `apiKeyLifecycle` is a required parameter. void resolveApiKeyAdmission(ql, headers, Date.now(), undefined, undefined); + // @ts-expect-error — `apiKeyLifecycle` is a required parameter. void resolveApiKeyPrincipal(ql, headers, Date.now(), undefined, undefined); + void resolveAuthzContext({ ql, headers, userAuthGates: undefined, apiKeyLifecycle: undefined }); + void resolveApiKeyAdmission(ql, headers, Date.now(), undefined, undefined, undefined); + void resolveApiKeyPrincipal(ql, headers, Date.now(), undefined, undefined, undefined); }; expect(typeof compileOnly).toBe('function'); }); @@ -224,7 +231,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the it('resolves the owner, credential `api_key`, with the account posture the doors refuse on', async () => { const { ql } = makeQl(); const { source } = postureSource({ [OWNER]: [PASSWORD_EXPIRED, MFA_REQUIRED] }); - const authz = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source }); + const authz = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source, apiKeyLifecycle: undefined }); expect(authz.userId).toBe(OWNER); expect(authz.credential).toBe('api_key'); // The account-scope posture answers, not the first (credential-scope) entry. @@ -236,7 +243,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the it('a credential-scope posture keeps the key working: no posture on the context', async () => { const { ql } = makeQl(); const { source } = postureSource({ [OWNER]: [PASSWORD_RESET_REQUIRED] }); - const authz = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source }); + const authz = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source, apiKeyLifecycle: undefined }); expect(authz.userId).toBe(OWNER); expect(authz.credential).toBe('api_key'); expect(authz.authGate).toBeUndefined(); @@ -247,11 +254,11 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the const { ql, tables } = makeQl(); const keyRowBefore = JSON.stringify(tables.sys_api_key); const { table, source } = postureSource({ [OWNER]: [MFA_REQUIRED] }); - const held = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source }); + const held = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source, apiKeyLifecycle: undefined }); expect(held.authGate).toEqual(MFA_REQUIRED); // The owner enrols; nothing about the key is rewritten or reissued. table[OWNER] = []; - const again = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source }); + const again = await resolveAuthzContext({ ql, headers: keyHeaders(), userAuthGates: source, apiKeyLifecycle: undefined }); expect(again.userId).toBe(OWNER); expect(again.authGate).toBeUndefined(); expect(JSON.stringify(tables.sys_api_key)).toBe(keyRowBefore); @@ -266,6 +273,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the headers: new Headers({ 'x-api-key': KEY, cookie: 'session=held' }), getSession, userAuthGates: source, + apiKeyLifecycle: undefined, }); expect(authz.userId).toBe(OWNER); expect(authz.credential).toBe('api_key'); @@ -278,7 +286,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the const { source } = postureSource({ [OWNER]: [MFA_REQUIRED] }); const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER } })); const authz = await resolveAuthzContext({ - ql, headers: new Headers({ 'x-api-key': KEY, cookie: 'session=ok' }), getSession, userAuthGates: source, + ql, headers: new Headers({ 'x-api-key': KEY, cookie: 'session=ok' }), getSession, userAuthGates: source, apiKeyLifecycle: undefined, }); expect(authz.userId).toBe(OWNER); expect(authz.authGate).toEqual(MFA_REQUIRED); @@ -289,6 +297,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the const { ql } = makeQl(); const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ cookie: 'session=held' }), getSession: async () => ({ user: { id: COOKIE_USER, authGate: PASSWORD_EXPIRED } }), @@ -300,7 +309,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the it('a posture computation that rejects leaves the resolver loud', async () => { const { ql } = makeQl(); const err = await resolveAuthzContext({ - ql, headers: keyHeaders(), userAuthGates: async () => { throw new Error('fault'); }, + ql, headers: keyHeaders(), userAuthGates: async () => { throw new Error('fault'); }, apiKeyLifecycle: undefined, }).catch((e) => e); expect(isAuthzStoreUnavailableError(err)).toBe(true); }); diff --git a/packages/core/src/security/api-key-lifecycle.test.ts b/packages/core/src/security/api-key-lifecycle.test.ts new file mode 100644 index 00000000000..e87faf3b3bb --- /dev/null +++ b/packages/core/src/security/api-key-lifecycle.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23367, ruling A] The API-key lifecycle policy at the shared key-admission + * seam (`resolveApiKeyAdmission`) and its raise through `resolveAuthzContext`. + * + * What these pin: + * - revoked, expired and inactive are three DISTINCT located refusals + * (`API_KEY_REVOKED`, `API_KEY_EXPIRED`, `API_KEY_INACTIVE`), raised as the + * branded `ApiKeyLifecycleRefusalError` (403) and never a fall-through to + * the session; + * - the inactivity judgment reads the PRE-stamp `last_used_at`, so a key's + * own call cannot revive it, and a refusal writes nothing (not revoked); + * - a key with no recorded use counts from the policy's landing, never from + * an earlier `created_at`, and from its signing when that is later; + * - a key signed with no expiry gets the one-year grace from the landing, + * stamped onto `expires_at` when admitted, and refused with that date after; + * - an admitted (or held) call stamps `last_used_at`, at most once an hour, + * and a stamp failure never fails the request and is said once; + * - each number is the source's, so a changed setting changes the verdict. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts'; + +import { + API_KEY_LAST_USED_STAMP_INTERVAL_MS, + ApiKeyLifecycleRefusalError, + apiKeyLifecyclePolicySourceOf, + hashApiKey, + isApiKeyLifecycleRefusalError, + resolveApiKeyAdmission, +} from './api-key.js'; +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { resolveAuthzContext } from './resolve-authz-context.js'; + +const DAY = 86_400_000; +const NOW = Date.parse('2027-06-01T00:00:00.000Z'); +const RAW = 'osk_lifecycle_test_key'; +const KEY_ID = 'key_1'; +const OWNER = 'usr_owner'; + +const iso = (ms: number): string => new Date(ms).toISOString(); + +interface Recorded { object: string; data: Record } + +/** + * One `sys_api_key` row, matched by hash; records every `update` it is asked + * for. `find` holds the caller's bound (by presence), and `update` routes + * through the real engine's dispatch contract, so the seam's stamp is proven + * to use a call shape `ObjectQL.update` accepts (`{ id, ...fields }`). + */ +function makeQl(row: Record, opts: { failUpdate?: boolean } = {}) { + const updates: Recorded[] = []; + const rows: any[] = [{ id: KEY_ID, key: hashApiKey(RAW), prefix: 'osk_lifecycl', user_id: OWNER, revoked: false, ...row }]; + const ql = { + updates, + find: async (object: string, findOpts: any) => { + if (object !== 'sys_api_key') return []; + const where = findOpts?.where ?? {}; + const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + })); + return typeof findOpts?.limit === 'number' ? matched.slice(0, findOpts.limit) : matched; + }, + update: async (object: string, data: Record, options?: any) => { + assertEngineUpdateDispatch(data as any, options); + if (opts.failUpdate) throw new Error('write refused'); + updates.push({ object, data }); + return data; + }, + }; + return ql; +} + +const headers = () => ({ 'x-api-key': RAW }); + +function policy(over: Partial = {}) { + return (): ApiKeyLifecyclePolicy => ({ + defaultExpiryDays: 90, + maxExpiryDays: 365, + inactivityDays: 90, + startedAt: iso(NOW - 10 * DAY), + ...over, + }); +} + +describe('[#23367] three distinct, located lifecycle refusals', () => { + it('revoked, expired and inactive each answer their own code', async () => { + const revoked = await resolveApiKeyAdmission(makeQl({ revoked: true }), headers(), NOW, undefined, undefined, policy()); + const expired = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW - DAY) }), headers(), NOW, undefined, undefined, policy()); + const inactive = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 91 * DAY) }), headers(), NOW, undefined, undefined, policy()); + + const codes = [revoked, expired, inactive].map((a) => (a.outcome === 'lifecycle' ? a.refusal.code : a.outcome)); + expect(codes).toEqual(['API_KEY_REVOKED', 'API_KEY_EXPIRED', 'API_KEY_INACTIVE']); + }); + + it('an expired key names its date', async () => { + const at = iso(NOW - DAY); + const a = await resolveApiKeyAdmission(makeQl({ expires_at: at }), headers(), NOW, undefined, undefined, undefined); + expect(a.outcome === 'lifecycle' && a.refusal.at).toBe(at); + expect(a.outcome === 'lifecycle' && a.refusal.message).toContain(at); + }); + + it('an unknown key is still `none` — a prober holding no real key learns nothing', async () => { + const a = await resolveApiKeyAdmission(makeQl({}), { 'x-api-key': 'osk_not_a_key' }, NOW, undefined, undefined, policy()); + expect(a.outcome).toBe('none'); + }); + + it('without a policy source the row facts still refuse, and no inactivity is judged', async () => { + const revoked = await resolveApiKeyAdmission(makeQl({ revoked: true }), headers(), NOW, undefined, undefined, undefined); + expect(revoked.outcome === 'lifecycle' && revoked.refusal.code).toBe('API_KEY_REVOKED'); + const idle = await resolveApiKeyAdmission( + makeQl({ last_used_at: iso(NOW - 400 * DAY) }), headers(), NOW, undefined, undefined, undefined); + expect(idle.outcome).toBe('admitted'); + }); +}); + +describe('[#23367] inactivity reads the PRE-stamp value, and a refusal writes nothing', () => { + it('a key idle more than 90 days is refused although this call would have stamped it', async () => { + const ql = makeQl({ last_used_at: iso(NOW - 91 * DAY) }); + const a = await resolveApiKeyAdmission(ql, headers(), NOW, undefined, undefined, policy()); + expect(a.outcome === 'lifecycle' && a.refusal.code).toBe('API_KEY_INACTIVE'); + // Not revoked, not stamped: the row is exactly as it was. + expect(ql.updates).toEqual([]); + // …so the next call is refused the same way — its own use did not revive it. + const again = await resolveApiKeyAdmission(ql, headers(), NOW + 1000, undefined, undefined, policy()); + expect(again.outcome === 'lifecycle' && again.refusal.code).toBe('API_KEY_INACTIVE'); + }); + + it('a key used 89 days ago is admitted', async () => { + const a = await resolveApiKeyAdmission( + makeQl({ last_used_at: iso(NOW - 89 * DAY) }), headers(), NOW, undefined, undefined, policy()); + expect(a.outcome).toBe('admitted'); + }); +}); + +describe('[#23367] a key with no recorded use counts from the landing', () => { + it('counts from the landing, never from an EARLIER created_at', async () => { + // Signed two years ago; the policy landed 10 days ago: idle for 10 days. + const recent = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), created_at: iso(NOW - 730 * DAY) }), + headers(), NOW, undefined, undefined, policy({ startedAt: iso(NOW - 10 * DAY) })); + expect(recent.outcome).toBe('admitted'); + + // Same row, the policy landed 100 days ago: refused, naming the landing. + const landedAt = iso(NOW - 100 * DAY); + const old = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), created_at: iso(NOW - 730 * DAY) }), + headers(), NOW, undefined, undefined, policy({ startedAt: landedAt })); + expect(old.outcome === 'lifecycle' && old.refusal.code).toBe('API_KEY_INACTIVE'); + expect(old.outcome === 'lifecycle' && old.refusal.at).toBe(landedAt); + }); + + it('counts from the signing when the key was signed after the landing', async () => { + const landedAt = iso(NOW - 300 * DAY); + const fresh = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), created_at: iso(NOW - 10 * DAY) }), + headers(), NOW, undefined, undefined, policy({ startedAt: landedAt })); + expect(fresh.outcome).toBe('admitted'); + + const signedAt = iso(NOW - 100 * DAY); + const stale = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), created_at: signedAt }), + headers(), NOW, undefined, undefined, policy({ startedAt: landedAt })); + expect(stale.outcome === 'lifecycle' && stale.refusal.at).toBe(signedAt); + }); + + it('with no landing recorded, a key with no recorded use is not judged idle', async () => { + const a = await resolveApiKeyAdmission( + makeQl({ expires_at: iso(NOW + DAY), created_at: iso(NOW - 730 * DAY) }), + headers(), NOW, undefined, undefined, policy({ startedAt: undefined })); + expect(a.outcome).toBe('admitted'); + }); +}); + +describe('[#23367] the one-year grace of a key signed with no expiry', () => { + it('is stamped onto expires_at (landing + 365 days) when the key is admitted', async () => { + const landedAt = NOW - 10 * DAY; + const ql = makeQl({ last_used_at: iso(NOW - DAY) }); + const a = await resolveApiKeyAdmission(ql, headers(), NOW, undefined, undefined, policy({ startedAt: iso(landedAt) })); + expect(a.outcome).toBe('admitted'); + expect(ql.updates).toHaveLength(1); + expect(ql.updates[0]!.data).toMatchObject({ id: KEY_ID, expires_at: iso(landedAt + 365 * DAY) }); + }); + + it('after the grace the key is refused API_KEY_EXPIRED, naming the date', async () => { + const landedAt = NOW - 366 * DAY; + const graceEnd = iso(landedAt + 365 * DAY); + const a = await resolveApiKeyAdmission( + makeQl({ last_used_at: iso(NOW - DAY) }), headers(), NOW, undefined, undefined, policy({ startedAt: iso(landedAt) })); + expect(a.outcome === 'lifecycle' && a.refusal.code).toBe('API_KEY_EXPIRED'); + expect(a.outcome === 'lifecycle' && a.refusal.at).toBe(graceEnd); + expect(a.outcome === 'lifecycle' && a.refusal.message).toContain(graceEnd); + }); +}); + +describe('[#23367] the last_used_at stamp', () => { + it('one admitted call writes last_used_at', async () => { + const ql = makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 2 * DAY) }); + await resolveApiKeyAdmission(ql, headers(), NOW, undefined, undefined, policy()); + expect(ql.updates).toEqual([{ object: 'sys_api_key', data: { id: KEY_ID, last_used_at: iso(NOW) } }]); + }); + + it('the throttle holds: a use inside the interval writes nothing, one past it writes', async () => { + const inside = makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - API_KEY_LAST_USED_STAMP_INTERVAL_MS + 1000) }); + await resolveApiKeyAdmission(inside, headers(), NOW, undefined, undefined, policy()); + expect(inside.updates).toEqual([]); + + const past = makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - API_KEY_LAST_USED_STAMP_INTERVAL_MS) }); + await resolveApiKeyAdmission(past, headers(), NOW, undefined, undefined, policy()); + expect(past.updates).toHaveLength(1); + }); + + it('a held key is stamped too (ruling C′ item 4: it resumes when the owner remediates)', async () => { + const ql = makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 2 * DAY) }); + const held = async () => [{ code: 'MFA_REQUIRED' as const, message: 'enrol' }]; + const a = await resolveApiKeyAdmission(ql, headers(), NOW, undefined, held, policy()); + expect(a.outcome).toBe('held'); + expect(ql.updates).toHaveLength(1); + }); + + describe('a stamp failure', () => { + let error: ReturnType; + beforeEach(() => { error = vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + afterEach(() => { error.mockRestore(); }); + + it('never fails the request, and is said once per engine', async () => { + const ql = makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 2 * DAY) }, { failUpdate: true }); + const first = await resolveApiKeyAdmission(ql, headers(), NOW, undefined, undefined, policy()); + const second = await resolveApiKeyAdmission(ql, headers(), NOW, undefined, undefined, policy()); + expect([first.outcome, second.outcome]).toEqual(['admitted', 'admitted']); + expect(error).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('[#23367] each number is the source\'s', () => { + it('a shorter inactivity window refuses what the default admits, and 0 turns it off', async () => { + const row = { expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 40 * DAY) }; + expect((await resolveApiKeyAdmission(makeQl(row), headers(), NOW, undefined, undefined, policy())).outcome).toBe('admitted'); + const short = await resolveApiKeyAdmission(makeQl(row), headers(), NOW, undefined, undefined, policy({ inactivityDays: 30 })); + expect(short.outcome === 'lifecycle' && short.refusal.code).toBe('API_KEY_INACTIVE'); + + const ancient = { expires_at: iso(NOW + DAY), last_used_at: iso(NOW - 900 * DAY) }; + expect((await resolveApiKeyAdmission(makeQl(ancient), headers(), NOW, undefined, undefined, policy({ inactivityDays: 0 }))).outcome) + .toBe('admitted'); + }); + + it('a policy source that rejects is the branded store outage, never an admission', async () => { + const failing = () => { throw new Error('auth service fault'); }; + const err = await resolveApiKeyAdmission(makeQl({}), headers(), NOW, undefined, undefined, failing).catch((e) => e); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + }); + + it('apiKeyLifecyclePolicySourceOf binds the service, and answers undefined for one with no policy', () => { + expect(apiKeyLifecyclePolicySourceOf(undefined)).toBeUndefined(); + expect(apiKeyLifecyclePolicySourceOf({})).toBeUndefined(); + const p = policy()(); + expect(apiKeyLifecyclePolicySourceOf({ resolveApiKeyLifecyclePolicy: () => p })?.()).toBe(p); + }); +}); + +describe('[#23367] resolveAuthzContext raises the refusal, branded, and never falls through to the session', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); }); + afterEach(() => { warn.mockRestore(); }); + + it('a revoked key beside a valid session cookie is refused 403 API_KEY_REVOKED', async () => { + const getSession = vi.fn(async () => ({ user: { id: 'usr_cookie' } })); + const err = await resolveAuthzContext({ + ql: makeQl({ revoked: true }), + headers: headers(), + getSession, + nowMs: NOW, + userAuthGates: undefined, + apiKeyLifecycle: policy(), + }).catch((e) => e); + expect(isApiKeyLifecycleRefusalError(err)).toBe(true); + expect(err).toBeInstanceOf(ApiKeyLifecycleRefusalError); + expect({ code: err.code, status: err.status }).toEqual({ code: 'API_KEY_REVOKED', status: 403 }); + expect(getSession).not.toHaveBeenCalled(); + }); + + it('a live key resolves its owner exactly as before', async () => { + const ctx = await resolveAuthzContext({ + ql: makeQl({ expires_at: iso(NOW + DAY), last_used_at: iso(NOW - DAY) }), + headers: headers(), + nowMs: NOW, + userAuthGates: undefined, + apiKeyLifecycle: policy(), + }); + expect({ userId: ctx.userId, credential: ctx.credential }).toEqual({ userId: OWNER, credential: 'api_key' }); + }); +}); diff --git a/packages/core/src/security/api-key.test.ts b/packages/core/src/security/api-key.test.ts index 4768287be31..eec80ff6c9e 100644 --- a/packages/core/src/security/api-key.test.ts +++ b/packages/core/src/security/api-key.test.ts @@ -62,7 +62,7 @@ describe('core api-key primitives', () => { it('resolveApiKeyPrincipal resolves a Bearer osk_ key (remote MCP client path)', async () => { const raw = 'osk_' + 'b'.repeat(24); const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` }, undefined, undefined, undefined); + const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` }, undefined, undefined, undefined, undefined); expect(p?.userId).toBe('u1'); }); @@ -84,36 +84,36 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => { // value the mint path really produces. { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org1', scopes: '["read"]', expires_at: FUTURE }, ]); - const p = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined); + const p = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined, undefined); expect(p).toEqual({ userId: 'u1', tenantId: 'org1', scopes: ['read'] }); }); it('resolves via Authorization: ApiKey', async () => { const raw = 'osk_valid'; const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const p = await resolveApiKeyPrincipal(ql, { authorization: `ApiKey ${raw}` }, undefined, undefined, undefined); + const p = await resolveApiKeyPrincipal(ql, { authorization: `ApiKey ${raw}` }, undefined, undefined, undefined, undefined); expect(p?.userId).toBe('u1'); }); it('returns undefined for no key / revoked / expired / unknown / owner-less', async () => { const raw = 'osk_x'; const base = (extra: any) => makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1', ...extra }]); - expect(await resolveApiKeyPrincipal(base({}), {}, undefined, undefined, undefined)).toBeUndefined(); // no key header - expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: true, user_id: 'u1' }]), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); - expect(await resolveApiKeyPrincipal(base({ expires_at: PAST }), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); - expect(await resolveApiKeyPrincipal(base({}), { 'x-api-key': 'osk_wrong' }, undefined, undefined, undefined)).toBeUndefined(); - expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: false }]), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); // no user_id + expect(await resolveApiKeyPrincipal(base({}), {}, undefined, undefined, undefined, undefined)).toBeUndefined(); // no key header + expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: true, user_id: 'u1' }]), { 'x-api-key': raw }, undefined, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(base({ expires_at: PAST }), { 'x-api-key': raw }, undefined, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(base({}), { 'x-api-key': 'osk_wrong' }, undefined, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: false }]), { 'x-api-key': raw }, undefined, undefined, undefined, undefined)).toBeUndefined(); // no user_id }); it('never matches a plaintext-stored key (hash lookup only)', async () => { const raw = 'osk_plain'; const ql = makeQl([{ key: raw, revoked: false, user_id: 'u1' }]); - expect(await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined, undefined)).toBeUndefined(); }); it('fail-closed when ql is missing/unusable', async () => { - expect(await resolveApiKeyPrincipal(undefined, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined)).toBeUndefined(); - expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(undefined, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined, undefined)).toBeUndefined(); }); }); @@ -140,7 +140,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined, undefined); expect(admission.outcome).toBe('admitted'); expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); }); @@ -157,7 +157,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', organization_id: 'org_a', organizationId: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined, undefined); // `single` (the default posture here) admits an org-less key, so this // asserts the SPELLING, not the refusal. expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBeUndefined(); @@ -165,7 +165,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { it('admits an org-less key under `single` — there is no wall to fail', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single', undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single', undefined, undefined); expect(admission.outcome).toBe('admitted'); }); @@ -179,13 +179,13 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('admits an org-less key under `group` — it already works there', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group', undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group', undefined, undefined); expect(admission.outcome).toBe('admitted'); }); it('REFUSES an org-less key under `isolated` — the posture where it is provably dead', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined, undefined); expect(admission.outcome).toBe('refused'); expect(admission.outcome === 'refused' && admission.reason).toBe('organization_required'); // The message is the operator-facing half of "loud at call time": it must @@ -218,7 +218,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('admits an org-less key when the posture is unknown (no tenancy service)', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined, undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined, undefined, undefined); expect(admission.outcome).toBe('admitted'); }); @@ -226,7 +226,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined, undefined); expect(admission.outcome).toBe('admitted'); expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); }); @@ -238,13 +238,13 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('resolveApiKeyPrincipal collapses a refusal to undefined (fail-closed for old callers)', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); + const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined, undefined); expect(principal).toBeUndefined(); }); it('an absent key is `none`, never a refusal', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated', undefined); + const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated', undefined, undefined); expect(admission.outcome).toBe('none'); }); }); diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index 757ef16d3e9..83b331840a7 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -25,6 +25,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from '@objectstack/spec/security'; import type { TenancyPosture } from '@objectstack/spec/security'; +import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts'; import { accountScopeAuthGate, type AuthGate, type UserAuthGateSource } from './auth-gate.js'; import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; @@ -114,7 +115,16 @@ export function parseScopes(value: unknown): string[] { /** Return true when an expiry timestamp is in the past (i.e. the key is dead). */ export function isExpired(value: unknown, nowMs: number): boolean { - if (value == null) return false; + const ms = instantMs(value); + return ms !== undefined && ms <= nowMs; +} + +/** + * Read a stored instant (`expires_at`, `last_used_at`, `created_at`) as epoch + * milliseconds, or `undefined` when the column is empty or unreadable. + */ +function instantMs(value: unknown): number | undefined { + if (value == null) return undefined; let ms: number; if (typeof value === 'number') { // Heuristic: seconds vs milliseconds epoch. @@ -124,10 +134,310 @@ export function isExpired(value: unknown, nowMs: number): boolean { } else if (typeof value === 'string') { ms = Date.parse(value); } else { - return false; + return undefined; + } + return Number.isNaN(ms) ? undefined : ms; +} + +// ── [#23367, ruling A] The API-key lifecycle policy ────────────────────────── +// +// Ruling `6159880505` (letter A, maintainer 「同意」): a key signed with no +// expiry lives 90 days; no signing may ask for more than a year; a key unused +// for 90 days is refused (not revoked: a new key is the remedy); keys signed +// before the policy with no expiry get a one-year grace from its landing; and +// expired, inactive and revoked are three distinct, located refusals. The +// numbers are auth configuration (`IAuthService.resolveApiKeyLifecyclePolicy`), +// and these constants are the defaults the auth service falls back to. + +const DAY_MS = 86_400_000; + +/** [#23367] Default life of a key signed with no `expires_at`, in days. */ +export const API_KEY_DEFAULT_EXPIRY_DAYS = 90; + +/** [#23367] Default ceiling on a signed key's life, in days from the signing. */ +export const API_KEY_MAX_EXPIRY_DAYS = 365; + +/** [#23367] Default inactivity window, in days without a recorded use. */ +export const API_KEY_INACTIVITY_DAYS = 90; + +/** + * [#23367] The grace a key signed with NO expiry gets, in days from the + * policy's landing (`ApiKeyLifecyclePolicy.startedAt`). A one-time transition, + * not a standing knob: the end is stamped onto the key's own `expires_at` the + * first time the key is admitted, so the keys page names the date, and a + * setting could not move a date already stamped. + */ +export const API_KEY_LEGACY_GRACE_DAYS = 365; + +/** + * [#23367] How stale `sys_api_key.last_used_at` may get before an admitted + * call stamps it again: at most one write per key per hour. The inactivity + * window is counted in days, so an hour of staleness never moves a verdict, + * and the field's promise ("updated on each API call") costs one write per + * key per hour instead of one per request. + */ +export const API_KEY_LAST_USED_STAMP_INTERVAL_MS = 60 * 60 * 1000; + +/** + * [#23367] The policy source the key-admission seam reads — the auth service's + * {@link ApiKeyLifecyclePolicy}, bound by {@link apiKeyLifecyclePolicySourceOf}. + * A function, not a value, so a request that carries no key never asks. + */ +export type ApiKeyLifecyclePolicySource = () => ApiKeyLifecyclePolicy | Promise; + +/** + * [#23367] Bind an auth service's lifecycle policy into the + * {@link ApiKeyLifecyclePolicySource} a transport hands `resolveAuthzContext`, + * or `undefined` when the service implements none (a kernel composed without + * `plugin-auth` has no auth configuration, so only the row's own facts — + * revoked, `expires_at` — are judged). Structural on purpose, like + * `userAuthGateSourceOf`: `@objectstack/core` does not depend on the plugin + * that provides it. + */ +export function apiKeyLifecyclePolicySourceOf( + authService: { + resolveApiKeyLifecyclePolicy?(): ApiKeyLifecyclePolicy | Promise; + } | null | undefined, +): ApiKeyLifecyclePolicySource | undefined { + if (!authService || typeof authService.resolveApiKeyLifecyclePolicy !== 'function') return undefined; + const service = authService; + return () => service.resolveApiKeyLifecyclePolicy!(); +} + +/** + * [#23367] The three lifecycle refusals, each declared as its wire pair — the + * ADR-0112 code (registered under `@objectstack/core`) beside the status it + * answers. `403` for all three, as the ruling words it ("the key's own 403 body + * names the date") and as a held key's posture refusal answers: the credential + * was recognised, and its state is what refuses it. Unknown keys keep the + * generic 401. + */ +const API_KEY_EXPIRED_REFUSAL = { code: 'API_KEY_EXPIRED', status: 403 } as const; +const API_KEY_INACTIVE_REFUSAL = { code: 'API_KEY_INACTIVE', status: 403 } as const; +const API_KEY_REVOKED_REFUSAL = { code: 'API_KEY_REVOKED', status: 403 } as const; + +export const API_KEY_EXPIRED_CODE = API_KEY_EXPIRED_REFUSAL.code; +export const API_KEY_INACTIVE_CODE = API_KEY_INACTIVE_REFUSAL.code; +export const API_KEY_REVOKED_CODE = API_KEY_REVOKED_REFUSAL.code; +export type ApiKeyLifecycleCode = + | typeof API_KEY_EXPIRED_CODE + | typeof API_KEY_INACTIVE_CODE + | typeof API_KEY_REVOKED_CODE; + +/** [#23367] The status every lifecycle refusal answers with (see the pairs above). */ +export const API_KEY_LIFECYCLE_REFUSAL_STATUS = API_KEY_REVOKED_REFUSAL.status; + +/** [#23367] One lifecycle refusal, as the seam decides it. */ +export interface ApiKeyLifecycleRefusal { + code: ApiKeyLifecycleCode; + /** The located message: which key, which date, and the remedy. Never the raw key or its hash. */ + message: string; + /** The date the refusal names (ISO 8601): the expiry, or the start of the idle window. Absent for a revocation. */ + at?: string; + /** The `sys_api_key` row id, for the operator; ⛔ never the key or its hash. */ + keyId?: string; + /** The key's owner. */ + userId?: string; +} + +/** The own-property brand {@link isApiKeyLifecycleRefusalError} tests (see `authz-store-unavailable.ts` on why not `instanceof`). */ +const API_KEY_LIFECYCLE_REFUSAL_BRAND = '__objectstackApiKeyLifecycleRefusal' as const; + +/** + * [#23367] The branded refusal `resolveAuthzContext` raises for a key that is + * revoked, expired or inactive. Thrown rather than returned as an envelope + * field, for the reason `AuthzStoreUnavailableError` is: a throw is loud by + * default. A face whose door relays the brand answers `status`, `code` and + * the located `message` in its own envelope; a face that does not catches it + * as any other identity fault and answers its generic 401 — it never admits. + */ +export class ApiKeyLifecycleRefusalError extends Error { + /** Brand — tested by {@link isApiKeyLifecycleRefusalError}. */ + readonly [API_KEY_LIFECYCLE_REFUSAL_BRAND] = true as const; + /** ADR-0112 wire code (registered in the ledger under `@objectstack/core`). */ + readonly code: ApiKeyLifecycleCode; + /** HTTP status a relaying face answers. */ + readonly status = API_KEY_LIFECYCLE_REFUSAL_STATUS; + /** The date the refusal names, when it names one. */ + readonly at?: string; + /** The refused key's row id — for logs; never sent by a door. */ + readonly keyId?: string; + /** The refused key's owner — for logs; never sent by a door. */ + readonly userId?: string; + + constructor(refusal: ApiKeyLifecycleRefusal) { + super(refusal.message); + this.name = 'ApiKeyLifecycleRefusalError'; + this.code = refusal.code; + if (refusal.at !== undefined) this.at = refusal.at; + if (refusal.keyId !== undefined) this.keyId = refusal.keyId; + if (refusal.userId !== undefined) this.userId = refusal.userId; + } +} + +/** [#23367] True when `err` is the branded API-key lifecycle refusal. */ +export function isApiKeyLifecycleRefusalError(err: unknown): err is ApiKeyLifecycleRefusalError { + return typeof err === 'object' && err !== null + && (err as Record)[API_KEY_LIFECYCLE_REFUSAL_BRAND] === true; +} + +/** How a refusal names the key: its stored, non-secret display prefix. */ +function keyLabel(row: any): string { + const prefix = typeof row?.prefix === 'string' && row.prefix ? row.prefix : undefined; + return prefix ? `This API key (${prefix}…)` : 'This API key'; +} + +const API_KEY_REMEDY = + 'Create a new key (POST /api/v1/keys, or the API Keys page) and replace this one wherever it is configured.'; + +/** + * The lifecycle verdict on a recognised row, or `undefined` when its own state + * admits it. Order: revoked, then expired (its own `expires_at`, else the + * legacy grace), then inactive. Reads the row as found — the PRE-stamp + * `last_used_at` — so a key's own call can never revive it. + */ +function lifecycleRefusalOf( + row: any, + nowMs: number, + policy: ApiKeyLifecyclePolicy | undefined, + ids: { keyId?: string; userId: string }, +): ApiKeyLifecycleRefusal | undefined { + const label = keyLabel(row); + if (row.revoked === true || row.revoked === 1) { + return { + code: API_KEY_REVOKED_REFUSAL.code, + message: `${label} was revoked, and a revoked key never works again. ${API_KEY_REMEDY}`, + ...ids, + }; + } + + const expiresMs = instantMs(row.expires_at ?? row.expiresAt); + if (expiresMs !== undefined) { + if (expiresMs <= nowMs) { + const at = new Date(expiresMs).toISOString(); + return { + code: API_KEY_EXPIRED_REFUSAL.code, + message: `${label} expired on ${at}. Keys do not renew. ${API_KEY_REMEDY}`, + at, + ...ids, + }; + } + } else { + const graceEndMs = legacyGraceEndMs(policy); + if (graceEndMs !== undefined && graceEndMs <= nowMs) { + const at = new Date(graceEndMs).toISOString(); + return { + code: API_KEY_EXPIRED_REFUSAL.code, + message: + `${label} was signed with no expiry, and its one-year grace under the API-key lifecycle policy ` + + `ended on ${at}. ${API_KEY_REMEDY}`, + at, + ...ids, + }; + } + } + + const idle = idleWindowStartMs(row, policy); + if (idle && policy && nowMs - idle.ms > policy.inactivityDays * DAY_MS) { + const at = new Date(idle.ms).toISOString(); + const since = idle.from === 'use' + ? `has not been used since ${at}` + : idle.from === 'signing' + ? `has not been used since it was signed on ${at}` + : `has no recorded use since the API-key lifecycle policy took effect on ${at}`; + return { + code: API_KEY_INACTIVE_REFUSAL.code, + message: + `${label} ${since}, longer than the ${policy.inactivityDays} days the inactivity policy allows, so it ` + + `is refused. It is not revoked. ${API_KEY_REMEDY}`, + at, + ...ids, + }; + } + return undefined; +} + +/** The end of a no-expiry key's grace: the policy's landing plus a year. */ +function legacyGraceEndMs(policy: ApiKeyLifecyclePolicy | undefined): number | undefined { + const startedMs = instantMs(policy?.startedAt); + return startedMs === undefined ? undefined : startedMs + API_KEY_LEGACY_GRACE_DAYS * DAY_MS; +} + +/** + * Where a key's idle window starts, or `undefined` when no inactivity policy + * applies or nothing anchors it. + * + * - a recorded use (`last_used_at`) anchors it; + * - a key with NO recorded use counts from the policy's landing — never from + * its own `created_at` when that is earlier (no row was ever stamped before + * the policy, so an early signing date says nothing about use) — or from + * its signing when that is later (a key signed after the landing has been + * unused only since then). + */ +function idleWindowStartMs( + row: any, + policy: ApiKeyLifecyclePolicy | undefined, +): { ms: number; from: 'use' | 'landing' | 'signing' } | undefined { + if (!policy || !(policy.inactivityDays > 0)) return undefined; + const lastUsedMs = instantMs(row.last_used_at ?? row.lastUsedAt); + if (lastUsedMs !== undefined) return { ms: lastUsedMs, from: 'use' }; + const startedMs = instantMs(policy.startedAt); + if (startedMs === undefined) return undefined; + const createdMs = instantMs(row.created_at ?? row.createdAt); + return createdMs !== undefined && createdMs > startedMs + ? { ms: createdMs, from: 'signing' } + : { ms: startedMs, from: 'landing' }; +} + +/** + * Engines whose stamp failure has already been reported — so the line is said + * once per engine, at the first failure (AGENTS.md, degradation log levels), + * not once per request of a key whose stamp keeps failing. + */ +const STAMP_FAILURE_REPORTED = new WeakSet(); + +/** + * [#23367] Stamp an admitted key's row: `last_used_at` when it is older than + * {@link API_KEY_LAST_USED_STAMP_INTERVAL_MS} (or empty), and, for a key + * signed with no expiry, the end of its grace as `expires_at` (the MFA-grace + * precedent: the clock is stamped on the row, so the keys page names it). + * + * ⛔ A stamp failure never fails the request: the key was judged on the row as + * found. It is a durability degradation all the same — the field claims each + * use, and an unstamped use lets a key in daily use read as idle — so it is + * said at `error`, once per engine, with the consequence and the remedy. + */ +async function stampAdmittedKey( + ql: any, + row: any, + keyId: string | undefined, + nowMs: number, + policy: ApiKeyLifecyclePolicy | undefined, +): Promise { + if (!keyId || !ql || typeof ql.update !== 'function') return; + const patch: Record = {}; + const lastUsedMs = instantMs(row.last_used_at ?? row.lastUsedAt); + if (lastUsedMs === undefined || nowMs - lastUsedMs >= API_KEY_LAST_USED_STAMP_INTERVAL_MS) { + patch.last_used_at = new Date(nowMs).toISOString(); + } + if (instantMs(row.expires_at ?? row.expiresAt) === undefined) { + const graceEndMs = legacyGraceEndMs(policy); + if (graceEndMs !== undefined) patch.expires_at = new Date(graceEndMs).toISOString(); + } + if (Object.keys(patch).length === 0) return; + try { + await ql.update('sys_api_key', { id: keyId, ...patch }, { context: { isSystem: true } }); + } catch (err) { + if (typeof ql === 'object' && STAMP_FAILURE_REPORTED.has(ql)) return; + if (typeof ql === 'object') STAMP_FAILURE_REPORTED.add(ql); + console.error( + `[security] API key use NOT recorded: the sys_api_key stamp (${Object.keys(patch).join(', ')}) failed ` + + `for key=${keyId}. The request was served, and every later one will be too, but a key in daily ` + + 'use whose uses are not recorded reads as unused and will be refused API_KEY_INACTIVE once the ' + + 'inactivity window passes. Fix: restore write access to sys_api_key for the system context. ' + + `Said once for this data engine. Cause: ${String((err as any)?.message ?? err)}`, + ); } - if (Number.isNaN(ms)) return false; - return ms <= nowMs; } /** The principal resolved from a valid `sys_api_key`. */ @@ -168,11 +478,19 @@ export interface ApiKeyPrincipal { export type ApiKeyRefusalReason = 'organization_required' | 'organization_membership_ended'; /** - * The verdict on an inbound API key. Four outcomes, deliberately distinct: + * The verdict on an inbound API key. Five outcomes, deliberately distinct: * - * - `none` — no key header, or a key that is unknown / revoked / expired / - * owner-less. Indistinguishable by design (never tell a prober which), and - * the caller MAY fall through to the session path exactly as before. + * - `none` — no key header, or a key that is unknown / owner-less. + * Indistinguishable by design (never tell a prober which), and the caller + * MAY fall through to the session path exactly as before. + * - `lifecycle` — [#23367, ruling A] a REAL key whose own state refuses it: + * revoked, expired (its `expires_at`, or the grace of a key signed with + * none), or inactive. Distinct and located by ruling: the holder of a key + * that once worked is told which state and which date, with the remedy (a + * new key). A prober holding no real key still learns nothing — an unknown + * hash stays `none`. The caller must NOT fall through to the session path, + * for the `refused` arm's reason; `resolveAuthzContext` raises it as the + * branded {@link ApiKeyLifecycleRefusalError}. * - `admitted` — a usable principal. * - `held` — [#23287, ruling C′ item 3] a real, intact key whose OWNER holds * an `account`-scope ADR-0069 posture (`AUTH_GATE_SCOPES`): the account's @@ -190,6 +508,7 @@ export type ApiKeyRefusalReason = 'organization_required' | 'organization_member */ export type ApiKeyAdmission = | { outcome: 'none' } + | { outcome: 'lifecycle'; refusal: ApiKeyLifecycleRefusal } | { outcome: 'admitted'; principal: ApiKeyPrincipal } | { outcome: 'held'; principal: ApiKeyPrincipal; gate: AuthGate } | { @@ -252,9 +571,10 @@ export function effectiveTenancyPosture( * the verdict of {@link resolveApiKeyAdmission}, collapsed to a principal. * * Fail-closed: returns `undefined` for a missing key, an unusable data engine, - * a lookup error, a key that is unknown / revoked / expired / owner-less, a key - * the tenancy posture refuses, and a key whose OWNER holds an `account`-scope - * ADR-0069 posture (`held`). + * a lookup error, a key that is unknown / owner-less, a key its own lifecycle + * refuses (revoked / expired / inactive — `lifecycle`), a key the tenancy + * posture refuses, and a key whose OWNER holds an `account`-scope ADR-0069 + * posture (`held`). * * ⚠️ The collapse is lossy by construction: a principal-or-nothing answer cannot * carry the posture's code, so a host that admits keys through this function @@ -273,8 +593,12 @@ export function effectiveTenancyPosture( * @param ql A data engine with `find(object, { where, limit, context })`. * @param headers Request headers (Web `Headers` or a plain object). * @param nowMs Clock for expiry checks (injectable for tests). - * @param tenancyPosture The deployment's effective tenancy posture, or `undefined` when no `tenancy` service is registered. - * @param userAuthGates `userAuthGateSourceOf(authService)`, or `undefined` when the kernel has no auth service. + * [#23367] `apiKeyLifecycle` is REQUIRED for the same reason, and with the + * same meaning of `undefined`. + * + * @param tenancyPosture The deployment's effective tenancy posture, or `undefined` when no `tenancy` service is registered. + * @param userAuthGates `userAuthGateSourceOf(authService)`, or `undefined` when the kernel has no auth service. + * @param apiKeyLifecycle `apiKeyLifecyclePolicySourceOf(authService)`, or `undefined` when the kernel has no auth service. */ export async function resolveApiKeyPrincipal( ql: any, @@ -282,8 +606,9 @@ export async function resolveApiKeyPrincipal( nowMs: number = Date.now(), tenancyPosture: TenancyPosture | undefined, userAuthGates: UserAuthGateSource | undefined, + apiKeyLifecycle: ApiKeyLifecyclePolicySource | undefined, ): Promise { - const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture, userAuthGates); + const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture, userAuthGates, apiKeyLifecycle); return admission.outcome === 'admitted' ? admission.principal : undefined; } @@ -326,16 +651,32 @@ export async function resolveApiKeyAdmission( * compiler. */ userAuthGates: UserAuthGateSource | undefined, + /** + * [#23367, ruling A] The auth service's API-key lifecycle policy + * (`apiKeyLifecyclePolicySourceOf(authService)`): the inactivity window and + * the landing that anchors keys with no recorded use or no expiry. + * + * REQUIRED, for the #23455 reason `userAuthGates` is: an optional source is + * how a new seam compiles while skipping the policy. `undefined` is the + * value a kernel without `plugin-auth` passes — there is no auth + * configuration there, so only the row's own facts (revoked, `expires_at`) + * are judged — ⛔ never the value a caller that can reach the `auth` + * service passes to quiet the compiler. + */ + apiKeyLifecycle: ApiKeyLifecyclePolicySource | undefined, ): Promise { const apiKey = extractApiKey(headers); if (!apiKey) return { outcome: 'none' }; if (!ql || typeof ql.find !== 'function') return { outcome: 'none' }; // Match by the indexed at-rest hash only — never query by the raw key. + // + // [#23367] By the hash alone: a REVOKED row is found too, so it can be + // refused with its own code instead of reading as an unknown key. let rows: any; try { rows = await ql.find('sys_api_key', { - where: { key: hashApiKey(apiKey), revoked: false }, + where: { key: hashApiKey(apiKey) }, limit: 1, context: { isSystem: true }, }); @@ -344,14 +685,36 @@ export async function resolveApiKeyAdmission( } if (rows && (rows as any).value) rows = (rows as any).value; const row = Array.isArray(rows) ? rows[0] : undefined; - if (!row || row.revoked === true) return { outcome: 'none' }; - - const expiresAt = row.expires_at ?? row.expiresAt; - if (isExpired(expiresAt, nowMs)) return { outcome: 'none' }; + if (!row) return { outcome: 'none' }; const userId = row.user_id ?? row.userId; if (!userId || typeof userId !== 'string') return { outcome: 'none' }; + // [#15256 / 2A] The row's own id — a non-secret handle for the refusal log. + // ⛔ Never `row.key` (the at-rest hash) and never the inbound `apiKey`. + const keyId = typeof row.id === 'string' && row.id ? row.id : undefined; + + // [#23367, ruling A] The key's own lifecycle, judged FIRST: a revoked, + // expired or inactive key is no credential at all, so its state is what the + // holder is told — before any posture question is asked of its owner. Read + // off the row AS FOUND, i.e. the pre-stamp `last_used_at`: the stamp below + // runs only for a key this verdict admits, so a key's own call can never + // revive it. + // + // The policy is asked only once a real, unrevoked key is in hand. A source + // that REJECTS leaves the policy unknown, and an unknown policy is not an + // admission: the branded store outage, as for the owner-posture source. + let policy: ApiKeyLifecyclePolicy | undefined; + if (apiKeyLifecycle && !(row.revoked === true || row.revoked === 1)) { + try { + policy = await apiKeyLifecycle(); + } catch (err) { + throw new AuthzStoreUnavailableError('api_key_policy', err); + } + } + const lifecycle = lifecycleRefusalOf(row, nowMs, policy, { keyId, userId }); + if (lifecycle) return { outcome: 'lifecycle', refusal: lifecycle }; + // [#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 @@ -363,10 +726,6 @@ export async function resolveApiKeyAdmission( ? row.active_organization_id : undefined; - // [#15256 / 2A] The row's own id — a non-secret handle for the refusal log. - // ⛔ Never `row.key` (the at-rest hash) and never the inbound `apiKey`. - const keyId = typeof row.id === 'string' && row.id ? row.id : undefined; - // [#8287] Posture-conditional refusal for a key that carries no organization. // // ⛔ Never backfilled — inferring the org from the owner's CURRENT membership @@ -459,6 +818,7 @@ export async function resolveApiKeyAdmission( // active gate whose session re-read fails gets). The computation itself fails // open on a lookup error, exactly as the session gate does, so this arm is a // fault in the service, not in its store. + let gate: AuthGate | null = null; if (userAuthGates) { let gates: readonly AuthGate[]; try { @@ -466,10 +826,17 @@ export async function resolveApiKeyAdmission( } catch (err) { throw new AuthzStoreUnavailableError('auth_gate', err); } - const gate = accountScopeAuthGate(gates); - if (gate) return { outcome: 'held', principal, gate }; + gate = accountScopeAuthGate(gates); } + // [#23367] The key is live: record the use (throttled), and stamp the grace + // of a key signed with no expiry. A HELD key is stamped too — its + // integration is using it, and ruling C′ item 4 serves it again the moment + // the owner remediates, which an idle clock run out during the hold would + // defeat. A key the tenancy posture refused (above) is not. + await stampAdmittedKey(ql, row, keyId, nowMs, policy); + + if (gate) return { outcome: 'held', principal, gate }; return { outcome: 'admitted', principal }; } diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 38280b01eac..3b936a5d7cd 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -63,7 +63,7 @@ const settle = async (p: Promise) => describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is not', () => { it('a permission-store OUTAGE refuses, with the branded error', async () => { - const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql: qlDown(), headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlDown(), headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); // The wire vocabulary an outage selects — an EXISTING ADR-0112 member. @@ -77,13 +77,13 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is it('⭐ THE TWIN: a reachable, genuinely EMPTY store still resolves to zero capabilities', async () => { // The assertion that keeps the fix honest. If this ever throws, the repair // has stopped distinguishing the two facts and merely moved the lie. - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); }); it('⭐ CONTROL: a healthy store still resolves the capability it grants', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toContain('studio.access'); }); @@ -91,15 +91,15 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is it('an ABSENT engine is not a failed read — it still resolves, as it always did', async () => { // `tryFind`'s `!ql` guard. An embedder that never wired a data plane must // keep resolving; only a read that was ISSUED and THREW is loud. - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: undefined, headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: undefined, headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); - const noFind = await resolveAuthzContext({ userAuthGates: undefined, ql: {} as any, headers: {}, ...SESSION }); + const noFind = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: {} as any, headers: {}, ...SESSION }); expect(noFind.userId).toBe(USER); }); it('an ANONYMOUS request never reaches the store, so an outage cannot make it loud', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlDown(), headers: {} }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlDown(), headers: {} }); expect(ctx.userId).toBeUndefined(); expect(ctx.systemPermissions).toEqual([]); }); @@ -380,7 +380,7 @@ const OUTAGE_SHAPES: ReadonlyArray unknown describe('[#13279 option A] ⭐ THE OUTAGE DIRECTION — a read failure that is not an unprovisioned table stays LOUD', () => { it.each(OUTAGE_SHAPES)('%s ⇒ AuthzStoreUnavailableError (503 SERVICE_UNAVAILABLE)', async (_label, make) => { - const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql: qlThrowing(make), headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlThrowing(make), headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); expect((r as any).e.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); @@ -397,7 +397,7 @@ describe('[#13279 option A] ⭐ THE OUTAGE DIRECTION — a read failure that is return []; }, }; - const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); expect((r as any).e.object).toBe('sys_user_permission_set'); @@ -428,7 +428,7 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio it.each(UNPROVISIONED_SHAPES)( '%s ⇒ zero capabilities, because that is the TRUE answer and not a fabrication', async (_label, make) => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlThrowing(make), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlThrowing(make), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); expect(ctx.permissions).toEqual([]); @@ -448,12 +448,13 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio // less means the quiet branch is quietly different. const unprovisioned = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: qlThrowing((o) => new Error(`no such table: ${o}`)), headers: {}, ...SESSION, }); - const empty = await resolveAuthzContext({ userAuthGates: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); + const empty = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(JSON.stringify(unprovisioned)).toBe(JSON.stringify(empty)); // CONTROL against a vacuous comparison: a HEALTHY store differs from both. - const healthy = await resolveAuthzContext({ userAuthGates: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); + const healthy = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(empty)); }); diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 3289ccc0620..f3d8ad480a2 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -125,6 +125,23 @@ export { resolveApiKeyPrincipal, resolveApiKeyAdmission, effectiveTenancyPosture, + // [#23367, ruling A] The API-key lifecycle policy: its defaults, the source + // binding every face passes, and the branded refusal a face relays. + API_KEY_DEFAULT_EXPIRY_DAYS, + API_KEY_MAX_EXPIRY_DAYS, + API_KEY_INACTIVITY_DAYS, + API_KEY_LEGACY_GRACE_DAYS, + API_KEY_LAST_USED_STAMP_INTERVAL_MS, + API_KEY_EXPIRED_CODE, + API_KEY_INACTIVE_CODE, + API_KEY_REVOKED_CODE, + API_KEY_LIFECYCLE_REFUSAL_STATUS, + apiKeyLifecyclePolicySourceOf, + ApiKeyLifecycleRefusalError, + isApiKeyLifecycleRefusalError, + type ApiKeyLifecyclePolicySource, + type ApiKeyLifecycleCode, + type ApiKeyLifecycleRefusal, type GeneratedApiKey, type ApiKeyPrincipal, type ApiKeyAdmission, diff --git a/packages/core/src/security/resolve-authz-context.guest-grants.test.ts b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts index 6d01ab1d62a..2f5058307aa 100644 --- a/packages/core/src/security/resolve-authz-context.guest-grants.test.ts +++ b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts @@ -77,7 +77,7 @@ function fakeEngine(opts: FakeOptions = {}) { } const anonymous = (engine: unknown, extra: Record = {}) => - resolveAuthzContext({ userAuthGates: undefined, ql: engine, headers: new Headers(), getSession: async () => undefined, ...extra }); + resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: engine, headers: new Headers(), getSession: async () => undefined, ...extra }); describe('ADR-0138 D3 — a bound set reaches the guest entry, in the derived organization', () => { it('carries the bound set and the one organization (D3 item 1, D4 item 1)', async () => { diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index 8d1003c187e..dff332b5adf 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -215,6 +215,7 @@ describe('[#11663 L2] acceptance criterion — the configured, VERIFIED account' const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers(), getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), @@ -313,6 +314,7 @@ describe('⭐ [#11663 L2 pin P1] the derivation reads the STORED row, never the const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'impostor@corp.example', email_verified: true })); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers(), getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts index f1923d088d5..0f7bec4412c 100644 --- a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -206,7 +206,7 @@ function makeQl(tables: Record, opts: { epoch?: boolean; middlewa */ async function armedQl(tables: Record): Promise { const ql = makeQl(tables); - await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {} }); + await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {} }); expect(ql.calls.length).toBe(0); expect(ql.middlewareCount()).toBe(1); return ql; @@ -380,6 +380,7 @@ async function resolveBoth(c: CallerClass) { const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: memoQl, headers: c.headers ?? {}, getSession: sessionReadThatResolves(memoQl, c.fixture), @@ -390,6 +391,7 @@ async function resolveBoth(c: CallerClass) { const baselineQl = makeQl(makeTables()); const baseline = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: baselineQl, headers: c.headers ?? {}, getSession: sessionReadOnly(c.fixture), @@ -426,20 +428,20 @@ describe('request-scoped grants memo — the round-trip count', () => { expect(oneResolution).toBe(6); const memoQl = await armedQl(makeTables()); - const withMemo = await resolveAuthzContext({ userAuthGates: undefined, ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); + const withMemo = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); expect(memoQl.calls.length).toBe(oneResolution); expect(multiset(memoQl.calls)).toEqual(multiset(standaloneQl.calls)); // An engine without the write-epoch seam: the memo declines, and the // request makes the two resolutions it made before the memo existed. const noSeamQl = makeQl(makeTables(), { epoch: false }); - const noSeam = await resolveAuthzContext({ userAuthGates: undefined, ql: noSeamQl, headers: {}, getSession: sessionReadThatResolves(noSeamQl, member), nowMs: T0 }); + const noSeam = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: noSeamQl, headers: {}, getSession: sessionReadThatResolves(noSeamQl, member), nowMs: T0 }); expect(noSeamQl.calls.length).toBe(2 * oneResolution); expect(noSeam).toEqual(withMemo); // …and one that cannot register a middleware (no write observer) declines too. const noMiddlewareQl = makeQl(makeTables(), { middleware: false }); - const noMiddleware = await resolveAuthzContext({ userAuthGates: undefined, ql: noMiddlewareQl, headers: {}, getSession: sessionReadThatResolves(noMiddlewareQl, member), nowMs: T0 }); + const noMiddleware = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: noMiddlewareQl, headers: {}, getSession: sessionReadThatResolves(noMiddlewareQl, member), nowMs: T0 }); expect(noMiddlewareQl.calls.length).toBe(2 * oneResolution); expect(noMiddleware).toEqual(withMemo); }); @@ -447,11 +449,11 @@ describe('request-scoped grants memo — the round-trip count', () => { it('the request that registers the engine\'s write observer stores nothing for it; the next one is served', async () => { const ql = makeQl(makeTables()); expect(ql.middlewareCount()).toBe(0); - const first = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + const first = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.middlewareCount()).toBe(1); expect(ql.calls.length).toBe(12); const before = ql.calls.length; - const second = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + const second = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.calls.length - before).toBe(6); expect(ql.middlewareCount()).toBe(1); expect(second).toEqual(first); @@ -476,14 +478,14 @@ describe('request-scoped grants memo — isolation', () => { return payload; }; - const pOwner = resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, owner), ownerRead), nowMs: T0, tenancyPosture: 'isolated' }); - const pMember = resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, member), memberRead), nowMs: T0, tenancyPosture: 'isolated' }); + const pOwner = resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, owner), ownerRead), nowMs: T0, tenancyPosture: 'isolated' }); + const pMember = resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, member), memberRead), nowMs: T0, tenancyPosture: 'isolated' }); await Promise.all([ownerRead.promise, memberRead.promise]); release.resolve(); const [ownerCtx, memberCtx] = await Promise.all([pOwner, pMember]); - const baselineOwner = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(owner), nowMs: T0, tenancyPosture: 'isolated' }); - const baselineMember = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baselineOwner = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(owner), nowMs: T0, tenancyPosture: 'isolated' }); + const baselineMember = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ownerCtx).toEqual(baselineOwner); expect(memberCtx).toEqual(baselineMember); expect(ownerCtx.userId).toBe('u_owner'); @@ -499,14 +501,14 @@ describe('request-scoped grants memo — isolation', () => { const ql = await armedQl(tables); const member = sessionOf('u_member', 'member@x.com', 'org_a'); - const first = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const first = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(first.positions).toContain('auditor'); // Revoked with NO epoch bump and outside the chain: what retires the first // request's resolution is the end of that request, and nothing else. tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); const before = ql.calls.length; - const second = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const second = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(second.positions).not.toContain('auditor'); expect(second.permissions).not.toContain('read_all'); expect(ql.calls.length - before).toBe(6); @@ -521,6 +523,7 @@ describe('request-scoped grants memo — isolation', () => { const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -544,6 +547,7 @@ describe('request-scoped grants memo — isolation', () => { const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: qlA, headers: {}, getSession: sessionReadThatResolves(qlB, member), @@ -552,7 +556,7 @@ describe('request-scoped grants memo — isolation', () => { }); expect(qlB.calls.length).toBe(6); expect(qlA.calls.length).toBe(6); - const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -561,6 +565,7 @@ describe('request-scoped grants memo — isolation', () => { const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -568,6 +573,7 @@ describe('request-scoped grants memo — isolation', () => { // read's resolution serves its step 2 (6 reads), and that scope closes. const inner = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), @@ -582,7 +588,7 @@ describe('request-scoped grants memo — isolation', () => { }); // Inner 8, outer step 2 afresh 8. expect(ql.calls.length).toBe(12); - const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); }); @@ -592,7 +598,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const postWriteBaseline = async (nowMs = T0) => { const after = makeTables(); after.sys_user_position = withoutMembersAuditor(after.sys_user_position); - return resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs, tenancyPosture: 'isolated' }); + return resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs, tenancyPosture: 'isolated' }); }; it('a revocation that bumped the epoch BEFORE the first resolution opened and lands before step 2', async () => { @@ -608,6 +614,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const read = sessionReadThatResolves(ql, member); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -637,7 +644,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re await landing.promise; tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(12); // Not landed yet: a fresh read still sees the row, and so did step 2. expect(ctx.positions).toContain('auditor'); @@ -651,6 +658,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const read = sessionReadThatResolves(ql, member); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -673,6 +681,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -695,7 +704,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re if (!started && object === 'sys_metadata_activation') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } return find(object, opts); }; - await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); await w; expect(ql.calls.length).toBe(12); }); @@ -703,10 +712,10 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re it('a step-2 clock later than the first resolution\'s, inside its validity window, is served', async () => { // temp_role is valid until T0 + 1 day: T0 + 1 hour is inside the window. const ql = await armedQl(makeTables()); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(6); expect(ctx.positions).toContain('temp_role'); - const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -714,16 +723,16 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re // The session read resolves at T0 (temp_role active until T0 + 1 day); // step 2 asks at T0 + 2 days, past that boundary. const ql = await armedQl(makeTables()); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx.positions).not.toContain('temp_role'); expect(ctx.permissions).not.toContain('temp_tools'); expect(ql.calls.length).toBe(12); - const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); // A step-2 clock EARLIER than the resolution it would be served. const ql2 = await armedQl(makeTables()); - await resolveAuthzContext({ userAuthGates: undefined, ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); expect(ql2.calls.length).toBe(12); }); @@ -732,6 +741,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: async () => { @@ -755,9 +765,9 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re return find(object, opts); }; const sink: { payload?: any; grants?: UserAuthzGrants } = {}; - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); expect(sink.grants).toBeUndefined(); - const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); }); @@ -767,7 +777,7 @@ describe('request-scoped grants memo — served values are clones', () => { const ql = await armedQl(makeTables()); const sink: { payload?: any; grants?: UserAuthzGrants } = {}; const member = sessionOf('u_member', 'member@x.com', 'org_a'); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(6); expect(ctx.positions).toEqual(sink.payload.user.positions); expect(ctx.positions).not.toBe(sink.payload.user.positions); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 86a1e023ebf..ccafb7d2e06 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -85,7 +85,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('contributor'); }); @@ -96,7 +96,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); }); @@ -110,7 +110,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('contributor_ps'); expect(ctx.systemPermissions).toContain('cap_x'); }); @@ -123,7 +123,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('platform_admin'); }); @@ -135,7 +135,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'o1' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).not.toContain('platform_admin'); }); @@ -146,12 +146,12 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('ai_seat'); }); it('anonymous (no session, no api key) → empty context', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.userId).toBeUndefined(); expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); @@ -193,7 +193,7 @@ describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', ( sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.email).toBe('ada@x.com'); expect(ctx.permissions).toContain('ai_seat'); expect(ql.counts.sys_user).toBe(1); @@ -721,7 +721,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged }); @@ -733,7 +733,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -748,7 +748,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).toContain('stand_in'); expect(ctx.positions).not.toContain('boundary'); }); @@ -763,7 +763,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.permissions).not.toContain('admin_full_access'); expect(ctx.positions).not.toContain('platform_admin'); }); @@ -775,7 +775,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -806,7 +806,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); // The half that was already correct. expect(ctx.accessible_org_ids).toEqual([]); // The half this card fixes — it used to carry `org_member`. @@ -824,7 +824,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('org_admin'); expect(ctx.accessible_org_ids).toEqual([]); }); @@ -847,6 +847,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { }; const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1', { org: 'o2' }), nowMs: NOW, }); expect(ctx.positions).toContain('org_member'); // the in-window row still grants @@ -865,7 +866,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).toContain('org_owner'); expect(ctx.positions).toContain('org_member'); expect(ctx.accessible_org_ids).toEqual(['o1']); @@ -902,6 +903,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { it('escalation, BEFORE: an ACTIVE owner membership still resolves TENANT_ADMIN', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(orgOwnerPositionTables({})), headers: H(), getSession: session('esc', { org: 'o1' }), nowMs: NOW, }); @@ -913,6 +915,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { it('escalation, AFTER: a LAPSED owner membership resolves MEMBER, not TENANT_ADMIN', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(orgOwnerPositionTables({ valid_until: PAST })), headers: H(), getSession: session('esc', { org: 'o1' }), nowMs: NOW, }); @@ -937,7 +940,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_permission_set: [{ user_id: 'esc2', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'psO', name: 'organization_admin' }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('org_owner'); // the role projection is closed expect(ctx.permissions).toContain('organization_admin'); // the grant row is not expect(ctx.posture).toBe('TENANT_ADMIN'); @@ -952,7 +955,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); // holding an explicit position must NOT cost the baseline anchor expect(ctx.positions).toContain('contributor'); expect(ctx.positions).toContain('everyone'); @@ -960,7 +963,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { it('anonymous resolution never gains `everyone`', async () => { const ql = makeQl({}); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: async () => undefined }); expect(ctx.positions).not.toContain('everyone'); expect(ctx.userId).toBeUndefined(); }); @@ -1032,7 +1035,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { it('resolves the principal × grants → posture matrix', async () => { const actual: Record = {}; for (const [name, fx] of Object.entries(FIXTURES)) { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); actual[name] = ctx.posture; } expect(actual).toEqual(EXPECTED_POSTURE); @@ -1041,7 +1044,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { it('posture is strictly nested: PLATFORM_ADMIN > TENANT_ADMIN > MEMBER', async () => { const rank = async (name: string) => { const fx = FIXTURES[name]; - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); return POSTURE_RANK[ctx.posture!]; }; expect(await rank('platform_admin')).toBeGreaterThan(await rank('tenant_admin')); @@ -1065,12 +1068,12 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { { id: 'psO', name: 'organization_admin' }, ], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('both', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('both', { org: 'o1' }) }); expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); it('anonymous principal carries no posture rung', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.posture).toBeUndefined(); }); }); @@ -1110,7 +1113,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }; - const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1') }); + const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1') }); const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1'); expect([...viaUser.positions].sort()).toEqual([...viaHttp.positions].sort()); expect([...viaUser.permissions].sort()).toEqual([...viaHttp.permissions].sort()); @@ -1191,7 +1194,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { it('adopts the key organization as the request tenant when membership holds', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_a', role: 'member' }])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBe('u1'); expect(ctx.tenantId).toBe('org_a'); expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0); @@ -1209,7 +1212,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('refuses a key whose owner is no longer a member of its organization', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(ctx.tenantId).toBeUndefined(); expect(ctx.permissions).toEqual([]); @@ -1220,14 +1223,14 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { const ql = makeQl(tables([ { user_id: 'u1', organization_id: 'org_a', role: 'member', valid_until: '2000-01-01T00:00:00Z' }, ])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); }); it('the same key under `group` is refused too — the wall is membership-derived there as well', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'group' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'group' }); expect(ctx.userId).toBeUndefined(); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); }); @@ -1239,7 +1242,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('does NOT apply the membership check under `single`', async () => { const ql = makeQl(tables([])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'single' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'single' }); expect(ctx.userId).toBe('u1'); expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0); }); @@ -1260,6 +1263,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: keyHeaders(), getSession: session('u1', { org: 'org_a' }), @@ -1284,7 +1288,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('[#14273] a post-grant refusal answers an envelope with no `authRefusal` — the reason is server-side only', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); @@ -1298,7 +1302,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_required']); @@ -1313,7 +1317,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { return inner.find(object, opts); }, }; - await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); // One read for `accessible_org_ids`, one for the fellow-org peer list that // an ACTIVE tenant already triggered before this change. The membership // assertion adds neither. @@ -1365,6 +1369,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('a DEACTIVATED position stops granting its permission sets', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), getSession: session('u1'), @@ -1379,6 +1384,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // so a name left standing would resolve the same grant one layer down. const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), getSession: session('u1'), @@ -1391,6 +1397,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('an ACTIVE position still grants (the flag is not a blanket revocation)', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(true)), headers: H(), getSession: session('u1'), @@ -1403,6 +1410,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('an ABSENT ledger row grants — deployed positions are not mass-revoked', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(undefined)), headers: H(), getSession: session('u1'), @@ -1414,6 +1422,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('the 0/1 storage shape deactivates too — what the primary driver returns', async () => { const off = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(0)), headers: H(), getSession: session('u1'), @@ -1421,6 +1430,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(off.permissions).not.toContain('contributor_ps'); const on = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(withActive(1)), headers: H(), getSession: session('u1'), @@ -1438,7 +1448,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // there is no flag to read, so nothing may be inferred from its absence. sys_metadata_activation: [off('position', 'something_else')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); }); @@ -1465,7 +1475,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { id: 'ps2', name: 'reviewer_ps' }, ], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.permissions).toContain('reviewer_ps'); expect(ctx.positions).not.toContain('contributor'); @@ -1488,7 +1498,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'crm_full')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.systemPermissions).not.toContain('cap_x'); expect(ctx.tabPermissions?.crm).toBeUndefined(); @@ -1507,7 +1517,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'admin_full_access')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); // Dropped BEFORE the derivation, so the posture cannot be read off a set // that no longer grants — the whole point of filtering at §6b rather than // after it. @@ -1532,7 +1542,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.permissions).toContain('crm_read'); }); @@ -1550,7 +1560,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], sys_metadata_activation: [off('position', 'contributor')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('crm_full'); expect(ctx.positions).not.toContain('contributor'); }); @@ -1590,6 +1600,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // (`convertDeactivatedCatalogRows`); the resolver never falls back to them. const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl({ ...withActive(undefined), sys_position: [{ id: 'r1', name: 'contributor', active: false }], @@ -1744,6 +1755,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_beta'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1755,6 +1767,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBeUndefined(); @@ -1772,6 +1785,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1793,6 +1807,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.org_user_ids).toEqual(['u_ex']); @@ -1803,6 +1818,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'group', }); expect(ctx.userId).toBe('u_ex'); @@ -1819,6 +1835,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'single', }); expect(ctx.tenantId).toBe('org_alpha'); @@ -1834,6 +1851,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), }); expect(ctx.tenantId).toBe('org_alpha'); @@ -1844,6 +1862,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', null), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1858,6 +1877,7 @@ describe('[#15409] a session organization claim that no membership backs', () => }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1874,6 +1894,7 @@ describe('[#15409] a session organization claim that no membership backs', () => const ql = makeQl(tables()); await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha', 'ses_victim'), tenancyPosture: 'isolated', }); const lines = dropLines(); @@ -1900,6 +1921,7 @@ describe('[#15409] a session organization claim that no membership backs', () => }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: { 'x-api-key': raw }, tenancyPosture: 'isolated', }); expect(ctx.userId).toBeUndefined(); @@ -2110,6 +2132,7 @@ describe('[#20515] with no active organization, only global grants apply', () => it(`${userId}: the claim is dropped AND the left organization's grant no longer applies`, async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha(userId), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe(userId); @@ -2122,6 +2145,7 @@ describe('[#20515] with no active organization, only global grants apply', () => it('CONTROL · a current member with the same grant and that organization active keeps it', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_member'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBe(ALPHA); @@ -2132,6 +2156,7 @@ describe('[#20515] with no active organization, only global grants apply', () => it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBeUndefined(); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 4d65e6b395b..e697acdf1f3 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -64,8 +64,8 @@ import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { isValueDomainMember } from '@objectstack/spec/shared'; -import { resolveApiKeyAdmission } from './api-key.js'; -import type { ApiKeyRefusalReason } from './api-key.js'; +import { ApiKeyLifecycleRefusalError, resolveApiKeyAdmission } from './api-key.js'; +import type { ApiKeyLifecyclePolicySource, ApiKeyRefusalReason } from './api-key.js'; import { normalizeAuthGate, type AuthGate, type UserAuthGateSource } from './auth-gate.js'; import { isGrantActive, nextGrantValidityBoundary } from './grant-validity.js'; import { openUserGrantsCache } from './resolve-user-grants-cache.js'; @@ -207,6 +207,21 @@ export interface ResolveAuthzInput { * could reach one. */ userAuthGates: UserAuthGateSource | undefined; + /** + * [#23367, ruling A] The auth service's API-key lifecycle policy, bound by + * `apiKeyLifecyclePolicySourceOf(authService)`. The key-admission seam reads + * it for the inactivity window and for the landing that anchors keys with + * no recorded use or no expiry; a key its own state refuses (revoked, + * expired, inactive) raises {@link ApiKeyLifecycleRefusalError} out of this + * resolver. + * + * REQUIRED beside {@link userAuthGates}, for the #23455 reason, and with the + * same rule for `undefined`: pass the bound source wherever the kernel's + * `auth` service is reachable (it answers `undefined` itself for a service + * with no policy), and a bare `undefined` only where no auth service exists + * at all — ⛔ never to quiet the compiler at a seam that could reach one. + */ + apiKeyLifecycle: ApiKeyLifecyclePolicySource | undefined; } /** @@ -236,9 +251,10 @@ export interface ResolveAuthzInput { * * ## Volume * - * Bounded by real credentials, not by traffic: an unknown, revoked, expired or - * absent key resolves to `outcome: 'none'` and is never a refusal, so a key - * scanner produces no lines here. One line per refused request, deliberately — + * Bounded by real credentials, not by traffic: an unknown or absent key + * resolves to `outcome: 'none'` and is never a refusal, so a key scanner + * produces no lines here (a revoked, expired or inactive key is a real one, and + * has its own line, {@link warnApiKeyLifecycleRefusal}). One line per refused request, deliberately — * a rate limiter would hide exactly the burst (an automation still running on a * key whose membership ended) that the operator most needs to see. * @@ -261,6 +277,21 @@ function warnApiKeyRefusal(details: { ); } +/** + * [#23367, ruling A] The operator's line for an API-key lifecycle refusal — + * the sibling of {@link warnApiKeyRefusal}, for the faces whose door answers + * the generic 401 instead of relaying the branded refusal: there, this line + * is the one place the reason is observable. Same content rules: the row id, + * the owner and the code; ⛔ never the raw key or its hash. One line per + * refused request, bounded by keys that really existed. + */ +function warnApiKeyLifecycleRefusal(code: string, keyId?: string, userId?: string): void { + console.warn( + `[security] API key refused (${code}): key=${keyId ?? ''} principal=${userId ?? ''}. ` + + 'A face that relays the refusal answered 403 with this code; any other face answered its generic 401.', + ); +} + /** * [#15409 — maintainer ruling 2026-09-05, option B] Say a DROPPED session * organization claim out loud, on the SERVER side, where the drop is decided — @@ -457,7 +488,18 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise('auth'); + } catch { + authService = undefined; + } + return apiKeyLifecyclePolicySourceOf(authService); +} + /** The status the REST and dispatcher faces answer a held principal with. */ const AUTH_GATE_REFUSAL_STATUS = 403; @@ -186,8 +207,10 @@ function stdioAuthGateRefusal(gate: AuthGate): Error & { code: string; status: n * resolved no localization at all. The assembler's field set is CLOSED, so the * NEXT `ExecutionContext` field cannot miss this transport the way those did. * - * Fail-closed: returns `undefined` for an unknown / revoked / expired / - * owner-less key. That contract is not re-implemented here — it IS + * Fail-closed: returns `undefined` for an unknown / owner-less key; a key its + * own lifecycle refuses (revoked / expired / inactive, #23367) raises the + * branded `ApiKeyLifecycleRefusalError` — `403`, its code and located message, + * the thrown-envelope shape this door's refusals take. That contract is not re-implemented here — it IS * `assembleExecutionContext`'s default entry (`!authz.userId ⇒ undefined`), * which is why the explicit guard this function used to carry is gone rather * than merely moved. Re-run per read, so revocation of a key takes effect on @@ -227,12 +250,14 @@ async function resolveStdioExecutionContext( localization: EntryLocalization | undefined, tenancyPosture: TenancyPosture | undefined, userAuthGates: UserAuthGateSource | undefined, + apiKeyLifecycle: ApiKeyLifecyclePolicySource | undefined, ): Promise { const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey }, tenancyPosture, userAuthGates, + apiKeyLifecycle, }); return assembleExecutionContext({ authz, @@ -489,13 +514,22 @@ export class MCPServerPlugin implements Plugin { // boot rather than starting a door whose admission was never decided. // [#23455] …and so is the owner's posture: a key whose owner holds an // `account`-scope posture resolves here as a HELD identity. + // [#23367] …and so is the key's own lifecycle: a revoked, expired or + // inactive key is no credential this transport can run under, so it + // takes the refusal-to-start, naming its code and date. const initial = await resolveStdioExecutionContext( ql, apiKey, undefined, await resolveStdioTenancyPosture(ctx), resolveStdioUserAuthGates(ctx), - ); + resolveStdioApiKeyLifecycle(ctx), + ).catch((err: unknown) => { + if (!isApiKeyLifecycleRefusalError(err)) throw err; + throw new Error( + `[MCP] OS_MCP_STDIO_API_KEY is refused (${err.code}): ${err.message} Refusing to start stdio (ADR-0101).`, + ); + }); if (!initial) { throw new Error( '[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). ' + @@ -652,12 +686,15 @@ export class MCPServerPlugin implements Plugin { // next call, and one that clears serves the next call — the refusal is // not a revocation, so nothing is written and nothing needs a restart. const resolvePrincipal = async (): Promise => { + // [#23367] A key its own lifecycle refuses raises its branded + // `403` envelope out of this call, as the held refusal below does. const ec = await resolveStdioExecutionContext( scopedQl, apiKey, await localizationForRead(), await resolveStdioTenancyPosture(ctx), resolveStdioUserAuthGates(ctx), + resolveStdioApiKeyLifecycle(ctx), ); if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)'); // No request path: no stdio call is a remediation or bootstrap route, diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 5525e1dfd5e..74ed687a8a8 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1003,7 +1003,7 @@ export const enObjects: NonNullable = { }, last_used_at: { label: "Last Used At", - help: "Automatically updated on each API call" + help: "Recorded when the key is used, at most once an hour. A key with no recorded use for the inactivity window (90 days by default) is refused until replaced" }, revoked: { label: "Revoked" 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 bfa152b1993..109f159cd48 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 @@ -1003,7 +1003,7 @@ export const esESObjects: NonNullable = { }, last_used_at: { label: "Último uso el", - help: "Se actualiza automáticamente en cada llamada a la API." + help: "Se registra cuando se usa la clave, como máximo una vez por hora. Una clave sin uso registrado durante el plazo de inactividad (90 días por defecto) se rechaza hasta que se sustituya." }, revoked: { label: "Revocado" 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 0a0a5a79ea0..5e702edb832 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 @@ -1003,7 +1003,7 @@ export const jaJPObjects: NonNullable = { }, last_used_at: { label: "最終使用日時", - help: "API 呼び出しのたびに自動更新されます" + help: "キーが使われたときに記録されます(最大 1 時間に 1 回)。非アクティブ期間(既定 90 日)内に使用記録のないキーは、新しいキーに置き換えるまで拒否されます" }, revoked: { label: "失効済み" 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 c41a7bd1e82..d68d0a315a2 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 @@ -1003,7 +1003,7 @@ export const zhCNObjects: NonNullable = { }, last_used_at: { label: "最近使用时间", - help: "每次 API 调用时自动更新" + help: "密钥被使用时记录,每小时至多一次。在不活动期限内(默认 90 天)没有使用记录的密钥会被拒绝,需更换新密钥" }, revoked: { label: "已撤销" diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 811e7ad35c7..39f6ec494fc 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -264,11 +264,17 @@ export const SysApiKey = ObjectSchema.create({ group: 'Lifecycle', }), + // [#23367, ruling A] The description used to read "Automatically updated + // on each API call" while nothing wrote the column at all. Its one writer + // is now the shared key-admission seam (`resolveApiKeyAdmission`, + // `@objectstack/core`), throttled to one write per key per hour; the + // inactivity expiry reads it. last_used_at: Field.datetime({ label: 'Last Used At', required: false, readonly: true, - description: 'Automatically updated on each API call', + description: + 'Recorded when the key is used, at most once an hour. A key with no recorded use for the inactivity window (90 days by default) is refused until replaced', group: 'Lifecycle', }), diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 30555c15d1e..2d426822864 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -43,9 +43,15 @@ import type { IDataEngine } from '@objectstack/core'; // `auth-manager` used to re-derive that standing itself, in two spellings // that had drifted from the declared authority and from each other; both // now ask the authority. Nothing in this file reads the grant tables. -import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/core'; +import { + API_KEY_DEFAULT_EXPIRY_DAYS, + API_KEY_INACTIVITY_DAYS, + API_KEY_MAX_EXPIRY_DAYS, + hasPlatformAdminStanding, + resolveUserAuthzGrants, +} from '@objectstack/core'; import type { AuthGate } from '@objectstack/core'; -import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; +import type { ApiKeyLifecyclePolicy, IEmailService, ISmsService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, resolveTenancyPosture, @@ -947,6 +953,27 @@ export interface AuthManagerOptions extends Partial { /** Days a user may defer MFA enrollment before the hard block. Default 7. */ mfaGracePeriodDays?: number; + /** + * [#23367, ruling A] The API-key lifecycle policy (`resolveApiKeyLifecyclePolicy`): + * the life of a key signed with no `expires_at` (default 90 days), the + * ceiling on a signed key's life (default 365), and the inactivity window + * after which an unused key is refused (default 90; 0 = off). Bound from the + * `auth` settings keys `api_key_*` by `bindAuthSettings`. + */ + apiKeyDefaultExpiryDays?: number; + apiKeyMaxExpiryDays?: number; + apiKeyInactivityDays?: number; + + /** + * [#23367] When this deployment started enforcing the API-key lifecycle + * policy (ISO 8601) — the anchor of the inactivity clock for keys with no + * recorded use, and of the one-year grace for keys signed with no expiry. + * Recorded ONCE, in the `auth` setting `api_key_policy_started_at`, by + * `bindAuthSettings` on the first boot that runs the policy, so it survives + * restarts and every node reads the same instant. ⛔ Never the process start. + */ + apiKeyPolicyStartedAt?: string; + /** * ADR-0069 D4 — session controls. Enforced in `customSession` (idle/absolute) * and the sign-in hook (concurrent). 0 = off for each. A revoked session is @@ -8192,6 +8219,33 @@ export class AuthManager { * error, answering `[]` — a transient hiccup must never lock a compliant user * out, through either credential. */ + /** + * [#23367, ruling A] The API-key lifecycle policy in force + * (`IAuthService.resolveApiKeyLifecyclePolicy`), every number defaulted to + * the ruling's: 90-day default expiry, one-year ceiling, 90-day inactivity. + * Read by the signing door (`POST /keys`) and the key-admission seam + * (`resolveApiKeyAdmission`) alike, so the two can never disagree. + */ + public resolveApiKeyLifecyclePolicy(): ApiKeyLifecyclePolicy { + const days = (value: unknown, fallback: number, min: number): number => { + const n = Math.floor(Number(value)); + return Number.isFinite(n) && n >= min ? n : fallback; + }; + const startedAt = typeof this.config.apiKeyPolicyStartedAt === 'string' + && !Number.isNaN(Date.parse(this.config.apiKeyPolicyStartedAt)) + ? this.config.apiKeyPolicyStartedAt + : undefined; + const maxExpiryDays = days(this.config.apiKeyMaxExpiryDays, API_KEY_MAX_EXPIRY_DAYS, 1); + return { + // The ceiling rules the default too: a default longer than the ceiling + // would mint the very key the ceiling refuses to sign. + defaultExpiryDays: Math.min(maxExpiryDays, days(this.config.apiKeyDefaultExpiryDays, API_KEY_DEFAULT_EXPIRY_DAYS, 1)), + maxExpiryDays, + inactivityDays: days(this.config.apiKeyInactivityDays, API_KEY_INACTIVITY_DAYS, 0), + ...(startedAt ? { startedAt } : {}), + }; + } + public async resolveUserAuthGates( userId: string, options: { organizationId?: string } = {}, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index f80ed382aae..ff1f5315e4d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -988,6 +988,76 @@ describe('AuthPlugin', () => { expect((manager as any).config.mfaGracePeriodDays).toBe(90); }); + // [#23367, ruling A] The API-key lifecycle policy is auth configuration: + // the settings keys reach the one policy both readers ask. + describe('[#23367] API-key lifecycle policy', () => { + it('answers the ruling\'s defaults when nothing is set, with no landing yet', async () => { + const { manager } = await bootWithAuthSettings({}); + expect(manager.resolveApiKeyLifecyclePolicy()).toEqual({ + defaultExpiryDays: 90, + maxExpiryDays: 365, + inactivityDays: 90, + }); + }); + + it('binds each api_key_* setting, so a changed setting changes the policy', async () => { + const { manager } = await bootWithAuthSettings({ + api_key_default_expiry_days: { value: 30, source: 'global' }, + api_key_max_expiry_days: { value: 180, source: 'global' }, + api_key_inactivity_days: { value: 0, source: 'global' }, + api_key_policy_started_at: { value: '2026-10-14T00:00:00Z', source: 'global' }, + }); + expect(manager.resolveApiKeyLifecyclePolicy()).toEqual({ + defaultExpiryDays: 30, + maxExpiryDays: 180, + inactivityDays: 0, + startedAt: '2026-10-14T00:00:00.000Z', + }); + }); + + it('never lets the default outlive the ceiling', async () => { + const { manager } = await bootWithAuthSettings({ + api_key_default_expiry_days: { value: 400, source: 'global' }, + api_key_max_expiry_days: { value: 365, source: 'global' }, + }); + expect(manager.resolveApiKeyLifecyclePolicy().defaultExpiryDays).toBe(365); + }); + + it('records the landing once, on kernel:bootstrapped, and never over a stored one', async () => { + const writes: Array<[string, string, unknown]> = []; + const boot = async (stored: { value?: unknown; source: string }) => { + hookCapture = createHookCapture(); + const settings = { + getNamespace: vi.fn(async () => ({ values: { api_key_policy_started_at: stored } })), + get: vi.fn(async () => stored), + set: vi.fn(async (ns: string, key: string, value: unknown) => { writes.push([ns, key, value]); }), + subscribe: vi.fn(), + }; + mockContext.hook = hookCapture.hookFn; + mockContext.getService = vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return settings; + return undefined; + }) as unknown as typeof mockContext.getService; + authPlugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }); + await authPlugin.init(mockContext); + await authPlugin.start(mockContext); + await hookCapture.trigger('kernel:ready'); + await hookCapture.trigger('kernel:bootstrapped'); + }; + + await boot({ source: 'default' }); + expect(writes).toHaveLength(1); + expect(writes[0]![0]).toBe('auth'); + expect(writes[0]![1]).toBe('api_key_policy_started_at'); + expect(Number.isNaN(Date.parse(String(writes[0]![2])))).toBe(false); + + writes.length = 0; + await boot({ value: '2026-10-14T00:00:00.000Z', source: 'global' }); + expect(writes).toEqual([]); + }); + }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { const { manager } = await bootWithAuthSettings({ lockout_threshold: { value: 5, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2c4cf3f1fbb..927ce2a92ea 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -129,8 +129,22 @@ interface SettingsReadSurface { namespace: string | undefined, handler: SettingsChangeHandler, ): SettingsUnsubscribe; + /** + * [#23367] Persist one key — used ONCE, to record when this deployment + * started enforcing the API-key lifecycle policy + * ({@link AuthPlugin.recordApiKeyPolicyStart}). Optional, like `subscribe`. + */ + set?( + namespace: string, + key: string, + value: unknown, + ctx?: Record, + ): Promise; } +/** [#23367] The auth setting that records when the API-key lifecycle policy took effect. */ +const API_KEY_POLICY_STARTED_AT_KEY = 'api_key_policy_started_at'; + /** * The `protocol` slot's metadata reader, as the user-import route uses it. * @@ -831,6 +845,18 @@ export class AuthPlugin implements Plugin { scheduleLegacySsoSecretMigration(ql, ctx.logger); }); + // [#23367, ruling A] Record when this deployment started enforcing the + // API-key lifecycle policy — ONCE, in the `auth` settings, so the anchor + // survives restarts and every node reads the same instant (⛔ never the + // process start). On `kernel:bootstrapped`, strictly after every + // `kernel:ready` handler, because the settings engine binds from one of + // them and a write inside that window is refused + // (`SETTINGS_ENGINE_NOT_BOUND`). Registered independently of + // `registerRoutes`: the policy is judged wherever keys are admitted. + ctx.hook('kernel:bootstrapped', async () => { + await this.recordApiKeyPolicyStart(ctx); + }); + // Setup App translations are now loaded by `PlatformObjectsPlugin` // (in @objectstack/platform-objects). Translation bundles belong with // the package that defines them; auth-plugin no longer piggy-backs on @@ -1633,6 +1659,57 @@ export class AuthPlugin implements Plugin { return this.authSettingsBinding; } + /** + * [#23367, ruling A] Record the API-key lifecycle policy's landing — the + * anchor of the inactivity clock for keys with no recorded use, and of the + * one-year grace for keys signed with no expiry — when no value is stored + * yet. One place: the `auth` setting `api_key_policy_started_at`, which + * `bindAuthSettings` binds into the manager (the write's change event + * re-runs that pass, so the anchor is live as soon as it lands). + * + * ⛔ Never overwrites a stored value: the anchor is written once, by the + * first boot that runs the policy, and only an operator moves it. + * + * A deployment with no settings service has nowhere to keep the anchor, so + * the policy runs without one: keys signed with no expiry get no grace date + * and keys with no recorded use are not judged idle until they record one. + * Functional, and said once at `warn`. A write that FAILS is the durability + * half — the deployment believes the policy is armed while the anchor never + * landed — so it is said at `error`, with the remedy. + */ + private async recordApiKeyPolicyStart(ctx: PluginContext): Promise { + if (!this.authManager) return; + let settings: SettingsReadSurface | undefined; + try { + settings = ctx.getService('settings'); + } catch { + settings = undefined; + } + if (!settings || typeof settings.get !== 'function' || typeof settings.set !== 'function') { + ctx.logger.warn( + '[auth] API-key lifecycle policy: no settings service, so the date the policy took effect cannot be ' + + 'recorded. Keys created with no expiry get no one-year grace date, and keys with no recorded use are ' + + 'not judged inactive until they record one. Register the settings service to record it.', + ); + return; + } + try { + await this.ensureAuthSettingsBound(ctx); + const stored = await settings.get('auth', API_KEY_POLICY_STARTED_AT_KEY, {}); + const explicit = stored && (stored.source ?? 'default') !== 'default'; + if (explicit && typeof stored?.value === 'string' && stored.value.trim()) return; + await settings.set('auth', API_KEY_POLICY_STARTED_AT_KEY, new Date().toISOString(), {}); + } catch (err: any) { + ctx.logger.error( + '[auth] API-key lifecycle policy: the date the policy took effect was NOT recorded ' + + `(auth.${API_KEY_POLICY_STARTED_AT_KEY}: ${String(err?.message ?? err)}). Keys created with no expiry ` + + 'stay without their one-year grace date, and keys with no recorded use are not judged inactive, while ' + + 'the rest of the policy runs as configured. Fix: restore the settings store and restart, or set ' + + `auth.${API_KEY_POLICY_STARTED_AT_KEY} (UTC, ISO 8601) in the settings console.`, + ); + } + } + /** * Bind the small open-source auth settings namespace to better-auth config. * @@ -1834,6 +1911,32 @@ export class AuthPlugin implements Plugin { if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n); } + // [#23367, ruling A] The API-key lifecycle policy. Only explicit values + // apply, as for every key here: an unset key leaves the manager's own + // default, which is the same number the manifest shows (90 / 365 / 90). + // The inactivity window takes 0 (off), so it uses a non-negative reader. + if (isExplicit('api_key_default_expiry_days')) { + const n = asPositiveInt(values.api_key_default_expiry_days); + if (n !== undefined) patch.apiKeyDefaultExpiryDays = Math.min(3650, n); + } + if (isExplicit('api_key_max_expiry_days')) { + const n = asPositiveInt(values.api_key_max_expiry_days); + if (n !== undefined) patch.apiKeyMaxExpiryDays = Math.min(3650, n); + } + if (isExplicit('api_key_inactivity_days')) { + const n = Math.floor(Number(values.api_key_inactivity_days)); + if (Number.isFinite(n) && n >= 0) patch.apiKeyInactivityDays = Math.min(3650, n); + } + // The landing anchor, once recorded (`recordApiKeyPolicyStart` writes it + // on the first boot that runs the policy). A value that does not parse + // as an instant is ignored: the anchor keeps its standing value rather + // than moving every legacy key's grace to a date nobody wrote. + if (isExplicit(API_KEY_POLICY_STARTED_AT_KEY)) { + const raw = asTrimmedString(values[API_KEY_POLICY_STARTED_AT_KEY]); + const ms = raw ? Date.parse(raw) : Number.NaN; + if (!Number.isNaN(ms)) patch.apiKeyPolicyStartedAt = new Date(ms).toISOString(); + } + // Session lifetime — days → seconds for better-auth's `session` // (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold). const session: { expiresIn?: number; updateAge?: number } = {}; diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index 0457e616291..436408251f7 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -235,6 +235,7 @@ async function resolveRequest(a: Arranged, bearer: string | null, tenancyPosture return a.probe.measure(() => resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: a.engine, headers, getSession: (h: any) => auth.api.getSession({ headers: h }), diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 4b8e759b88d..be90935ad5b 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -482,6 +482,7 @@ async function bootPrincipal(source: PostureSource) { const enforced = async (userId: string) => { const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: engine, headers: {}, getSession: async () => ({ diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index 4858659b12b..f88cd400b81 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -205,6 +205,7 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { const enforced = async (userId: string) => { const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: engine, headers: {}, getSession: async () => ({ diff --git a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts index 376659dbf29..c0329380be3 100644 --- a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts +++ b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts @@ -125,8 +125,10 @@ export async function bootRequestContext( const authz = await resolveAuthzContext({ // [#23455] This kit composes no auth service and presents no API key, so - // no owner posture can be asked: `undefined`, passed on purpose. + // no owner posture can be asked: `undefined`, passed on purpose. [#23367] + // The same holds for the API-key lifecycle policy. userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers(), // [#15349] The posture a transport supplies. Threaded rather than omitted diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index a5a0fefbb62..de9f95eecaa 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -18,6 +18,9 @@ import { // for the shared key-admission seam; the posture the resolver decides rides // the envelope the share-link door reads (`ExecutionContext.authGate`). userAuthGateSourceOf, + // [#23367] The API-key lifecycle policy source, and its branded refusal. + apiKeyLifecyclePolicySourceOf, + isApiKeyLifecycleRefusalError, } from '@objectstack/core'; import type { EngineMiddleware, OperationContext } from '@objectstack/objectql'; import type { @@ -968,6 +971,8 @@ export class SharingServicePlugin implements Plugin { getSession, tenancyPosture, userAuthGates: userAuthGateSourceOf(authService), + // [#23367] The API-key lifecycle policy, from the same service. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); // [ADR-0069 · #23287] The envelope carries the posture the // shared resolver decided for whichever credential admitted @@ -988,6 +993,11 @@ export class SharingServicePlugin implements Plugin { // answer a genuine anonymous caller gets. Re-raised so the // outage is not laundered into an authentication verdict. if (isAuthzStoreUnavailableError(err)) throw err; + // [#23367, ruling A] A key its own lifecycle refused (revoked, + // expired, inactive) is re-raised too: the family's door answers + // a throw that declares its status with that status and code, so + // the caller is told the key's state and date, not a bare 401. + if (isApiKeyLifecycleRefusalError(err)) throw err; return {}; // anonymous → authed routes 401 } }; diff --git a/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts b/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts index 014367f3fed..908ac8c50e3 100644 --- a/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts @@ -65,6 +65,13 @@ describe('#7728: sys_api_key.key (hash) never serializes on the generic read pat const keyStillAuthenticates = async (raw: string): Promise => { const res = await stack.api('/data/sys_api_key', { headers: { 'x-api-key': raw } }); if (res.status === 200) return true; + // [#23367, ruling A] A revoked key is refused with its own located code + // (`403 API_KEY_REVOKED`); an unrecognised one with the generic 401. + if (res.status === 403) { + const body: any = await res.json(); + expect(body?.error?.code).toBe('API_KEY_REVOKED'); + return false; + } expect(res.status).toBe(401); return false; }; diff --git a/packages/qa/dogfood/test/api-key-lifecycle.dogfood.test.ts b/packages/qa/dogfood/test/api-key-lifecycle.dogfood.test.ts new file mode 100644 index 00000000000..e75f15abeb5 --- /dev/null +++ b/packages/qa/dogfood/test/api-key-lifecycle.dogfood.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23367, ruling A] The API-key lifecycle policy, end to end on the showcase + * stack: the real engine, the real settings service, the real doors. + * + * What a unit double cannot prove, and this does: + * - the policy's landing is RECORDED in the `auth` settings on boot + * (`api_key_policy_started_at`), so the anchor survives a restart; + * - a signing with no `expires_at` gets the 90-day default, and one past the + * one-year ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`; + * - one admitted call really WRITES `last_used_at` through the real engine — + * the column is readonly and identity-managed, and the stamp's system write + * must land anyway; + * - a key that existed with no expiry gets its grace stamped onto + * `expires_at` (the landing plus 365 days) the first time it is used. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { hashApiKey } from '@objectstack/core'; +import { bootShowcase } from './showcase-boot.js'; + +const DAY = 86_400_000; + +describe('#23367: the API-key lifecycle policy on a real stack', () => { + let stack: VerifyStack; + let token: string; + + const readKey = async (id: string) => { + const res = await stack.apiAs(token, 'GET', `/data/sys_api_key/${id}`); + expect(res.status).toBe(200); + const body: any = await res.json(); + return body.record ?? body.data ?? {}; + }; + + beforeAll(async () => { + stack = await bootShowcase({}); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('records when the policy took effect, once, in the auth settings', async () => { + const settings = await stack.kernel.getServiceAsync('settings'); + const started = await settings.get('auth', 'api_key_policy_started_at', {}); + expect(started.source).not.toBe('default'); + expect(Number.isNaN(Date.parse(String(started.value)))).toBe(false); + }); + + it('signs with the 90-day default, refuses a date past the ceiling, and stamps the first use', async () => { + const before = Date.now(); + const minted = await stack.apiAs(token, 'POST', '/keys', { name: 'lifecycle-default' }); + expect(minted.status).toBe(201); + const data: any = (await minted.json()).data; + const expiresMs = Date.parse(String(data.expires_at)); + expect(expiresMs).toBeGreaterThanOrEqual(before + 90 * DAY - 60_000); + expect(expiresMs).toBeLessThanOrEqual(Date.now() + 90 * DAY + 60_000); + + const tooFar = await stack.apiAs(token, 'POST', '/keys', { + name: 'lifecycle-too-far', + expires_at: new Date(Date.now() + 400 * DAY).toISOString(), + }); + expect(tooFar.status).toBe(400); + expect(((await tooFar.json()) as any)?.error?.code).toBe('API_KEY_EXPIRY_TOO_LONG'); + + expect((await readKey(String(data.id))).last_used_at ?? null).toBeNull(); + const used = await stack.api('/data/sys_api_key', { headers: { 'x-api-key': String(data.key) } }); + expect(used.status).toBe(200); + const stamped = await readKey(String(data.id)); + expect(Number.isNaN(Date.parse(String(stamped.last_used_at)))).toBe(false); + }); + + it('gives a key that existed with no expiry its grace, stamped on first use', async () => { + const settings = await stack.kernel.getServiceAsync('settings'); + const startedAt = Date.parse(String((await settings.get('auth', 'api_key_policy_started_at', {})).value)); + + // A key from before the policy: inserted the way a pre-policy mint left it, + // with no `expires_at`, owned by the signed-in admin. + const probe = await stack.apiAs(token, 'POST', '/keys', { name: 'lifecycle-owner-probe' }); + expect(probe.status).toBe(201); + const userId = String((await readKey(String(((await probe.json()) as any).data.id))).user_id); + const raw = 'osk_lifecycle_legacy_key_dogfood'; + const engine = await stack.kernel.getServiceAsync('objectql'); + const inserted: any = await engine.insert('sys_api_key', { + name: 'legacy', key: hashApiKey(raw), prefix: raw.slice(0, 12), user_id: userId, revoked: false, + }, { context: { isSystem: true } }); + const id = String(inserted?.id ?? inserted?.[0]?.id); + + const used = await stack.api('/data/sys_api_key', { headers: { 'x-api-key': raw } }); + expect(used.status).toBe(200); + const row = await readKey(id); + // The grace is the landing plus a year, wherever the key was first seen. + expect(Math.abs(Date.parse(String(row.expires_at)) - (startedAt + 365 * DAY))).toBeLessThan(1000); + }); +}); diff --git a/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts b/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts index f4956023397..72c043a8805 100644 --- a/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts @@ -93,9 +93,17 @@ describe('#8053: a member revokes their OWN sys_api_key', () => { const keyStillAuthenticates = async (raw: string): Promise => { const res = await stack.api('/data/showcase_task?$top=1', { headers: { 'x-api-key': raw } }); if (res.status === 200) return true; - // 401 = credential rejected. Anything else (e.g. 403) would mean the key - // WAS accepted and something later refused — a different fact entirely. - expect(res.status, `revoked-key probe should be 401, got ${res.status}`).toBe(401); + // [#23367, ruling A] A revoked key is refused with its own located code, + // `403 API_KEY_REVOKED` — the credential recognised and its state refusing + // it. Any OTHER 403 would mean the key WAS accepted and something later + // refused — a different fact entirely — so the code decides, not the + // status. An unrecognised key keeps the generic 401. + if (res.status === 403) { + const body: any = await res.json(); + expect(body?.error?.code, 'a 403 on the key probe must be the key\'s own revocation').toBe('API_KEY_REVOKED'); + return false; + } + expect(res.status, `revoked-key probe should be 401 or 403 API_KEY_REVOKED, got ${res.status}`).toBe(401); return false; }; diff --git a/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts b/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts index 583eb289040..ce416e53771 100644 --- a/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts @@ -68,6 +68,15 @@ describe('#7727: sys_api_key revoke/restore through the declared product route', const keyStillAuthenticates = async (raw: string): Promise => { const res = await stack.api('/data/sys_api_key', { headers: { 'x-api-key': raw } }); if (res.status === 200) return true; + // [#23367, ruling A] A revoked key is refused with its own located code, + // `403 API_KEY_REVOKED`; an unrecognised one with the generic 401. A 403 + // carrying any other code would be the key accepted and something later + // refused — a different fact, so the code is asserted, not the status. + if (res.status === 403) { + const body: any = await res.json(); + expect(body?.error?.code).toBe('API_KEY_REVOKED'); + return false; + } expect(res.status).toBe(401); return false; }; diff --git a/packages/qa/dogfood/test/armed.ts b/packages/qa/dogfood/test/armed.ts index bb6fb83c7f3..08b5626e0d7 100644 --- a/packages/qa/dogfood/test/armed.ts +++ b/packages/qa/dogfood/test/armed.ts @@ -79,7 +79,7 @@ // disarm and #8049's `passwordHistoryCount: 0` disarm. A guard for vacuous // assertions that was never shown to fail would be the joke it exists to stop. -import { resolveAuthzContext, userAuthGateSourceOf } from '@objectstack/core'; +import { apiKeyLifecyclePolicySourceOf, resolveAuthzContext, userAuthGateSourceOf } from '@objectstack/core'; import type { VerifyStack } from '@objectstack/verify'; /** The system read context every probe uses — probes observe, never mutate. */ @@ -219,6 +219,8 @@ export async function resolveAuthzFor(stack: VerifyStack, token: string): Promis // [#23455] The owner-posture source REST names, from the same `auth` // service: the key-admission seam asks it for an API key's owner. userAuthGates: userAuthGateSourceOf(authService), + // [#23367] And the lifecycle policy, from the same service, beside it. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); return { userId: ctx?.userId, diff --git a/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts b/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts index fa3205baca7..f87faba43f5 100644 --- a/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts @@ -66,6 +66,7 @@ describe('sys_attachment reads inherit parent RLS — the COUNT too (#9483)', () const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts index 5057852daa5..05843a696d8 100644 --- a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts @@ -83,6 +83,7 @@ describe('sys_attachment delete gate under an unscoped multi-delete (#9483)', () if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), @@ -278,6 +279,7 @@ describe('sys_attachment unscoped multi-delete is refused on its SHAPE, not on e if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 2795e9badb9..c53721fb665 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -159,6 +159,7 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren const api = auth?.api ?? (typeof auth?.getApi === 'function' ? await auth.getApi() : undefined); const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${token[who]}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index 721d76295ef..89352758eb3 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -193,6 +193,7 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 4cf0389930f..eedf1ae1a05 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -150,6 +150,7 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts index e455a1c58ff..ba1c876bb0b 100644 --- a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts @@ -136,6 +136,7 @@ describe('controlled_by_parent parent gates: sys_attachment and sys_comment judg if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const context = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts index d5dfab4b238..f239c6e4986 100644 --- a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -129,6 +129,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { it('the delegate RESOLVES the delegated position while inside the window', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionFor(delegateId), nowMs: Date.now(), }); expect(ctx.positions, 'delegate holds the delegated position during the window').toContain('vacation_approver'); @@ -138,6 +139,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { const afterExpiry = Date.parse(validUntil) + 1000; const ctx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: {}, getSession: sessionFor(delegateId), nowMs: afterExpiry, }); expect(ctx.positions, 'the delegated grant is gone the instant its window closes').not.toContain('vacation_approver'); diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts index 40e25e4db0f..8cb0895363c 100644 --- a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -185,6 +185,7 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' adminToken = await stack.signIn(); adminCtx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${adminToken}` }), getSession: async (h: unknown) => { diff --git a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts index b716bc2246f..e9f34eca7e0 100644 --- a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts @@ -80,6 +80,7 @@ describe('[ADR-0123 D2 / #8208] a tenant-scoped write with no active organizatio const adminCtx = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers: new Headers({ authorization: `Bearer ${adminToken}` }), getSession: async (h: unknown) => { diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index ad75465561e..b213dc3c055 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -189,6 +189,7 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index edb390a1427..95c4e71b769 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -70,6 +70,7 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index df4c3feab83..45004861493 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -173,6 +173,7 @@ async function callerContext(b: Booted): Promise { const headers = new Headers({ authorization: `Bearer ${b.token}` }); return resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: b.ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 14292597b0f..8a886dc8cdf 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -572,6 +572,7 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post const headers = new Headers({ 'x-api-key': RAW_EXMEMBER_KEY }); const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: qlWith({ memberships: MEMBER_ROWS }), headers, getSession: async () => undefined, @@ -611,6 +612,7 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post const headers = new Headers({ 'x-api-key': RAW_EXMEMBER_KEY }); const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: qlWith({ memberships: MEMBER_ROWS }), headers, getSession: async () => undefined, diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index d7010df8083..208d9705821 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -235,7 +235,7 @@ async function bootFederation( const engine = { find: async (object: string, query: any) => { if (object === 'sys_api_key') { - return query?.where?.key === hashApiKey(API_KEY) && query?.where?.revoked === false + return query?.where?.key === hashApiKey(API_KEY) ? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }] : []; } diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index 38514a68b8e..d47059d0eac 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -389,6 +389,7 @@ const authService = { async function resolveFor(credential: string) { const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: grantsEngine as any, headers: new Headers({ authorization: credential }), getSession: async (h: any) => authService.api.getSession({ headers: h }), diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index b9e3ee85610..fcaa11fa676 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -344,6 +344,7 @@ const authService = { async function resolveFor(credential: string) { const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, ql: grantsEngine as any, headers: new Headers({ authorization: credential }), getSession: async (h: any) => authService.api.getSession({ headers: h }), diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index bb2112bdb26..601189ca01f 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -277,6 +277,7 @@ function mountBoth() { } const authz = await resolveAuthzContext({ userAuthGates: undefined, + apiKeyLifecycle: undefined, // [#9593] The SAME engine the admin spelling resolves `objectql` to, // stated rather than omitted. It used to be `undefined` here, which was // right while only a session mattered; now that one spelling reads diff --git a/packages/rest/src/rest-api-key-lifecycle.pin.test.ts b/packages/rest/src/rest-api-key-lifecycle.pin.test.ts new file mode 100644 index 00000000000..faeb6e2e03f --- /dev/null +++ b/packages/rest/src/rest-api-key-lifecycle.pin.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23367, ruling A] The REST face relays the API-key lifecycle refusal. + * + * The shared resolver raises the branded `ApiKeyLifecycleRefusalError` for a + * key that is revoked, expired or inactive. `computeExecCtx` resolves no + * context for it — so every door keeps the fail-closed answer a dead key has + * always had — and remembers the refusal beside the request; `enforceAuth` + * then answers its `403`, its own code and its located message instead of the + * generic 401. + * + * What is pinned, through the real `enforceAuth`: + * - revoked, expired and inactive answer three distinct codes; + * - the expired answer names the date; + * - a cookie beside the dead key never answers for it; + * - control: no credential at all is still the generic 401, and a live key + * is served. + */ + +import { describe, expect, it } from 'vitest'; +import { hashApiKey } from '@objectstack/core'; +import { RestServer } from './rest-server.js'; + +const HTTP_STUB = { + get: () => {}, post: () => {}, put: () => {}, delete: () => {}, patch: () => {}, + use: () => {}, listen: async () => {}, close: async () => {}, +} as any; + +const KEY = 'osk_rest_lifecycle_pin'; +const OWNER = 'u_key_owner'; +const DAY = 86_400_000; + +function serverWith(authService: any, ql: any): RestServer { + return new RestServer( + HTTP_STUB, + {} as any, + {} as any, + undefined, + undefined, + undefined, + async () => authService, + async () => ql, + ); +} + +/** Equality and `$in` — the two shapes the resolver's reads use here; any other operator is refused, never matched. */ +function matchesWhere(row: any, where: any): boolean { + for (const [field, cond] of Object.entries(where ?? {})) { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!(cond as any).$in.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +function qlWithKey(row: Record) { + const tables: Record = { + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), prefix: 'osk_rest_lif', user_id: OWNER, revoked: false, scopes: '[]', ...row }], + sys_member: [], + sys_user: [{ id: OWNER, email: 'owner@example.com' }, { id: 'u_cookie', email: 'cookie@example.com' }], + sys_user_permission_set: [], + sys_permission_set: [], + }; + return { + find: async (object: string, q: any = {}) => { + const rows = (tables[object] ?? []).filter((r: any) => matchesWhere(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + update: async () => ({}), + }; +} + +/** No gate active; the policy is the ruling's, landed 10 days ago. */ +function auth() { + return { + isAuthGateActive: () => false, + api: { + getSession: async ({ headers }: { headers: Headers }) => + (headers.get('cookie') ? { user: { id: 'u_cookie' } } : null), + }, + resolveApiKeyLifecyclePolicy: () => ({ + defaultExpiryDays: 90, + maxExpiryDays: 365, + inactivityDays: 90, + startedAt: new Date(Date.now() - 10 * DAY).toISOString(), + }), + }; +} + +async function drive(rest: RestServer, headers: Record) { + const req = { params: {}, query: {}, headers, method: 'GET', path: '/api/v1/data/account' }; + const state: any = { status: 0, body: undefined }; + const res: any = { + status: (c: number) => { state.status = c; return res; }, + json: (b: any) => { state.body = b; }, + header: () => res, send: () => {}, + }; + const ctx = await rest.resolvePackageRouteExecutionContext(req); + const blocked = (rest as any).enforceAuth(req, res, ctx); + return { ctx, blocked, state }; +} + +describe('[#23367] REST relays the API-key lifecycle refusal through enforceAuth', () => { + + it('revoked, expired and inactive answer 403 with three distinct codes', async () => { + const codes: string[] = []; + for (const row of [ + { revoked: true }, + { expires_at: '2000-01-01T00:00:00.000Z' }, + { expires_at: '2999-01-01T00:00:00.000Z', last_used_at: new Date(Date.now() - 120 * DAY).toISOString() }, + ]) { + const r = await drive(serverWith(auth(), qlWithKey(row)), { 'x-api-key': KEY }); + expect(r.ctx).toBeUndefined(); + expect(r.blocked).toBe(true); + expect(r.state.status).toBe(403); + codes.push(r.state.body.error.code); + } + expect(codes).toEqual(['API_KEY_REVOKED', 'API_KEY_EXPIRED', 'API_KEY_INACTIVE']); + }); + + it('the expired answer names the date', async () => { + const r = await drive(serverWith(auth(), qlWithKey({ expires_at: '2000-01-01T00:00:00.000Z' })), { 'x-api-key': KEY }); + expect(r.state.body.error.message).toContain('2000-01-01T00:00:00.000Z'); + }); + + it('a cookie beside the dead key never answers for it', async () => { + const r = await drive(serverWith(auth(), qlWithKey({ revoked: true })), { 'x-api-key': KEY, cookie: 'session=ok' }); + expect(r.ctx).toBeUndefined(); + expect(r.state.body.error.code).toBe('API_KEY_REVOKED'); + }); + + it('control: no credential is still the generic 401, and a live key is served', async () => { + const none = await drive(serverWith(auth(), qlWithKey({})), {}); + expect(none.state.status).toBe(401); + expect(none.state.body.code).toBe('UNAUTHENTICATED'); + + const live = await drive(serverWith(auth(), qlWithKey({ expires_at: '2999-01-01T00:00:00.000Z' })), { 'x-api-key': KEY }); + expect(live.ctx?.userId).toBe(OWNER); + expect(live.blocked).toBe(false); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8bcfb6dda9d..d69a2b87312 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -20,6 +20,8 @@ import { // form doors' tenancy read (see `registerFormEndpoints`). isServiceNotRegisteredError, assembleExecutionContext, normalizeAuthGate, userAuthGateSourceOf, type AuthGate, + // [#23367] The API-key lifecycle policy source, and its branded refusal. + apiKeyLifecyclePolicySourceOf, isApiKeyLifecycleRefusalError, type ApiKeyLifecycleRefusalError, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one // owner, shared with the runtime dispatcher's `/security` domain. @@ -2387,6 +2389,18 @@ export class RestServer { return undefined; } + /** + * [#23367, ruling A] The API-key lifecycle refusal a request's identity + * resolution raised (revoked, expired, inactive), kept beside the REQUEST + * by {@link computeExecCtx}'s catch so {@link enforceAuth} can answer it. + * The resolution itself still answers "no context", exactly as for any + * identity fault: every door that never reaches `enforceAuth` keeps the + * fail-closed answer it has always given a dead key, and the one that does + * now says which state and which date. Weak, so it lives no longer than + * the request object. + */ + private readonly apiKeyLifecycleRefusals = new WeakMap(); + /** * Reject anonymous requests with HTTP 401 — unconditionally (#3963: the * `api.requireAuth` opt-out is retired). Returns `true` if the response was @@ -2396,6 +2410,10 @@ export class RestServer { * The check is intentionally narrow: only `context?.userId` counts as * "authenticated". `isSystem` flags are never set on inbound HTTP * requests (they're internal-only), so they cannot bypass this gate. + * + * [#23367] A request whose API key its own lifecycle refused resolves no + * context either, and is answered here with the refusal's `403`, code and + * located message instead of the generic 401. */ private enforceAuth(req: any, res: any, context: any): boolean { // ADR-0069 — authentication-policy gate (password expiry, enforced MFA). @@ -2436,6 +2454,11 @@ export class RestServer { isSystem: context?.isSystem, method: req?.method, })) { + const lifecycle = req && typeof req === 'object' ? this.apiKeyLifecycleRefusals.get(req) : undefined; + if (lifecycle) { + res.status(lifecycle.status).json({ error: { code: lifecycle.code, message: lifecycle.message } }); + return true; + } res.status(ANONYMOUS_DENY_STATUS).json(ANONYMOUS_DENY_BODY); return true; } @@ -3263,6 +3286,8 @@ export class RestServer { getSession, tenancyPosture, userAuthGates: userAuthGateSourceOf(authService), + // [#23367] The API-key lifecycle policy, from the same service. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); // [commit f586f1a89] The anonymous contract IS the shared assembler's default // entry: no resolved principal → no context → 401. Taken early here @@ -3439,6 +3464,15 @@ export class RestServer { // (measured: with `tryFind` loud but this net untouched, the // package door answered 401, byte-identical to a genuine anonymous // caller). Every OTHER fault still fails closed exactly as before. + // + // [#23367, ruling A] A key its own lifecycle refused (revoked, + // expired, inactive) resolves no context too — so every door keeps + // its fail-closed answer — and is remembered beside the request + // for `enforceAuth`, which answers its `403` and code. + if (isApiKeyLifecycleRefusalError(err) && req && typeof req === 'object') { + this.apiKeyLifecycleRefusals.set(req, err); + return undefined; + } return rethrowAuthzStoreUnavailable(err); } } diff --git a/packages/runtime/src/api-key-lifecycle.pin.test.ts b/packages/runtime/src/api-key-lifecycle.pin.test.ts new file mode 100644 index 00000000000..c51583642c5 --- /dev/null +++ b/packages/runtime/src/api-key-lifecycle.pin.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23367, ruling A] The API-key lifecycle policy on the runtime face. + * + * ## The signing door (`POST /keys`) + * + * - a signing that names no `expires_at` gets the policy's default life + * (90 days unless configured) — never a permanent key; + * - an `expires_at` further away than the ceiling (one year unless + * configured) is refused `400 API_KEY_EXPIRY_TOO_LONG`, and nothing is + * stored; exactly the ceiling is accepted; + * - both numbers are the auth service's (`resolveApiKeyLifecyclePolicy`), so + * a changed setting changes the answer; + * - a kernel with no `auth` service keeps the pre-policy signing. + * + * ## The dispatcher face relays the refusal (ruling A item 3) + * + * `HttpDispatcher`'s identity step re-raises the branded lifecycle refusal + * beside the store outage, so `dispatch()` leaves with it and the transport's + * error render answers its declared `403` and ledger code — on `POST /keys`, + * and on the MCP HTTP endpoint (`/api/v1/mcp`), driven through the mounted + * route. Nothing is minted, and a cookie beside the dead key never answers for + * it. The relay is by BRAND: an unbranded throw at the same step, even one + * spelling the same code and status, is still answered as an anonymous + * request (the generic 401), and so is an unknown key. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hashApiKey, isApiKeyLifecycleRefusalError, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ApiErrorSchema } from '@objectstack/spec/api'; +import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts'; + +import { HttpDispatcher, type HttpDispatcherResult, type HttpProtocolContext } from './http-dispatcher.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const DAY = 86_400_000; +const NOW = Date.parse('2027-01-01T00:00:00.000Z'); + +function policyService(over: Partial = {}) { + return { + resolveApiKeyLifecyclePolicy: (): ApiKeyLifecyclePolicy => ({ + defaultExpiryDays: 90, + maxExpiryDays: 365, + inactivityDays: 90, + ...over, + }), + }; +} + +function signingKernel(auth: unknown) { + const rows: any[] = []; + const ql = { + insert: async (_obj: string, data: any) => { + rows.push({ id: `key_${rows.length + 1}`, ...data }); + return { id: `key_${rows.length}` }; + }, + find: async () => [], + }; + const services: Record = { objectql: ql, auth }; + const kernel: any = { + getService: (n: string) => services[n], + getServiceAsync: async (n: string) => { + const svc = services[n]; + if (svc instanceof Error) throw svc; + return svc; + }, + }; + return { kernel, rows }; +} + +const caller = () => ({ + request: { headers: {} }, + response: {}, + environmentId: undefined, + executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] }, +}); + +function responseOf(res: HttpDispatcherResult): NonNullable { + if (!res.response) throw new Error('handleKeys answered no response at all'); + return res.response; +} + +describe('[#23367] POST /keys — the default life and the ceiling', () => { + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); + afterEach(() => { vi.useRealTimers(); }); + + it('a signing with no expires_at gets now + 90 days', async () => { + const { kernel, rows } = signingKernel(policyService()); + const res = responseOf(await new HttpDispatcher(kernel).handleKeys('POST', { name: 'ci' }, caller())); + const expected = new Date(NOW + 90 * DAY).toISOString(); + expect(res.status).toBe(201); + expect(rows[0].expires_at).toBe(expected); + expect(res.body.data.expires_at).toBe(expected); + }); + + it('exactly one year is accepted', async () => { + const { kernel, rows } = signingKernel(policyService()); + const oneYear = new Date(NOW + 365 * DAY).toISOString(); + const res = responseOf(await new HttpDispatcher(kernel).handleKeys('POST', { expires_at: oneYear }, caller())); + expect(res.status).toBe(201); + expect(rows[0].expires_at).toBe(oneYear); + }); + + it('a day past the ceiling is refused 400 API_KEY_EXPIRY_TOO_LONG, and nothing is stored', async () => { + const { kernel, rows } = signingKernel(policyService()); + const tooFar = new Date(NOW + 366 * DAY).toISOString(); + const res = responseOf(await new HttpDispatcher(kernel).handleKeys('POST', { expires_at: tooFar }, caller())); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('API_KEY_EXPIRY_TOO_LONG'); + // Located: the refusal names the latest date this signing may ask for. + expect(res.body.error.message).toContain(new Date(NOW + 365 * DAY).toISOString()); + expect(rows).toHaveLength(0); + }); + + it('both numbers are the auth service\'s: a changed setting changes the answer', async () => { + const short = signingKernel(policyService({ defaultExpiryDays: 7, maxExpiryDays: 30 })); + const dflt = responseOf(await new HttpDispatcher(short.kernel).handleKeys('POST', {}, caller())); + expect(dflt.body.data.expires_at).toBe(new Date(NOW + 7 * DAY).toISOString()); + const refused = responseOf(await new HttpDispatcher(short.kernel).handleKeys( + 'POST', { expires_at: new Date(NOW + 31 * DAY).toISOString() }, caller())); + expect(refused.body.error.code).toBe('API_KEY_EXPIRY_TOO_LONG'); + }); + + it('a kernel with no auth service keeps the pre-policy signing', async () => { + const { kernel, rows } = signingKernel(undefined); + const res = responseOf(await new HttpDispatcher(kernel).handleKeys('POST', { name: 'x' }, caller())); + expect(res.status).toBe(201); + expect(rows[0].expires_at).toBeUndefined(); + }); + + it('an auth service that is registered and broken is the outage, never a policy-less mint', async () => { + const { kernel, rows } = signingKernel(new Error('auth failed to build')); + const err = await new HttpDispatcher(kernel).handleKeys('POST', { name: 'x' }, caller()).catch((e) => e); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(rows).toHaveLength(0); + }); +}); + +// ── The dispatcher face: the refusal is relayed, by brand ─────────────────── + +const KEY = 'osk_dispatcher_lifecycle_pin'; +/** `/keys` takes its anonymous decision as its first statement (ADR-0138 D2), so it shows what the face resolved. */ +const ROUTE = '/keys'; +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function dispatcherWithKey(row: Record) { + const keyHash = hashApiKey(KEY); + const getSession = vi.fn(async ({ headers }: { headers: Headers }) => ( + headers.get('cookie') ? { user: { id: 'u_cookie' }, session: { userId: 'u_cookie' } } : null + )); + const inserted: any[] = []; + const objectql = { + find: vi.fn(async (object: string, q: any) => ( + object === 'sys_api_key' && q?.where?.key === keyHash + ? [{ id: 'key_1', key: keyHash, prefix: 'osk_dispatch', user_id: 'u_owner', revoked: false, scopes: '[]', ...row }] + : [] + )), + insert: vi.fn(async (_object: string, data: any) => { inserted.push(data); return { id: 'key_new' }; }), + update: vi.fn(async (_object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }), + getObjects: vi.fn().mockReturnValue({}), + registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) }, + }; + const services: Record = { + objectql, + auth: { isAuthGateActive: () => false, getApi: async () => ({ getSession }), ...policyService() }, + }; + const kernel: any = { + getState: () => 'running', + getService: (n: string) => services[n] ?? null, + getServiceAsync: async (n: string) => services[n] ?? null, + context: { getService: (n: string) => services[n] ?? null }, + }; + const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + (dispatcher as any).domainDeps.logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + return { dispatcher, inserted, getSession, kernel }; +} + +const post = (headers: Record) => new Request(`http://tenant.example/api/v1${ROUTE}`, { + method: 'POST', + headers: { ...JSON_HEADERS, ...headers }, + body: JSON.stringify({ name: 'minted by a key' }), +}); +const pctx = (r: Request): HttpProtocolContext => ({ request: r }); + +async function rejectionOf(p: Promise): Promise { + try { + await p; + } catch (err) { + return err; + } + return undefined; +} + +describe('[#23367] the dispatcher face relays a key its lifecycle refuses, by brand', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); }); + afterEach(() => { warn.mockRestore(); }); + + it('a revoked key leaves `dispatch()` as the branded 403 API_KEY_REVOKED, and nothing is minted', async () => { + const { dispatcher, inserted } = dispatcherWithKey({ revoked: true }); + const err = await rejectionOf(dispatcher.dispatch('POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': KEY })))); + expect(err, '`dispatch()` RESOLVED — the refusal was absorbed inside the pipeline').toBeDefined(); + expect(isApiKeyLifecycleRefusalError(err)).toBe(true); + expect(err).toMatchObject({ code: 'API_KEY_REVOKED', status: 403 }); + expect(inserted).toEqual([]); + }); + + it('a cookie beside the dead key never answers for it: 403 API_KEY_EXPIRED, nothing minted', async () => { + const { dispatcher, inserted } = dispatcherWithKey({ expires_at: '2000-01-01T00:00:00.000Z' }); + const err = await rejectionOf(dispatcher.dispatch( + 'POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': KEY, cookie: 'session=ok' })))); + expect(err).toMatchObject({ code: 'API_KEY_EXPIRED', status: 403 }); + expect(inserted).toEqual([]); + }); + + it('an UNBRANDED throw at the identity step, even one spelling the same code and status, is still the generic 401', async () => { + const { dispatcher, inserted } = dispatcherWithKey({ revoked: true }); + const lookalike = Object.assign(new Error('not the brand'), { code: 'API_KEY_REVOKED', status: 403 }); + vi.spyOn(dispatcher as any, 'timedResolveExecutionContext').mockRejectedValue(lookalike); + const result: any = await dispatcher.dispatch('POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': KEY }))); + expect(result.response?.status).toBe(401); + expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(inserted).toEqual([]); + }); + + it('an unknown key is still the generic 401', async () => { + const { dispatcher, inserted } = dispatcherWithKey({}); + const result: any = await dispatcher.dispatch( + 'POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': 'osk_never_issued' }))); + expect(result.response?.status).toBe(401); + expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(inserted).toEqual([]); + }); + + it('control: the same key, live, is served', async () => { + const { dispatcher, inserted } = dispatcherWithKey({ expires_at: '2999-01-01T00:00:00.000Z' }); + const result: any = await dispatcher.dispatch('POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': KEY }))); + expect(result.response?.status).toBe(201); + expect(inserted).toHaveLength(1); + }); +}); + +// ── MCP HTTP, on the wire: the mounted `/api/v1/mcp` route and its render ─── + +/** A fake `IHttpServer` recording the handlers the plugin mounts. */ +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { handlers[`${verb} ${path}`] = handler; }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +async function mountOn(kernel: unknown) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.({ + getKernel: () => kernel, + getService: (n: string) => (n === 'http.server' ? server : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, on: () => {}, + } as any); + return handlers; +} + +async function drive(handler: ((req: any, res: any) => any) | undefined, headers: Record) { + expect(handler, 'route must be mounted').toBeTypeOf('function'); + const res: any = { + statusCode: undefined, body: undefined, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + end() { return res; }, + }; + await handler!({ headers, query: {}, body: { jsonrpc: '2.0', id: 1, method: 'tools/list' } }, res); + return { status: res.statusCode as number, body: res.body }; +} + +const MCP = 'POST /api/v1/mcp'; + +describe('[#23367] MCP HTTP answers a dead key with its own 403 and code, on the wire', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); }); + afterEach(() => { warn.mockRestore(); }); + + it('a revoked key: 403 API_KEY_REVOKED in the declared envelope', async () => { + const { kernel } = dispatcherWithKey({ revoked: true }); + const handlers = await mountOn(kernel); + const { status, body } = await drive(handlers[MCP], { 'x-api-key': KEY }); + expect(status).toBe(403); + expect(body?.success).toBe(false); + const parsed = ApiErrorSchema.safeParse(body?.error); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(body?.error?.code).toBe('API_KEY_REVOKED'); + }); + + it('an inactive key: 403 API_KEY_INACTIVE', async () => { + const { kernel } = dispatcherWithKey({ + created_at: '2000-01-01T00:00:00.000Z', + last_used_at: '2000-01-01T00:00:00.000Z', + expires_at: '2999-01-01T00:00:00.000Z', + }); + const handlers = await mountOn(kernel); + const { status, body } = await drive(handlers[MCP], { 'x-api-key': KEY }); + expect(status).toBe(403); + expect(body?.error?.code).toBe('API_KEY_INACTIVE'); + }); + + it('an unknown key is no lifecycle refusal: it passes the identity step as anonymous, and `POST /keys` answers it the generic 401', async () => { + const { kernel } = dispatcherWithKey({}); + const handlers = await mountOn(kernel); + // The fixture registers no MCP service, so the anonymous request reaches + // the MCP door and is answered there (501), never at the identity step. + const mcp = await drive(handlers[MCP], { 'x-api-key': 'osk_never_issued' }); + expect(mcp.status).not.toBe(403); + expect(mcp.body?.error?.code).not.toMatch(/^API_KEY_/); + const keys = await drive(handlers['POST /api/v1/keys'], { 'x-api-key': 'osk_never_issued' }); + expect(keys.status).toBe(401); + expect(keys.body?.error?.code).toBe('UNAUTHENTICATED'); + }); +}); diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index c5be489cfaf..a3c2710ed41 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -15,6 +15,12 @@ * - Body is whitelisted to `name` (+ optional `expires_at`); any * `key` / `id` / `user_id` / `revoked` in the body is ignored, so a * caller cannot forge a known-secret or escalate. + * - [#23367, ruling A] The API-key lifecycle policy (the auth service's + * `resolveApiKeyLifecyclePolicy`, the same source the key-admission seam + * reads): a signing that names no `expires_at` gets the default life (90 + * days unless configured), and one that asks for a later date than the + * ceiling (one year unless configured) is refused `400 + * API_KEY_EXPIRY_TOO_LONG`. Exactly the ceiling is accepted. * - `scopes` are intentionally NOT accepted from the body in v1: the * verify path ADDS scopes to the principal's permissions, so honouring * arbitrary body scopes would be an escalation vector. A generated key @@ -36,13 +42,28 @@ import { isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, + apiKeyLifecyclePolicySourceOf, } from '@objectstack/core'; +import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { generateApiKey } from '../security/api-key.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +const DAY_MS = 86_400_000; + +/** + * [#23367] The clock skew the ceiling forgives. A client that computes "one + * year from now" on its own clock and sends it is asking for exactly the + * ceiling; a client clock a few seconds ahead of the server's must not turn + * that into a refusal. + */ +const EXPIRY_CEILING_SKEW_MS = 60_000; + +/** [#23367] The ceiling refusal, as its wire pair (ADR-0112 ledger, `@objectstack/runtime`). */ +const API_KEY_EXPIRY_TOO_LONG = { code: 'API_KEY_EXPIRY_TOO_LONG', status: 400 } as const; + /** * The legacy branch matched `=== '/keys' || startsWith('/keys/') || * startsWith('/keys?')` — a segment match PLUS the query-string form some @@ -95,7 +116,7 @@ export async function handleKeysRequest( const rawName = typeof body?.name === 'string' ? body.name.trim() : ''; const name = rawName || 'API Key'; - let expiresAt: string | undefined; + let requestedMs: number | undefined; if (body?.expires_at != null && body.expires_at !== '') { const ms = typeof body.expires_at === 'number' ? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at) @@ -106,7 +127,7 @@ export async function handleKeysRequest( if (ms <= Date.now()) { return { handled: true, response: deps.error('Invalid expires_at: must be in the future', 400) }; } - expiresAt = new Date(ms).toISOString(); + requestedMs = ms; } const ql = (await deps.getObjectQL(context, context.environmentId)) @@ -162,6 +183,48 @@ export async function handleKeysRequest( } const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false; + // ── [#23367, ruling A] The lifecycle policy, from the auth service. ── + // + // The same source the key-admission seam judges with, so the life a key + // is signed with and the life it is admitted under cannot disagree. A + // kernel with no `auth` service has no auth configuration: the signing + // then keeps its pre-policy behaviour (no default, no ceiling). An `auth` + // service that is registered and FAILED to build is the outage it is — + // the #15900 reasoning above, for the same kind of input: the policy + // decides the life of the credential being minted, and an input never + // read decides nothing. + let policy: ApiKeyLifecyclePolicy | undefined; + try { + const source = apiKeyLifecyclePolicySourceOf( + await deps.resolveServiceOrLoud(context, 'auth', context.environmentId), + ); + policy = source ? await source() : undefined; + } catch (err) { + throw new AuthzStoreUnavailableError('api_key_policy', err); + } + + const nowMs = Date.now(); + let expiresAt: string | undefined; + if (requestedMs !== undefined) { + if (policy && requestedMs > nowMs + policy.maxExpiryDays * DAY_MS + EXPIRY_CEILING_SKEW_MS) { + const latest = new Date(nowMs + policy.maxExpiryDays * DAY_MS).toISOString(); + return { + handled: true, + response: deps.error( + `expires_at ${new Date(requestedMs).toISOString()} is further away than this deployment allows: an ` + + `API key lives at most ${policy.maxExpiryDays} days, so the latest expiry a key signed now may ` + + `have is ${latest}. Ask for that date or an earlier one, and rotate the key before it expires.`, + API_KEY_EXPIRY_TOO_LONG.status, + { code: API_KEY_EXPIRY_TOO_LONG.code }, + ), + }; + } + expiresAt = new Date(requestedMs).toISOString(); + } else if (policy) { + // No expiry asked for: the policy's default life, never a permanent key. + expiresAt = new Date(nowMs + policy.defaultExpiryDays * DAY_MS).toISOString(); + } + if (walled && !activeOrganizationId) { // Refuse rather than mint. Under a walled posture an org-less key // reads nothing (`isolated`) or reads by a rule that has nothing to do diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index 4f85166ce88..177a04a1621 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -162,12 +162,15 @@ describe('HttpDispatcher.handleKeys (POST /keys — key generation)', () => { expires_at: '2000-01-01T00:00:00Z', }, { context: { isSystem: true } }); - const resolved = await resolveExecutionContext({ + // [#23367] An expired key is a real key its own lifecycle refuses: the + // shared resolver raises the branded refusal (403 API_KEY_EXPIRED, naming + // the date) instead of answering an anonymous context, and whoever catches + // the resolution decides how loud it is. + await expect(resolveExecutionContext({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': raw } }, - }); - expect(resolved.userId).toBeUndefined(); + })).rejects.toMatchObject({ code: 'API_KEY_EXPIRED', status: 403 }); }); }); diff --git a/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts b/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts index 82c0b90c6a6..f02711f300a 100644 --- a/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp-oauth.test.ts @@ -52,7 +52,7 @@ function makeHarness(opts: HarnessOptions = {}) { return { id: 'new1', ...data }; }, find: async (object: string, q: any) => { - if (object === 'sys_api_key' && q?.where?.key === apiKeyHash && q?.where?.revoked === false) { + if (object === 'sys_api_key' && q?.where?.key === apiKeyHash) { return [{ id: 'k1', key: apiKeyHash, user_id: 'keyUser', revoked: false }]; } return []; diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 45aac2be330..22d1fe62b33 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -10,6 +10,8 @@ import { // every seam between `resolveAuthzContext` and a door), and the tenancy // read is classified by the REGISTRY's own "never registered" brand. rethrowAuthzStoreUnavailable, isServiceNotRegisteredError, + // [#23367] ...and the branded API-key lifecycle refusal, by the same kind of brand. + isApiKeyLifecycleRefusalError, // [#21941] The environment-membership gate raises the same loud outage // when its own read cannot answer. AuthzStoreUnavailableError, @@ -708,7 +710,15 @@ export class HttpDispatcher { // with no credential, which is the "changed disguise" the shared // `rethrowAuthzStoreUnavailable` exists to end. It re-raises that // class by brand and returns `undefined` for everything else, so - // every other fault still degrades exactly as before: + // every other fault still degrades exactly as before. + // + // [#23367, ruling A item 3] The one other throw that leaves: a key + // its own lifecycle refused (revoked, expired, inactive). It is a + // verdict on the credential presented, not an absent one, and it + // carries its declared 403 and ledger code, which the transport's + // error render answers. Matched by brand, so every unbranded fault + // still falls through to the anonymous request below. + if (isApiKeyLifecycleRefusalError(err)) throw err; rethrowAuthzStoreUnavailable(err); // anonymous request — leave executionContext undefined } diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 41005a0f00b..8ed5ab0105f 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -79,11 +79,14 @@ describe('resolveExecutionContext — API key verify path', () => { expect(ctx.userId).toBe('u1'); }); + // [#23367, ruling A] A revoked or expired key is a REAL key its own + // lifecycle refuses: it is refused with its own code (the branded 403 + // refusal), never resolved as a principal and never read as an unknown key. it('rejects a revoked key', async () => { const raw = 'osk_revoked'; const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: true, user_id: 'u1' }]; - const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); - expect(ctx.userId).toBeUndefined(); + await expect(resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw }))) + .rejects.toMatchObject({ code: 'API_KEY_REVOKED', status: 403 }); }); it('rejects an expired key', async () => { @@ -91,8 +94,8 @@ describe('resolveExecutionContext — API key verify path', () => { const rows = [ { id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1', expires_at: PAST }, ]; - const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); - expect(ctx.userId).toBeUndefined(); + await expect(resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw }))) + .rejects.toMatchObject({ code: 'API_KEY_EXPIRED', status: 403 }); }); it('rejects an unknown key', async () => { diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 477d9958db9..561c4612fe9 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -48,6 +48,7 @@ import { // [#23287, ruling C′ item 3] The auth service's per-user posture // computation, bound for the shared key-admission seam. userAuthGateSourceOf, + apiKeyLifecyclePolicySourceOf, type AuthGate, } from '@objectstack/core'; @@ -251,6 +252,11 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise) { + const http = new MockHttp(); + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + const keyHash = hashApiKey(KEY); + const services: Record = { + 'http-server': http, + auth: { + api: { getSession: async () => undefined }, + resolveUserAuthGates: async () => [], + resolveApiKeyLifecyclePolicy: () => ({ defaultExpiryDays: 90, maxExpiryDays: 365, inactivityDays: 90 }), + }, + objectql: { + find: async (object: string, q: any) => ( + object === 'sys_api_key' && q?.where?.key === keyHash + ? [{ id: 'key_1', key: keyHash, prefix: 'osk_settings', user_id: OWNER, revoked: false, scopes: '[]', ...row }] + : [] + ), + }, + }; + let readyHook: (() => Promise) | undefined; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: () => {}, + getService: (name: string) => services[name], + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') readyHook = fn; }, + getKernel: () => kernel, + }; + const plugin = new SettingsServicePlugin({ manifests: [], env: {}, actionHandlers: {} }); + await plugin.init(ctx); + await plugin.start(ctx); + await readyHook!(); + if (http.routes.length === 0) throw new Error('fixture: the settings routes were not registered'); + return { http }; +} + +async function drive(route: Registered, key: string) { + const answer: { status: number; body: any } = { status: 200, body: undefined }; + const res: any = { + json(data: unknown) { answer.body = data; return res; }, + send() { return res; }, + status(code: number) { answer.status = code; return res; }, + header() { return res; }, + }; + const params: Record = {}; + for (const seg of route.path.split('/')) if (seg.startsWith(':')) params[seg.slice(1)] = 'branding'; + await route.handler( + { params, query: {}, body: route.verb === 'GET' ? undefined : { workspace_name: 'x' }, headers: { 'x-api-key': key }, method: route.verb, path: route.path } as unknown as IHttpRequest, + res as IHttpResponse, + ); + return answer; +} + +const DAY = 86_400_000; +const LONG_AGO = '2000-01-01T00:00:00.000Z'; +const FAR = '2999-01-01T00:00:00.000Z'; + +describe('[#23367] settings family — a key its lifecycle refuses answers its own 403 and code', () => { + it('a revoked key is refused 403 API_KEY_REVOKED on every mounted route', async () => { + const { http } = await bootPlugin({ revoked: true }); + for (const route of http.routes) { + const answer = await drive(route, KEY); + expect(answer.status, `${route.verb} ${route.path}`).toBe(403); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('API_KEY_REVOKED'); + } + }); + + it('an expired key is refused 403 API_KEY_EXPIRED', async () => { + const { http } = await bootPlugin({ expires_at: LONG_AGO }); + const answer = await drive(http.routes[0], KEY); + expect(answer.status).toBe(403); + expect(answer.body?.error?.code).toBe('API_KEY_EXPIRED'); + }); + + it('an idle key is refused 403 API_KEY_INACTIVE', async () => { + const idleSince = new Date(Date.now() - 200 * DAY).toISOString(); + const { http } = await bootPlugin({ created_at: idleSince, last_used_at: idleSince, expires_at: FAR }); + const answer = await drive(http.routes[0], KEY); + expect(answer.status).toBe(403); + expect(answer.body?.error?.code).toBe('API_KEY_INACTIVE'); + }); + + it('an unknown key is no lifecycle refusal: the anonymous floor answers the generic 401', async () => { + const { http } = await bootPlugin({}); + for (const route of http.routes) { + const answer = await drive(route, 'osk_never_issued'); + expect(answer.status, `${route.verb} ${route.path}`).toBe(401); + expect(answer.body?.error?.code).toBe('UNAUTHENTICATED'); + } + }); + + it('control: the same key, live, is served', async () => { + const { http } = await bootPlugin({ expires_at: FAR }); + const list = http.routes.find((r) => r.verb === 'GET' && !r.path.includes(':')); + expect(list, 'the family index route').toBeDefined(); + const answer = await drive(list!, KEY); + expect(answer.status).toBe(200); + expect(answer.body?.success).toBe(true); + }); +}); diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index de3132dcb95..490ff4e684a 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -39,6 +39,8 @@ import { ANONYMOUS_DENY_STATUS, evaluateAuthGate, isAuthzStoreUnavailableError, + // [#23367] The API-key lifecycle refusal's brand, relayed beside the outage. + isApiKeyLifecycleRefusalError, shouldDenyAnonymous, } from '@objectstack/core'; import type { ErrorCode } from '@objectstack/spec/api'; @@ -99,8 +101,9 @@ const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: tru * runs only for a caller the anonymous floor admitted. * - `refuse` maps anything thrown — by the context resolver or by `serve` — to * this route's envelope. It is the route's own `catch` arm, unchanged; the - * door calls it for every throw except an authorization-store outage, which - * the door relays itself ({@link relayAuthzStoreOutage}). + * door calls it for every throw except an authorization-store outage and an + * API key's lifecycle refusal, which the door relays itself + * ({@link relayAuthzStoreOutage}, {@link relayApiKeyLifecycleRefusal}). */ interface SettingsFamilyRoute { serve(req: IHttpRequest, res: IHttpResponse, ctx: SettingsContext): Promise; @@ -150,6 +153,20 @@ function relayAuthzStoreOutage(res: IHttpResponse, err: unknown): boolean { return true; } +/** + * [#23367, ruling A item 3] Relay an API key's own lifecycle refusal + * (revoked, expired, inactive) as the envelope it declares: its `403` and its + * ledger code (`API_KEY_REVOKED` / `API_KEY_EXPIRED` / `API_KEY_INACTIVE`), the + * answer REST gives the same key. Read off the error, as the outage relay + * above reads it, and scoped to the brand the same way: every other throw + * keeps its existing arm. Returns `true` when the refusal was answered. + */ +function relayApiKeyLifecycleRefusal(res: IHttpResponse, err: unknown): boolean { + if (!isApiKeyLifecycleRefusalError(err)) return false; + sendError(res, err.status, err.code, err.message); + return true; +} + /** * ADR-0069 — refuse a session the authentication policy HOLDS (an expired * password, enforced MFA not yet enrolled), which is gated out of every @@ -201,7 +218,8 @@ export function registerSettingsRoutes( * * 1. Resolve the caller (`contextFromRequest`). This is identity resolution, * not settings logic; an authorization-store outage it raises is relayed - * as its own `503` (#15999), and any other throw takes the route's own + * as its own `503` (#15999), an API key's lifecycle refusal as its own + * `403` and code (#23367), and any other throw takes the route's own * `refuse` arm exactly as it did before the door existed. * 2. ADR-0069: a held session is refused `403` with the gate's code * ({@link refuseHeldSession}), asked first as REST's `enforceAuth` asks @@ -232,6 +250,7 @@ export function registerSettingsRoutes( await route.serve(req, res, ctx); } catch (err: any) { if (relayAuthzStoreOutage(res, err)) return; + if (relayApiKeyLifecycleRefusal(res, err)) return; route.refuse(res, err); } }) satisfies RouteHandler); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 3fde596ec8d..7c72080fa4a 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -8,6 +8,10 @@ import { // bound for the shared key-admission seam; the posture the resolver decides // is what this resolver puts on the context (`SettingsContext.authGate`). userAuthGateSourceOf, + // [#23367] The API-key lifecycle policy source, bound beside it, and the + // brand of the refusal it can produce. + apiKeyLifecyclePolicySourceOf, + isApiKeyLifecycleRefusalError, // [#16013] The posture derivation, in ONE call. It reads the posture IN // FORCE off the `tenancy` service (never the REQUESTED one an env var asks // for) and carries decision-1-option-A's classification (#13906) for every @@ -327,6 +331,8 @@ export class SettingsServicePlugin implements Plugin { getSession, tenancyPosture, userAuthGates: userAuthGateSourceOf(authService), + // [#23367] The API-key lifecycle policy, from the same service. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); // [ADR-0069 · #23287] The posture the shared resolver decided for // whichever credential admitted the caller (`authz.authGate`): a held @@ -360,6 +366,11 @@ export class SettingsServicePlugin implements Plugin { // caller with no permissions — re-raise it rather than returning an // enforced-but-empty context the routes read as a denial. if (isAuthzStoreUnavailableError(err)) throw err; + // [#23367] A key its own lifecycle refused (revoked, expired, + // inactive) is a verdict, not a missing caller: re-raised by brand + // for the family's door (`registerSettingsRoutes`), which relays it + // as its declared 403 and ledger code beside the store outage. + if (isApiKeyLifecycleRefusalError(err)) throw err; return { enforced: true }; // fail closed } }; diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index bbf4861e571..d8603430e1b 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -113,6 +113,10 @@ export const esES: PlatformTranslationData = { title: 'Red', description: 'Restringe desde dónde pueden autenticarse los usuarios.', }, + api_keys: { + title: 'Claves de API', + description: 'Cuánto duran las claves de API personales. Una clave que caduca, que pasa sin usarse el plazo de inactividad o que se revoca se rechaza con su propio código de error, y la solución es siempre crear una clave nueva.', + }, social: { title: 'Inicio de sesión social', description: @@ -212,6 +216,22 @@ export const esES: PlatformTranslationData = { label: 'Rangos de IP permitidos', help: 'Rangos CIDR o IP exactas (una por línea o separadas por comas), p. ej. 203.0.113.0/24. Cuando se define, se rechaza el inicio de sesión desde fuera de estos rangos. Vacío = sin restricción. Requiere un proxy de confianza que defina X-Forwarded-For.', }, + api_key_default_expiry_days: { + label: 'Duración predeterminada de la clave (días)', + help: 'Una clave creada sin fecha de caducidad caduca este número de días después de crearse. Nunca supera la duración máxima de abajo.', + }, + api_key_max_expiry_days: { + label: 'Duración máxima de la clave (días)', + help: 'La fecha de caducidad más lejana que puede pedir una clave nueva, contada desde su creación. Una más lejana se rechaza (API_KEY_EXPIRY_TOO_LONG); exactamente este número de días se acepta.', + }, + api_key_inactivity_days: { + label: 'Caducidad por inactividad (días)', + help: 'Una clave sin uso registrado durante este número de días se rechaza (API_KEY_INACTIVE). No se revoca; cree una clave nueva. Una clave nunca usada cuenta desde su creación, o desde el inicio de la política de abajo si es anterior. 0 desactiva la caducidad por inactividad.', + }, + api_key_policy_started_at: { + label: 'Inicio de la política de claves', + help: 'Se registra automáticamente (UTC, ISO 8601) la primera vez que el servidor se ejecuta con esta política. Las claves creadas antes sin fecha de caducidad caducan un año después; las claves sin uso registrado cuentan su inactividad desde aquí. Cambiarlo mueve ambas fechas de las claves que aún no tienen una.', + }, google_enabled: { label: 'Habilitar inicio de sesión con Google', help: 'Requiere un ID de cliente y un secreto de OAuth de Google desde Google Cloud Console.', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index cfe30048bea..b37cc1c6d40 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -113,6 +113,10 @@ export const jaJP: PlatformTranslationData = { title: 'ネットワーク', description: 'ユーザーがどこから認証できるかを制限します。', }, + api_keys: { + title: 'API キー', + description: '個人 API キーの有効期間。期限切れ、非アクティブ期間を超えて未使用、または失効したキーはそれぞれのエラーコードで拒否され、対処は常に新しいキーの作成です。', + }, social: { title: 'ソーシャルサインイン', description: @@ -212,6 +216,22 @@ export const jaJP: PlatformTranslationData = { label: '許可 IP レンジ', help: 'CIDR レンジまたは正確な IP(1行に1つ、またはカンマ区切り)。例:203.0.113.0/24。設定すると、この範囲外からのサインインを拒否します。空欄で制限なし。X-Forwarded-For を設定する信頼できるプロキシが必要です。', }, + api_key_default_expiry_days: { + label: 'キーの既定の有効期間(日)', + help: '有効期限を指定せずに作成したキーは、作成からこの日数で期限切れになります。下の最大有効期間を超えることはありません。', + }, + api_key_max_expiry_days: { + label: 'キーの最大有効期間(日)', + help: '新しいキーが指定できる最も遠い有効期限(作成時から数えます)。これより長いものは拒否されます(API_KEY_EXPIRY_TOO_LONG)。ちょうどこの日数は受け付けます。', + }, + api_key_inactivity_days: { + label: '非アクティブによる失効(日)', + help: 'この日数のあいだ使用記録のないキーは拒否されます(API_KEY_INACTIVE)。失効(revoke)はされません。新しいキーを作成してください。一度も使われていないキーは作成時から、それが下のポリシー開始時刻より古い場合はポリシー開始時刻から数えます。0 で非アクティブ失効をオフにします。', + }, + api_key_policy_started_at: { + label: 'キーポリシーの開始時刻', + help: 'サーバーがこのポリシーで初めて動作したときに自動で記録されます(UTC、ISO 8601)。それ以前に有効期限なしで作成されたキーはこの時刻の 1 年後に期限切れになり、使用記録のないキーはこの時刻から非アクティブ期間を数えます。変更すると、まだ日付が付いていないキーの両方の日付が動きます。', + }, google_enabled: { label: 'Google ログインを有効化', help: 'Google Cloud Console の Google OAuth クライアント ID とシークレットが必要です。', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 808117d28f9..605e0fbcc67 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -256,6 +256,10 @@ export const zhCN: PlatformTranslationData = { title: '网络', description: '限制用户可以从哪里登录。', }, + api_keys: { + title: 'API 密钥', + description: '个人 API 密钥的有效期。过期、超过不活动期限未使用或已吊销的密钥会以各自的错误码被拒绝,补救方式都是新建密钥。', + }, social: { title: '社交登录', description: '配置内置的 Google 登录提供商。部署环境变量仍优先生效。', @@ -354,6 +358,22 @@ export const zhCN: PlatformTranslationData = { label: '允许的 IP 段', help: 'CIDR 段或精确 IP(每行一个,或用逗号分隔),例如 203.0.113.0/24。设置后,来自这些范围之外的登录将被拒绝。留空表示不限制。需要可信代理设置 X-Forwarded-For。', }, + api_key_default_expiry_days: { + label: '默认密钥有效期(天)', + help: '未指定到期日期的密钥在创建后经过这么多天到期。不会超过下方的最长有效期。', + }, + api_key_max_expiry_days: { + label: '密钥最长有效期(天)', + help: '新密钥可以申请的最远到期日期,从创建时算起。更长的会被拒绝(API_KEY_EXPIRY_TOO_LONG);恰好这么多天可以接受。', + }, + api_key_inactivity_days: { + label: '不活动失效(天)', + help: '这么多天内没有使用记录的密钥会被拒绝(API_KEY_INACTIVE)。它不会被吊销;请新建密钥。从未使用的密钥从创建时算起,若早于下方的策略开始时间则从策略开始时间算起。0 表示关闭不活动失效。', + }, + api_key_policy_started_at: { + label: '密钥策略开始时间', + help: '服务器首次以此策略运行时自动记录(UTC,ISO 8601)。在此之前创建且没有到期日期的密钥在此时间一年后到期;没有使用记录的密钥从此时间开始计算不活动期。修改它会移动尚未获得日期的密钥的这两个日期。', + }, google_enabled: { label: '启用 Google 登录', help: '需要在 Google Cloud Console 中创建的 Google OAuth 客户端 ID 与密钥。', diff --git a/packages/services/service-storage/src/storage-routes.api-key-lifecycle.pin.test.ts b/packages/services/service-storage/src/storage-routes.api-key-lifecycle.pin.test.ts new file mode 100644 index 00000000000..021560716b2 --- /dev/null +++ b/packages/services/service-storage/src/storage-routes.api-key-lifecycle.pin.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23367, ruling A item 3] The parent-governed DOWNLOAD door answers a key + * its own lifecycle refuses with that refusal's 403 and ledger code. + * + * ## What is pinned, through the booted plugin's own authorizer + * + * - a revoked key → `403 API_KEY_REVOKED`, and NO capability is minted; + * - an expired key → `403 API_KEY_EXPIRED`, same; + * - an unknown key is no lifecycle refusal: the door's generic + * `401 AUTH_REQUIRED`, nothing minted; + * - control: the same key, live, gets its signed URL. + * + * The path: `buildFileReadAuthorizer` re-raises the branded + * `ApiKeyLifecycleRefusalError` beside the store outage, and the download + * gate (`authorizeDownload`, `storage-routes.ts`) relays it beside the outage + * relay. The fixture is `file-read-tenancy-posture-admission.test.ts`'s, + * reduced to one reachable field-owned file and no tenancy service. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectKernel, hashApiKey } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { StorageServicePlugin } from './storage-service-plugin.js'; + +const KEY_LIVE = 'osk_23367_live'; +const KEY_REVOKED = 'osk_23367_revoked'; +const KEY_EXPIRED = 'osk_23367_expired'; + +const BASE_PATH = '/api/v1/storage'; +const FILE_ID = 'file_23367'; + +/** + * Equality plus `$in` — the two shapes the shared resolver issues — refusing + * every other shape loudly, so a combinator it does not implement can never + * read as a field that happened not to match. + */ +function matchesWhere(row: Record, where: unknown): boolean { + for (const [field, cond] of Object.entries((where ?? {}) as Record)) { + if (field.startsWith('$')) { + throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + } + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + const inList = (cond as { $in?: unknown }).$in; + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray(inList)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!inList.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +/** `owner_id` is a user nobody holds a key for, so the uploader shortcut is never what an arm travels through. */ +function makeEngine() { + const tables: Record>> = { + sys_api_key: [ + { id: 'key_live', key: hashApiKey(KEY_LIVE), prefix: 'osk_23367_li', user_id: 'u_holder', revoked: false, expires_at: '2999-01-01T00:00:00.000Z' }, + { id: 'key_revoked', key: hashApiKey(KEY_REVOKED), prefix: 'osk_23367_re', user_id: 'u_holder', revoked: true }, + { id: 'key_expired', key: hashApiKey(KEY_EXPIRED), prefix: 'osk_23367_ex', user_id: 'u_holder', revoked: false, expires_at: '2000-01-01T00:00:00.000Z' }, + ], + sys_user: [{ id: 'u_holder', email: 'u_holder@example.com' }], + sys_file: [ + { + id: FILE_ID, + key: 'files/23367.pdf', + name: '23367.pdf', + mime_type: 'application/pdf', + size: 12, + scope: 'record', + status: 'committed', + acl: 'private', + owner_id: 'u_uploader', + ref_object: 'contract', + ref_id: 'rec_open', + ref_field: 'attachment', + }, + ], + }; + return { + find: async (object: string, q: Record = {}) => { + if (object === 'contract') { + const context = (q.context ?? {}) as { userId?: string }; + return context.userId === 'u_holder' ? [{ id: 'rec_open' }] : []; + } + const rows = (tables[object] ?? []).filter((row) => matchesWhere(row, q.where)); + return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + findOne: async (object: string, q: Record = {}) => { + const rows = (tables[object] ?? []).filter((row) => matchesWhere(row, q.where)); + return rows[0] ?? null; + }, + }; +} + +function makeRes() { + const answer = { status: 200, json: undefined as Record | undefined }; + const api = { + json(data: Record) { answer.json = data; return api; }, + send() { return api; }, + status(code: number) { answer.status = code; return api; }, + header() { return api; }, + }; + return { res: api as unknown as IHttpResponse, answer }; +} + +let rootDirs: string[] = []; +let warnSpy: ReturnType; + +async function mount() { + const rootDir = join(tmpdir(), `os-23367-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + rootDirs.push(rootDir); + + const routes = new Map(); + const httpServer = { + get: (path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }, + post: (path: string, handler: RouteHandler) => { routes.set(`POST:${path}`, handler); }, + put: (path: string, handler: RouteHandler) => { routes.set(`PUT:${path}`, handler); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + const services = new Map(); + services.set('objectql', makeEngine()); + services.set('http-server', httpServer); + // No session path: this pin is about API keys. + services.set('auth', { api: { getSession: async () => undefined } }); + + const readyHooks: Array<() => Promise | void> = []; + // A real kernel with no `tenancy` registered: the branded "never + // registered" answer, so no posture refusal can be what an arm rides on. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + const ctx = { + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string) => { + const s = services.get(name); + if (!s) throw new Error(`service '${name}' not registered`); + return s; + }, + hook: (event: string, fn: () => Promise | void) => { + if (event === 'kernel:ready') readyHooks.push(fn); + }, + getKernel: () => kernel, + } as unknown as PluginContext; + + const plugin = new StorageServicePlugin({ + adapter: 'local', + basePath: BASE_PATH, + local: { rootDir, signingSecret: 'test-secret-23367' }, + bindToSettings: false, + }); + await plugin.init(ctx); + await plugin.start(ctx); + for (const hook of readyHooks) await hook(); + + const storage = services.get('storage') as { + getPresignedDownload: (key: string, ttl: number, opts?: unknown) => Promise; + }; + const mintSpy = vi.spyOn(storage, 'getPresignedDownload'); + const path = `${BASE_PATH}/files/:fileId/url`; + const handler = routes.get(`GET:${path}`); + if (!handler) throw new Error(`fixture: no handler registered for GET ${path}`); + + return { + call: async (rawKey: string) => { + const { res, answer } = makeRes(); + await handler( + { params: { fileId: FILE_ID }, query: {}, body: undefined, headers: { 'x-api-key': rawKey }, method: 'GET', path } as unknown as IHttpRequest, + res, + ); + return answer; + }, + minted: () => mintSpy.mock.calls.length, + }; +} + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(async () => { + warnSpy.mockRestore(); + vi.restoreAllMocks(); + for (const dir of rootDirs) await fs.rm(dir, { recursive: true, force: true }); + rootDirs = []; +}); + +describe('[#23367] the download door answers a key its lifecycle refuses with its own 403 and code', () => { + it('a revoked key: 403 API_KEY_REVOKED, and no capability is minted', async () => { + const h = await mount(); + const res = await h.call(KEY_REVOKED); + expect(res.status).toBe(403); + expect(res.json?.success).toBe(false); + expect(res.json?.error?.code).toBe('API_KEY_REVOKED'); + expect(h.minted()).toBe(0); + }); + + it('an expired key: 403 API_KEY_EXPIRED, and no capability is minted', async () => { + const h = await mount(); + const res = await h.call(KEY_EXPIRED); + expect(res.status).toBe(403); + expect(res.json?.error?.code).toBe('API_KEY_EXPIRED'); + expect(h.minted()).toBe(0); + }); + + it('an unknown key is no lifecycle refusal: the generic 401 AUTH_REQUIRED, nothing minted', async () => { + const h = await mount(); + const res = await h.call('osk_never_issued'); + expect(res.status).toBe(401); + expect(res.json?.error?.code).toBe('AUTH_REQUIRED'); + expect(h.minted()).toBe(0); + }); + + it('control: the same door serves a live key its signed URL', async () => { + const h = await mount(); + const res = await h.call(KEY_LIVE); + expect(res.status).toBe(200); + expect(h.minted()).toBe(1); + }); +}); diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 672e6af2fa2..a0f1a0213d8 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -15,7 +15,7 @@ import { sendOk, sendError, sendServerFault } from '@objectstack/types'; // (`src` under vitest aliases, `dist` under the published `exports`), so two // copies of the class make `instanceof` answer FALSE for a genuine instance. // See `packages/core/src/security/authz-store-unavailable.ts`. -import { isAuthzStoreUnavailableError } from '@objectstack/core'; +import { isAuthzStoreUnavailableError, isApiKeyLifecycleRefusalError } from '@objectstack/core'; // ADR-0069 — the authentication-policy gate's ONE decision, asked at this // family's door (`registerStorageFamily`), never re-derived here. import { evaluateAuthGate } from '@objectstack/core'; @@ -564,6 +564,14 @@ export function registerStorageFamily( sendError(res, err.status, err.code, err.message); return false; } + // [#23367, ruling A item 3] An API key its own lifecycle refused + // (revoked, expired, inactive), re-raised by brand by the authorizer: + // relayed the same way, as its declared 403 and ledger code — the answer + // REST gives the same key. Scoped to its brand like the arm above. + if (isApiKeyLifecycleRefusalError(err)) { + sendError(res, err.status, err.code, err.message); + return false; + } verdict = 'deny'; // a failed authz check must never fall open } if (verdict === 'unauthenticated') return refuseAnonymousDownload(res); diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 26ce5268fd2..2d72bc4509a 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -20,6 +20,9 @@ import { // judges a key-admitted request by the KEY's answer. resolveApiKeyAdmission, userAuthGateSourceOf, + // [#23367] The API-key lifecycle policy source, and its branded refusal. + apiKeyLifecyclePolicySourceOf, + isApiKeyLifecycleRefusalError, } from '@objectstack/core'; import type { IHttpServer, @@ -28,6 +31,7 @@ import type { ISecurityService, IStorageService, IFileAccessDelegate, + ApiKeyLifecyclePolicy, } from '@objectstack/spec/contracts'; import { OBSERVABILITY_METRICS_SERVICE, @@ -1162,6 +1166,8 @@ function buildAuthSessionResolver( type GateAuthService = { isAuthGateActive?(): boolean; resolveUserAuthGates?(userId: string, options?: { organizationId?: string }): Promise; + /** [#23367] The API-key lifecycle policy (`IAuthService.resolveApiKeyLifecyclePolicy`). */ + resolveApiKeyLifecyclePolicy?(): ApiKeyLifecyclePolicy; }; /** @@ -1233,6 +1239,10 @@ function buildAuthGateSessionReader( Date.now(), await resolveAdmissionTenancyPosture(registry), userAuthGateSourceOf(auth), + // [#23367] The API-key lifecycle policy, from the same service. A key + // its own lifecycle refuses (`lifecycle`) carries no posture and is + // left to the identity resolution below, which fails it closed. + apiKeyLifecyclePolicySourceOf(auth), ); if (admission.outcome === 'held') return { authGate: admission.gate }; if (admission.outcome !== 'none') return undefined; @@ -1406,6 +1416,8 @@ function buildFileReadAuthorizer( getSession, tenancyPosture, userAuthGates: userAuthGateSourceOf(resolveGateAuthService(registry)), + // [#23367] The API-key lifecycle policy, from the same service. + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(resolveGateAuthService(registry)), }); if (!authz.userId) return 'unauthenticated'; @@ -1461,6 +1473,12 @@ function buildFileReadAuthorizer( // the file-read door answers the 503 it is instead of a silent 'deny' // indistinguishable from a genuine refusal. if (isAuthzStoreUnavailableError(err)) throw err; + // [#23367] A key its own lifecycle refused (revoked, expired, inactive) + // is not a read verdict either: re-raised by brand for the download + // door, which relays it as its declared 403 and ledger code beside the + // store outage. Never a 'deny', which would answer it as a refusal on + // the record. + if (isApiKeyLifecycleRefusalError(err)) throw err; return 'deny'; // fail closed } }; diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 9b513551625..801eef2cf11 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -29,6 +29,7 @@ "AnalyticsResult (interface)", "AnalyticsStrategy (interface)", "ApiEndpointMatch (interface)", + "ApiKeyLifecyclePolicy (interface)", "ApprovalActionAttachment (interface)", "ApprovalActionKind (type)", "ApprovalActionRow (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 95141781216..77f65fbc1f2 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -29,6 +29,7 @@ "AnalyticsResult": "src/contracts/analytics-service.ts#AnalyticsResult (interface)", "AnalyticsStrategy": "src/contracts/analytics-service.ts#AnalyticsStrategy (interface)", "ApiEndpointMatch": "src/contracts/metadata-service.ts#ApiEndpointMatch (interface)", + "ApiKeyLifecyclePolicy": "src/contracts/auth-service.ts#ApiKeyLifecyclePolicy (interface)", "ApprovalActionAttachment": "src/contracts/approval-service.ts#ApprovalActionAttachment (interface)", "ApprovalActionKind": "src/contracts/approval-service.ts#ApprovalActionKind (type)", "ApprovalActionRow": "src/contracts/approval-service.ts#ApprovalActionRow (interface)", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index efbadc2f409..810ecbf9cbc 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -302,6 +302,13 @@ export const ERROR_CODE_LEDGER = { 'VALIDATION_FAILED', // record-level validation; carries `fields[]` (#3977) ], '@objectstack/runtime': [ + // [#23367, ruling A] 400 on `POST /keys` (`domains/keys.ts`): the signing + // asked for an `expires_at` further away than the API-key lifecycle + // policy's ceiling (`api_key_max_expiry_days`, one year by default). Not a + // VALIDATION_ERROR synonym: the body is well formed and the date is valid; + // the policy refuses its distance, and the remedy (ask for a nearer date, + // rotate before it) is the policy's own. + 'API_KEY_EXPIRY_TOO_LONG', // [#16041] `POST /analytics/query` / `/analytics/sql` refused a // `timeDimensions[].dateRange` string outside the closed date-range // vocabulary (`AnalyticsDateRangeSchema` in `data/analytics.zod.ts`, @@ -923,6 +930,18 @@ export const ERROR_CODE_LEDGER = { 'DEFAULT_RECORD_PAGE_CONFLICT', ], '@objectstack/core': [ + // [#23367, ruling A] The API-key lifecycle refusals: three distinct key + // states, decided once at the shared key-admission seam + // (`resolveApiKeyAdmission`, `security/api-key.ts`) and raised by + // `resolveAuthzContext` as the branded `ApiKeyLifecycleRefusalError` + // (`status: 403`, a located message naming the date). A face that relays + // the brand answers this code; a face that does not fails closed to its + // generic 401. None re-spells a standard member: `EXPIRED_TOKEN` is the + // session/JWT expiry whose remedy is a refresh, and no refresh exists for a + // key — the remedy here is a new key. + 'API_KEY_EXPIRED', // past its `expires_at`, or past the one-year grace of a key signed with none + 'API_KEY_INACTIVE', // no recorded use within the inactivity window; not revoked, a new key is the remedy + 'API_KEY_REVOKED', // its `revoked` flag is set 'ERR_BULK_RESULT_MISMATCH', 'FILTER_TOKEN_UNKNOWN', // filter references an unknown context token 'FILTER_TOKEN_UNRESOLVED', diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index 8e6d76e0daf..68913cc3e9e 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -59,6 +59,33 @@ export interface AuthResult { error?: string; } +/** + * [#23367, ruling A] The API-key lifecycle policy, as the auth service holds + * it ({@link IAuthService.resolveApiKeyLifecyclePolicy}). Every number is in + * whole days and already defaulted; the defaults are the ruling's (90, 365, + * 90). + */ +export interface ApiKeyLifecyclePolicy { + /** Days a key lives when its signing names no `expires_at`. */ + defaultExpiryDays: number; + /** The longest life a signing may ask for, in days from the signing; a longer `expires_at` is refused. */ + maxExpiryDays: number; + /** + * Days without a recorded use (`sys_api_key.last_used_at`) after which a + * key is refused as inactive. `0` turns the inactivity expiry off. + */ + inactivityDays: number; + /** + * When this deployment started enforcing the policy (ISO 8601), recorded + * once and kept in the `auth` settings. A key with no recorded use counts + * its inactivity from here (or from its own signing, when later), and a + * key signed with no expiry expires a year after it. Absent when it could + * not be recorded (no settings service): such keys are then judged on + * their own row alone. + */ + startedAt?: string; +} + export interface IAuthService { /** * Handle an incoming HTTP authentication request @@ -183,6 +210,22 @@ export interface IAuthService { options?: { organizationId?: string }, ): Promise[]>; + /** + * [#23367, ruling A] The API-key lifecycle policy in force, read from the + * auth configuration (the `auth` settings keys `api_key_*`, bound into the + * service). Two readers, one source: the signing door (`POST /keys`, + * `@objectstack/runtime`) applies the default expiry and refuses a longer + * one than the ceiling, and the key-admission seam + * (`resolveApiKeyAdmission`, `@objectstack/core`) judges inactivity and + * the grace of keys signed with no expiry. + * + * Synchronous and cheap: the answer is the configuration already held in + * memory, so a transport may ask it on every request that carries a key. + * + * @returns The policy, every number already defaulted + */ + resolveApiKeyLifecyclePolicy?(): ApiKeyLifecyclePolicy; + /** * Verify an OAuth 2.1 access token issued by this deployment's embedded * authorization server, for the MCP surface (#2698). Resolves to the diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9c69fcc72d3..918e52dac27 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3906,6 +3906,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/api-key-lifecycle.pin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/app-plugin.seed-locale-producer.test.ts", "verb": "findOne",