Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c03443b
feat(plugin-security, runtime, metadata-protocol, core)!: permission-…
claude Nov 10, 2026
ae63437
fix(driver-sql): the widening arm keeps or safely retires an 18.0 NUL…
claude Nov 10, 2026
fa0d46c
docs(permissions): the grant stamp's system column bind is a census r…
claude Nov 10, 2026
72f0a68
docs(changeset): the #32164 declaration reads `Clause-②: no (narrowin…
claude Nov 10, 2026
f8e938d
fix(driver-sql): the widening arm keeps a narrower key a hash shadow …
claude Nov 10, 2026
f453a71
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
8ea7199
feat(metadata-protocol, objectql, driver-mongodb): the grant's packag…
claude Nov 10, 2026
e539145
fix(runtime, plugin-security): a permission set's ledger row is its h…
claude Nov 10, 2026
6c0c79d
test(cli): the duplicates integration run accounts for the grant's pa…
claude Nov 10, 2026
58f31b6
fix(driver-mongodb): the refused runtime-owned unique's error line ke…
claude Nov 10, 2026
a573065
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
0005675
test(metadata-protocol): the old-key pin reads the last statement by …
claude Nov 10, 2026
0736796
fix(driver-sql): the widening arm keeps an unresolved hash-shadow car…
claude Nov 10, 2026
c51686c
fix(metadata-protocol, cli, driver-mongodb): the grant guard follows …
claude Nov 10, 2026
4b62e66
docs(plugin-security): declare that a shadow-carried grant key keeps …
claude Nov 10, 2026
719eb54
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
aa4c4c4
docs(changeset): the grant guard's condition 1 is measured on MySQL 8…
claude Nov 12, 2026
ec39bd8
fix(plugin-security, runtime)!: one grant of a set per user and organ…
claude Nov 12, 2026
371bd8d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
0859911
fix(plugin-security, runtime): the one-grant check answers after the …
claude Nov 12, 2026
cb42580
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
b38e2ec
test(plugin-security, runtime): pin the one-grant check's declared pr…
claude Nov 12, 2026
6075ae7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .changeset/32164-permission-set-doors-take-a-package.md

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,24 @@ in the security catalog, or the route answers `404`. That covers a position or
permission set the environment authored through the metadata door as well as
one a package ships: the ledger row records the package when there is one and
leaves `package_id` empty when there is none, and the resolver honours both
alike, because it keys the row by type and name. A write the ledger refuses
alike, because it keys the row by type and name. For a permission set two
packages hold, `?package=<id>` names one (ADR-0150 D3): without it the route
answers `409` `RESOURCE_CONFLICT` with `details.candidates`, a package that does
not hold the set answers `409` too, and the row records the named package.
Each holder keeps its own row (the permission ledger is keyed by package and
name), so switching one package's set never touches the other's. A row that
names no package, or a package that no longer serves the name, is read by the
name alone: while two packages hold the name and the environment does not, it
switches off every holder, and switching a holder on while such a row stands
answers `409`, and nothing is written. Where the environment holds the name
beside a package, the name alone is the environment's set: a row naming no
package, or stamped `sys_metadata` (the overlay's provenance), is the
environment's own switch, and a row naming a package that no longer serves the
name keeps the environment's set off the same way. A disabled package still counts as a holder here. With one holder, a
package or the environment, such a row is the holder's and the write takes it
over. `package` is the only query parameter, and only
for `permission`; any other, or a repeated or blank one, answers `400`
before any lookup. A write the ledger refuses
answers with the status its code means: a record validation failure is `400`
`VALIDATION_FAILED`, a hook's refusal keeps its own pair, and a store failure
is `503` `SERVICE_UNAVAILABLE`. It is the same authority
Expand Down
21 changes: 21 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ The position's definition is the only binding that grants: the retired
`sys_position_permission_set` junction object is no longer registered, and a
row an older database still holds confers nothing.

A set is identified by its package and its name (ADR-0150 D4), so a direct
grant can name the owning package in `permission_set_package`. A set one
package holds needs none: the grant is stored with that package. A set two
packages hold, granted with no package, is refused `409` `RESOURCE_CONFLICT`,
and the message names every holder. A package that does not hold the set is
refused `409` whatever the number of holders, and nothing is stored. One user
holds one grant of a set per organization, as before, except that when two
packages hold the set the user may hold each package's set once. A second
grant written through the engine is refused as a duplicate record (`409`
`UNIQUE_VIOLATION` on the wire). Below the engine the store's indexes keep one
grant per set, package and organization, and one grant that names no package
per set and organization; the in-memory driver (`driver-memory`) builds no
index for grants that name no package.

The assignment writes are **governed** (ADR-0090 D12): tenant-level admins pass
through to the ordinary checks; a delegated admin needs a covering
`adminScope`; plain CRUD on the tables grants nothing by itself. A position's
Expand Down Expand Up @@ -472,6 +486,13 @@ the source never reach the copy.
a new snake_case machine name: a name failing that rule answers `400`, and a
name any permission set already uses (the source's own included) answers `409`
`RESOURCE_CONFLICT`. An unknown source answers `404`.
- **Two packages' sets of one name (ADR-0150 D3).** `?package=<id>` names
the source's package; the body's `package` still names where the copy lands.
When two packages hold a set of the source's name, a clone with no
`?package=` answers `409` `RESOURCE_CONFLICT` with `details.candidates`, and
a package that does not hold the set answers `409` too. A target name two
packages hold is taken (`409`). `package` is the only query parameter: any
other, or a repeated or blank one, answers `400`.
- **Every permission facet is copied** — `objects`, `fields`,
`systemPermissions`, `tabPermissions`, `rowLevelSecurity`, `description`.
- **The server drops a declared set of keys**: the package provenance
Expand Down
6 changes: 5 additions & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,12 @@ bugs — it is the protection or the side effect that silently does not happen.
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. Two exceptions, both judged in every context: a `formula` value, which the `computed` strip takes from every caller, and a value for a stored field that declares `expression` when it differs from the value the engine computes in its place (`stored_expression`), so strict still refuses both | `insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
| 23 | **Referential-integrity check skipped** — except a `runAs: 'system'` flow's record writes, which keep it | objectql | Get: writes proceed against unreachable/unresolvable targets — a seed replay, an import, a restore or a cascade may store a reference to a row that does not exist yet. Lose: an `isSystem` caller can write a **dangling reference**. A flow run's elevated context (`resolveRunDataContext`) carries an internal mark that keeps the check, so a flow's `create_record` / `update_record` naming a row that does not exist is refused `400 VALIDATION_FAILED` / `reference_not_found`, as on the user path; its authorization elevation is unchanged. A write the flow's write sets off (a hook's `ctx.api`, its `sudo()`, a cascade leg of a flow's delete) spreads the run's context and is checked as the flow's own, as on the user path. The mark is a symbol no flow definition or request can spell, and `ExecutionContextSchema.parse` drops it. Where a system write runs the check (a flow run's), it runs after the record's validation rules, as before; every other write runs it before them. An update's check does not judge again a value every row it lands on already stores; a system write compares those rows as stored, every other write only as the caller may read them, so a column the caller cannot read forgives nothing | `assertReferencesResolve`, `referencesCheckedBeforeRules`, `storedReferencesCallerReads` |
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `assertPositionNamesCatalogRow` |
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name, and `409 RESOURCE_CONFLICT` naming the holders when two packages hold it and the write names neither, or names a package that does not hold it (ADR-0150 D3/D5, #32164); a grant that names no set at all is refused `required` for every caller. The grant names its set by name, and by package in `permission_set_package` when it names one (ADR-0150 D4); nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` |
| 23e | **A `secret` field takes a stored reference as given** — a value already spelled `secret:` is stored unchanged | objectql | Get: a system writer may copy a `secret:` reference onto a record as it stands (seed replay, a migration). Every other caller may only seal a new value or keep its own record's ref, and its own is the value it reads in that column: on a create any such value is refused, and on an update it would be kept only where every record the write lands on serves exactly that reference to this caller's own read door. The read path serves a `secret` column masked to every caller, so every reference such a caller sends is refused `400 VALIDATION_ERROR` before anything is written, with one answer whatever it names and whatever the column holds; an echoed read mask still leaves the column as it is. Lose: the check that the reference is the record's own — a system writer that copies one is trusted to mean it. Inbound HTTP never carries the flag | `encryptSecretFields` |
| 23f | **Agent ceiling grant check skipped** — a `sys_user_permission_set` write naming an MCP agent ceiling set is not refused | plugin-security | Get: a system writer that is not a seed is not judged. The platform-admin promotion and the organization-admin reconcile name their sets in code, and neither is a ceiling; the self-registration grant writes the configured `audience.selfRegistrationPermissionSet`, which the audience validation (`assertAudienceConfig`, plugin-auth) refuses when it names an agent ceiling set; an identity-provider group mapping is refused at its save, and its projected grant again before each write, when its set is outside the organization boundary; the development-only demo-staff seed grants only sets its stack declares, and no stack declares an agent ceiling's name. Lose: nothing on this table — a seed write (`seedReplay`: a stack's `data`, a published `seed` item) is judged like a non-system one. A non-system or seed insert, or such an update whose resulting row names one, is refused `400 VALIDATION_FAILED` / `invalid_value` at `permission_set`, for every caller, a platform administrator included; a `sys_user_position` write naming a position whose definition names one is refused `invalid_value` at `position` for every writer, a system one included, except a system update that leaves the position as stored; a delete is not judged (ADR-0090 D10). These hooks, the metadata door's position gate and the `SecurityPlugin` baseline check are the doors that hold the rule; they do not judge a position a person holds without an assignment row, or a position definition that reaches the security catalog without passing the metadata door, and whether the read side refuses those is a separate decision with the maintainer (the security seat, #32004) | `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts#judges` |
| 23g | **AI service account grant check skipped** — a `sys_user_permission_set` or `sys_user_position` write giving an AI service account a set above the agent ceiling is not refused | plugin-security | Get: a system writer is not judged. The organization-admin reconcile binds nothing for an account, and the platform bootstraps grant only people. Lose: seed replay writes the grants its stack declares, unjudged, so a seed that gives an account a set carrying View All, Modify All, transfer, restore, purge, a system permission or an admin scope stores it; those bits are still cleared when the account's sets are evaluated (`packages/plugins/plugin-security/src/agent-ceiling-clamp.ts`). A non-system insert, or a non-system update whose resulting row gives an account such a set (directly, or through a position binding one), is refused `400 VALIDATION_FAILED` / `invalid_value` at `permission_set` or `position`, for every caller, a platform administrator included; the holder's `sys_user` row is read as the system only when the caller's own door reads it; a delete is not judged | `packages/plugins/plugin-security/src/agent-account-grant-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/agent-account-grant-refusal.ts#onUpdate` |
| 23h | **A grant's package column, sent on an update, is judged rather than ignored** — a system writer's `permission_set_package` reaches the package stamp | plugin-security | Get: the boot backfill and `os migrate stored-references bind` bind a grant to the package that holds its set by writing the column alone (ADR-0150 D8 R1), and a package that holds the set lands as written. Lose: nothing a holder could not already do — a named package that does not hold the set is refused `409 RESOURCE_CONFLICT`, `not-held-by-qualifier`, naming the holders, at any holder count, and the row is left as it was (#32164, F3); on insert every writer's named package is judged the same way. The same stamp serves `sys_audience_binding_suggestion` (R3, its `permission_set_package` beside `permission_set_name`): a system insert or column-only update there naming a package that does not hold the set, FROM re-stamped (insert) or landed as written (update) TO the same `409`, the row left as it was; `os migrate stored-references bind --table sys_audience_binding_suggestion` writes through it. A non-system update's `permission_set_package` never reaches the stamp: the engine's `readonly` update strip drops it, so it is never stored and never judged. The same seam keeps one grant of a set per user and organization, as the grant's key before #32164 did (two packages' sets of one name excepted): on an update it judges the package the row stores after the write, a system writer's value or else the stored one, and it reads the other grants of the same user, set and organization as the system, in the writer's transaction, answering only the old key's duplicate-record refusal (`409`), never a row. It runs after the grant table's other refusals, and for a non-system write it steps aside on a holder the write names that the caller's own read does not return: the engine's reference check answers that holder as a user that does not exist (#29029) | `packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts#onUpdate`, `#oneGrantOnUpdate`, `#holderLeftToEngine` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `buildDriverOptions` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects. Except app-automation's elevation on a ledger: an update, delete, transfer, restore or purge of an object declared `managedBy: 'append-only'` from app-automation's elevation (a flow run's, an app-authored hook's, an action body's, read by `elevationSource`) is refused `PERMISSION_DENIED` ahead of the short-circuit, by the same predicate, in `assertAppAutomationAppendOnlyWriteAllowed`; so is such a context's delete of a record a ledger row references, whose reference clean-up would change that row; an insert is not judged by this check; the platform's own writers, whose system context carries neither a run nor the mark, are unchanged | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed`, `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
Expand Down Expand Up @@ -613,6 +614,9 @@ and CI refuses one that differs from what the tree produces.
| `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` | 1 | 3 |
| `packages/plugins/plugin-security/src/security-plugin-security-service.ts#registerSecurityService` | 3 | 7 |
| `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` | 1 | 6c |
| `packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts#holderLeftToEngine` | 1 | 23h |
| `packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts#onUpdate` | 1 | 23h |
| `packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts#oneGrantOnUpdate` | 1 | 23h |
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | 1 | 58 |
| `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite` | 1 | 25 |
| `packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks` | 1 | 31 |
Expand Down
Loading
Loading