Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/31739-ai-service-account-register.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/core': patch
'@objectstack/runtime': patch
'@objectstack/plugin-auth': patch
'@objectstack/plugin-security': patch
'@objectstack/platform-objects': patch
---

feat(plugin-auth): register an AI service account and mint its API key (ADR-0151 D1, D4)

Clause-②: yes (widening) — two new auth doors (`POST /api/v1/auth/service-account/register`, `POST /api/v1/auth/service-account/create-key`), two new `sys_user` actions, and `readRequestedApiKeyExpiry`, `signApiKeyExpiry`, `ApiKeyExpiryRefusal` and `ApiKeyExpiryCeilingRefusal` newly exported by `@objectstack/core`. Nothing existing is removed, retyped or refused: the break-glass guard and the upgrade-report audience now leave AI service accounts out, and an account row is only written by the new door (or by a seed).
Metadata-protocol: none

An organization administrator, or a platform administrator, can now register an **AI service account**: Setup → Users → *Register AI Service Account*, or `POST /api/v1/auth/service-account/register` with a `name` and an optional `ownerId`. One act writes a `sys_user` of kind `agent`, accountable to the named owner (the caller by default; the owner must be an active member of the organization), with a placeholder email that is never delivered and never collides with a real address, and a plain `member` membership of the caller's active organization. If either write is refused, neither stays.

The account's owner, an administrator of its organization, or a platform administrator mints its API key: *Create API Key* on the account, or `POST /api/v1/auth/service-account/create-key` with `userId`. The key is shown once, bound to the account's organization, and signed under the same expiry policy and ceiling as `POST /api/v1/keys` (the two now share one rule in `@objectstack/core`); a date past the ceiling is refused `400 VALIDATION_FAILED` at `expires_at`, with the message `POST /api/v1/keys` gives, which names the latest date allowed. `POST /api/v1/keys` answers exactly as before. The key acts as the account, under the agent ceiling.

Both doors read the caller from a signed-in session only: anonymous is `401`, and a caller carrying only an API key (the account's own included) is `401` too, so an account can neither register another nor mint its own key. Refusals reuse the existing codes: `403 PERMISSION_DENIED`, `400 VALIDATION_FAILED`, `404 RESOURCE_NOT_FOUND` for an id that is not an account, `409 RESOURCE_CONFLICT` for an account with no active membership, `503` when the authority read is unavailable.

An AI service account is never counted as one of the administrators the break-glass guard keeps (whatever grant or membership grade it holds), and never receives the upgrade report or counts as an organization's administrator there.
20 changes: 20 additions & 0 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting.
layer, is the boundary for what actions can do.
- **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and
send it as `x-api-key` — for CI, scripts, and agents without a browser.
- **AI service account (unattended).** When an agent runs on its own, not
on your behalf, give it its own identity instead of your key. An
organization administrator (or a platform administrator) registers one
(Setup → Users → *Register AI Service Account*, or
`POST /api/v1/auth/service-account/register` with `name` and an optional
`ownerId`): a user of kind `agent`, a plain member of the active
organization, accountable to a named person who is a member there (the
caller by default). Its name carries its purpose. It never signs in: it has
no password and no session, and its email is a placeholder that is never
delivered. Its owner, or an administrator of its organization, mints its
key (*Create API Key* on the account, or
`POST /api/v1/auth/service-account/create-key` with `userId`), shown once,
bound to the account's organization and under the same expiry ceiling as
any key. The key acts as the account: only the grants given to the account
count, an account never holds administrator standing, and the agent
ceiling clears View All, Modify All and the other elevated permissions from
whatever sets it is given. Both doors take a signed-in session only, so a
key — the account's own included — can neither register an account nor mint
one's key. An account is never counted as one of the administrators that
keep an organization manageable, and never receives administrator mail.

Per client:

Expand Down
13 changes: 13 additions & 0 deletions content/docs/permissions/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ ObjectStack implements a multi-layered security model:
└─────────────────────────────────────┘
```

### AI service accounts

An unattended agent gets its own identity, an **AI service account**: a user
of kind `agent`, accountable to a named person, holding a plain membership of
one organization. Its permissions are exactly the grants given to it; it never
holds administrator standing, and the agent ceiling clears View All, Modify
All, transfer, restore, purge, system permissions and admin scopes from any
set it is given (giving it such a set is refused). Registration is an
organization or platform administrator's act; its owner or an administrator
mints its API key. It never signs in, and it is never counted among the
administrators that keep an organization manageable. See
[AI service account](/docs/ai/agents#connect-your-ai-byo-ai-over-mcp).

### Security Layers

1. **Object Permissions**: Control create, read, update, delete on entire objects
Expand Down
40 changes: 20 additions & 20 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **57 of the 284 sites are spelled that way**. A
forwarding shim cannot, and **57 of the 286 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **284** |
| 175 write call sites | quoted in the merged changeset | **286** |
| 24 carrying no tenant context | quoted in the merged changeset | **3** provable and tenancy-enabled; **30** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **201 of 284** decidable, **83** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 161 decidably elevated, 0 decidably not, 113 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **202 of 286** decidable, **84** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 161 decidably elevated, 0 decidably not, 115 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -211,17 +211,17 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 62 sites reached
through an erased (`any`) receiver, and the 75 that name their object through a
through an erased (`any`) receiver, and the 76 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
161 of 284 (57%) as decidably elevated, with 113 more whose elevation is a
161 of 286 (56%) as decidably elevated, with 115 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `3 / 284`, and say what it is**: the sites whose options argument was
⇒ **Cite `3 / 286`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **30 further sites** have an options argument this
Expand All @@ -233,31 +233,31 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **284** |
| …whose object name is statically decidable | 201 |
| …whose object name is chosen at run time | 83 |
| write call sites on the application surface | **286** |
| …whose object name is statically decidable | 202 |
| …whose object name is chosen at run time | 84 |
| …against an object with tenancy ENABLED | 195 |
| …against an object that declares tenancy off | 6 |
| threading a tenant context | 217 |
| …against an object that declares tenancy off | 7 |
| threading a tenant context | 219 |
| PROVABLY carrying none (options read, no context key) | **10** |
| …of those, against a decidably tenancy-enabled object | **3** |
| options argument UNREADABLE — may or may not carry one | 57 |
| …of those, against a decidably tenancy-enabled object | 30 |
| threading a decidably ELEVATED (`isSystem`) context | 161 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 113 |
| threading a context whose elevation is a run-time fact | 115 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 222 |
| receiver carried a readable engine type | 224 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 126 |
| object name spelled through a `const` | 75 |
| object name spelled through a `const` | 76 |
| object name is an `object: string` parameter | 10 |
| object name is some other run-time expression | 73 |
| object name is some other run-time expression | 74 |

### Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-11-09 at `6ae0dd203`.
Measured on 2026-11-09 at `3c52359aa`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1094 |
| engine-shaped types recognised | 123 |
| tracked non-test sources scanned | 1097 |
| engine-shaped types recognised | 124 |
| declared objects in the registry | 133 |
| same-named calls subtracted as non-engine | 253 |
| same-named calls subtracted as non-engine | 255 |

{/* END GENERATED: tenant-audit-census */}
22 changes: 12 additions & 10 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 284 |
| Object name statically decidable | 201 |
| Object name chosen at run time | 83 |
| Write call sites | 286 |
| Object name statically decidable | 202 |
| Object name chosen at run time | 84 |
| Against a tenancy-enabled object | 195 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 217 |
| Against an object declaring tenancy off | 7 |
| Threading a tenant context | 219 |
| Provably carrying none | 10 |
| …and decidably tenancy-enabled | 3 |
| Options argument unreadable | 57 |
| …and decidably tenancy-enabled | 30 |
| Threading a decidably elevated context | 161 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 113 |
| Threading a context of undecidable elevation | 115 |

## Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-11-09 at `6ae0dd203`.
Measured on 2026-11-09 at `3c52359aa`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1094 |
| engine-shaped types recognised | 123 |
| tracked non-test sources scanned | 1097 |
| engine-shaped types recognised | 124 |
| declared objects in the registry | 133 |
| same-named calls subtracted as non-engine | 253 |
| same-named calls subtracted as non-engine | 255 |

## Every site

Expand Down Expand Up @@ -182,6 +182,8 @@ Measured on 2026-11-09 at `6ae0dd203`.
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `update` | `sys_scim_connection_credential` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/scim-group-projection.ts` | `delete` | `sys_user_permission_set` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/scim-group-projection.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/service-account-doors.ts` | `insert` | `sys_api_key` | disabled | context, elevation undecidable | 1 |
| `packages/plugins/plugin-auth/src/service-account-doors.ts` | `insert` | `USER_OBJECT` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/security/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,104 @@ export function apiKeyLifecyclePolicySourceOf(
return () => service.resolveApiKeyLifecyclePolicy!();
}

// ── [#23367, ADR-0151 D4] The life a key is signed with ──────────────────────
//
// The one rule every door that mints a key applies — `POST /keys` for the
// caller's own key, and the AI service account key door — so a person's key
// and an account's key are bound by the same default life and the same
// ceiling. Two steps, because the doors read the request before they read the
// policy (a body that does not parse is refused without asking the auth
// service, whose outage answers 503).

/**
* [#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 API_KEY_EXPIRY_CEILING_SKEW_MS = 60_000;

/** A request `expires_at` that is no date, or not in the future: always `400 VALIDATION_ERROR`. */
export interface ApiKeyExpiryRefusal {
ok: false;
status: 400;
code: 'VALIDATION_ERROR';
message: string;
}

/**
* A date further away than the policy's ceiling. It carries no wire code: each
* door answers it with its own ledgered code (`POST /keys` with
* `API_KEY_EXPIRY_TOO_LONG`, under `@objectstack/runtime`), and `latest` is the
* latest expiry a key signed now may have.
*/
export interface ApiKeyExpiryCeilingRefusal {
ok: false;
status: 400;
ceiling: true;
latest: string;
message: string;
}

/**
* Step 1, the request: the `expires_at` a caller asked for, as epoch
* milliseconds, or `undefined` when it asked for none (absent or empty). A
* number below 1e12 is read as seconds, a larger one as milliseconds. A value
* that does not parse as a date, or is not in the future, is refused `400
* VALIDATION_ERROR`.
*/
export function readRequestedApiKeyExpiry(
requested: unknown,
nowMs: number,
): { ok: true; requestedMs?: number } | ApiKeyExpiryRefusal {
if (requested == null || requested === '') return { ok: true };
const ms = typeof requested === 'number'
? (requested < 1e12 ? requested * 1000 : requested)
: Date.parse(String(requested));
if (Number.isNaN(ms)) {
return { ok: false, status: 400, code: 'VALIDATION_ERROR', message: 'Invalid expires_at: must be a parseable date' };
}
if (ms <= nowMs) {
return { ok: false, status: 400, code: 'VALIDATION_ERROR', message: 'Invalid expires_at: must be in the future' };
}
return { ok: true, requestedMs: ms };
}

/**
* Step 2, the policy: the `expires_at` the key is signed with. No date asked
* for gets the policy's default life (never a permanent key), or no expiry
* when no policy is configured (a kernel with no `auth` service has no auth
* configuration). A date further away than the policy's ceiling is refused
* with a message naming the latest date that would be accepted (each door
* picks the wire code); exactly the ceiling is accepted.
*/
export function signApiKeyExpiry(
policy: ApiKeyLifecyclePolicy | undefined,
requestedMs: number | undefined,
nowMs: number,
): { ok: true; expiresAt?: string } | ApiKeyExpiryCeilingRefusal {
if (requestedMs === undefined) {
return policy
? { ok: true, expiresAt: new Date(nowMs + policy.defaultExpiryDays * DAY_MS).toISOString() }
: { ok: true };
}
if (policy && requestedMs > nowMs + policy.maxExpiryDays * DAY_MS + API_KEY_EXPIRY_CEILING_SKEW_MS) {
const latest = new Date(nowMs + policy.maxExpiryDays * DAY_MS).toISOString();
return {
ok: false,
status: 400,
ceiling: true,
latest,
message:
`expires_at ${new Date(requestedMs).toISOString()} is further away than this deployment allows: the `
+ `Authentication API key policy's Maximum key lifetime is ${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.',
};
}
return { ok: true, expiresAt: new Date(requestedMs).toISOString() };
}

/**
* [#23367] The three lifecycle refusals, each declared as its wire pair — the
* ADR-0112 code (registered under `@objectstack/core`) beside the status it
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export {
API_KEY_REVOKED_CODE,
API_KEY_LIFECYCLE_REFUSAL_STATUS,
apiKeyLifecyclePolicySourceOf,
// [ADR-0151 D4] The one signing-expiry rule both mint doors apply.
readRequestedApiKeyExpiry,
signApiKeyExpiry,
type ApiKeyExpiryRefusal,
type ApiKeyExpiryCeilingRefusal,
ApiKeyLifecycleRefusalError,
isApiKeyLifecycleRefusalError,
type ApiKeyLifecyclePolicySource,
Expand Down
Loading
Loading