Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
96fb3e9
patch round 1: the four red CI packages, and the changeset names the …
claude Oct 10, 2026
fb2855f
changeset: platform-objects ships the regenerated position form bundles
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
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
23d3545
docs: the active switch is the activation ledger; a catalogue row's a…
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
52 changes: 52 additions & 0 deletions .changeset/15204-s1-position-permission-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/plugin-security': minor
'@objectstack/plugin-auth': minor
'@objectstack/platform-objects': patch
---

feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4)

Clause-②: yes

<!-- adr-0087: registered position-permission-sets-declared -->

**BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes.

**FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant.

```ts
definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] });
```

The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position.

**`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing.

**`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`):

- A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone.
- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors).
- **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read.
- The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition.
- `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot.
- Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog.
- An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before.
- `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot.

**`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it).

**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A new hook refuses a ledger write that switches `admin_full_access` off (`refuseLedgerSwitchingAdminOff`). Its `sys_permission_set` hooks stay registered, unchanged.

**`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES.

**`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row.

**What stops granting at upgrade, and what to do.**

- **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding.

- **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code.
- **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today.
- **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged.
6 changes: 3 additions & 3 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,9 @@ export const SysHolidayCalendar = ObjectSchema.create({
```

The reason is leverage, not authorization. The bucket's charter members are the
RBAC link tables — `sys_user_position`, `sys_user_permission_set`,
`sys_position_permission_set` — which are the grant surface of the entire
permission model. Every row a CSV import writes still passes the delegated-admin
RBAC link tables — `sys_user_position` and `sys_user_permission_set`, the grant
surface of the entire permission model, and `sys_position_permission_set`,
whose rows no longer grant (a position's sets are its `permissionSets`). Every row a CSV import writes still passes the delegated-admin
gate, RLS and permission-set adjudication one at a time, so an admin who cannot
grant a permission set by hand cannot grant it by file either. What differs is
blast radius: row by row, one misclick affects one person; one wrong CSV is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. |
| `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). |
| `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). |
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`, `sys_metadata_activation`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |

---

Expand Down
56 changes: 31 additions & 25 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Authorization splits into three concerns that stay decoupled:
resource merely *references* one by name (see Requirement, below).
2. **Assignment** — *who holds it* — permission sets / positions / user
bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`,
`sys_position_permission_set`, `sys_user_position`). Runtime records,
`sys_user_position`, and the sets a position names in its `permissionSets`). Runtime records,
maintained by admins in Setup — and, since ADR-0090 D12, a **governed**
surface: writing them requires tenant-level administration or a covering
delegated `adminScope`, never just CRUD on the tables.
Expand Down Expand Up @@ -232,8 +232,9 @@ one of two doors, each writing only what it owns:
unaffected while a set is still a draft (drafts never enter the active
resolve).
- **Environment-admin door** (`metadata-admin`) — the cross-package all-objects
matrix plus subject **assignment** (`sys_user_permission_set`,
`sys_position_permission_set`), edited **live** (config). It owns env-authored
matrix plus subject **assignment** (`sys_user_permission_set`), edited
**live** (config); the sets a position distributes are its definition's
`permissionSets`. It owns env-authored
sets (`managedBy` `platform`/`user`) and assignments — not package sets.
Since ADR-0094 the set **definition** itself has one authoritative store —
the metadata layer: an env-door write to `sys_permission_set` (the Setup
Expand Down Expand Up @@ -395,26 +396,32 @@ substrate above.
## Grant lifecycle: the `active` switch (ADR-0049)

Validity windows above date a **user's grant row**. The second lifecycle
control dates the **catalogue row itself**: `sys_permission_set.active` and
`sys_position.active`, the switch behind the Deactivate action on both objects.
It answers a different question — "switch this grant off for everyone, without
deleting it or unwinding the assignments" — and it is enforced in the same
place, by the same discipline: **resolution-time filtering, fail-closed**, in
control switches off the **permission set or position itself**: a
`sys_metadata_activation` row of type `permission` or `position` whose `active`
reads false. The activation ledger is deployment-wide. It answers a different
question — "switch this grant off for everyone, without deleting it or
unwinding the assignments" — and it is enforced in the same place, by the same
discipline: **resolution-time filtering, fail-closed**, in
`resolveAuthzContext`, with no cleanup job involved.

A catalogue row's own `active` column (`sys_permission_set.active`,
`sys_position.active`) no longer decides a grant. That column is what Setup's
Deactivate action writes today, so a Deactivate in Setup does not switch the
grant off.

- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
ledger, so the switch does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
- Assignments are untouched. `sys_user_position` and
`sys_user_permission_set` rows stay exactly as they were, and re-activating
the catalogue row restores every grant it carried, at the next resolution.
`sys_user_permission_set` rows stay exactly as they were, and switching the
ledger row back on restores every grant it carried, at the next resolution.

The explain engine reports a deactivated-but-held row with the same dedicated
contributor state the expired case gets (`state: 'deactivated'`, "held —
Expand All @@ -428,20 +435,19 @@ 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).

**Absent is ACTIVE.** Only a stored value that really reads false takes a grant
away, so a row that predates the column keeps granting. The same predicate
(`isRowActive`) is used by every reader, including the last-administrator
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0135 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.
**Absent is ACTIVE.** Only a ledger row whose `active` reads false takes a
grant away; a permission set or position with no ledger row is in effect. The
last-administrator guard's enumeration still reads the catalogue row's
`active`, so it counts a row-deactivated `admin_full_access` as off and refuses
more than it must, never less.

**Switching the break-glass set off is refused.** A ledger row switching
`admin_full_access` off would un-make every platform admin who holds it
*through a grant row*, in one write — and switching it back on needs the
permission just lost. The last-administrator guard refuses such a ledger
write outright, whatever the administrator population (ADR-0135 D5.2), and
refuses one whose target row it cannot read. Switching it back on is never
refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
4 changes: 2 additions & 2 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ Two independent switches, and you need both:
| An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing |

The role carries no ObjectStack authority of its own: it passes through as a
position name, and with no `sys_position_permission_set` binding it resolves to
nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint
position name, and with no position of that name declaring `permissionSets` it
resolves to nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint
permits.** A default deployment is unaffected — nobody holds the role until you
set it.

Expand Down
Loading
Loading