Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
7b51812
wip: PositionSchema.permissionSets; resolver reads positions, sets an…
claude Oct 10, 2026
8cbedf9
wip: SecurityPlugin binds the catalog read; everyone declares the bas…
claude Oct 10, 2026
1e3a4b1
wip: conversion function, static catalog testkit, golden query move r…
claude Oct 10, 2026
d6a43e5
wip: core suites read the catalog; admin-standing surface re-measured
claude Oct 10, 2026
df2ac6b
wip: runtime and plugin-security fixtures bind the catalog
claude Oct 10, 2026
4da892e
wip: plugin-security fixtures declare the catalog; everyone baseline …
claude Oct 10, 2026
ead16a0
wip: rest fixtures declare the catalog
claude Oct 10, 2026
cb7e21d
wip: plugin-auth fixtures declare the catalog
claude Oct 10, 2026
8dca0b1
wip: hono-server, sharing and automation fixtures declare the catalog
claude Oct 10, 2026
35e8fcd
wip: example personas declare permissionSets; spec docs and liveness …
claude Oct 10, 2026
58d33e5
fix(plugin-security): a Setup row edit carries the definition's permi…
claude Oct 10, 2026
4362ea1
wip: cloud-connection fixtures declare the catalog
claude Oct 10, 2026
e9fc856
wip: service-datasource, service-settings and approvals fixtures decl…
claude Oct 10, 2026
4a2d686
wip: changeset; pin the walled promotion path through the metadata door
claude Oct 10, 2026
e6baf14
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
af4aa34
test: compact the per-package security-catalog test helper
claude Oct 10, 2026
15f5c97
spec: register the position-permission-sets-declared ADR-0087 semanti…
claude Oct 10, 2026
7564191
spec: the position form offers permissionSets
claude Oct 10, 2026
56d4949
core: deactivation of positions and permission sets is read from the …
claude Oct 10, 2026
4726c1e
changeset: deactivation moves to the activation ledger
claude Oct 10, 2026
6e05833
cloud-connection: the fake engines answer the activation ledger
claude Oct 10, 2026
862b773
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
997aaa6
plugin-security: the #18535 test registry stamps the package id
claude Oct 10, 2026
055f5df
platform-objects: regenerate the metadata-form bundles for permission…
claude Oct 10, 2026
dca5407
platform-objects: translate the position form's permissionSets row in…
claude Oct 10, 2026
83b54d8
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
7c94256
plugin-security: the security readers read the catalog and the activa…
claude Oct 10, 2026
06c25c5
plugin-security: the delegated-admin gate's suites read the catalog; …
claude Oct 10, 2026
8ae6143
plugin-security: resolution, boot and assignment-refusal suites read …
claude Oct 10, 2026
ecfebd3
plugin-security: the remaining suites declare their catalog and switc…
claude Oct 10, 2026
7cfa440
plugin-security: pin explain/enforce agreement on the ledger and the …
claude Oct 10, 2026
96fb3e9
patch round 1: the four red CI packages, and the changeset names the …
claude Oct 10, 2026
5789191
core: the catalog read's docs name the activation ledger as the deact…
claude Oct 10, 2026
fb3861c
plugin-security: an unused fake-engine parameter is spelled as unused
claude Oct 10, 2026
cce1497
plugin-security: the one-holder refusal answers 409 UNIQUE_VIOLATION …
claude Oct 10, 2026
6186338
plugin-security: the test doubles hold the double-limit contract; per…
claude Oct 10, 2026
fb2855f
changeset: platform-objects ships the regenerated position form bundles
claude Oct 10, 2026
b609736
plugin-security: the catalog gate test's where matcher refuses what i…
claude Oct 10, 2026
2c3a598
docs: a position's sets are its permissionSets; a junction row grants…
claude Oct 10, 2026
bf6bdf0
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
5a960f8
dogfood: the delegation and scope-fallback fixtures declare their pos…
claude Oct 10, 2026
4c9f967
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
5381fd1
changeset: the one-holder refusal's envelope, as the wire answers it
claude Oct 10, 2026
73c76fb
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
d0cab9f
port #22750: render-projection-diff generates the base's git-ignored …
claude Oct 11, 2026
6d6c654
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 11, 2026
23d3545
docs: the active switch is the activation ledger; a catalogue row's a…
claude Oct 11, 2026
4ffaf17
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 11, 2026
6e99c2b
changeset: the ADR-0087 disposition names stage 1's entry as already …
claude Oct 11, 2026
738026f
docs(permissions): the explainer's deactivated pointer and the positi…
claude Oct 11, 2026
d5af7e1
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 2026
4dc0454
plugin-security: pin the catalog loader's three answers and its fail-…
claude Oct 11, 2026
07a4716
scripts: the engine-double ledger records the catalog-loader pin's fi…
claude Oct 11, 2026
047a877
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 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
40 changes: 40 additions & 0 deletions .changeset/15204-s2a-security-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@objectstack/core': minor
'@objectstack/plugin-security': minor
---

feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4)

Clause-②: yes

<!-- adr-0087: not-required (already-registered position-permission-sets-declared) stage 1 registered this cutover: row-only catalog items confer nothing, and this changeset carries that rule to the security plugin's own readers -->

**BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog.

**Permission-set resolution (`@objectstack/plugin-security`).**

- FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false.
- TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too.
- Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package.
- A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read.

**Delegated administration (ADR-0090 D12).**

- FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row.
- TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged).
- A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored.
- A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing".

**The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose.

**Position assignments (`sys_user_position.position`).**

- FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused.
- TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted.

**One position name per deployment under `single`.**

- FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`).
- TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`.

**`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin.
12 changes: 8 additions & 4 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,8 @@ an expiry: deactivation is an incident-response control, installation-wide,
and there is no date on the user's own grant row to notice — without the
state, the admin diagnosing "why did access disappear" would get the same
answer as for a user who never held the grant, with no pointer to the
catalogue row somebody switched off. Reported for the grant rows explain
`sys_metadata_activation` row somebody switched off — the explainer reads the
same ledger the resolver does, so explain and enforce agree. Reported for the grant rows explain
already walks: `sys_user_position` rows and direct `sys_user_permission_set`
grants (a deactivated set reached only through an active position's linkage is
not re-derived — that would replicate the resolver's aggregation).
Expand All @@ -455,9 +456,12 @@ Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
position — an admin must still be able to unbind and clean up what they just
switched off — and the write gates that judge audience-anchor bindings and a
delegated administrator's blast radius keep reading every row, deactivated
included: dropping rows there would make a refused binding *permitted* and a
delegate's boundary *narrower*, which is the opposite of switching access off.
delegated administrator's blast radius do not consult the activation ledger:
they judge every binding and assignment in front of them — including the
permission sets a position's definition names — whether or not the ledger
switched it off, because skipping a switched-off one would make a refused
binding *permitted* and a delegate's boundary *narrower*, which is the
opposite of switching access off.

## Governance: how "declared = enforced" is kept true

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ that silently does not happen.
| 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` |
| 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. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no `sys_position` row is not refused | plugin-security | Get: a system writer can store an assignment naming no catalog row — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. 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 no catalog row the writer's context reaches carries the name: its organization's rows plus the organization-less ones, read as `{ ...context, isSystem: true }`, never a bare `{ isSystem: true }`, so a name only another organization carries is refused like one nobody carries. It is the same stand-down, and the same tenant scope, as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
| 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()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. 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 | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
| 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` |
| 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 a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
| 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 | `packages/objectql/src/engine.ts#buildDriverOptions` |
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ export {
// [#20580] The session arm's membership check (#15409 ruling B), so the
// permission explainer resolves the user it explains through the same one.
vetOrganizationClaim,
// [ADR-0049, ADR-0131 D3, ADR-0126 §4] The one deactivation read of the
// security catalog — the `sys_metadata_activation` ledger, never a catalog
// row's `active` column — shared with the explainer and the security plugin.
readDisabledCatalogNames,
resolveLocalizationContext,
type ResolvedAuthzContext,
type ResolveAuthzInput,
Expand Down Expand Up @@ -232,7 +236,8 @@ export { isRowActive, type ActivatableRow } from './row-active.js';
// ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions,
// permission sets, capabilities) over the engine registry and the metadata
// service. It says a definition EXISTS under a name, never that it is in
// effect: the row `active` flag above stays the authority for that.
// effect: the activation ledger is the authority for that
// (`readDisabledCatalogNames`, exported with the resolver below).
export {
createSecurityCatalogReader,
bindSecurityCatalogReader,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,8 +771,13 @@ function ledgerUnregistered(ql: any): boolean {
* engine answers "object not found" rather than a missing table — so the
* registry is asked first, as `ObjectQLPlugin`'s own ledger hydration does,
* and the read is not issued. An engine with no registry to ask is read.
*
* Exported so every other reader of "is this switched off" — the permission
* explainer's dropped-grant report, the security plugin's position-name fold —
* asks this one read instead of a second copy, and explain and enforce cannot
* disagree about deactivation.
*/
async function readDisabledCatalogNames(
export async function readDisabledCatalogNames(
ql: any,
positions: readonly string[],
sets: readonly string[],
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/security/security-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@
*
* ## What this read does NOT answer
*
* - ⛔ **Whether an item is in effect.** Deactivation lives on the catalog
* ROW (`sys_position.active`, `sys_permission_set.active`; standing ruling:
* the row flag stays authoritative), and no definition carries it. An entry
* here says a definition EXISTS under that name — never that it grants. A
* caller that drops the row flag because it now reads definitions would let
* a deactivated set grant again: the one widening this card must never
* open. Read the flag with `isRowActive` (`row-active.ts`) as before.
* - ⛔ **Whether an item is in effect.** No definition carries the switch:
* deactivation is the activation ledger's (`sys_metadata_activation`, one
* row per `(metadata_type, name)`; ADR-0126 §3 regime C as ADR-0131 D6
* amends it), read through `readDisabledCatalogNames`
* (`resolve-authz-context.ts`) — never a catalog row's `active` column. An
* entry here says a definition EXISTS under that name — never that it
* grants. A caller that reads a definition and skips the ledger would let a
* deactivated set grant again: the one widening this read must never open.
* - **The position → permission-set binding as a separate fact.** It is part
* of the position definition (`PositionSchema.permissionSets`, ADR-0131
* D3/D4); the resolver reads it from the entry this module answers, and no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function catalogFromTables(tables: Record<string, readonly unknown[] | un
for (const row of setRows) {
if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue;
const definition: Definition = { name: row.name };
for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) {
for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects'], ['admin_scope', 'adminScope']] as const) {
const value = parsed(row[column] ?? row[key]);
if (value !== undefined && value !== null) definition[key] = value;
}
Expand All @@ -65,9 +65,16 @@ export function catalogFromTables(tables: Record<string, readonly unknown[] | un
permissionSets: setRows,
bindings: (tables.sys_position_permission_set ?? []) as Definition[],
});
// A position definition also carries the row's `delegatable` (ADR-0091 D3),
// which the delegated-administration gate reads from the definition.
const positionRows = (tables.sys_position ?? []) as Definition[];
const delegatable = (name: string): boolean =>
positionRows.some((r) => r.name === name && (r.delegatable === true || r.delegatable === 1 || r.delegatable === '1'));
return {
permissions,
positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])),
positions: positions.flatMap((f) => (f.fate === 'conflicting'
? []
: [{ name: f.name, permissionSets: [...f.permissionSets], ...(delegatable(f.name) ? { delegatable: true } : {}) }])),
};
}

Expand Down
Loading
Loading