Skip to content

fix(security): enforce active on sys_permission_set and sys_position (#8613) - #8713

Merged
qq9340100 merged 2 commits into
mainfrom
claude/issue-8613-enforce-active-on-grants
Aug 14, 2026
Merged

fix(security): enforce active on sys_permission_set and sys_position (#8613)#8713
qq9340100 merged 2 commits into
mainfrom
claude/issue-8613-enforce-active-on-grants

Conversation

@qq9340100

@qq9340100qq9340100 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes#8613

Both RBAC grant catalogues ship a Deactivate action whose confirmation dialog promises, in all four locales, that access stops. Nothing read the column. Measured on the real resolver before this change: a position seeded active: false still granted its permission sets, and a permission set seeded active: false still returned posture: PLATFORM_ADMIN with its system permissions.

Implements the maintainer ruling of 2026-08-14 (comment 5293338173, and the scope widening in 5294608710): enforce for both objects, at every true grant-resolution source, never partial.

Where it is enforced, and why there

packages/core/src/security/resolve-authz-context.ts — the single seam every transport (REST server, runtime dispatcher, MCP, runAs:'user' automation) resolves authorization through. Both tables were already read there, so the predicates cost zero new hot-path queries.

stepbeforeafter
6a sys_positionids mapped, no flag reada deactivated row contributes no sys_position_permission_set grants, and its name leaves positions
6b sys_permission_setrows read by id, no flag reada deactivated row is dropped before any derivation — no name, no system_permissions, no tab_permissions, and no hasPlatformAdminGrant
plugin-security DB loaderwhere: { name: { $in: names } }same predicate applied to the rows it fetched

sys_position.active is only enforceable here. Downstream in plugin-security the position to permission-set linkage is already collapsed into a flat permissions list, so a set held via a deactivated position is indistinguishable there from one granted directly, and filtering there would over-revoke a set the user also holds in their own right (pinned: "a set held via BOTH a deactivated position and a direct grant still resolves").

Dropping the position NAME is not cosmetic: resolvePermissionSetsForContext requests context.positions as permission-set names too (position names are commonly reused as set names), so a name left standing would resolve the same grant one layer down.

The plugin-security loader is not dead code

Measured, and pinned by permission-set-active.test.ts: an active position whose name matches a deactivatedsys_permission_set row arrives at that loader with the name still standing. Core filtered the position catalogue and the sets reached by id; neither judged that row. Assumption A2 in the dispatch is therefore confirmed live, not defence-in-depth-that-nothing-reaches.

One predicate, three readers

New: isRowActive in @objectstack/core (security/row-active.ts), shared by the resolver, the loader, and the break-glass guard that SIMULATES a write to the flag — a guard modelling "deactivated" differently from the resolver would permit exactly the write it exists to refuse.

It is "explicitly deactivated", never "explicitly active", and both halves are measured:

  • Absent means ACTIVE.active carries defaultValue: true, but a row predating the column, arriving through a migration, or projected with a fields list that omits it carries no value. Requiring true would revoke every such row on upgrade — a silent mass revocation nobody asked for. isGrantActive reads absent bounds as unbounded for the same reason (ADR-0091 D2). Every pre-existing fixture in resolve-authz-context.test.ts carries no active key and is the pin for this direction.
  • row.active === false alone is an enforcement hole. SQLite stores booleans as 0/1; the memory driver round-trips real booleans. The false-set is false | 0 | '0' | 'false', and the guard's cases run on a real better-sqlite3 :memory: database so the 0 shape is exercised for real, not asserted about.

Filtering in memory rather than as an active: truewhere: the predicate form would ALSO drop NULL rows, and boolean where coercion differs per driver. The rows are already fetched, so the answer is identical on every driver at no query cost.

Grant-resolution reads ONLY — the correction the escalation flagged is binding

Taken literally, "filter the named sys_position positional lookups" is access-WIDENING. These four seams stay unfiltered, each with a comment naming why:

  • security-plugin.tsassertAudienceAnchorBindingGate — a write gate. Filtering out a deactivated everyone row makes the name resolve empty, the loop continues and the gate SKIPS: a tenant-wide anchor binding refused today would be permitted.
  • delegated-admin-gate.tssetsBoundToPosition — a blast-radius read. Dropping rows NARROWS a delegated admin's boundary and lets a delegate act past their scope.
  • delegated-admin-gate.ts:338 / :586 — administration surfaces. Filtering makes a deactivated position unmanageable, the opposite of the incident-response posture this card is for.

A fifth seam was found and classified rather than filtered — see "Findings filed" below.

Break-glass: the prerequisite, closed here

Enforcing the flag makes plugin-auth's PERMISSION_SET_STANDING_KEYS = ['name'] false, and its #6084 comment asserted in writing that only a payload touching name can move the administrator enumeration. Deactivating admin_full_access un-makes every platform admin at once, by a payload touching neither name nor any identity table, through a row action with no visibility or condition guard — and re-activating needs the permission just lost (the seeders deliberately never reconcile active, so no restart restores it).

  • active joins the standing keys and the stale comment is rewritten.
  • The enumeration projectsactive (fields is a projection — a column left out would read as absent, i.e. ACTIVE, and the guard would model an environment where nothing is ever deactivated) and judges it with the same shared predicate.
  • Write shape (4) in the file header becomes delete / rename / deactivate.

Two things beyond the card's letter, both stated so they can be pushed back on:

  1. The amplifier.break-glass 不变量的第四条路径无守卫:删/改名 admin_full_access 那条 sys_permission_set 行,一次废掉所有 platform admin #6084's zero-administrator exemption reads "nobody to protect, proceed". A deactivated break-glass set produces that emptiness and leaves no dangling grant, so the break-glass 不变量的第四条路径无守卫:删/改名 admin_full_access 那条 sys_permission_set 行,一次废掉所有 platform admin #6084 predicate cannot see it — one deactivation would empty the population and wave every later ban, delete and downgrade through. An admin_full_access row that exists, is correctly named, is switched off, and still has unscoped in-window grants pointing at it is now read as emptied rather than fresh, with its own remedy sentence.
  2. The way back. A write that RESTORES standing is exempt from that refusal, measured through the same simulation. Without it the refusal would be unrecoverable from inside the product: the only fix is an update, and every update would be refused. Pinned by "THE WAY BACK IS OPEN".

sys_position needs no analogous guard, and the reason is measured: platform-admin standing is read from UNSCOPED sys_user_permission_set grants only (a position-bound admin_full_access never conferred it, in the resolver or in the guard), and org-administrator standing from sys_member.role. Deactivating a position cannot empty either. Stated in the code so the next author does not have to re-derive it.

Behaviour change on deployed data — stated, not discovered

Any sys_permission_set or sys_position row currently carrying active: falsestops granting the moment this lands, with no migration step to notice. That is the correct direction (it is what the dialog said when someone clicked Deactivate), but on an installation that used the switch believing it was inert it is a real revocation. The changeset is minor and carries a **BREAKING** marker, an ADR-0087 disposition, and the pre-upgrade query for listing deactivated rows. A row whose active is absent or NULL is unaffected.

No dialog copy changes — the dialogs become true rather than needing rewording — so the serial-after-#8599/#8601 locale-bundle constraint is not re-armed. pnpm check:i18n confirms all 9 packages' bundles in sync.

packages/spec is untouched: active is row state, not authored metadata (PermissionSetSchema is a strictObject and permission-set-projection.ts's ROW_STATE_COLUMNS says so in as many words). No spec key, export, or stored shape moves.

Docs: all 30 flagged pages screened, one gap filled

The docs-drift-check set was reproduced locally (node scripts/docs-audit/affected-docs.mjs --json origin/main — 35 entries, of which 5 are release-owned and were not opened or touched) and every one of the 30 hand-written pages was screened for deactivat|reactivat|停用 and for active co-occurring with permission-set / position / capability / grant vocabulary.

No existing sentence is made wrong by this change. Only four pages carry the vocabulary at all:

pageverdict
permissions/sharing-rules.mdx:189-190about sys_sharing_rule.active — a different object whose deactivation already worked. Correct as written, untouched.
permissions/permissions-matrix.mdx:164same: "deactivating or deleting a rule withdraws the grants it materialized". Correct, untouched.
kernel/runtime-services/sharing-service.mdx:62same: "Deactivate or edit the sharing rule instead". Correct, untouched.
permissions/authorization.mdx:211"drafts never enter the active matrix" — an unrelated sense of the word. Untouched.

The other 26 flagged pages: screened, 0 hits — the mapper's breadth here is a three-package dependency edge on @objectstack/core, not 30 candidates.

The PM's four highest-risk candidates, each opened and read: permissions-matrix.mdx (verdict above), access-recipes.mdx (0 hits — it describes the capability/assignment/requirement split and never the lifecycle of either row), explain.mdx (0 hits, but see #8714 below), authorization.mdx — which is where the one real gap was.

The gap was an omission, not an error.authorization.mdx documents ADR-0091 validity windows as the resolution-time grant-lifecycle filter, down to "an expired unscoped admin_full_access grant no longer derives platform_admin" — the exact consequence this change adds a second path to. A reader would reasonably conclude there is only one such filter. A sibling section now states the enforced semantics, that assignments are untouched and re-activation restores them, that absent means ACTIVE, that deactivating the break-glass set is refused while re-activation never is, and what deactivation deliberately does not touch (admin surfaces, write gates, blast-radius reads). check:docs-audit-scope and check:role-word green.

Verification — all at 6fb96f8d1, the head of this PR

Re-run in full after the docs commit, not carried over from the code commit.

pnpm --filter @objectstack/core test 34 files / 803 tests passed
pnpm --filter @objectstack/plugin-security test 60 files / 1139 tests passed
pnpm --filter @objectstack/plugin-auth test 53 files / 1220 tests passed
pnpm --filter {plugin-security,plugin-auth} typecheck Done (core is DEBT-ledgered, no script)

Gates re-derived from the actual changed paths (scripts/pm/dispatch-gates.mjs) and run after the final commit — check:authz-resolver, check:nul-bytes, check:cross-package-test-inputs, check:kernel-hook-pairs, check:test-source-alias, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:docs-audit-scope, check:role-word, check:i18n, check:type-check-debt --re-measure (33 ledger entries, none above its recorded number), plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset: all OK.

Reverse verification, direction predicted before running: RED. Reverting only resolve-authz-context.ts to origin/main (fix committed first, restored with git restore --source=HEAD) turns 9 of the new cases red with the deactivated grants resolving — expected [ 'admin_full_access' ] to not include 'admin_full_access' — while row-active.test.ts, a pure predicate with no dependency on that seam, stays green. The ACTIVE and ABSENT cases pass in both directions, so the block is not vacuous in either.

The break-glass half carries its own reverse verification in-file: unguarded, the deactivation succeeds, every row survives untouched, and the ban of the last administrator then succeeds — the amplification, on the engine as it behaved before this change.

Findings filed (not fixed here)


Generated by Claude Code

#8613)
Both objects ship a Deactivate action promising, in four locales, that access
stops. Nothing read the column: a position seeded `active: false` still granted
its permission sets, and a permission set seeded `active: false` still returned
`posture: PLATFORM_ADMIN`.
Enforced at the single derivation seam every transport resolves through —
`resolveAuthzContext` / `resolveUserAuthzGrants` in @objectstack/core. Both
tables were already read there, so this costs zero new hot-path queries:
- step 6a drops a deactivated `sys_position` and its bound permission sets, and
removes the name from `positions` so the name-reuse path cannot resolve the
same grant one layer down;
- step 6b drops a deactivated `sys_permission_set` BEFORE any derivation, so
`hasPlatformAdminGrant` cannot be read off a set that no longer grants;
- the plugin-security DB loader applies the same predicate, which is what judges
a set reached by NAME through an active position of the same name.
`isRowActive` (new, @objectstack/core) is the one predicate all three readers
share: explicitly deactivated, never "explicitly active" — an absent column is
ACTIVE, so rows predating the field are not mass-revoked, and the 0/1 storage
shape is judged as well as a literal `false`.
Grant-resolution reads ONLY. The write gates and blast-radius reads in
plugin-security (`assertAudienceAnchorBindingGate`, `setsBoundToPosition`, the
delegated-admin surfaces) stay unfiltered: dropping a deactivated row there
makes a refused binding permitted, narrows a delegate's boundary, and makes a
deactivated position unmanageable.
Break-glass, closed in the same change: `active` joins
PERMISSION_SET_STANDING_KEYS, the enumeration projects and judges the column,
and an environment whose `admin_full_access` row is already deactivated reads as
emptied rather than as a bootstrap window. Re-activation stays permitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 3:48pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security.

30 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core)
  • content/docs/automation/webhooks.mdx(via @objectstack/core)
  • content/docs/concepts/north-star.mdx(via packages/core)
  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/kernel/services.mdx(via @objectstack/core)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx(via packages/core, @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core)
  • content/docs/plugins/development.mdx(via @objectstack/core)
  • content/docs/plugins/index.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

5 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/releases/v12.mdx(via @objectstack/core)
  • content/docs/releases/v15.mdx(via @objectstack/core)
  • content/docs/releases/v17.mdx(via @objectstack/core)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…ndows (#8613)
Screened the 30 hand-written docs the docs-drift mapper flags for this change
(the 5 release-owned pages are read-only and untouched). No existing sentence is
made wrong: nothing documented what `sys_permission_set.active` or
`sys_position.active` meant, in either direction. The three files carrying the
deactivation vocabulary all describe `sys_sharing_rule.active` — a different
object whose deactivation already worked — and remain correct as written.
The real gap is the omission. `authorization.mdx` documents ADR-0091 validity
windows as THE resolution-time grant-lifecycle filter, down to "an expired
unscoped `admin_full_access` grant no longer derives `platform_admin`" — the
exact consequence this change adds a second path to. A reader would conclude
there is only one such filter.
Adds a sibling section stating the enforced semantics, that assignments are
untouched and re-activation restores them, that absent means ACTIVE (so rows
predating the column keep granting), that deactivating the break-glass set is
refused under ADR-0024 D5.2 while re-activation never is, and what deactivation
deliberately does NOT touch (admin surfaces, write gates, blast-radius reads —
filtering those is access-widening).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
@qq9340100Claude

Copy link
Copy Markdown
CollaboratorAuthor

Sequencing note — in-flight sibling PR #8709 also edits resolve-authz-context.ts

Recording this so whoever lands second does not have to re-derive it.

PR #8709 (issue #8287, API keys minted against the minter's active organization) edits the same file this PR does. I measured the interaction rather than predicting it — git merge-tree --write-tree between the two branch heads, read-only:

  • packages/core/src/security/resolve-authz-context.ts auto-merges clean. The hunks are disjoint: this PR sits at line 36 and ~367–419 (resolveUserAuthzGrants), fix(identity): API keys are minted against the minter's active organization (#8287) #8709 at ~31–203 (imports, both interfaces, resolveAuthzContext).
  • The one conflict is a content conflict in packages/core/src/security/resolve-authz-context.test.ts.
  • That path carries no os-regen merge driver (git check-attr mergeunspecified), so the conflict is loud — it cannot resolve itself silently or drop either side's cases.

Ruling: both PRs land in ordinary order, neither holds for the other. Whichever lands second merges main and resolves that one test-file conflict by keeping both sets of cases — this PR's deactivated-grant cases and #8709's API-key admission cases pin different behaviours and neither supersedes the other.

This PR is unblocked and unchanged by the above; no action needed here unless it lands second.


Generated by Claude Code

@qq9340100
qq9340100 marked this pull request as ready for review August 14, 2026 16:21
@qq9340100Claude

Copy link
Copy Markdown
CollaboratorAuthor

Veto window closed — maintainer approved the last-admin-guard.ts extension

Provenance. Instruction from the maintainer, in the PM session for epic #8667, 2026-08-14. Their word, verbatim: 「同意」 — given in reply to a four-pillar analysis of this specific extension, after they asked for it with 「你帮我从平台长远合理性等角度四维分析」.

What was held, and why

When I accepted this PR I recorded a veto window rather than landing it immediately, because the change widens PERMISSION_SET_STANDING_KEYS from ['name'] to ['name', 'active'] — a list whose #6084 comment asserted in writing that only a name payload can move the platform-administrator enumeration. That assertion was true before this card and is false after it. Widening an invariant a maintainer had adjudicated, plus adding an exemption path inside a security guard, is not a seat's call to make silently.

This item also sits on the human floor (security/permission boundary), where the standing rule is that the maintainer decides regardless of how the analysis comes out. So the window was held open rather than auto-adjudicated.

The analysis it was approved on

All four pillars pointed the same way:

  • Business need — the Deactivate row action is offered on every row with no visibility or condition guard, and once active becomes a resolution-time predicate, admin_full_access becomes a one-click, in-product-unrecoverable installation-wide lockout. Re-activating requires the permission the click just removed, and the seeders deliberately never reconcile active.
  • Long-term platform soundnessPERMISSION_SET_STANDING_KEYS is a cache of what the resolver reads. Once the resolver reads active, a list omitting it is simply wrong. Adding it restores declared = enforced rather than widening a contract.
  • Avoiding AI coding error — stripping the extension is the higher-risk option: it would leave break-glass 不变量的第四条路径无守卫:删/改名 admin_full_access 那条 sys_permission_set 行,一次废掉所有 platform admin #6084's comment standing as an authoritative, recently-dated, and false assertion for the next reader. Rewriting that comment is the highest-value part of this change. The way-back exemption is implemented as a measurement through the same simulation rather than a special-case branch for one column, which is the more corrosion-resistant shape.
  • Startup-stage scope discipline — all three parts are forced consequences of the ruling, not additions: without the key-list entry the card ships an unguarded lockout path; without the amplifier fix the guard ends up weaker than today (one deactivation empties the administrator population and every later ban, delete and downgrade sails through unguarded); without the way-back exemption the refusal is unrecoverable. No new API, UI, or public surface. The PR also declined to add a symmetric sys_position guard, on measured grounds — resisting symmetry for its own sake.

Residual, stated rather than discovered

The new refusal is not a hypothetical branch. Because active is inert today, a deployment may already have clicked Deactivate on admin_full_access with no consequence; on upgrade it enters the no-administrator state and this guard refuses identity-table writes, naming the remedy (set active back to true). That is the correct outcome — a loud refusal with a named fix beats silent total loss of access — but it is a real post-upgrade behaviour, not theory.

Auto-merge enabled. Not ADR-class: verified against the PR's own path surface — no docs/adr/**, .claude/**, skills/**, AGENTS.md, or content/docs/releases/, and packages/spec untouched.

Filed separately, not bundled here

The correspondence between the resolver's reads and this standing-keys list is maintained only by prose comments and author vigilance — no gate binds them. That is the structural gap this card exposed, and it gets its own card rather than riding this PR.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sys_permission_set.active and sys_position.active are unenforced too — both Deactivate dialogs promise access stops, and it does not

2 participants

@qq9340100@claude