Skip to content

fix(platform-objects): offer the platform-admin-gated user, OAuth and SSO actions only to a platform admin - #22068

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21903-platform-admin-affordances
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21903-platform-admin-affordances

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21903
Clause-②: no

What changes

Thirteen first-party actions are now offered only to a platform administrator. That is the one standing their endpoints admit.

  • Before: each action was offered to every principal who could open the row or list. That covered org owners, org admins, delegated admins and plain members. The endpoint then answered each of them 403, because every one of these doors runs the ADR-0068 platform-admin gate first.
  • After: each action declares visible: 'current_user.isPlatformAdmin == true'. That is ruling A-lite's predicate (record 6019378035), the one sys_member.add_member landed with in PR fix(platform-objects): offer Add Member only to a platform admin #22064. It is ANDed with any term the action already had, and requiresFeature composes onto it at parse time.
  • The members:
    • sys_user: ban_user, unban_user, unlock_user, create_user, set_user_password, impersonate_user and set_user_manager;
    • sys_oauth_application: disable_oauth_application and enable_oauth_application;
    • sys_sso_provider: register_sso_provider, register_saml_provider, request_domain_verification and verify_domain.
  • The enumeration pin: packages/platform-objects/src/platform-admin-affordance-standing.test.ts imports every *.object.ts under packages/ and judges every action whose target door is platform-admin gated. An action added later fails it until its visible carries the standing.

The doors and the callers they admit are unchanged. No key, export or parameter is added, and no label changes. That makes Clause-②: no, and the changeset is a @objectstack/platform-objects patch.

File surface. This is the surface the claim declared:

  • the three producers in packages/platform-objects/src/identity/;
  • the lowering-matrix rows in packages/platform-objects/src/platform-objects.test.ts;
  • the new enumeration pin;
  • .changeset/21903-platform-admin-affordance-visibility.md.

Two existing tests in the same identity/ directory evaluate these predicates, so their fixtures were re-judged (see Acceptance notes): action-predicate-sparse-face.test.ts and sys-user-set-manager-action.test.ts. There is no packages/spec, plugin-auth, @objectstack/formula, docs/adr or dogfood edit.

The census (H1), measured at 56bf27affb

Every first-party action with a /api/v1/auth/ target lives in packages/platform-objects/src/identity/. The gate lines are in packages/plugins/plugin-auth/src/auth-plugin.ts unless named otherwise.

Action Target Door and its gate Gated visible served before visible served after
sys_user.ban_user /admin/ban-user mount :2670, gateAdmin :2672 yes features.admin == true (current_user.isPlatformAdmin == true) && features.admin == true
sys_user.unban_user /admin/unban-user mount :2691, gateAdmin :2693 yes features.admin == true same as ban_user
sys_user.unlock_user /admin/unlock-user mount :2490, judgePlatformAdmin :2502 yes features.admin == true same as ban_user
sys_user.create_user /admin/create-user mount :2601, gateAdmin :2603 yes features.admin == true same as ban_user
sys_user.set_user_password /admin/set-user-password mount :2851, gateAdmin :2853 yes features.admin == true same as ban_user
sys_user.impersonate_user /admin/impersonate-user better-auth endpoint replaced in place (admin-impersonate-endpoint.ts:198, caller predicate :213, wired at auth-manager.ts:3609) yes features.admin == true same as ban_user
sys_user.set_user_manager /admin/set-user-manager mount :2536, gateAdmin :2538 yes has(record.source) && record.source != "idp_provisioned" current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"
sys_oauth_application.disable_oauth_application /admin/oauth2/toggle-disabled mount :2355, judgePlatformAdmin :2372 yes (has(record.disabled) && record.disabled != true) && features.oidcProvider != false (current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false
sys_oauth_application.enable_oauth_application /admin/oauth2/toggle-disabled as above yes (has(record.disabled) && record.disabled == true) && features.oidcProvider != false (current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true) && features.oidcProvider != false
sys_sso_provider.register_sso_provider /admin/sso/register mount :2468, gateAdmin :2470 yes none current_user.isPlatformAdmin == true
sys_sso_provider.register_saml_provider /admin/sso/register-saml mount :2958, gateAdmin :2962 yes none current_user.isPlatformAdmin == true
sys_sso_provider.request_domain_verification /admin/sso/request-domain-verification mount :2983, gateAdmin :2988 yes none current_user.isPlatformAdmin == true
sys_sso_provider.verify_domain /admin/sso/verify-domain mount :3002, gateAdmin :3005 yes none current_user.isPlatformAdmin == true
sys_member.add_member (the precedent) /organization/add-member mount :2932, gateAdmin :2934 yes unchanged: (current_user.isPlatformAdmin == true) && features.organization != false unchanged
sys_oauth_application.create_oauth_application /sys-oauth-application/register mount :3038, session only no features.oidcProvider != false unchanged
sys_oauth_application.rotate_client_secret /oauth2/client/rotate-secret vendor route: @better-auth/oauth-provider 1.7.3 rotateClientSecretEndpoint authorizes the client's owner (client.userId === session.user.id); plugin-auth configures no clientPrivileges no features.oidcProvider != false unchanged
sys_oauth_application.delete_oauth_application /oauth2/delete-client vendor route: deleteClientEndpoint, same owner check no features.oidcProvider != false unchanged
sys_sso_provider.delete_sso_provider /sso/delete-provider vendor route: @better-auth/sso 1.7.3 checkProviderAccess admits the provider's owner or an admin of its organization no none unchanged

Targets are spelled after /api/v1/auth.

The dispatch's hypotheses, measured

  • H1: confirmed, with one count corrected.
    • The family is the thirteen actions above plus add_member.
    • The body's "sys_oauth_application: two actions" is two of the five that object declares. The other three authorize the session or the application's owner, as the table shows.
    • The body's "sys_sso_provider: four actions" is four of five. delete_sso_provider's door is the vendor's provider-access check.
    • Outside object files, service-datasource registers two type-level actions. Their doors ask for the manage_platform_settings capability (admin-routes.ts:272), and the file rules out an isPlatformAdmin arm (admin-routes.ts:268). They are not members.
    • sys_organization.change_slug targets /api/v1/cloud/..., which another repository serves. Its door is NOT MEASURED here.
  • H2: confirmed.
    • lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:352) composes (existing) && gate at :417.
    • Where an action already had a visible, the standing is ANDed in the repo's existing spelling for an authored composed predicate. That is one flat conjunction with the principal term first, as the sys_user self-service predicates spell theirs.
    • Each served result is pinned in the lowering matrix.
  • H3: confirmed. No member's door admits a principal below platform admin, apart from the legacy scalar noted below.
    • Offered (before / after): I evaluated the served predicate from the built dist with celEngine, binding current_user the way the console does. The whole scope goes in as extra, with one subject under current_user, user, ctx.user and os.user.
      • At 56bf27affb, every member was offered to all five principals: platform admin, org owner, org admin, delegated admin and plain member.
      • At efd167f5a3, each member is offered to the platform admin alone.
      • The four non-members are unchanged.
    • Admitted: I called the built judgePlatformAdmin on the same five session shapes. It admits the platform admin and refuses the other four with 403 PERMISSION_DENIED. It also refuses a session whose positions carry a tenant-written platform_admin without the rung.
      • The impersonate caller predicate (admin-impersonate-endpoint.ts:213) refuses the same principals with 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS. Its oracle is the posture rung, which auth-manager.ts:4083 also emits as the session's isPlatformAdmin.
      • On a real showcase boot at this head, admin-route-nonadmin-refusal.dogfood.test.ts and admin-platform-admin-standing.dogfood.test.ts passed 14 of 14. They cover all thirteen member doors in both directions.
    • Page reach: sys_sso_provider also requires manage_platform_settings at object level (sys-sso-provider.object.ts:60). That is a capability, not the rung, so the predicate is still needed for a holder of the capability who is not a platform admin.
  • H4: confirmed. No structural oracle exists without a new export.
    • plugin-auth exports no mount table.
    • auth-route-ledger.ts is package-internal, and its rows state the gate only in note prose.
    • VENDOR_ADMIN_PATH_PREFIX names the namespace, but plugin-auth is not a dependency of platform-objects.
    • So the pin uses the narrowest honest form, read off the mounts:
      • the /api/v1/auth/admin/ namespace;
      • minus /admin/has-permission (a query that answers every caller) and /admin/stop-impersonating (it admits the impersonated session);
      • plus /api/v1/auth/organization/add-member.
    • What it misses (also stated in the test's header):
      • a gated mount added outside the namespace;
      • a capability-gated door;
      • routes another repository serves;
      • actions not declared on an object file.
    • Dogfood pin (c) was not needed. The door-level check is already pinned by the two dogfood sweeps above, which passed 14 of 14.

Tests

All runs are at the final head efd167f5a3.

  • Pin (a), the lowering matrix in platform-objects.test.ts: every member has a row with its composed served predicate, beside add_member's row.
    • set_user_manager and the four sys_sso_provider rows are new.
    • The two exact-source assertions on the OAuth toggle pair were updated.
  • Pin (b), platform-admin-affordance-standing.test.ts: 5 cases.
    • The instrument is live: the walk loads more than 50 object files, and the family contains all 14 known members. That is a floor, not the exact set.
    • The oracle does not include the four non-member targets or the two excluded namespace routes.
    • Every member's served visible carries current_user.isPlatformAdmin == true.
    • No refused principal is offered any member on any candidate row. The refused principals are the four grades and a tenant-written platform_admin position without the rung. current_user is bound through extra.
    • The platform admin is still offered every member.
  • The package: pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 passed 63 files and 1006 tests. On main it ran 62 files and 996 tests: this adds 5 pin cases and 5 matrix rows.
  • Typecheck: pnpm --filter @objectstack/platform-objects typecheck passed.
    • The test-layer program (tsconfig.test.json) compiles all four touched test files: --listFiles counts 1 hit each, and the build program counts 0.
    • Its only errors are the 3 already ledgered in src/feature-gate-guard.test.ts.
  • Dogfood, real showcase boot: these passed on a real showcase boot:
    • admin-route-nonadmin-refusal and admin-platform-admin-standing: 14 of 14;
    • org-admin-affordance-reach: 14 of 14.

Reverse verification, on committed HEAD efd167f5a3

  • Prediction: both pins turn red.
  • Mutation: scripts/ablation-replace.mjs replaced set_user_manager's predicate with its pre-change text, has(record.source) && record.source != "idp_provisioned".
    • The anchor count went from 1 to 0, and the file's blob from 0b9e8e738fb4 to 0ac51042a82d.
    • An outer trap … EXIT INT TERM used absolute paths.
    • Both pins import the subject from source: a relative import, and the absolute .object.ts path. No dist/ is in their resolution path, so no rebuild was needed.
  • Result: red, as predicted, 3 failed of 137.
    • (a) The SysUser.set_user_manager row expected current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned" and received the mutated text.
    • (b) "each member serves a visible that carries the standing term" listed sys_user.set_user_manager.
    • (b) "no principal the gate refuses is offered a member" listed five leaks. The org owner, org admin, delegated admin, plain member and the tenant-written position were each offered set_user_manager.
  • Restore: git checkout HEAD -- on the absolute path.
    • The blob after the restore and the HEAD blob are both 0b9e8e738fb4911b9a379b2ebc699344f68127bd.
    • git diff HEAD is empty, and git status is clean.
    • Both ablation-replace and the outer script proved this.

Gates

These ran at efd167f5a3. origin/main was still 56bf27affb at the last fetch, so there was nothing to merge.

  • Full build: turbo run build --filter=!@objectstack/docs --concurrency=2 passed 72 of 72 tasks.
  • The derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 63 commands (31 pnpm, 32 node). All 63 exited 0.
    • --ran, with each exit code recorded: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero).
    • The build-dependent ones printed real verdicts. For example, check:dual-build-cjs-loads loaded 106 entries across 66 packages, and check:dts-closure swept 72 packages.
  • The artifact-roster block: 54 commands outside the total. 51 exited 0.
    • Three answered NOT WIRED (exit 2) because they need pull-request context: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths.
    • check-partof-closing-keyword was rerun with this body as PR_BODY. The other two need this PR's number, and CI runs them.
    • check-sdui-manifest passed, but its objectui version comparison is NOT CHECKED: there is no objectui checkout here.
    • check:console-injection ran its self-test only, because there is no console dist/.
  • The four symbol-anchor sweeps: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors and check:adr-anchors all passed.
  • Lint, as a proven narrowing: pnpm exec eslint --no-inline-config --format json over the seven touched TypeScript files.
    • The JSON output counts 7 files, with 0 errors and 0 warnings.
    • --print-config resolves a config for each file, so none is ignored.
    • Type-aware linting is off. No resolved config carries parserOptions.project or projectService, and eslint.config.mjs:327-328 states the repo never enables it. So this diff cannot move the verdict on any untouched file.
  • Left to CI, as a declared narrowing:
    • the type-check lanes;
    • Test Core;
    • the Dogfood Regression Gate beyond the three files above;
    • Build Core;
    • Temporal Conformance;
    • the repo-wide pnpm lint.

Acceptance notes

  • The door also admits a legacy user.role === 'admin' scalar (platform-admin-gate.ts:80-84, and the impersonate predicate's first half), and this predicate does not read it. This is the boundary PR fix(platform-objects): offer Add Member only to a platform admin #22064 recorded for add_member. Nothing in ObjectStack writes that scalar for a platform admin, and re-synthesizing it is vetoed. Noted, not filed.
  • Two fixtures were re-judged, because the predicates they evaluate changed:
    • action-predicate-sparse-face.test.ts bound an org owner through user:. Under that binding @objectstack/formula re-derives isPlatformAdmin from positions.
      • With the standing term in place, the owner would have short-circuited the record half of three predicates to false, and the sweep exists to test exactly that half.
      • Its OAuth per-site verdicts would also have gone red.
      • It now binds the same principal through extra, with the rung, the way the console binds it. The only alternative under user: was a platform_admin position, the spelling the standing must never be read from.
    • sys-user-set-manager-action.test.ts evaluated its "offered to an admin" case for a principal with no standing. It now binds a platform admin the same way.
  • An unmeasured observation about SSO providers, not filed.
    • request_domain_verification and verify_domain pass the platform-admin gate and then delegate to @better-auth/sso's checkProviderAccess. So does delete_sso_provider's door, with no gate in front.
    • That check admits only the provider's owner or an admin of its organization.
    • By reading, a second platform admin who did not register an org-less provider would be offered these actions and then refused by the vendor. This was not reproduced on a boot.
    • It is a different principal from this card's family. The standing remains the necessary term.
  • The pin's oracle and what it misses are written in its header. Every exclusion is named, with the principal that the excluded door admits.

Generated by Claude Code

… a platform admin

The thirteen sys_user, sys_oauth_application and sys_sso_provider actions
whose door runs the ADR-0068 platform-admin gate now declare
`visible: 'current_user.isPlatformAdmin == true'`, composed with their
existing terms. A repo-wide enumeration pin holds every first-party action
targeting a platform-admin-gated door to the same standing.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 4 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via idp_provisioned (literal, a string literal in actions))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/authentication.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/authorization.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/permission-metadata.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/positions.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/sharing-rules.mdx (via platform_admin (literal, a string literal in a comment in actions))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/releases/v17/17-4.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/releases/v17/17-5.mdx (via platform_admin (literal, a string literal in a comment in actions))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 3 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 56bf27affbe5e3f5aca8001ed0218d013b04bf22 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 139d7857393d62678f5ec07afd0dcf6ae29cdd02 — the merge of head efd167f5a317ae9ecfe8c24f8c80e13cd8cd4d7f into base 56bf27affbe5e3f5aca8001ed0218d013b04bf22, 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 139d7857393d62678f5ec07afd0dcf6ae29cdd02 && git checkout 139d7857393d62678f5ec07afd0dcf6ae29cdd02
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56bf27affbe5e3f5aca8001ed0218d013b04bf22 efd167f5a317ae9ecfe8c24f8c80e13cd8cd4d7f && git checkout -B drift-repro 56bf27affbe5e3f5aca8001ed0218d013b04bf22 && git merge --no-ff efd167f5a317ae9ecfe8c24f8c80e13cd8cd4d7f

node scripts/docs-audit/affected-docs.mjs --json 56bf27affbe5e3f5aca8001ed0218d013b04bf22

⚠️ 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 56bf27affbe5e3f5aca8001ed0218d013b04bf22 → 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

ACCEPT (seat review) — PR #22068 at head efd167f5a3

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-07T10:55Z. The dev's report is os-dev-report on #21903. It applies ruling A-lite's predicate (6019378035, landed on add_member by #21886) to the rest of the family. This is the family's closing card.

  • The change. The seat read every changed visible line in the diff. Thirteen actions gain current_user.isPlatformAdmin == true:
    • added to ten (sys_user's ban_user, unban_user, unlock_user, create_user, set_user_password and impersonate_user, and sys_sso_provider's four bridge actions);
    • ANDed ahead of the existing term on three (set_user_manager and the sys_oauth_application toggle pair).
    • requiresFeature composes onto each at parse time (lowerRequiresFeature). No other source line moves. Doors, keys, exports and labels are unchanged.
  • The census corrected the body (H1). Each door was re-derived from plugin-auth's mounts with file:line. sys_oauth_application has 2 members of 5, and sys_sso_provider has 4 of 5. The four non-members authorize the session or the record's owner (the vendor's owner checks), so they stay ungated. add_member is the precedent, unchanged.
  • Reach (H3), measured both ways.
    • At base, every member was offered to all five principals. At head, each is offered to the platform admin alone. Each served predicate was evaluated with current_user bound through extra.
    • The built gate refuses the other four principals, and also a tenant-written platform_admin position without the rung.
    • On a real showcase boot, the two existing admin-route dogfood sweeps pass 14/14 across all 13 member doors.
  • Pins:
    • (a) lowering-matrix rows for every member, with the composed predicate;
    • (b) the new enumeration pin platform-admin-affordance-standing.test.ts. Its oracle is the /api/v1/auth/admin/ namespace minus two caller-open mounts, plus add_member's mount, which is the narrowest honest form without a new plugin-auth export (H4). Its header names what it misses.
    • Two existing tests (action-predicate-sparse-face, sys-user-set-manager-action) now bind their principal through extra with the rung, so their record-half verdicts still evaluate. Their assertions are not loosened.
    • Reverse verification on the committed head reverted set_user_manager's predicate. (a) and (b) went red, with five leaked principals listed. It was restored by blob equality.
  • Changeset, sentence by sentence against the diff: the 13 named actions, "offered only to a platform administrator", and "each door, key, export and label unchanged" match the file list and the visible lines.
  • Clause-②: no, patch. No door's accept set moves, and no packages/spec, plugin-auth or formula file is in the diff. No contract review is owed.
  • CI on efd167f5a3: 31 success, and the 3 skips are in the roster.
  • Readings against main at ae97841556: NOT governed (0 of 8 paths), 460 changed lines, and git merge-tree is clean.
  • Noted, not filed (PR Acceptance notes, 承接者:无):
    • by reading, @better-auth/sso's checkProviderAccess may refuse a second platform admin on an org-less provider it did not register (not measured);
    • the legacy role === 'admin' scalar the gate also admits (fix(platform-objects): offer Add Member only to a platform admin #22064's boundary);
    • sys_organization.change_slug targets a door another repository serves, not classified.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 10:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 879bd38 Oct 7, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21903-platform-admin-affordances branch October 7, 2026 11:24
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 size/m tests tooling

Projects

None yet

2 participants