Skip to content

feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names - #31443

Merged
objectstack-fleet[bot] merged 27 commits into
mainfrom
claude/project-thread-dv4dek
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 27 commits into
mainfrom
claude/project-thread-dv4dek

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Refs #30959

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:身份提供商经 SCIM 推送的组,现在可以按平台管理员保存的映射,给组成员授予一个普通权限集;成员移出组,授权随之撤销。
  • 为什么改:此前 IdP 推送的组只落库、不授权,客户必须把每个组成员关系再手工授一遍权限集(卡 plugin-auth: a SCIM group push grants nothing — no projection maps a group to a role or permission set, though last-admin-guard counts it a live write shape #30959,裁决「以上都同意」)。
  • 风险与回滚:映射永远不能授予管理员身份,保存时用 plugin-security 自己的组织边界判定拒绝;手工授予的权限从不被对账删除。回滚即撤掉本 PR,没有映射时行为与今天一致。
  • 先看:packages/plugins/plugin-auth/src/scim-group-projection.ts(对账只删带本投影标记的授权),packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts(保存时的边界拒绝),packages/plugins/plugin-security/src/delegated-admin-gate.ts(判定函数原样上提)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-group-projection.test.ts,预期 14 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

An identity provider that pushes a group over SCIM now changes what its members may do, through a mapping a platform administrator saves.

  • Storage. New platform object sys_idp_group_mapping (packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts): one row per mapping with typed columns source_kind (closed select, V1 value scim, the seam plugin-auth SSO: expose the operator knobs Enterprise SSO needs — IdP-initiated sign-in, IdP group to role / permission set, and refusing unknown users instead of JIT #30967 reuses for SSO group claims), source_id (the SCIM connection), group_external_id or group_display_name (exactly one), permission_set and organization_id. The data API only lists it. One row in PLATFORM_OBJECTS_BY_PACKAGE.
  • Writer. POST /api/v1/auth/admin/scim/set-group-mapping (connectionId, groupExternalId or groupDisplayName, permissionSet) and POST /api/v1/auth/admin/scim/delete-group-mapping (mappingId), behind the same platform-admin gate as /admin/scim/mint-credential. Both are in the route ledger, its conformance pin and the two dogfood admin-route censuses. The organization is the one that owns the connection's live credentials (active and unexpired, the same check the SCIM bearer verifier uses); a connection with no credential is refused reference_not_found, one whose every credential is revoked or expired, or whose live credentials name no organization or two, rule_violation. Re-saving the same group and set is idempotent in that organization; when the same-key row was saved while another organization owned the connection, the save is refused 409 RESOURCE_CONFLICT naming that mapping (delete it first), so the answer never names an organization the row does not carry.
  • Admin boundary. The save refuses every set outside the organization boundary with 400 VALIDATION_FAILED, field permissionSet, code rule_violation, and stores nothing: admin_full_access, the organization-administrator sets, the agent ceiling sets, a set with a superuser wildcard or an adminScope, and a set with a capability not scoped to the organization. The judgement is plugin-security's own organizationBoundaryFailure, lifted verbatim from a private method of DelegatedAdminGate to a module function (the method is now a one-line delegate) and served as an optional member of the registered security service. plugin-auth takes no dependency on plugin-security and keeps no copy of the list; it feature-detects the member and fails closed (503, nothing saved, nothing granted).
  • Spec. One optional member, ISecurityService.organizationBoundaryFailure?(setName: string), resolving to the failure reason as a string or null when the set is inside the boundary (packages/spec/src/contracts/security-service.ts), declared in registered-security-service-members.pin.test.ts. Optional, so every existing implementer still type-checks.
  • Mechanism. scim({ … }) in auth-manager-plugin-list.ts now takes the vendor projection (createScimGroupProjection). roles.map returns the connection's mapping rows that match the group (by external id when the row names one, else by exact display name); roles.exists re-judges the boundary at grant time; reconcileUser writes through the ObjectQL engine inside the SCIM transaction with the request's system context, so the audit actor is svc:scim:CONNECTION_ID. An engine refusal becomes the vendor's APIError, never a SCIM 500.
  • Provenance. A projected grant carries reason = scim-group-projection: provisioning domain 'ID'. Reconcile deletes only rows with that exact marker, so a grant made by hand, including one for the same set, is never removed, and a set already held by hand is not written again. Saving or deleting a mapping re-runs the vendor's reconcileSCIMProjection for the connection, so the change applies to current members at once.
  • Last-admin guard. Its texts no longer tell operators that "the SCIM group mapping is too broad": a mapping never moves administrator standing, so the refusal now says to grant another account administrator standing first.
  • Translations. The new object's labels are translated in every translated locale main ships (now nine, with pt-BR, ru and de); the bundles are regenerated, and bundle-ownership.test.ts lists the object.
  • Ownership ceremony. @objectstack/cli's ADR-0131 D10 inventory (organization-ownership-inventory.ts) gains a row for sys_idp_group_mapping with the attribution fate, like sys_connector_connection beside it: every mapping row is written with its organization, so a NULL row has no producer. Without it os migrate organization-ownership refused any database holding the new table.
  • Censuses. The tenant-audit census (content/docs/permissions/tenant-audit-census.mdx and its counts file) and the platform-object tenancy census are regenerated for the three new write sites and the new object; the page's hand-written figures are updated to the census.

Follow-up: the ADR recording this decision (mechanism, storage, admin boundary, vendor @better-auth/scim 1.7.12) plus one pointer line in ADR-0134 D4 comes as a second PR from this branch after this one merges, so its symbol anchors resolve against landed code. That PR says Fixes #30959, so the card stays open until the ADR lands (this build PR only refers to it).

Tests

packages/plugins/plugin-auth/src/scim-group-projection.test.ts drives the real producer: AuthManager.handleRequest() with a real SCIM bearer over @better-auth/scim 1.7.12, on a real ObjectQL engine over better-sqlite3, with mappings saved through the door's own function and judged by the real plugin-security security service over a test catalog.

Row Pin Ablation (mutation → red)
(1) mapped group push grants the set, actor svc:scim:okta-30959 grant row + one insert with that actor projection wiring removed → red; writer without the SCIM context → red
(2) member removed → grant revoked row gone + one delete with that actor projection wiring removed → red; writer without the SCIM context → red
(3) admin set refused at save 400, VALIDATION_FAILED, [permissionSet, rule_violation], no row boundary refusal disabled → red
(4) set outside the organization boundary refused at save same shape boundary refusal disabled → red
(5) provisioning with no mapping still works SCIM create + PATCH 2xx, no grant regression pin
CONTROL unmapped group no grant group match removed from roles.map → red
CONTROL hand-made grants (admin_full_access + the mapped set by hand) untouched by push and removal provenance marker check removed → red
save/delete reproject current members grant appears on save, goes on delete projection wiring removed → red
(S1) connection whose credential names no organization refused at save 400, VALIDATION_FAILED, [connectionId, rule_violation], no row door's no-organization check disabled → red
(S1) projection never writes a grant without an organization org-less mapping read: no grant, one warning reconcile's admits call removed → red; admits organization check disabled → red
(S2) reconcile judges the boundary itself: set redefined as administrator after the save reconcileUser called without roles.exists: no grant, one warning; a real push then grants nothing reconcile's admits call removed → red
(S4) credential revoked in organization A, a new one minted in B save 200 into B, the row carries B live-credential filter removed from the door → red
(S4) every credential revoked 400, VALIDATION_FAILED, [connectionId, rule_violation], no row live-credential filter removed from the door → red
(S5) re-save after revoke in A and mint in B 409, RESOURCE_CONFLICT, message names the A mapping and A, the single row stays in A; CONTROL same-organization re-save answers 200 created: false organization match removed from the door → red

Each ablation ran through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob restored equal to HEAD). The three S1/S2 pins were committed red first (cdd23489) before the fix (fe3aea79), the two S4 pins likewise (ef76a598 before ff3c8f8e), and the S5 pin too (82e0cee3 before 18a95de4). scim-connection-service.test.ts adds unit cases for scimCredentialIsLive (active false, 0, null or absent; no expiry; past; exactly now; future; unreadable).

Acceptance notes

Verification

Head d8ef8e69, after merging origin/main four times without rebasing (1efd345f, ae12be81, 52aa0fc6, and 1ccf149e at 645ed3d1, which includes the de bundle from #31511). The first merge resolved delegated-admin-gate.ts by keeping #31384's helpers; the lifted predicate is still verbatim. The census artefacts were regenerated from main's copy.

  • Build: turbo run build 120/120 tasks.
  • Typecheck: pnpm --filter PKG typecheck (including each package's check:test-typecheck) exit 0 for plugin-security, platform-objects, plugin-auth, spec and cli; check:type-check-debt exit 0.
  • Lint: eslint --no-inline-config over the touched packages, exit 0.
  • Suites on the merged tree: plugin-auth 243 files, 4337 passed / 10 skipped (on d8ef8e69); plugin-security 5886 passed / 45 skipped; platform-objects 1886 passed (on d8ef8e69); cli 7582 passed / 9 skipped (unit and integration). The ownership-ceremony files (inventory unit, dev-organization-ownership-stale-column, start-organization-ownership-refusal, declared-rule-mirrors, no-organization, sqlite-wasm) were 10 red before the inventory row and are 42/42 green on 42f60764 with cli rebuilt.
  • i18n: check:i18n, check:i18n-coverage (none new), check:i18n-stale-fill and check:i18n-walk-parity exit 0 on d8ef8e69; the de bundle keeps only the id source-hash entry, like the other locales. check-tenant-audit-census, check:platform-object-tenancy-census and check:auth-mount-ledger exit 0 there too.
  • Acceptance file: 14/14 passed; ten ablations red as listed under Tests.
  • Derived gates: dispatch-gates --commands derived 130 families on 95349ede; all 130 ran there, every one exit 0, and --ran reconciled 130 run, 0 NOT-MEASURED, 0 UNRUN. d8ef8e69 adds only the main merge and the de bundle; the i18n, census and ledger gates above were re-run on it. pnpm --filter @objectstack/plugin-auth typecheck (with check:test-typecheck) exit 0 on 95349ede.
  • Line budget against main: 48 files, +2061 −111. Generated output (translation bundles in nine locales, two census artefacts and the tenancy census JSON) is 585 of it; authored is 26 files, +1476 −69.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

A sys_idp_group_mapping row per SCIM connection names which ordinary
permission set a group grants; the vendor projection reconciles grants
inside the SCIM transaction. The save door refuses any set outside the
organization boundary, judged by plugin-security's own predicate served
as an optional security-service member.

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
…nd dogfood rows, changeset

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
…; census and ownership rows

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 776f80e0db449e847b087b85bb9da8ea338170a0
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts, packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts, packages/plugins/plugin-auth/src/scim-group-projection.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, packages/plugins/plugin-auth/src/manifest.ts, packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts, packages/platform-objects/src/identity/index.ts

Gate verdicts on this head, as read: commits/776f80e0/check-runs answers total_count: 0 — 0 success, 0 skipped, 0 failure, 0 in progress. Five check-suites (vercel, fly-io, claude, cloudflare-workers-and-pages, objectstack-fleet) are queued with latest_check_runs_count: 0, the combined status is pending with zero contexts, and actions/runs?head_sha=776f80e0 lists zero workflow runs. No derived-gate family has a CI verdict on this commit; the dev report's own gate line says the same ("CI check-runs: not yet read"). This record judges the diff, never the checks: the lane's green claim is local and unwitnessed, and the landing still owes green checks. pulls/31443 also answers mergeable_state: dirty (see ④ S3).

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong.

  1. ISecurityService.organizationBoundaryFailure?(setName: string), resolving to the reason string or null — a new member on a published contract, optional. RIGHT. Additive and optional, so every other implementer still satisfies the interface (the only server is plugin-security's Object.assign extension). The pin test's compile-time satisfies DeclaredOptionality clause forces the ledger row, and the row added is 'optional', matching the ?. Absence is read as refuse, not as admit, so the member correctly stays OUT of MUST_SERVE_OPTIONAL (that list is for optional members whose absence a consumer reads as an admitting fallback).
  2. Published-surface artefacts of packages/spec — RIGHT that none change: packages/spec/api-surface/contracts.json records export NAMES (ISecurityService once, no members) and api-surface-signatures.json hashes define* factories only; neither is member-granular, so no regeneration is owed and none is missing.
  3. PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] gains 'sys_idp_group_mapping' — RIGHT, one row, in sorted position (after sys_identity_email, before sys_import_job), matching the object the diff registers through platform-objects/src/identity/index.ts, manifest.ts#authIdentityObjects and i18n-extract.config.ts.
  4. New platform object's accept set — RIGHT. managedBy: 'engine-owned' (no user writes; resolveCrudAffordances resolves fully locked), enable.apiMethods: ['list'], protection.lock: 'full', requiredPermissions: ['manage_platform_settings'] — byte-for-byte the posture of sys_scim_connection_credential beside it, so the data API widens by a LIST only and the two admin mounts are the sole writers.
  5. Two new REST routes (POST /api/v1/auth/admin/scim/{set,delete}-group-mapping) — RIGHT and completely declared: AUTH_ROUTE_LEDGER rows, the conformance pin's self-mounted allowlist, and both dogfood admin-route censuses (admitted-for-admin, refused-for-non-admin). requires: 'scim' on the save matches the code's 503 when SCIM is off; its absence on the delete matches the unconditional mount (both are mounted in a plain block, not behind a SCIM conditional) — the same asymmetry mint-credential / revoke-credential already carries.
  6. plugin-security's exported surface — RIGHT: organizationBoundaryFailure is exported from the MODULE delegated-admin-gate.ts but src/index.ts still exports only DelegatedAdminGate, isTenantAdmin and DelegatedAdminGateDeps, so the package entry does not move (claim amendment 6718415748 honoured). plugin-auth takes no dependency on plugin-security; it reaches the predicate through ctx.getService('security') with a typeof === 'function' feature test.
  7. plugin-auth's public surface: AuthManagerOptions.organizationBoundary?, AuthManager.organizationBoundaryJudge(), AuthManager.reconcileScimProjectionDomain() — RIGHT (additive, optional, host-wired); src/index.ts is untouched, so scim-group-projection.ts and idp-group-mapping-admin.ts stay internal modules.
  8. The vendor seam widens: scim({ … }) now receives projection. RIGHT, and the one behavioural widening the card asked for.
  9. Refusal-text surface: last-admin-guard.ts L35/L121/L561/L600/L1946 no longer send an operator to a SCIM group mapping, and the L1784 deprovision sentence is deliberately kept (a real path today). RIGHT, and exactly the correction the ruling ordered.

② Semver level

.changeset/30959-scim-group-permission-set-mapping.md declares patch for @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/platform-objects, @objectstack/spec — the four published packages the diff moves, and no others (qa/dogfood is private; content/docs, docs/audits and scripts/ publish nothing). MATCHES what the diff publishes.

Metadata-protocol: none is right: no metadata type, authorable key, accepted value, default or publish door changes. The new table is engine-owned and list-only, and the new contract member is an implementer-facing service seam, not something a metadata author writes. With kind none the Post-Task Checklist §3 level is exactly patch — not a floor the author may raise — and a non-protocol widening ships patch besides. No BREAKING banner and no ADR-0087 marker are owed (nothing an author wrote is retired or renamed).

Clause-②: yes (widening) — declared line-initially in BOTH the PR body and the changeset body, one arm from the closed pair, matching the ruling's own Clause-②: yes (widening) (6718345088). Not (narrowing): no declared capability is taken away; the only refusals added are on a write surface that did not exist before.

Clause-②: yes (widening)

③ Boundary flags

  • Dev flags (dev report 6719157245 deviations), all five answered: (a) the second packages/spec edit (the optional member) — covered by claim amendment 6718429681 and reviewed here as a hits file; CORRECT call: a cross-package call is a contract member, not a SERVED_NOT_DECLARED row, by the pin test's own stated rule. (b) organizationBoundaryFailure lifted verbatim to a module function with the private method as a one-line delegate — verified verbatim (text identical but for catalog arriving as a parameter) and not re-exported; amendment 6718415748. (c) beyond-acceptance behaviour (save/delete reproject now; boundary re-judged at grant time) — accepted, except that the second half is unpinned: see ④ S2. (d) census artefacts regenerated outside the order's file list — lane remainder, not reviewed here, and the gates required it. (e) no Console surface — accepted; the ruling named no UI, and the doors are the only writer.
  • open_questions: [] — nothing to answer or escalate.
  • security_findings: "withheld" — noted; a withheld finding is outside this record's inputs and is neither adopted nor contradicted here. This review found no admin-escalation path (see ④ fork 2).
  • Escalated: nothing to the maintainer on the contract. One state item for the owning seat, not a code finding: this head carries no check-run verdicts at all (see the gate line above), so nothing green backs the dev's 128-family local round; and the branch does not merge (④ S3). A PASS record is not a substitute for either.
  • Acceptance deviation already ruled: the card's "mapping removes the last admin → SCIM 403" row is structurally unreachable under fork 2 = no, and the ruling replaced it with "mapping names an admin set → refused at save". Rows (3) and (4) of the acceptance file are that replacement, with status, code and field asserted and zero rows stored. Correctly carried.

④ Design judgment

packages/spec/src/system/constants/platform-object-names.ts (hits). The constant row names sys_idp_group_mapping, and the object it names exists in this diff with that exact name, exported from identity/index.ts and registered in manifest.ts#authIdentityObjects and i18n-extract.config.ts — its first consumer, the owning package's own consistency pin, and the bundle-ownership list all see it. Judged against the ruling it implements (6718345088 fork 1 = (a), "a platform-object row per SCIM connection, typed columns, written by the platform admin who mints the SCIM credential"): the shape is per-connection (source_kind + source_id, with source_kind a closed select whose single value is scim, leaving #30967 the SSO value as the ruling's build note required), the columns are typed rather than a JSON blob, and the row carries the grant's organization. HOLDS, no finding.

packages/spec/src/contracts/security-service.ts (hits). The member is a pure declaration — a signature plus JSDoc, no runtime logic in packages/spec. Its JSDoc is accurate against the predicate it declares, condition by condition (name denylist, objects['*'] superuser bits, adminScope, a systemPermissions entry not declared scope: 'org', and "a set the catalog does not hold cannot be shown inside"), and it states the caller's duty — feature-detect, fail CLOSED. Both consumers do exactly that (auth-plugin.ts typeof judge === 'function', else undefined; the save answers 503 and stores nothing; the projection grants nothing). Its first consumer is idp-group-mapping-admin.ts:239-250 and its only implementer security-plugin-security-service.ts:422-428, which serves organizationBoundaryFailure(securityCatalogReaderOf(ql), setName) — the SAME ql the gate is constructed with (security-plugin.ts:2155 new DelegatedAdminGate({ ql, … }), delegated-admin-gate.ts:1384 catalog() = securityCatalogReaderOf(this.deps.ql)), so the served answer and the gate's own answer are one predicate over one catalog. HOLDS, no finding.

plugin-security/src/delegated-admin-gate.ts. The lift is verbatim: organizationBoundaryFailure (L564-593) is the former private body with catalog as a parameter instead of this.catalog(), and the method (L1171) is a one-line delegate passing this.catalog(). No criterion, order or message changed, so the gate's own verdicts are untouched and the new door cannot drift from them. Agrees with the contract sentence "never by a copy of its list". No finding (but see S3 — this file is one of the three that conflict with main).

plugin-auth/src/idp-group-mapping-admin.ts (the mapping save door). Sentence by sentence against the contract and the ruling:

  • "written only by the platform admin who mints the SCIM credential (the same door)" — HOLDS. Both mounts call gateAdmin(c) (ADR-0068 judgePlatformAdmin) as their FIRST statement, ahead of any body read, the same gate mint-credential/revoke-credential run; both dogfood censuses pin the refusal for a non-admin and the pass-through for an admin.
  • "the data API only lists it, so no other writer exists" — HOLDS as a bypass test. The object is managedBy: 'engine-owned' (platform-internal bucket, no user writes) with apiMethods: ['list'], so REST data writes, batch writes and imports are refused before any gate question; and the row's permission_set can never be EDITED in place (the door only inserts and deletes), so a saved mapping cannot be re-pointed at an admin set.
  • "a mapping that names an admin set is refused at save, by the gate's own criteria" — HOLDS. L239-250 resolves the judge per call, and ANY non-null reason becomes 400 VALIDATION_FAILED, field permissionSet, code rule_violation, with nothing stored (the insert is strictly after the check). Because the predicate is the gate's module function, the refusal covers admin_full_access, the organization-admin sets, the agent ceilings, a superuser wildcard, an adminScope, a non-org capability, AND a name the catalog does not hold — fail-closed on an absent set, which also closes the "rename the mapped set to something that does not exist yet" trick. No judge at all ⇒ 503, nothing saved.
  • "the organization is the one that owns the connection, never a value the caller names" — HOLDS for the shape: connectionId is the only connection input, the body key set is closed (unknown_field on anything else), a connection with no credential is reference_not_found and one owned by two organizations rule_violation. One gap: see S1.
  • "saving or deleting reprojects current members at once" — HOLDS, and is the one row of the acceptance file that pins it end to end. A failed reprojection does not undo the row and is reported (reprojected: false plus an operator-grade log line), which the docblock states.

plugin-auth/src/scim-group-projection.ts (the member and the writer).

  • Per-connection matching — HOLDS: connectionMappings filters on source_kind AND source_id, so one connection's rows can never answer another's group, and mappingMatchesGroup is external id when the row names one, else exact display name — exactly the reading the dev report declares.
  • "writes in the same engine transaction, with the SCIM actor" — HOLDS. The writer is withSystemContext(engine), whose writes carry authSystemWriteContext(), which inside a SCIM protocol request resolves to the service principal svc:scim:CONNECTION_ID (plugin-auth: SCIM-driven writes leave sys_audit_log rows with actor, user_id and metadata null, so no audit row names the SCIM connection that acted #30960) with no human; engine calls inside the SCIM request join the ambient transaction the adapter opens in scimRequestScope (ADR-0034), so a refused grant rolls the whole SCIM mutation back. The acceptance file observes the actor on both the insert and the delete.
  • "a refusal reaches the IdP as the vendor's APIError with its own status, not a SCIM 500" — HOLDS for the write path, which is the one that can be refused: the writer is wrapped in withValidationErrorMapping, which maps a ValidationError (400), an engine policy refusal (403), a referential restriction (409) and a declared read fault (503) to APIError, and rethrows anything else verbatim. Note the boundary honestly: the READER and engineOrThrow() are not wrapped, so a missing engine or an unregistered grant table still surfaces as a plain Error → SCIM 500. That is the right side of the line (an infrastructure fault is not a declared refusal, and an IdP retrying it is correct), so no finding — but it is the reason the 403-becomes-500 class is closed only for refusals.
  • "reconcile never removes a grant it did not make" — HOLDS, and this is the strongest part of the design. Deletion requires text(row.reason) === reason, an exact per-domain marker; the insert side skips a desired (set, organization) the user already holds through ANY row, so a hand-made grant is neither duplicated (the grant table's unique index is (user_id, permission_set, organization_id)) nor adopted. The CONTROL case pins both directions with admin_full_access and the mapped set held by hand.
  • Fork 2 at projection time — the honest answer: the save-time refusal is the only enforcement this tree measures. A set that passes the boundary at save and LATER grows an administrator bit (its catalog definition edited, or deleted and recreated under the same name) is caught only by roles.exists, and reconcileProjectedGrants itself performs no boundary call before writer.insert — see S2.

plugin-security/src/security-plugin-security-service.ts — the member is added inside the existing Object.assign extension, with its optionality declared in the pin test; the registered object stays typed against the contract for its literal half. Agrees. auth-plugin.ts / auth-manager.ts / auth-manager-plugin-list.ts — the lazy judge resolves per call (plugin-security starts after plugin-auth), swallows a service-lookup throw into undefined, and binds judge.call(svc) so the member keeps its this; the projection is wired with the engine getter, the same lazy judge and a warn sink. Agrees. last-admin-guard.ts — the four texts the ruling named are corrected at the symbol, their assertions re-pinned positively (the new sentence) and negatively (the ledger path still must NOT name a group mapping). Agrees. sys-idp-group-mapping.object.ts — see the posture above; its docblock claims match the door and the projection it cites.

Findings

  • S1 · should-fix · packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts:237 (with :257 and packages/plugins/plugin-auth/src/scim-group-projection.ts:197-205). A connection whose credential rows carry no organization_id saves a mapping with NO organization, and the projection then inserts a sys_user_permission_set row with no organization_id — an UNSCOPED grant, the platform bucket a scoped read returns in every organization (the #2949 rung reads an unscoped row as deployment-wide; auto-org-admin-grant.test.ts states a scoped read returns organization-less rows too). That is a shape the delegated-administration gate can never produce: on an organization administrator's insert it STAMPS the caller's organization onto an organization-less row (delegated-admin-gate.ts, "an insert that named no organization lands in the caller's"), and it refuses a row that carries none. So the door's own invariant — an IdP group grants only what an organization administrator could assign inside the organization boundary — is not held in this case, and the object's field description ("the organization that owns the SCIM connection") and the changeset's "in the organization that owns the connection" are both false there. Reachable without any admin set: scim-connection-admin.ts#resolveOwningOrganization answers { ok: true } with no organization when sys_organization is unregistered or holds no row, and readConnection then yields organizations: [''], length 1, so the ambiguity refusal does not fire — note the asymmetry, two organizations are refused as ambiguous while zero is silently unscoped. Fix: refuse the save when the connection owns no organization (or resolve the Default Organization at save), and never insert a grant without an organization_id.
  • S2 · should-fix · packages/plugins/plugin-auth/src/scim-group-projection.ts:167-205 (the exists half at :137-156). The contract sentences this diff writes — the projection docblock ("roles.exists confirms … whose set is still inside the organization boundary"), AuthManagerOptions.organizationBoundary's JSDoc ("the SCIM group projection re-judges at grant time") and the PR's acceptance note — rest entirely on the vendor calling roles.exists on every path that reaches reconcileUser. reconcileProjectedGrants re-reads the mapping rows by id and inserts row.permission_set with NO boundary call of its own, and nothing in this tree measures the vendor's ordering: the acceptance file has no case where a mapped set becomes admin-scoped after the save, the five ablations cover roles.map, the marker, the wiring and the write context but not exists, and @better-auth/scim 1.7.12 is neither vendored nor type-stubbed here (the vendor records its own desired grants in sys_scim_projection_grant, so a reconcile driven off stored rows need not re-ask). If exists is not consulted on a given path, every member of the group is granted the now-admin-scoped set with no refusal and no warning; if the vendor instead treats exists: false as an error, the documented "grants nothing, warned once" becomes a failed SCIM push. Fix: judge the boundary inside reconcileProjectedGrants for each desired set before inserting (and skip-plus-warn on a failure), which makes the second line of defence independent of vendor call order, and pin it with a case that redefines the mapped set after the save.
  • S3 · should-fix · packages/plugins/plugin-security/src/delegated-admin-gate.ts:564 (branch state, not a line defect). This head does not merge into main: pulls/31443 answers mergeable_state: dirty, and git merge-tree origin/main origin/claude/project-thread-dv4dek conflicts in three files — content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md and, in review scope, delegated-admin-gate.ts, where main's cd6618e8 (fix(plugin-security): a delegated refusal names one reason for a target user the caller cannot read #31384) inserted its own top-level helpers at the very anchor the lifted predicate occupies and also rewrote the class body the private method sits in. The conflict reads as textual, not semantic (nothing in fix(plugin-security): a delegated refusal names one reason for a target user the caller cannot read #31384 touches the boundary predicate), but the resolution lands in an in-scope file, so the merge of origin/main the PR already promises (serial with plugin-auth SSO: expose the operator knobs Enterprise SSO needs — IdP-initiated sign-in, IdP group to role / permission set, and refusing unknown users instead of JIT #30967) must be made and the two census artefacts regenerated on top of it — and the resolved head owes a delta review of this finding plus S1/S2.
  • N1 · nit · scim-group-projection.ts:100. rowsOf is declared async and awaits nothing; every call site already awaits its argument.
  • N2 · nit · idp-group-mapping-admin.ts:313 and the delete route's ledger note. Deleting a mapping while SCIM is off removes the row but reprojects nothing, so grants the projection already made stay until an operator removes them by hand; the ledger note ("deletes that row and re-projects the connection so the grants it made go") does not carry that caveat. The answer's reprojected: false does say it to the caller.
  • N3 · nit · sys-idp-group-mapping.object.ts:84-98. "The row names exactly one" group key is enforced only by the save door; the schema leaves both columns optional and unconstrained. Acceptable while the door is the only writer (and it is), but the invariant lives nowhere the engine can hold it.
  • N4 · nit (declared edge). Two provisioning domains projecting the same (set, organization) share one grant row whose marker names the first writer, so deleting the first mapping drops the row until the second domain's next reconcile re-inserts it. Already recorded as a known edge in the PR and acceptance notes.

No must-fix. The ruling's two forks hold as ruled: fork 1 — rows are per-connection, typed, and written only through the platform-admin door (no data-API, batch or import write path exists); fork 2 — no mapping naming an admin set can be stored, by the gate's own criteria over the gate's own catalog, and the stored mapping cannot be re-pointed; the residual exposure is the post-save redefinition of an ordinary set (S2) and the unscoped-organization case (S1), neither of which confers administrator standing.

Implemented-by: claude/project-thread-dv4dek
Reviewed-by: session_01FEkQ5p3T5YWhLjurajudnQ

VERDICT: PASS

…e-time boundary (red)

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
…; reconcile judges every grant before it writes

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
… write's tenant; census regenerated

Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/spec, touching 123 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/identity/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

109 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5ee59f5ef449de22926af221d8fc9b7baf78a2d0.

⛔ 21 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/identity/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 3 anchor(s) matched too much of the corpus to be a work list: organization_id (symbol, 39 pages), VALIDATION_FAILED (literal, 44 pages), organization_id (literal, 39 pages)
  • 21 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 185 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7cf36d660780d2a43174be0b328e49a39aab452e — the merge of head d8ef8e6930b0c876b3c81f5988871973a4703817 into base 5ee59f5ef449de22926af221d8fc9b7baf78a2d0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7cf36d660780d2a43174be0b328e49a39aab452e && git checkout 7cf36d660780d2a43174be0b328e49a39aab452e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 d8ef8e6930b0c876b3c81f5988871973a4703817 && git checkout -B drift-repro 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 && git merge --no-ff d8ef8e6930b0c876b3c81f5988871973a4703817

node scripts/docs-audit/affected-docs.mjs --json 5ee59f5ef449de22926af221d8fc9b7baf78a2d0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 42f60764b32e451474ddb0523a99a5946e2dd262
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts, packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts, packages/plugins/plugin-auth/src/scim-group-projection.ts, packages/plugins/plugin-auth/src/scim-group-projection.test.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, packages/plugins/plugin-auth/src/manifest.ts, packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts, packages/platform-objects/src/identity/index.ts, packages/platform-objects/scripts/i18n-extract.config.ts, packages/platform-objects/src/apps/translations/pt-BR.objects.generated.ts, packages/platform-objects/src/apps/translations/ru.objects.generated.ts, packages/cli/src/utils/organization-ownership-inventory.ts, .changeset/30959-scim-group-permission-set-mapping.md

Delta round on the record of head 776f80e0 (PASS, no must-fix, S1/S2/S3 should-fix). The PR's own commits since that head: cdd23489 (S1/S2 pins, red), fe3aea79 (S1/S2 fix), 0110f584 (grant insert carries its organization as the write's tenant; census regenerated), fe826d68 + 861c284a (pt-BR and ru labels), d6b9645c (test-typecheck fix), 20e729e1 + 42f60764 (ADR-0131 D10 inventory row, then the tracker id taken out of its citation), 6244fbdb (changeset names @objectstack/cli), plus three merges of origin/main. Nothing under docs/adr is in the diff.

Gate verdicts on this head, as read: commits/42f60764/check-runs lists 69 runs, every one completed with conclusion success or skipped (the skips are opt-in and matrix-shadow rows); none failed, none in progress. In particular Check Changeset, Governed Surface Queue Guard, Type Check · workspace, Type Check · debt ledger, Lint & Repo Gates, Test Core (8/8), Build Core, Dogfood Regression Gate and both PR-body guards are green. The combined legacy status is pending with zero contexts, which is no verdict either way. pulls/31443 answers mergeable: true, mergeable_state: clean; git merge-tree --write-tree origin/main 42f60764 (origin/main at a3ac7fb3) produces a tree with no conflict.

① Derived judgments

Carried unchanged from the 776f80e0 record where the tree did not move (items 1-9 there: the optional contract member and its 'optional' ledger row, no packages/spec surface artefact owed, the sorted PLATFORM_OBJECTS_BY_PACKAGE row, the object's list-only engine-owned posture, the two ledgered routes, plugin-security's package entry unmoved, plugin-auth's additive host-wired options, the vendor projection seam, the corrected guard texts). Re-read on this head, each still holds. New or moved since:

  1. ORGANIZATION_OWNERSHIP_INVENTORY gains sys_idp_group_mapping (packages/cli/src/utils/organization-ownership-inventory.ts:787-799): census: PMAIN, fate: 'attribution', anchors: []. RIGHT on the inventory's own rules: scripts/platform-object-tenancy-census.json on this head lists the object reach: "in" with tenantField: "organization_id" and the census is platform-census-main, so the PMAIN population is the right one and the test that holds census and inventory together is satisfied (Test Core green). Fate 3 with no anchor means "under single the Default Organization, otherwise reported", the same declaration sys_connector_connection and sys_mailbox beside it carry, and it matches the object's actual stamping: the only writer (runSetIdpGroupMapping) now puts organization_id: organizationId on every row unconditionally and refuses a connection whose credential names no organization, so a NULL row has no producer on this head (the comment says exactly that). No parent anchor is possible — source_id is a connection id, not a credential row id — so an empty anchor list is honest, not lazy. The citation (${CENSUS_MAIN} (in reach, added since the first census; one row per SCIM connection, in the organization that owns it); ${D10_SINGLE}) carries no tracker number, which the inventory test's own rule ("a runtime string carries no tracker number", not.toMatch(/#\d/)) requires; 42f60764 moved #30959 into the code comment, where provenance belongs. One wording nit, N1 below.
  2. @objectstack/cli joins the changeset at patch. RIGHT: packages/cli/package.json is a published package (no private), the diff moves it, and the change is a behaviour fix (os migrate organization-ownership stopped refusing a database holding the new table).
  3. Translations. The object is in i18n-extract.config.ts (import and objects list), bundle-ownership.test.ts owns it, and all eight locale bundles main ships carry a sys_idp_group_mapping block; pt-BR (fe826d68) and ru (861c284a) are hand translations of the en block, read key by key: every field label, help, the scim option label and the all view label are present and faithful (including the "set this or the display name, not both" and "administrator sets are refused" sentences). Each locale's source-hashes gains the one copy-hash key the extractor assigns to the untranslated fields.id.label ("ID"), the same key in every locale. RIGHT.
  4. Test-typecheck fix (d6b9645c): the three (saved.body as { data: { mappingId: string } }).data.mappingId casts from the door's discriminated result union became one typed helper mappingIdOf(saved) that asserts status === 200, narrows on body.success and returns String(body.data.mappingId). RIGHT: it removes the unsound cast rather than adding a debt entry (packages/plugins/plugin-auth/test-typecheck-debt.json is unchanged against main), and it makes a refused save fail the case at the save instead of at an undefined id later. Type Check · workspace and Type Check · debt ledger are green on this head.
  5. reconcileProjectedGrants signature now takes an admits predicate (scim-group-projection.ts:187-190) and MappingRow is exported. Both are module-internal to plugin-auth (src/index.ts untouched), so no published surface moves. RIGHT.
  6. Grant insert context (0110f584): writer.insert(GRANT_OBJECT, {…, organization_id: org, reason}, { context: { isSystem: true, tenantId: org } }). RIGHT, and it does not lose the SCIM actor: withSystemContext spreads authSystemWriteContext() FIRST and the caller's context over it (objectql-adapter.ts:760-762), so actor: 'svc:scim:CONNECTION_ID' survives and only isSystem/tenantId are set by the caller. The tenant-audit census counts the site as enabled | elevated accordingly.

② Semver level

.changeset/30959-scim-group-permission-set-mapping.md declares patch for @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/platform-objects, @objectstack/spec and @objectstack/cli — exactly the five published packages the diff moves (@objectstack/dogfood is private: true; content/docs, docs/audits, scripts/ publish nothing). MATCHES. The 776f80e0 record's one gap here (cli moved, changeset silent) is closed by 6244fbdb.

Metadata-protocol: none stands: no metadata type, authorable key, accepted value, default or publish door changes; the cli row is ceremony data, the new table engine-owned and list-only, the contract member implementer-facing. Kind none ⇒ exactly patch (Post-Task Checklist §3), and a non-protocol widening ships patch besides. No BREAKING banner, no ADR-0087 marker owed.

Clause-②: yes (widening) is line-initial in BOTH the PR body and the changeset body, one arm from the closed pair, matching the ruling's own line (6718345088). Not (narrowing): the only refusals added since 776f80e0 (organization-less connection at save; boundary re-judged at grant time) narrow a surface that did not exist on main.

Clause-②: yes (widening)

③ Boundary flags

  • S1 (unscoped grant from an organization-less connection) — RESOLVED, judged against the tree. Save side: idp-group-mapping-admin.ts:237-239 refuses connection.organizations.includes('') with 400 VALIDATION_FAILED, field connectionId, code rule_violation, BEFORE the ambiguity check and before any boundary call or insert; readConnection maps a NULL organization_id to '' (text()), so a credential row with no organization is what trips it, and the asymmetry the earlier record named (two organizations refused, zero silently unscoped) is gone. The row literal is now organization_id: organizationId with no conditional spread (:263). Projection side: admits (scim-group-projection.ts:141-162) refuses a mapping row whose organization_id is empty before it ever reaches the boundary judge, and reconcileProjectedGrants skips a row admits refuses (:209), so no grant without an organization is written; the insert carries organization_id: org unconditionally and the organization as the write's tenant (:226-229). The object description, changeset and ledger sentences ("in the organization that owns the connection") are now true. Pinned by two red-first cases in cdd23489 ((S1) door refusal: 400 / VALIDATION_FAILED / [connectionId, rule_violation] / no row stored; (S1) reconcileUser over an engine whose mapping rows read back without an organization: no grant, one warning matching /no organization/), green in Test Core on this head. The PR body's ablation column for these rows is the lane's claim and is not adopted here; the pins themselves are in the tree.
  • S2 (reconcile relied on the vendor calling roles.exists) — RESOLVED. reconcileProjectedGrants now takes admits and calls it for every mapping row before the row enters desired (:205-211), so the boundary is judged inside the write path on every vendor route, whether or not exists ran first; exists is now row ? admits(row) : false (:173), one predicate on both sides; and because a refused row is not desired, a grant the projection made earlier for a set that later grew an administrator bit is REVOKED on the next reconcile (:232-236), not merely not re-granted — stronger than the earlier record asked. The docblock and the AuthManagerOptions.organizationBoundary JSDoc now describe this. Pinned red-first by the (S2) case: the catalog's SALES set is redefined with an adminScope after the save, reconcileUser is called directly with no exists call (no grant, one warning), and the same push then goes through the real vendor (pushGroup) and grants nothing — which also answers the earlier record's open question about how @better-auth/scim 1.7.12 treats exists: false: the push lands and grants nothing, no SCIM 500.
  • S3 (branch did not merge) — RESOLVED. Three merges of origin/main (1efd345f, ae12be81, 52aa0fc6); merge-tree against current origin/main (a3ac7fb3) is conflict-free and the PR reads mergeable_state: clean. The in-scope resolution, delegated-admin-gate.ts: git diff origin/main...42f60764 on that file is ONE hunk pair — the module-level organizationBoundaryFailure(catalog, setName) inserted after referenceCheckAsksCallerAboutUser (keeping fix(plugin-security): a delegated refusal names one reason for a target user the caller cannot read #31384's helpers above it) and the private method reduced to return organizationBoundaryFailure(this.catalog(), setName). Read line against line with main's method body: denylist check and message, catalog.resolve('permission', …) and the "not in the security catalog" refusal, the objects['*'] viewAllRecords/modifyAllRecords loop, adminScope ?? admin_scope, the systemPermissions loop with scope !== 'org' and both message arms, return null — identical text; the only change is catalog arriving as a parameter where the method read this.catalog(). Behaviour unchanged from main's method, as the brief requires.
  • Earlier nits: N1 (rowsOf needlessly async) fixed in fe3aea79; N2 (delete-mapping ledger note silent on SCIM-off) fixed, the note now says "with SCIM off nothing is re-projected and those grants stay until removed by hand; the answer says reprojected: false". N3 (one-group-key invariant held only by the door) and N4 (shared grant row across two domains) stand as declared edges, unchanged.
  • Dev flags on the delta (PR body Verification): the three merges without rebase and the census regeneration from main's copy — verified by merge-tree and the census JSON's new in-reach entry; "the lifted predicate is still verbatim" — verified above, not taken from the body. No new dev report comment on the card since 6719157245 and open_questions: [] there; nothing to escalate on the contract. security_findings: "withheld" remains outside this record's inputs. The ADR (fork 3) is not in this diff and is not owed here.

④ Design judgment

packages/spec/src/system/constants/platform-object-names.ts (hits). HOLDS as at 776f80e0: one sorted row naming the object the diff registers. Against fork 1 = (a) on this head the shape is stronger than before: every row now provably names its organization (the save refuses otherwise), so "a platform-object row per SCIM connection, typed columns, written by the platform admin who mints the credential" is true of every row the tree can hold, and the D10 inventory row declares that same fact to the ceremony. No finding.

packages/spec/src/contracts/security-service.ts (hits). Unchanged since 776f80e0 and still accurate against its implementer (security-plugin-security-service.ts:427-428, organizationBoundaryFailure(securityCatalogReaderOf(ql), setName)) and the lifted module function, which is main's predicate verbatim over the same catalog the gate uses. Its two consumers both feature-detect and fail closed: the door answers 503 and stores nothing; the projection's admits treats "no judge" as a refusal (grants nothing, warns once). The JSDoc sentence "nothing that depends on the answer is saved or granted" is now literally enforced on the grant path too, which it was not at 776f80e0. No finding.

plugin-security/src/delegated-admin-gate.ts. The lift is verbatim against main's method (③ S3); the gate's own verdicts cannot drift from the served member. No finding.

plugin-auth/src/idp-group-mapping-admin.ts. Fork 1 and fork 2, sentence by sentence, as at 776f80e0, plus the S1 closure: gate first, closed body key set, connection must exist, must name exactly one organization, judge must exist, set must pass the boundary, then insert with the organization, then audit, then reproject. The row's permission_set and organization_id are never edited in place (insert/delete only), so a saved mapping cannot be re-pointed. Trying to break the organization reading: the only remaining way to reach a wrong organization is a credential row deleted or re-pointed by hand, and the credential object is list-only with mint/revoke as its only doors — not reachable through any door. One finding on how the door reads those credential rows, S4 below.

plugin-auth/src/scim-group-projection.ts. Per-connection matching, same-transaction writes with the SCIM actor, APIError mapping on the write path, exact-marker deletion — all as at 776f80e0. Fork 2 at projection time is now enforced in the tree, not assumed of the vendor: admits runs before every insert and revokes what it no longer admits. The one remaining vendor assumption is benign: if the vendor skipped reconcileUser for some path, nothing is granted. Probed tenantId: org on the insert against withSystemContext's precedence — the actor survives (① 6). Probed the delete path: it carries no tenantId but is a system write by row id of a row the projection itself wrote, so tenant scoping does not apply. No finding.

plugin-auth/src/scim-group-projection.test.ts. The three new cases pin exactly the S1/S2 contract sentences and were committed red before the fix. withoutOrganization stages the organization-less rows at the engine's read because this engine refuses an organization-less credential row outright — stated in the case, honest about reachability. The catalogPermissions indirection lets S2 redefine the set in the live catalog the real security service reads, so the re-judgement is measured against the real predicate, not a stub. Agrees.

packages/cli/src/utils/organization-ownership-inventory.ts (D10 row). Judged in ① 1 against the inventory's populations, fate vocabulary, citation rule and the object's actual stamping. HOLDS; N1 wording nit.

Translations, i18n-extract.config.ts, bundle-ownership.test.ts. Agree with the object (① 3). .changeset. Agrees with what publishes (②).

Findings

  • S4 · should-fix · packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts:128-144 (readConnection), with the refusal at :240-242. The owning organization is read off EVERY sys_scim_connection_credential row of the connection, inactive ones included: the query has no active condition, while the verifier and the revoke door (scim-connection-service.ts:299-301, :360-365, read) treat anything not affirmatively active as off. Revocation only switches active to false and leaves the row, and the credential object is list-only with mint/revoke as its only doors. So the ordinary rotation "revoke the credential minted in organization A, mint a new one in organization B" leaves organizations = [A, B] forever, the save refuses rule_violation on connectionId — and the refusal's own remedy, "Revoke the stray credentials first", cannot clear it, because a revoked row still counts. The connection is then permanently unmappable through any door, and the design sentence "the organization is the one that owns the connection" is read off credentials that no longer authenticate anything. It fails CLOSED and confers nothing, so not a must-fix. Fix: read only active rows in readConnection (the same active === false || active === 0 || active == null reading the verifier uses) for both organizations and domains, and keep the two-organization refusal for two ACTIVE credentials; or, at minimum, make the remedy true. Pin it with a revoke-then-mint-elsewhere case.
  • N1 · nit · packages/cli/src/utils/organization-ownership-inventory.ts:796. The citation says "one row per SCIM connection"; the table holds one row per mapping and a connection may have many. "each row names one SCIM connection" is what the tree does. No effect on the fate or the ceremony.
  • N2 · nit · packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts:108-114. organization_id stays required: false while the door now stamps it on every row and the inventory row rests on "a NULL row has no producer". True while the door is the only writer (it is), but the invariant lives only in the door, as N3 of the earlier record said of the group key. A required: true would let the engine hold it.

No must-fix. S1, S2 and S3 of the 776f80e0 record are each resolved in the tree and pinned. The ruling's forks hold as ruled: fork 1 — per-connection typed rows, every one naming exactly one organization, written only through the platform-admin door; fork 2 — no admin set can be saved, and on this head none can be granted either, because the boundary is re-judged inside the write path and a later-redefined set is revoked; fork 3 — no ADR in this diff, owed as the separate Tier H PR.

Implemented-by: claude/project-thread-dv4dek
Reviewed-by: session_01FEkQ5p3T5YWhLjurajudnQ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b13015e748b7439d0a90430f4f51bb59eeeb7aaf
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts, packages/plugins/plugin-auth/src/scim-connection-service.ts, packages/plugins/plugin-auth/src/scim-group-projection.ts, packages/plugins/plugin-auth/src/scim-group-projection.test.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, packages/plugins/plugin-auth/src/manifest.ts, packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts, packages/platform-objects/src/identity/index.ts, packages/platform-objects/scripts/i18n-extract.config.ts, packages/platform-objects/src/apps/translations/pt-BR.objects.generated.ts, packages/platform-objects/src/apps/translations/ru.objects.generated.ts, packages/cli/src/utils/organization-ownership-inventory.ts, .changeset/30959-scim-group-permission-set-mapping.md

Delta round on the record of head 42f60764 (comment 6722234751: PASS, no must-fix, S4 should-fix, N1/N2 nits). git log --first-parent 42f60764..b13015e7 is four commits, no merges: ef76a598 (two S4 pins in scim-group-projection.test.ts, red: +28/-3, test file only), ff3c8f8e (the fix: idp-group-mapping-admin.ts +19 lines, scim-connection-service.ts +31/-? lines, nothing else), 59e94f90 (one changeset sentence), b13015e7 (one line: the S4 pin's body cast widened through unknown). git diff 42f60764 b13015e7 --stat is exactly those four files, 64 insertions, 19 deletions. Nothing outside the S4 fix, its pins, the changeset sentence and the test cast moved; origin/main was not merged in this delta.

Gate verdicts on this head, as read: commits/b13015e7/check-runs lists every run completed with conclusion success or skipped (skips are the opt-in and matrix-shadow rows); none failed, none in progress. In particular Check Changeset, Governed Surface Queue Guard, Type Check · workspace, Type Check · debt ledger, TypeScript Type Check, Lint & Repo Gates, Lint · ESLint, Test Core (8/8), Build Core, Dogfood Regression Gate and the PR-body guards are green.

① Derived judgments

Items 1-9 of the 776f80e0 record and 1-6 of the 42f60764 record are carried unchanged: no file they judge moved in this delta. New on this head:

  1. scimCredentialIsLive(row, now = Date.now()) is exported from scim-connection-service.ts:283-292 and is the bearer verifier's former inline predicate, lifted verbatim (compared below, ③ item 2). It is a module export of plugin-auth, not re-exported from src/index.ts, so no published surface moves. RIGHT.
  2. The verifier's returned expiresAt is now rawExpiry != null ? new Date(rawExpiry) : undefined after the liveness gate. Because the gate has already refused a NaN or past expiry, the second parse of the same string yields the same valid future Date the old code kept in parsed; the result object is byte-identical on every accepted row. RIGHT.
  3. readConnection (idp-group-mapping-admin.ts:136-152) now selects active and expires_at too, derives organizations from rows scimCredentialIsLive admits and domains from every row. The docblock states and justifies the split: a delete must re-project every domain the connection ever provisioned, revoked credentials included, so stale grants there are swept. The earlier S4 text asked for both to be active-only; keeping domains on every row is the stronger reading for cleanup and is documented. RIGHT.
  4. Refusal order in runSetIdpGroupMapping (:245-253): not found, then no live credential (rule_violation, message names revoked or expired), then a live credential naming no organization, then two live organizations. The empty-list check precedes includes('') so an all-revoked connection gets the new message, not the organization-less one. The two-organization remedy "Revoke the stray credentials first" is now true: a revoke makes the row non-live and it stops counting. RIGHT.
  5. Delete door (runDeleteIdpGroupMapping, :330-331) uses readConnection only for connection.found and connection.domains; the new refusal is in the save door alone, so a mapping on a fully revoked connection can still be deleted and its grants swept. RIGHT.

② Semver level

Unchanged from 42f60764: patch for the five published packages the diff moves; Metadata-protocol: none; Clause-②: yes (widening) line-initial in PR body and changeset. The one changeset edit (59e94f90) adds a prose sentence ("Only live credentials, active and unexpired, as the bearer verifier reads them, decide that organization") inside the existing paragraph, moving no header line. The new refusal (no live credential) narrows a surface that does not exist on main. MATCHES.

Clause-②: yes (widening)

③ Boundary flags

  1. S4 — RESOLVED, judged against the tree. readConnection reads the owning organization from live rows only (① 3); an all-revoked connection is refused 400 VALIDATION_FAILED on connectionId with code rule_violation and nothing stored (① 4). Pinned red-first in ef76a598 by two cases: (S4) revoke the ORG credential, mint in org_scim_30959_b, save lands 200 with data.organizationId === ORG_B and the one stored row carries organization_id: ORG_B; (S4) revoke the only credential, save answers 400 / VALIDATION_FAILED / [['connectionId','rule_violation']] and the table stays empty. Both green in Test Core on this head. b13015e7 only widens the first pin's cast through unknown (the door's result is a discriminated union, so the direct cast did not typecheck); it changes no assertion.
  2. Bearer verifier accept set — UNCHANGED against main, compared predicate by predicate. main verifyScimBearerToken (scim-connection-service.ts:297-308 there) vs head scimCredentialIsLive + call site (:283-292, :316): (a) active: active === false || active === 0 || active == null refuses on both; true, 1, and any other truthy value (a 'true' string included) pass on both; (b) missing or null or undefined expires_at: main skipped the block and accepted; head returns true and accepts; (c) unreadable expiry: new Date(raw) NaN refused on both; (d) clock: main refused when parsed.getTime() was less than or equal to Date.now(); head returns live when parsed.getTime() is strictly greater than now, with now defaulting to Date.now() evaluated once at the call, the exact complement, equal timestamp refused on both; (e) order of checks: active before expiry on both, and !row precedes both on both; (f) connectionId empty refusal and the provisioningDomainId fallback are untouched. No credential main accepted is refused, none main refused is accepted; no widening, no narrowing. The storage-fault arm (catch returns null) is untouched. The revoked-bearer arm stays pinned end to end (scim-connection-admin.test.ts:362-416, revoked token answers 401, successor still provisions).
  3. Projection side consistent with the door's live-only reading. scim-group-projection.ts never reads sys_scim_connection_credential: a grant's organization is text(row.organization_id) off the mapping row the door stamped at save (:208, :226-229), and admits refuses an organization-less row. There is no second derivation to drift from the door. The consequence is the one finding below, S5: the row's organization is fixed at save, and the fix now endorses re-homing a connection by revoke-then-mint.
  4. Earlier findings carried by reference to comment 6722234751: S1, S2, S3 of 776f80e0 RESOLVED there and unmoved here; N1 (inventory citation wording, organization-ownership-inventory.ts:796) and N2 (organization_id required: false on the object) stand, their files untouched in this delta. N3 and N4 of 776f80e0 stand as declared edges.
  5. Dev flags: the PR body's ablation claims are the lane's and not adopted; the red-first order is read off the commit sequence (ef76a598 test-only before ff3c8f8e src-only).

④ Design judgment

packages/spec/src/contracts/security-service.ts (hits) and packages/spec/src/system/constants/platform-object-names.ts (hits). Unchanged since 776f80e0; the judgments of the two earlier records hold on this head. Fork 1 is stronger again: on this head every mapping row's organization is the one a credential that still authenticates named at save time. No finding.

plugin-auth/src/scim-connection-service.ts. The lift is verbatim (③ 2) and the single predicate now governs both doors, which is what S4 asked for. Trying to break it: now is caller-supplied only in the signature, every production call takes the default, so no caller can move the clock; the function is pure over the row and never touches the engine. One nit, N3.

plugin-auth/src/idp-group-mapping-admin.ts. Save: gate, closed body key set, connection exists, has a live credential, that credential names one organization, judge exists, set passes the boundary, insert with the organization, audit, reproject (① 4). Trying to break the organization reading after the fix: the only way to make the connection ambiguous now is two live credentials in two organizations, which is the genuine ambiguity the refusal is for and its remedy clears. The idempotent re-save path is where the fix opens a reachable edge, S5 below.

plugin-auth/src/scim-group-projection.ts. Read, not moved. Consistent with the door in that it holds no credential reading of its own (③ 3). No finding beyond S5.

plugin-auth/src/scim-group-projection.test.ts. The two S4 pins exercise exactly the two contract sentences the changeset added (a revoked credential does not decide; a connection with no live credential is refused) through the real revoke door and the real save door, and read the stored row back. The unknown cast in b13015e7 is a test-typecheck fix with no debt entry (test-typecheck-debt.json unchanged). Agrees.

.changeset. The added sentence agrees with ① 3 and ③ 2 ("as the bearer verifier reads them" is literally true: same function). Agrees.

Findings

  • S5 · should-fix · packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts:276-283 (the existing lookup) with :298 (the answered organizationId). The re-save lookup matches an existing row by source_kind, source_id, permission_set and group key, not by organization_id, and the answer's organizationId is the connection's live organization, not the row's. The S4 fix makes re-homing a connection an endorsed flow (changeset: a credential revoked in A does not block a mapping once one is minted in B). After that re-home, a mapping saved under A stays in A: re-saving the same group and set answers 200 with data.organizationId: B and created: false while the stored row, and every grant the projection writes from it, remain in A. Before this delta the path was unreachable (the save was refused as ambiguous). It confers nothing new, the A grant is one A's platform administrator mapped, so not a must-fix; but the door's answer misreports the row's organization and the changeset sentence "every mapping, and every grant it makes, belongs to exactly one organization" is true of a different organization than the door reports. Fix: include organization_id in the existing match and refuse a same-key row held in another organization with rule_violation on connectionId naming the mapping to delete first (or re-point and audit it); pin with revoke-in-A, mint-in-B, re-save. A changeset sentence alone cannot cure the misreported organizationId.
  • N3 · nit · packages/plugins/plugin-auth/src/scim-connection-service.ts:283-292. scimCredentialIsLive is now a pure exported function with an injectable now, yet its expiry arms have no direct pin: the mint door refuses a past or unreadable expiry (scim-connection-admin.test.ts:285-293), so no existing test reaches a stored row that has since expired, on main or here. Four one-line cases (active 0 / false / null; null expiry; NaN expiry; past and future against an injected now) would hold the shared predicate for both doors. No effect on correctness; equivalence is established by reading in ③ 2.
  • N1, N2 of comment 6722234751 stand unchanged.

No must-fix. S4 is resolved in the tree and pinned; the bearer verifier's accept set is unchanged against main; the projection holds no competing derivation. The ruling's forks hold as at 42f60764: fork 1 per-connection typed rows each naming exactly one organization that a live credential owns; fork 2 no administrator set saved or granted; fork 3 the ADR not in this diff, owed separately.

Implemented-by: claude/project-thread-dv4dek
Reviewed-by: session_01FEkQ5p3T5YWhLjurajudnQ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 95349ede3f5e7d4868f54980044eb71ddcded420
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts, packages/plugins/plugin-auth/src/scim-group-projection.test.ts, packages/plugins/plugin-auth/src/scim-connection-service.test.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, .changeset/30959-scim-group-permission-set-mapping.md

Delta round on the record of head b13015e7 (comment 6723129278: PASS, no must-fix, S5 should-fix, N3 nit). git log --first-parent b13015e7..95349ede is three commits, no merges: 82e0cee3 (tests only: new scim-connection-service.test.ts +27, one S5 case in scim-group-projection.test.ts +26), 18a95de4 (src only: idp-group-mapping-admin.ts +14/-2), 95349ede (one ledger-note line, one changeset line). git diff --stat is exactly those five files, 69 insertions, 4 deletions. Nothing outside S5, N3, the ledger note and the changeset moved; origin/main was not merged in this delta.

Gate verdicts on this head, as read: commits/95349ede/check-runs lists 66 runs. One red: Type Check · consumer gates (failure), caused by main's #31511 (1ebe0c53) adding the de locale after this branch's last merge, so the new object labels lack de; not this delta's defect, the dev is merging main and translating. Every other completed run is success or skipped (Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, Lint · ESLint, Type Check · debt ledger, Type Check · source gates, Build Core, Dogfood Regression Gate, PR-body guards, Spec property liveness). At render time Test Core (8 shards), Test UI (16 shards), Type Check UI and Type Check · workspace were still in_progress; no second red was read.

① Derived judgments

Items 1-9 of the 776f80e0 record, 1-6 of the 42f60764 record and 1-5 of the b13015e7 record are carried unchanged: no file they judge moved except idp-group-mapping-admin.ts, re-read whole below. New on this head:

  1. Organization-aware match (idp-group-mapping-admin.ts:281-293). The same-key rows (connection, set, group key, unchanged predicate) are now filtered to sameKey; elsewhere is the first with text(r.organization_id) !== organizationId, existing the first with it equal. organizationId cannot be '' here (includes('') refused earlier), so an equal match always carries the connection's live organization. RIGHT.
  2. 409 refusal (:284-292). fail(409, 'RESOURCE_CONFLICT', message) with no fields, the same shape the revoke door uses for an already-revoked credential (scim-connection-admin.ts:388), and the generic conflict member of errors.zod.ts:95/189. The message names the old mapping id, its organization, the connection's current organization and the delete remedy. The return precedes row insert, writeMappingAudit and reproject: nothing is written, nothing re-projected, no audit row (consistent with the other refusals). The status union is widened to 200 | 400 | 404 | 409 | 503, and the Hono handler (auth-plugin.ts:4243) passes status through to c.json, so the wire status is 409. RIGHT.
  3. No path answers an organization the row does not carry. The 200 arm answers organizationId only after existing (same organization) or a fresh insert stamped with that organization; the created: false answer therefore names the stored row's organization. The delete door answers no organization at all (mappingId, connectionId, permissionSet, deleted, reprojected, reconciledUsers). RIGHT.
  4. Same-organization idempotent path unchanged in effect: existing found, no insert, no audit, re-project, created: false. Pinned as the CONTROL step of the S5 case. RIGHT.
  5. Delete door (:313-342): deletes by id, re-projects every domain the connection ever provisioned (readConnection.domains, all rows). reconcileProjectedGrants (scim-group-projection.ts:187-239) keys desired and held grants by (set, organization) off the mapping row, so after a re-home a deleted A-row's A-grants fall out of desired and are removed; a B-row's B-grants are untouched. It has no cross-organization blind spot of S5's kind: it reads nothing from the connection's live organization and reports none. RIGHT.

② Semver level

Unchanged from b13015e7: patch for the five published packages the PR moves; Metadata-protocol: none; Clause-②: yes (widening) line-initial in PR body and changeset. The changeset edit adds one sentence (idempotent re-save; 409 RESOURCE_CONFLICT naming the old mapping when another organization saved it) inside the existing paragraph; no header line moved. The ledger note gains the same clause. The new refusal narrows a surface that does not exist on main. MATCHES.

Clause-②: yes (widening)

③ Boundary flags

  1. S5 — RESOLVED, judged against the tree. ① 1-4 above. Pinned red-first in 82e0cee3 (tests) before 18a95de4 (src): save, re-save in ORG (200, mappingId equal, created: false, organizationId: ORG), revoke the ORG credential, mint in org_scim_30959_b, re-save answers 409 / RESOURCE_CONFLICT, message contains the mapping id and ORG, and the table still holds exactly [[mappingId, ORG]]. The pin reads the real revoke and mint services and the real door. It does not assert that reconcile was not called; the code order (① 2) makes that so.
  2. N3 — RESOLVED. scim-connection-service.test.ts holds eight cases against scimCredentialIsLive(row, now) with an injected now: it.each over active in false, 0, null, undefined (four, each refused: the predicate's === false || === 0 || == null); active with expires_at: null and active 1 with no expires_at accepted (rawExpiry == null arm); past expiry refused; expiry equal to now refused (strictly-greater comparison); expiry one millisecond ahead accepted and 'not a date' refused (NaN arm). Every arm of the function is reached; the truthy-string active case is not pinned, which matches the predicate's deliberate "refuse only what is not affirmatively active" reading. Agrees.
  3. Earlier findings carried by reference: S1-S3 of 776f80e0 and S4 of 42f60764/b13015e7 RESOLVED and unmoved. N1 (inventory wording, organization-ownership-inventory.ts:796) and N2 (organization_id required: false on the object) stand, their files untouched. N4 of 776f80e0 stands as a declared edge.
  4. Dev flags: the PR body's local-run claims are the lane's and not adopted; the red-first order is read off the commit sequence.

④ Design judgment

packages/spec/src/contracts/security-service.ts (hits) and packages/spec/src/system/constants/platform-object-names.ts (hits). Unchanged since 776f80e0; the earlier judgments hold. Fork 1 is now closed on the re-save side too: a mapping row's organization is the one a live credential named when it was saved, and the door never reports another. No finding.

plugin-auth/src/idp-group-mapping-admin.ts. Trying to break it after the fix: (a) two same-key rows, one in A and one in B, cannot arise through this door (the first save in B is refused while the A row stands); (b) a same-key row with an empty organization_id can only come from outside the door (the data API is list-only) and is treated as elsewhere, refused 409 with nothing written, which is the safe side; (c) the existing match cannot pick a row of another organization since the elsewhere check runs first over the whole sameKey list. The message's remedy spells the mount-relative path POST /admin/scim/delete-group-mapping without the /api/v1/auth base, as the module's path constants do. One nit, N4.

plugin-auth/src/scim-group-projection.test.ts. The S5 case exercises both sentences the changeset adds (idempotent same-organization re-save; cross-organization refusal naming the old mapping) and reads the stored row back. The inner ORG_B const shadows nothing (block-scoped, same literal as the S4 case). Agrees.

plugin-auth/src/scim-connection-service.test.ts. ③ 2. Agrees.

auth-route-ledger.ts and .changeset. Both name the 409 and the delete-first remedy; both agree with ① 2. Agrees.

Findings

  • S5 RESOLVED, N3 RESOLVED; N1, N2 of comment 6722234751 stand unchanged.
  • N4 · nit · packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts:289. The 409 remedy cites POST /admin/scim/delete-group-mapping, the mount-relative path; the route ledger and the changeset spell it POST /api/v1/auth/admin/scim/delete-group-mapping. A same-key row stamped with an empty organization (reachable only outside the door, N2) is refused with the message reading "in organization ''". Neither affects correctness or the contract.

No must-fix. S5 and N3 are resolved in the tree and pinned; the delete door has no counterpart blind spot; the only red check-run is main's de-locale drift, owed to the pending main merge, not to this diff.

Implemented-by: claude/project-thread-dv4dek
Reviewed-by: session_01FEkQ5p3T5YWhLjurajudnQ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

席位核对 · ACCEPT · 2026-11-08T16:42Z · head d8ef8e6930b0c876b3c81f5988871973a4703817

Seat domain:server#6 reviewed the diff of its own dispatch (claim 6718392258 on #30959 with amendments 6718415748 and 6718429681, under the maintainer ruling recorded at 6718345088, dev report 6719157245). I checked it against GitHub, not against the report.

  1. Shape: 48 files, +2061 / −111 against main. 26 files (+1476 / −69) are authored; the rest is generated (translation bundles in nine locales, two census artefacts, the tenancy census JSON).
  2. Contract review: five records on this PR, all at CONTRACT_REVIEW_TIER, schema face:
    • 6719306407 on 776f80e0: PASS, should-fix S1–S3.
    • 6722234751 on 42f60764: PASS. It resolves S1–S3 and raises S4 (readConnection counted inactive credentials).
    • 6723129278 on b13015e7: PASS. It resolves S4 and raises S5 (a re-save after a re-home misreported its organization).
    • 6723714687 on 95349ede: PASS. It resolves S5 (409 RESOURCE_CONFLICT naming the old mapping) and N3. No should-fix is left.
    • Nits are not carried.
  3. Delta since the last record (95349ede → d8ef8e69):
    • 1ccf149e merges origin/main at 645ed3d1 (which includes i18n(platform-objects): ship the platform objects, Setup, Studio and Account in German #31511, the de locale) once, with no rebase. Its tree equals git merge-tree --write-tree 95349ede 645ed3d1 (ee28e76d), so it is a pure auto-merge with no hand resolution.
    • d8ef8e69 touches only de.objects.generated.ts (+48) and de.source-hashes.generated.ts (+1): the 19 new labels in German, regenerated.
    • No source, spec, test or changeset byte moved, so the record 6723714687 still covers the reviewed surface. A translation bundle is outside the schema face.
  4. Level:
    • Patch for @objectstack/plugin-auth, plugin-security, platform-objects, spec and cli.
    • Clause-②: yes (widening) (an additive optional contract member and one constants row). Metadata-protocol: none. No migration entry is owed.
  5. Gates / check-runs:
    • Dev, on d8ef8e69: check:i18n, i18n-coverage, i18n-stale-fill, i18n-walk-parity, tenant-audit-census, platform-object-tenancy-census and auth-mount-ledger all exit 0. Suites: platform-objects 1886 passed, plugin-auth 4337 passed / 10 skipped.
    • The 130 derived gates and the plugin-auth typecheck (with check:test-typecheck) stand from 95349ede; the delta adds no file they read.
    • CI on d8ef8e69: 73 check-runs: 60 success, 12 skipped, none failed. Every required context of main is green (Lint UI skipped, as on any PR with no UI change). The one cancelled is a Check Changeset run that the body-edit rerun superseded; both of its other runs on this head are green.
    • The head merges cleanly with current main.
  6. Body: the seat patched the PR body (signed line) for the new head. It also switched Fixes #30959 to Refs #30959. The claim scopes a second PR from this branch (the ADR, Tier H, the maintainer merges it), so plugin-auth: a SCIM group push grants nothing — no projection maps a group to a role or permission set, though last-admin-guard counts it a live write shape #30959 stays open and that ADR PR is the one that closes it.
  7. Landing: check-governed-merges --pr 31443 says not governed, 2172 changed lines (≤ 3000). I am landing on the maintainer's standing words 「按照 pm-dispatch 规则复核过的都授权」 (2026-10-25T11:35Z) and 「你应该使用 pm-dispatch 技能审核和落地pr」 (2026-11-07T16:59Z), through relay pr_ready and then automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 16:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 16:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit ed4b832 Nov 8, 2026
73 of 74 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dv4dek branch November 8, 2026 17:13
os-project-manager pushed a commit that referenced this pull request Nov 8, 2026
…base

sys_idp_group_mapping (from #31443) and the sys_api_key revoked_reason
`administrator_suspended` option and its updated help (from #31622).

Co-Authored-By: Claude <noreply@anthropic.com>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…the inherited admin cookie (objectstack-ai#31871)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi96VGP7vMPA9C54zyjz2LMch)_

Fixes objectstack-ai#31827

Clause-②: no
Metadata-protocol: none

## 维护者速读

- 改了什么:夜间 live 层的成员夹具改为只用成员自己的凭据发 API 请求(成员自己的会话 cookie 加它的
Bearer),成员登录也从空状态开始;`read-only-member-record.spec.ts` 只换成调用新助手,断言一条未动。
- 为什么改:在 `@playwright/test` 里,不指定 `storageState` 新建的请求上下文会继承配置里的管理员
cookie,于是成员请求同时带着管理员 cookie 和成员 Bearer。objectstack-ai#31469 起服务端拒绝这种「两个账号」的请求,数据路由回
401,夜间层因此变红。席位裁定:改夹具,不改产品。
- 风险与回滚:只动 `ui/e2e/live/` 下两个测试文件,不发布任何包,不动 `packages/`。回滚即 revert。
- 先看:`ui/e2e/live/member.ts` 的 `memberRequestContext` 和模块注释里的 ⛔ 段。
- 两分钟验证:起后端后跑 `OS_TEST_TIERS=nightly pnpm --filter
@objectstack/example-showcase exec vitest run
test/console-live-specs.live.test.ts`,预期 `28 passed`。
- 需要你做的:无,席位落地。

## What changes

- `ui/e2e/live/member.ts` gains `memberRequestContext(member)`: a
request context carrying the member's own session cookie and its Bearer,
with its `storageState` named so the config's administrator cookie is
not inherited.
- The member's sign-in context starts from an empty `storageState` (`{
cookies: [], origins: [] }`) instead of the inherited administrator
cookie, so the sign-in request names the member only.
- `read-only-member-record.spec.ts` builds its premise context with the
helper. That is its only change: the `playwright` fixture it no longer
uses leaves the destructuring, and no assertion moves.
- The module comment states the trap: inside `@playwright/test`,
`playwright.request.newContext()` and the imported
`request.newContext()` both start with the config's `use.storageState`.

No test was skipped, renamed or loosened. No change under `packages/`.

## Why

The nightly live tier went red at `6be71abc` on
`read-only-member-record.spec.ts:48`, premise step: the member's `GET
/api/v1/data/showcase_inquiry/<id>` answered 401 where 200 was expected.

Step 0 (seat ruling 6735238125):

- **Bisect.** Each commit measured on a fresh backend booted by
`ui/e2e/live/ci/start-backend.sh`, with the spec's request shape:

  | Commit | Result |
  |:--|:--|
  | `44a030bf` (last green nightly) | green, read 200 / write 403 |
  | `133f75aec0~1` | green, 200 / 403 |
  | `133f75aec0` objectstack-ai#31469 | **red**, 401 / 401 |
  | `ed4b83226b` objectstack-ai#31443 | red |
  | `59630d9357` objectstack-ai#31622 | red |
  | `8c9260f79c` objectstack-ai#31657 | red |
  | `f127b04e07` objectstack-ai#31686 | red |
  | `6be71abc` (red nightly) | red |

- **Cause.** The member's context was `playwright.request.newContext({
extraHTTPHeaders: { Authorization: 'Bearer <member>' } })`. Inside
`@playwright/test` that context inherits the config's
`use.storageState`, which is the global setup's administrator cookie.
Measured: the context's cookie equals the administrator's. So each
premise request carried two accounts' credentials. Before objectstack-ai#31469 the
bearer won. Since objectstack-ai#31469 the session read refuses such a request with
`CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, and the data route answers its
own anonymous 401. objectstack-ai#31469's acceptance notes describe exactly that
data-route answer, so the product behaves as ruled and the fixture
relied on what objectstack-ai#31469 ruled out.

## Sweep (owed by the ruling)

Every live spec and helper under `ui/e2e/live/` that builds a request
context or sends a credential:

| Site | Accounts it sends | Action |
|:--|:--|:--|
| `read-only-member-record.spec.ts` premise context | admin cookie
(inherited) + member Bearer | **moved onto `memberRequestContext`** |
| `member.ts` sign-in context (`request.newContext()`) | admin cookie
(inherited) on the member's sign-in | **moved onto an empty
`storageState`** |
| `read-only-member-record.spec.ts` member browser context | member
`storageState` named explicitly | one account, unchanged |
| `global-setup.ts` sign-in context | none (global setup runs before any
`use` state exists) | one account, unchanged |
| `inline-edit-polish-2572.spec.ts` `request` + Bearer | admin cookie +
admin token from the same `state.json` | one account, unchanged |
| `studio-customize-journey.spec.ts` `request` override | `storageState`
passed by name (admin), no other credential | one account, unchanged |
| `studio-object-designer.spec.ts` in-page `fetch` | admin page's own
cookie + its own localStorage token | one account, unchanged |
| every other spec's `request.get/post/…` (cascading-options,
grid-file-upload, list-row-action-cel, master-detail,
record-form-direct-url, record-history-display) | admin `request`
fixture alone | one account, unchanged |

Measured for the two inherited sites: the imported
`request.newContext()` and `playwright.request.newContext()` each start
with 1 cookie under this config; with `storageState: { cookies: [],
origins: [] }` they start with 0.

## Verification

All heavy runs went through `scripts/pm/os-verify-lock.sh`. Head
`86082eb9b5`, backend closure and `pnpm console:build` rebuilt at the
head first.

- **Nightly live file, head:** `OS_TEST_TIERS=nightly pnpm exec vitest
run test/console-live-specs.live.test.ts` in `examples/app-showcase`:
Playwright `28 passed (1.3m)`, vitest `Test Files 1 passed (1)`, `Tests
1 passed (1)`.
- **Ablation:** `scripts/ablation-replace.mjs` deletes the helper's
`storageState` line in `member.ts` (anchor 1 → 0, blob `d4e1a39d33f5` →
`dc6c2ff0d686`), so the inherited administrator cookie is back. The same
run: `✘ read-only-member-record.spec.ts:48`, `Expected: 200`, `Received:
401`, `Test Files 1 failed (1)`. Restored: blob equals HEAD's
(`d4e1a39d33f5`), `git diff HEAD` empty, `git status --porcelain` empty.
- **Before the fix, on main `3487ccc1`:** the same nightly run was red,
1 of 28, the same 401.
- **Dispatch gates:** see the os-dev-report on objectstack-ai#31827 for each command's
exit code and the `--ran` reconciliation; `pnpm lint` and `eslint` over
the two touched files are reported there too.

No changeset: the diff publishes nothing (`ui/e2e/live/` only), so this
PR carries `skip-changeset`.

## Human Minimum

- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [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. (Publishes
nothing; `skip-changeset`.)
- [x] 6. `packages/spec` touched: n/a.
- [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. (The 维护者速读 section is
the seat's required Chinese summary.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADHkmPpwZNVKwJLQGotdJy

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmPpwZNVKwJLQGotdJy)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides protocol:system size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant