Skip to content

Commit d463b99

Browse files
fix(plugin-auth,plugin-audit): a SCIM-driven write names the SCIM connection that performed it, on its audit row (#30989)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D4o7Mmi48dsRAPhuMW15NwD)_ domain:server#5 · os-elon-musk · session_016WV4cyRYCwPvYJ68qTXzEP Fixes #30960 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:IdP 通过 SCIM 开通、停用、删除用户时,平台写的每条审计记录现在标明是哪个 SCIM 连接做的:`actor` 记为 `svc:scim:连接ID`,`metadata` 里带 `scim_connection_id`,`user_id` 仍为空(没有人在操作)。 - **为什么改**:以前这些记录的「谁做的」三栏全空,审计员查不出离职员工是被哪个 IdP 停用的;企业版 SCIM 离职流程(cloud#3667)和审计导出(cloud#3665)都要靠这个归属。 - **风险与回滚**:只在 SCIM 请求内生效,其他写入不变;bearer 和它的摘要永远进不了记录;两个连接并发时各记各的。这三点都有测试钉住。回滚即 revert。 - **先看**:① `scim-connection-service.ts` 的 `ScimRequestScope.connectionId` 和 `recordScimRequestConnection`(槽位为什么能到达每一次写入);② `auth-actor-attribution.ts` 中 `authSystemWriteContext` 开头的 SCIM 分支;③ plugin-audit `audit-writers.ts` 的 metadata 一段。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-transaction-scope.test.ts`,预期 9 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes A SCIM protocol request is performed by the identity provider's connection, which its bearer verifies as. Every `sys_audit_log` row such a request writes now names that connection. **`@objectstack/plugin-auth`** 1. **The slot.** `scimRequestScope`, the store `handleRequest` opens with `run(...)` around every `/scim/v2/*` request, is now typed `ScimRequestScope` and carries an optional `connectionId`. `recordScimRequestConnection(verification)` writes the verified connection's id into the CURRENT store (first verification wins; a no-op outside the scope and on a failed verification) and returns the verification unchanged. `scimRequestConnectionId()` reads it. 2. **The verifier records it.** The `verifyBearerToken` callback in `auth-manager-plugin-list.ts` now returns `recordScimRequestConnection(await verifyScimBearerToken(...))`. `verifyScimBearerToken` itself is unchanged and still pure. 3. **Every write reads it.** `authSystemWriteContext()`, the context builder the adapter's `withSystemContext` (and the membership reconciler) uses for every better-auth-owned write, returns `{ isSystem: true, actor: 'svc:scim:CONNECTION_ID' }` when the request has a verified connection, and builds the context exactly as before otherwise. No `attributedUserId`, so `user_id` stays null. The deactivation's ban write (`reconcileScimUserLifecycle`) goes through the vendor-bound adapter, so it carries the same actor. 4. `scimServicePrincipal(connectionId)` spells the label, `SCIM_SERVICE_PRINCIPAL_PREFIX` is `svc:scim:`. **`@objectstack/plugin-audit`** 5. New `scim-connection-attribution.ts` (`SCIM_CONNECTION_ID_KEY`, `scimConnectionOf`), in the shape of `impersonation-attribution.ts`. In `audit-writers.ts`, the CRUD ledger row's `metadata` gains `scim_connection_id` when the row's actor label starts with `svc:scim:`. The label is already recorded on `actor` by the existing service-principal fallback (`userId ?? session.actor`). Rows of every other actor are byte-identical. ## Why The card's measurement on a licensed objectos-ee serve: every SCIM-driven row had `actor`, `user_id` and `metadata` null. Reproduced here first, on `main` `256a4b72`, through `AuthManager.handleRequest` with a real bearer, over better-sqlite3, with plugin-audit's real writer and `sys_audit_log` registered: a SCIM create, a deactivation, a second create and a delete wrote 21 rows, and all 21 had `actor`, `user_id` and `metadata` null. After the change, on this PR's head, the same sequence wrote 21 rows. 20 have `actor: svc:scim:okta-30960`, `user_id: null` and `metadata: {"scim_connection_id":"okta-30960"}`. The 21st is noted below. Neither the bearer nor its digest occurs in any row, before or after. ## Tests Two homes, the split the package boundary already draws. plugin-auth does not depend on plugin-audit, and `auth-account-event-audit.test.ts` records that the row shape is plugin-audit's to pin. **`packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts`**: a new top-level `describe('[#30960] …')`, in the shared harness of the SCIM request scope. Each case drives `AuthManager.handleRequest()` with a real bearer on a real `ObjectQL` over better-sqlite3, and records every engine `insert` / `update` / `delete` with the context it reached the RAW engine with, beneath `withSystemContext`, which is the context the audit hooks are built from. - **(e)** A create, a deactivation and a delete: each request's writes all carry `isSystem: true` and `actor: 'svc:scim:okta-14522'`, and none carries `attributedUserId`, `impersonatedBy` or `performedBy`. The deactivation includes the `sys_user` ban write (`banned` set), and the delete includes the `sys_scim_user` delete and the `sys_user` ban write. - **(f)** Across all three, no write's data, query or context contains the bearer or its digest. The control: the capture holds at least 5 writes and does contain the label. - **(g)** A sign-up before a SCIM request and a sign-in after it carry no `actor` at all, and `svc:scim:` occurs nowhere in their writes. - **(h)** Isolation: a second connection (`entra-30960`) is minted. Both requests' credential probes are held at a barrier until BOTH have arrived, so both connections are verified before either request writes. The `sys_user` insert for each email carries its own request's connection. Every write the vendor stamps with a `connection_id` carries the actor of that same connection, and no write carries any other actor. **`packages/plugins/plugin-audit/src/audit-writers.test.ts`**: a new top-level `describe('audit writers — a SCIM connection is the actor of its writes (#30960)')`. - The create, the ban update and the delete with `session.actor: 'svc:scim:okta-30960'`: `actor` is the label, `user_id` is null, `metadata` is exactly `{"scim_connection_id":"okta-30960"}`. - `svc:cloud-control`, `svc:flow:mirror_status` and a bare `svc:scim`: actor recorded, no `metadata`. - A user's write beside a SCIM label: the user is the actor and there is no SCIM key. **Ablations** (`node scripts/ablation-replace.mjs`, anchor hit 1 → 0, run, restore proven blob == HEAD and `git diff HEAD` empty; plugin-auth's and plugin-audit's tests import the edited modules from `src/`, so no rebuild sits between the mutation and the run): | Leg | Mutation | Red | |:--|:--|:--| | A1 | the verifier does not record the connection | (e) (f) (h) | | A1b | the verifier records with `scimRequestScope.enterWith(...)`, the rejected design | (e) (f) (h): the writes never see it | | A2 | the bearer recorded as the connection id | (e) (f) (h) | | A3 | a process-wide slot instead of the per-request store | (g) (h) | | A5 | the `scim_connection_id` spread deleted | the three SCIM row cases | | A6 | any `svc:` label read as a SCIM connection | the other-principal case | Two first attempts did not apply as written. A5's empty replacement was refused (the tool asks for `--delete`) and ran nothing. A3's held half was refused because its replacement contained its own anchor, so only the reading half landed and that run stayed green while testing nothing. Both were re-run with corrected anchors, and the table reports the re-runs. The ablations ran at `4cd757af`, before the cookie case was dropped (see the acceptance notes); under A1, A1b and A2 that case also went red, and the cases the table names are the ones still in the file. A4 is in the acceptance notes. ## Acceptance notes - **Why the slot reaches every write.** The verifier runs in a continuation the endpoint handler does not descend from: the `enterWith` note in `scim-connection-service.ts`, measured on 1.7.2, which this PR does not re-open. A field written on the store that `handleRequest`'s `run(...)` opened is visible to both frames, because both run inside that one `run(...)`. Each request has its own object, so requests do not share it. A1b is the measurement: recording through `enterWith` turns (e), (f) and (h) red. The template is the existing (a)–(d) measurement in the same file. - **The actor form, `svc:scim:CONNECTION_ID`.** It follows the incumbent `svc:KIND:NAME` shape (`svc:flow:FLOW_NAME`, ADR-0014 D2), as the triage audit rules. It uses the connection id, not a name, because the id is immutable and keys `sys_scim_connection_binding`, `sys_scim_user` and the credential row. The credential row has no connection name; its `label` names one credential, which a rotation replaces. - **ADR tension, recorded and not resolved here.** ADR-0118 D5 says the actor dimension is two-valued and that "which automation" is answered by an association field. `ExecutionContext.performedBy`'s doc reads that as keeping `sys_audit_log.actor` to a user id or null. The incumbent `svc:flow:` label and `sys_audit_log.actor`'s own description ("a user id, svc:NAME, or null") put service principals on `actor`. This PR follows the triage ruling (the incumbent) and also writes D5's association field, `metadata.scim_connection_id`. - **Why the metadata key is read off the label.** A hook receives only `session.actor` and the closed `provenance` copy list that `@objectstack/objectql`'s `buildProvenance` builds. A typed carrier for the connection id would need a key on `ExecutionContext` and `HookContext.provenance` in `packages/spec` plus a line in objectql, and both are outside this card's file surface. So the plugin-audit half reads the one channel that already reaches it, and the column and the key cannot disagree. The cost is that the `svc:scim:` prefix is spelled in both packages (plugin-audit does not depend on plugin-auth). The two test files' literals hold the spellings equal. The plugin-audit file surface is used as the claim allows ("only if the row writer must read a new context field"). The writer reads no new field. It reads the existing `session.actor`, and its `metadata` block gains one key. - **`Clause-②: yes (widening)`, against the claim's provisional `no`.** Re-derived from the diff, by the criterion 「本卡放宽接受集或扩大公开面吗」. `authSystemWriteContext` is published (`export * from './auth-actor-attribution.js'`), and the built `packages/plugins/plugin-auth/dist/index.d.ts` declares its return type with the new `actor?: string` member, which widens the public surface. The SCIM helpers are not on the published surface (0 hits in `dist/index.d.ts`), and neither is plugin-audit's new module (0 hits). Nothing is narrowed. The changeset level is `patch` (`Metadata-protocol: none`). - **The one row that stays null.** The `sys_oauth_resource` create is written when the first request builds the better-auth instance, before any bearer is verified. It is the platform's boot write, not a provisioning one. The pins warm the instance with a SCIM read first, for that reason. - **`sys_member`.** `reconcile-membership.ts` builds its write context with the same `authSystemWriteContext()`, so a SCIM create that binds a membership names the connection. It is not driven here, because the rig assigns no organization. - **A case removed as vacuous.** A SCIM request that also carries a signed-in human's session cookie was pinned to credit no human. A4, the ablation that let the human win, stayed GREEN: on a SCIM request the session resolver credits no human even with a cookie. The SCIM-first ordering in `authSystemWriteContext` is therefore not observable through the door, and the case was deleted rather than kept as a pin that cannot fail. The ordering stays: the bearer is the request's only principal, and it saves a session resolution per write. - **Length bound, not measured on PostgreSQL.** `sys_audit_log.actor` is `maxLength: 255` (driver-sql makes it a `varchar(255)`), and so is `sys_scim_connection_credential.connection_id`. A connection id longer than 246 characters gives a label longer than 255. SQLite stores it. On PostgreSQL the ledger insert would be refused and the row lost through the writer's swallow-and-report path. Which connection ids are accepted is the mint door's decision (#30958, seat 3), where a bound of 246 would close it. - **Activity timeline.** The `sys_activity` mirror row of these writes still shows the system (`actor_id` null). The card is about `sys_audit_log`, so this is noted and not changed. - **Serial with #30958.** No PR existed for #30958 before this branch's first push or at opening. `origin/main` was merged at `4876c8cf` (it brought #30879's plugin-auth changes, and nothing conflicted). ## Verification All at `727dacbe`, the head this PR opens with (`origin/main` `1aeeafd9` merged in at `4876c8cf`; `origin/main` has since moved 11 commits, none touching `packages/plugins/plugin-auth` or `packages/plugins/plugin-audit`, so it is not merged again). - `pnpm --filter @objectstack/plugin-auth test`: `Test Files 229 passed (229)`, `Tests 4151 passed | 10 skipped (4161)` (lock `VERDICT command-exit 0`). - `pnpm --filter @objectstack/plugin-audit test`: `Test Files 75 passed (75)`, `Tests 1181 passed (1181)` (lock `VERDICT command-exit 0`). - `pnpm --filter @objectstack/plugin-auth typecheck` and `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0 (`check:test-typecheck: OK` for both). - The reproduction above, re-run on `ad43453b` (`727dacbe` adds one comment): 20 of 21 rows named, the bearer and its digest absent from every row. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands; all ran, plus `pnpm lint`, `pnpm check:cli-command-ids` and `pnpm check:adr-symbol-anchors`: all exit 0. Three first exited 3 (PREREQUISITE NOT MET): `check-plugin-teardown-shape.mjs --self-test` on a shallow clone, exit 0 after `git fetch --unshallow origin main`; `check:dual-build-cjs-loads` and `check:i18n` with no whole-tree `dist/`, exit 0 after `pnpm exec turbo run build --concurrency=2` (lock `VERDICT command-exit 0`, 119/119 tasks). `dispatch-gates.mjs --ran`: exit 0, "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED". - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. - Built `packages/plugins/plugin-auth/dist/index.d.ts`: `authSystemWriteContext()` returns a Promise of `{ isSystem: true; actor?: string; … }`; `scimRequestConnectionId`, `recordScimRequestConnection`, `scimServicePrincipal`: 0 hits. Built `packages/plugins/plugin-audit/dist/index.d.ts`: `scimConnectionOf`, `SCIM_CONNECTION_ID_KEY`: 0 hits. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cyRYCwPvYJ68qTXzEP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5106ae commit d463b99

9 files changed

Lines changed: 508 additions & 5 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-audit': patch
4+
---
5+
6+
fix(plugin-auth,plugin-audit): a SCIM-driven write names the SCIM connection that performed it, on its audit row
7+
8+
Clause-②: yes (widening) — `authSystemWriteContext()`, published through `export * from './auth-actor-attribution.js'`, now declares an optional `actor` member on the context it returns; nothing it returned before is removed or narrowed.
9+
Metadata-protocol: none
10+
11+
An identity provider's SCIM writes (the `sys_user` create, the deactivation's ban update, the delete, and the `sys_scim_*` rows written beside them) used to leave `sys_audit_log` rows with `actor`, `user_id` and `metadata` all null, so no row said which SCIM connection (which IdP) acted, and "who deactivated this account" had no answer.
12+
13+
- **`@objectstack/plugin-auth`.** The SCIM request scope `handleRequest` opens around every `/scim/v2/*` request now carries the connection the bearer verified as: the bearer verifier records it, and `authSystemWriteContext()` gives every write of that request `actor: 'svc:scim:CONNECTION_ID'` on `ExecutionContext.actor`, with no `attributedUserId`. The connection id is the label's identifier because it is immutable and keys every resource the connection provisions. Writes outside a SCIM request are unchanged.
14+
- **`@objectstack/plugin-audit`.** The audit writer records that label on `sys_audit_log.actor`, as it records any service principal, and the connection id alone as `metadata.scim_connection_id`, read off the same label so the two never disagree. `user_id` stays null, because no human acted. Rows of every other actor are unchanged.
15+
- **Never on a row:** the bearer, its digest and the credential row stay out of the write context, so none of them can reach `actor`, `metadata` or any other column.

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2997,3 +2997,74 @@ describe('audit writers — the activity row of an org-less record names the der
29972997
expect(asked).toBe(0);
29982998
});
29992999
});
3000+
3001+
/**
3002+
* [#30960] A SCIM connection's write names the connection, and only that write.
3003+
*
3004+
* plugin-auth hands every write of a SCIM protocol request to the engine as
3005+
* `{ isSystem: true, actor: 'svc:scim:CONNECTION_ID' }` with no
3006+
* `attributedUserId` (pinned at the engine by plugin-auth's
3007+
* `scim-transaction-scope.test.ts`, which spells the same label), and the
3008+
* engine surfaces it as `session.actor`. The ledger row then carries the
3009+
* label on `actor`, the connection id alone as `metadata.scim_connection_id`,
3010+
* and a null `user_id`: no human acted.
3011+
*/
3012+
describe('audit writers — a SCIM connection is the actor of its writes (#30960)', () => {
3013+
const SCIM_ACTOR = 'svc:scim:okta-30960';
3014+
3015+
it.each([
3016+
['the SCIM create', 'afterInsert', 'create', { result: { id: 'u_1', email: 'ann@example.com' } }],
3017+
[
3018+
'the deactivation’s ban update',
3019+
'afterUpdate',
3020+
'update',
3021+
{ previous: { id: 'u_1', banned: false }, result: { id: 'u_1', banned: true } },
3022+
],
3023+
['the delete', 'afterDelete', 'delete', { previous: { id: 'su_1', user_name: 'ann' }, result: { id: 'su_1' } }],
3024+
])('%s: actor names the connection, metadata carries its id, user_id stays null', async (_label, event, action, rows) => {
3025+
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
3026+
installAuditWriters(engine as any, 'test.audit');
3027+
await fire(event, {
3028+
object: action === 'delete' ? 'sys_scim_user' : 'sys_user',
3029+
input: { id: (rows.result as { id: string }).id },
3030+
...rows,
3031+
session: { isSystem: true, actor: SCIM_ACTOR },
3032+
});
3033+
const audit = created.find((c) => c.object === 'sys_audit_log');
3034+
expect(audit?.row.action).toBe(action);
3035+
expect(audit?.row.actor).toBe(SCIM_ACTOR);
3036+
expect(audit?.row.user_id).toBeNull();
3037+
expect(JSON.parse(audit?.row.metadata)).toEqual({ scim_connection_id: 'okta-30960' });
3038+
});
3039+
3040+
it('another service principal’s row is unchanged: actor recorded, no metadata', async () => {
3041+
for (const actor of ['svc:cloud-control', 'svc:flow:mirror_status', 'svc:scim']) {
3042+
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
3043+
installAuditWriters(engine as any, 'test.audit');
3044+
await fire('afterInsert', {
3045+
object: 'crm_lead',
3046+
input: { id: 'lead-1' },
3047+
result: { id: 'lead-1', name: 'Acme' },
3048+
session: { isSystem: true, actor },
3049+
});
3050+
const audit = created.find((c) => c.object === 'sys_audit_log');
3051+
expect(audit?.row.actor, actor).toBe(actor);
3052+
expect(audit?.row.metadata, actor).toBeUndefined();
3053+
}
3054+
});
3055+
3056+
it('a user’s write carries no SCIM key even beside a SCIM label: the user is the actor', async () => {
3057+
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
3058+
installAuditWriters(engine as any, 'test.audit');
3059+
await fire('afterInsert', {
3060+
object: 'crm_lead',
3061+
input: { id: 'lead-1' },
3062+
result: { id: 'lead-1', name: 'Acme' },
3063+
session: { userId: 'user-7', actor: SCIM_ACTOR },
3064+
});
3065+
const audit = created.find((c) => c.object === 'sys_audit_log');
3066+
expect(audit?.row.actor).toBe('user-7');
3067+
expect(audit?.row.user_id).toBe('user-7');
3068+
expect(audit?.row.metadata).toBeUndefined();
3069+
});
3070+
});

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
import type { LedgerRecordWriteAction } from './audit-log-field-redaction.js';
9292
import { SIGN_IN_STAMP_EVENT, isSignInStamp } from './sign-in-stamp.js';
9393
import { IMPERSONATED_BY_KEY, IMPERSONATED_BY_NAME_KEY, impersonatorOf } from './impersonation-attribution.js';
94+
import { SCIM_CONNECTION_ID_KEY, scimConnectionOf } from './scim-connection-attribution.js';
9495
import type { MentionGateInput } from './mention-recipient-gate.js';
9596
// [ADR-0139 E6] The ONE derivation of the two writer-stamped columns the
9697
// withheld-row rule reads, shared with the before-insert hook and the backfill.
@@ -2105,6 +2106,11 @@ export function installAuditWriters(
21052106
// a strict sys_user lookup); the service principal lands on `actor`.
21062107
const actorLabel: string | null =
21072108
userId ?? (typeof sess.actor === 'string' && sess.actor.trim() ? sess.actor.trim() : null);
2109+
// [#30960] The SCIM connection an identity provider's provisioning write
2110+
// was performed by: read off the label `actor` carries
2111+
// (`svc:scim:CONNECTION_ID`, see `scim-connection-attribution.ts`), so the
2112+
// association field and the column can never disagree.
2113+
const scimConnectionId: string | undefined = scimConnectionOf(actorLabel);
21082114
// [commit 1408fe385, honouring #8287's ruling] The audited RECORD'S OWN organization
21092115
// wins; the acting session's active organization is the fallback. ⛔ Do not
21102116
// flip this back to `sess.organizationId ?? recordOrgId`.
@@ -2227,13 +2233,19 @@ export function installAuditWriters(
22272233
// `impersonated_by` follows the same rule: present ONLY on a write made
22282234
// from an impersonation session, beside a `user_id` that stays the
22292235
// impersonated user. No key ⇒ that user acted themselves.
2230-
if (performedByClientId || impersonatedBy || signInStamp) {
2236+
//
2237+
// `scim_connection_id` follows the same rule: present ONLY on a write a
2238+
// SCIM connection performed, beside the `svc:scim:CONNECTION_ID` actor it
2239+
// is read from and a `user_id` that stays null. No key ⇒ no SCIM
2240+
// connection acted.
2241+
if (performedByClientId || impersonatedBy || signInStamp || scimConnectionId) {
22312242
auditRow.metadata = safeStringify({
22322243
// A sign-in stamp names its event the way a `config_change` row does
22332244
// (`metadata.event`); every other CRUD row still carries no event.
22342245
...(signInStamp ? { event: SIGN_IN_STAMP_EVENT } : {}),
22352246
...(performedByClientId ? { performed_by: performedByClientId, on_behalf_of: userId ?? null } : {}),
22362247
...(impersonatedBy ? { [IMPERSONATED_BY_KEY]: impersonatedBy } : {}),
2248+
...(scimConnectionId ? { [SCIM_CONNECTION_ID_KEY]: scimConnectionId } : {}),
22372249
});
22382250
}
22392251

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which SCIM connection performed a write — the metadata key the audit writer
5+
* stamps on a SCIM-provisioned row, in one place.
6+
*
7+
* An identity provider (Okta, Entra ID) provisions, deactivates and
8+
* deprovisions users through the SCIM 2.0 endpoints, authenticated by a bearer
9+
* that `@objectstack/plugin-auth` verifies as one CONNECTION. No human acts, so
10+
* the row's `user_id` stays null. plugin-auth names the connection as the
11+
* write's service principal on the attribution-only `ExecutionContext.actor`,
12+
* spelled `svc:scim:CONNECTION_ID` (`scimServicePrincipal` in plugin-auth's
13+
* `scim-connection-service.ts`, the incumbent `svc:<name>` shape of ADR-0014
14+
* D2), and the engine hands it to hooks as `HookContext.session.actor`. The
15+
* writer records it on `sys_audit_log.actor`, as it does every service
16+
* principal, and ALSO under this key:
17+
*
18+
* - `sys_audit_log.metadata.scim_connection_id` — the connection id alone, so
19+
* an auditor (or the audit export) asks "which connection did this" of a
20+
* named field rather than by parsing the label (ADR-0118 D5: "which
21+
* non-user acted" is answered by an association field).
22+
*
23+
* One source, so the two never disagree: the key is READ OFF the label the
24+
* `actor` column carries, never off a second carrier. It is present only on a
25+
* row whose actor is a SCIM connection; the ABSENCE is the record that no SCIM
26+
* connection performed the write. Nothing in the authorization path reads it,
27+
* and neither the bearer nor its digest ever reaches the label.
28+
*
29+
* The prefix is spelled here rather than imported: this package does not
30+
* depend on `@objectstack/plugin-auth`. The two spellings are held equal by
31+
* plugin-auth's `scim-transaction-scope.test.ts` (the label its writes carry)
32+
* and this package's `audit-writers.test.ts` (the label this module reads).
33+
*/
34+
35+
/** The `ExecutionContext.actor` prefix of a SCIM connection's service-principal label. */
36+
export const SCIM_SERVICE_PRINCIPAL_PREFIX = 'svc:scim:';
37+
38+
/** The metadata key naming the SCIM connection that performed the write. */
39+
export const SCIM_CONNECTION_ID_KEY = 'scim_connection_id';
40+
41+
/**
42+
* The SCIM connection a row's actor label names, or `undefined` when the actor
43+
* is not a SCIM connection (a user id, another service principal, or none).
44+
*/
45+
export function scimConnectionOf(actorLabel: string | null | undefined): string | undefined {
46+
if (typeof actorLabel !== 'string' || !actorLabel.startsWith(SCIM_SERVICE_PRINCIPAL_PREFIX)) return undefined;
47+
const connectionId = actorLabel.slice(SCIM_SERVICE_PRINCIPAL_PREFIX.length);
48+
return connectionId.length > 0 ? connectionId : undefined;
49+
}

‎packages/plugins/plugin-auth/src/auth-actor-attribution.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161

6262
import { AsyncLocalStorage } from 'node:async_hooks';
6363
import { agentForwardPerformedBy } from './agent-forward-identity-view.js';
64+
import { scimRequestConnectionId, scimServicePrincipal } from './scim-connection-service.js';
6465

6566
/**
6667
* The human a better-auth write is credited to, as the session names them.
@@ -212,10 +213,21 @@ function attributedActorOf(resolved: Awaited<ReturnType<AuthActorResolver>>): At
212213
*/
213214
export async function authSystemWriteContext(): Promise<{
214215
isSystem: true;
216+
actor?: string;
215217
attributedUserId?: string;
216218
impersonatedBy?: string;
217219
performedBy?: { clientId: string };
218220
}> {
221+
// [#30960] A SCIM protocol request is performed by the identity provider's
222+
// CONNECTION, which its bearer verified as — no human acted. Its writes are
223+
// attributed to that connection as a service principal, on
224+
// `ExecutionContext.actor` (`svc:scim:CONNECTION_ID`, see
225+
// `scimServicePrincipal`), and `attributedUserId` stays absent, so
226+
// `sys_audit_log.user_id` stays null.
227+
const scimConnectionId = scimRequestConnectionId();
228+
if (scimConnectionId !== undefined) {
229+
return { isSystem: true, actor: scimServicePrincipal(scimConnectionId) };
230+
}
219231
const actor = await resolveAttributedActor();
220232
const attributedUserId = actor?.userId;
221233
// An impersonation session's write is credited to the impersonated user

‎packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,9 @@ export async function buildPluginList(this: PluginListHost): Promise<any[]> {
793793
if (enabled.scim) {
794794
await this.addOptionalPlugin(plugins, 'scim', async () => {
795795
const { scim } = await import('@better-auth/scim');
796-
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
796+
const { verifyScimBearerToken, recordScimRequestConnection } = await import(
797+
'./scim-connection-service.js'
798+
);
797799
const secret = this.resolveAuthSecret();
798800
return scim({
799801
connections: [],
@@ -811,7 +813,15 @@ export async function buildPluginList(this: PluginListHost): Promise<any[]> {
811813
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
812814
const engine = this.config.dataEngine;
813815
if (!engine) return null; // no store to verify against — fail closed
814-
return verifyScimBearerToken(engine as never, secret, input.token);
816+
// The verified connection is WRITTEN INTO the scope
817+
// `handleRequest` opened — a field on the store both this frame
818+
// and the endpoint handler read — not entered as a new store, so
819+
// every provisioning write of the request is attributed to it
820+
// (`ScimRequestScope.connectionId` says why that reaches the
821+
// writes where an `enterWith` did not).
822+
return recordScimRequestConnection(
823+
await verifyScimBearerToken(engine as never, secret, input.token),
824+
);
815825
},
816826
},
817827
// [#14360] The host half of `active`: stable @better-auth/scim

‎packages/plugins/plugin-auth/src/objectql-adapter.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,9 @@ export function withSystemContext(engine: IDataEngine): IDataEngine {
754754
// better-auth-owned write: the attributed human, the administrator
755755
// impersonating them on an impersonation session, and a forwarded AI agent's
756756
// client ([ADR-0090 D10 rule 5, #28367]), so the audit row records each side.
757+
// Inside a SCIM protocol request it is the verified connection instead, as
758+
// the service principal `actor: 'svc:scim:CONNECTION_ID'` with no human
759+
// ([#30960]; `ScimRequestScope.connectionId` says how it reaches here).
757760
const asAttributedSystem = async (q: any) => ({
758761
...(q ?? {}),
759762
context: { ...(await authSystemWriteContext()), ...(q?.context ?? {}) },

‎packages/plugins/plugin-auth/src/scim-connection-service.ts‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,93 @@ import { AsyncLocalStorage } from 'node:async_hooks';
7474
* locally (#3653). Core flows never had native DB transactions before (the
7575
* adapter factory's default is the sequential as-is fallback), so this keeps
7676
* them at their historical posture rather than weakening anything.
77+
*
78+
* The store also carries the request's ATTRIBUTION slot — see
79+
* {@link ScimRequestScope.connectionId}.
7780
*/
78-
export const scimRequestScope = new AsyncLocalStorage<{ scim: true }>();
81+
export const scimRequestScope = new AsyncLocalStorage<ScimRequestScope>();
82+
83+
/**
84+
* One SCIM protocol request's scope: opened empty by `handleRequest`, its
85+
* `connectionId` filled in once by the bearer verifier.
86+
*/
87+
export interface ScimRequestScope {
88+
scim: true;
89+
/**
90+
* The SCIM connection this request's bearer verified as — the IdP that is
91+
* acting. Recorded by {@link recordScimRequestConnection} from inside the
92+
* `verifyBearerToken` callback; read by {@link scimRequestConnectionId} when
93+
* the adapter builds each write's context (`authSystemWriteContext`), so
94+
* every provisioning write of the request — the `sys_user` create, the
95+
* deactivation's ban update, `sys_member`, `sys_scim_*` — is attributed to
96+
* the connection.
97+
*
98+
* Why a MUTABLE slot on a store `handleRequest` already opened, and not a
99+
* new store: the verifier runs in a continuation the endpoint handler does
100+
* not descend from (the `enterWith` note above, measured). A store the
101+
* verifier opened or entered never reaches the writes. The object opened
102+
* here, though, is the store of BOTH frames — the verifier's and the
103+
* handler's — because both run inside the one `run(...)`; writing a field
104+
* on it is seen by every later read in the request. Each request gets its
105+
* own object from its own `run(...)`, so two concurrent requests on two
106+
* connections never see each other's slot (pinned by
107+
* `scim-transaction-scope.test.ts`, the attribution block).
108+
*
109+
* Only the connection id is kept. The bearer, its digest and the credential
110+
* row are never placed here, so no write context can carry them.
111+
*/
112+
connectionId?: string;
113+
}
79114

80115
/** Is the current async chain inside an authenticated SCIM protocol request? */
81116
export function inScimRequestScope(): boolean {
82117
return scimRequestScope.getStore()?.scim === true;
83118
}
84119

120+
/**
121+
* Record the connection a SCIM bearer verified as on the current request's
122+
* scope, and hand the verification back unchanged.
123+
*
124+
* A no-op outside a SCIM request scope, on a failed verification (`null`), and
125+
* once a connection is already recorded: the first verified connection of a
126+
* request is the one its writes are attributed to, so a later call cannot
127+
* re-attribute writes already in flight.
128+
*/
129+
export function recordScimRequestConnection<T extends ScimBearerVerification | null>(verification: T): T {
130+
const scope = scimRequestScope.getStore();
131+
if (scope?.scim === true && verification && scope.connectionId === undefined) {
132+
scope.connectionId = verification.connection.id;
133+
}
134+
return verification;
135+
}
136+
137+
/** The connection the current SCIM request's bearer verified as, or `undefined`. */
138+
export function scimRequestConnectionId(): string | undefined {
139+
const scope = scimRequestScope.getStore();
140+
return scope?.scim === true ? scope.connectionId : undefined;
141+
}
142+
143+
/**
144+
* The service-principal label of a SCIM connection, as it lands on
145+
* `ExecutionContext.actor` and from there on `sys_audit_log.actor`.
146+
*
147+
* `svc:scim:CONNECTION_ID` — the incumbent `svc:<name>` shape
148+
* (`ExecutionContext.actor`, ADR-0014 D2) with the kind segment first, the way
149+
* an automation run is `svc:flow:FLOW_NAME`. The connection id is the
150+
* identifier and not a display name: it is immutable, it scopes every resource
151+
* the connection provisions, it is what `sys_scim_connection_binding` and
152+
* `sys_scim_connection_credential` are keyed by, and the credential row holds
153+
* no connection name to use instead (its `label` names one credential, which a
154+
* rotation replaces). The audit writer also records the id on its own, as
155+
* `metadata.scim_connection_id` (ADR-0118 D5's association field).
156+
*/
157+
export function scimServicePrincipal(connectionId: string): string {
158+
return `${SCIM_SERVICE_PRINCIPAL_PREFIX}${connectionId}`;
159+
}
160+
161+
/** The `ExecutionContext.actor` prefix every SCIM connection's label starts with. */
162+
export const SCIM_SERVICE_PRINCIPAL_PREFIX = 'svc:scim:';
163+
85164
/** The ObjectStack-owned credential store (see platform-objects/identity). */
86165
export const SCIM_CREDENTIAL_OBJECT = 'sys_scim_connection_credential';
87166

0 commit comments

Comments
 (0)