diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 9968e661b0e..bab5796bc51 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -138,7 +138,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | :--- | :--- | | `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | -| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. The clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) copies the whole definition from the security catalog without reading a `sys_permission_set` record; the refusal does not name it yet. | +| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) with a new name. The clone door copies the whole definition from the security catalog. | A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 9e02192755e..240b1d26ff1 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -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` 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 +RBAC assignment tables — `sys_user_position` and `sys_user_permission_set`, the +grant surface of the entire permission model. (A position's own sets are its +definition's `permissionSets`, not rows.) 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 diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 6b9be300853..9b8cc9231a6 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -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`, `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. | +| `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_user`, `sys_metadata_activation`) and by any `metadata.changed` (positions and permission sets are catalog definitions, so a change to one is a metadata change); `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. | --- diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 86391cff932..54acecea49b 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -32,12 +32,14 @@ Authorization splits into three concerns that stay decoupled: via [`defineCapability`](#package-capability-declaration-adr-0066-d1); extended by admins in Setup. A capability is **not** a contract and has no `inputs` — a 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_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. +2. **Assignment** — *who holds it* — permission sets and positions, which are + catalog definitions served by name from the environment registry (a + position names the sets it distributes in its `permissionSets`; ADR-0131 + D3), handed to users by the assignment rows `sys_user_permission_set` and + `sys_user_position`, which name the set or position. The assignment rows are + 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. 3. **Requirement** — *what a resource needs* — an object / field / action references a capability as a contract. A resource never bakes in "who", only "what is required". @@ -217,63 +219,53 @@ Remember the three-way separation: a capability is **not** a contract. You ### Two doors, one metadata (ADR-0086 D6/D7) -The `managedBy` provenance axis is not just descriptive — the platform -**populates and gates on it**, so editing a permission set flows through exactly -one of two doors, each writing only what it owns: +Package provenance is not just descriptive — the registry records the owning +package on every item it serves and the metadata door **gates on it**, so +editing a permission set flows through exactly one of two doors, each writing +only what it owns: - **Package door** (studio `/studio/:packageId/access`) — a package's own set is **metadata**, so edits are saved as a **draft** stamped with the `packageId` (`saveMetaItem` `mode:'draft'`) and go live with the package's atomic **Publish**, exactly like Data and Interfaces. On publish, the security catalog serves the published body from the registry, owned by the package - (ADR-0131 D3). The landed publish fires `onMetadataMutation`, which runs the - audience-binding suggestion reconcile and re-derives the `everyone` anchor's - accepted package defaults. Until stage 7 retires the ADR-0094 projector, the - publish (a package-door draft publish, or a writable base's direct publish) - also has the projector write a `sys_permission_set` projection row, - `managedBy:'admin'` with no `packageId`, and the boot reconcile re-creates it - if it is missing. No authorization path reads that row, and uninstalling the - package removes it. Enforcement is unaffected while a set is still a draft - (drafts never enter the active resolve). + (ADR-0131 D3); no row is written for it. The landed publish fires + `onMetadataMutation`, which runs the audience-binding suggestion reconcile + and re-derives the `everyone` anchor's accepted package defaults. + Enforcement is 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`), 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 - CRUD) is transparently redirected into an env-scope metadata save - (`saveMetaItem`), and the data record is a **pure projection** the platform - re-derives on every metadata mutation (awaited — no staleness window) and at - boot. A package's published set gets such a row too, until stage 7 — the - transitional `managedBy:'admin'` row described in the Package-door bullet - above. Under a `group` or `isolated` tenancy posture that Setup write is the - platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the - redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its - declared body rather than removing it; renaming through the data door is - rejected (the name is the metadata identity — clone instead). Subject - assignments remain plain config rows, unchanged. -- **Data-layer gate (evolved by ADR-0094)** — the security middleware still - **refuses** any payload that forges package provenance (insert or update, - single or array) and the lifecycle ops with no overlay translation - (`transfer`/`restore`/`purge`) on package rows, failing closed ahead of the - CRUD check — even a `modifyAllRecords` super-user is blocked. An ordinary - admin-door **edit of a set whose definition ships as a code artifact is - refused with `403 not_overridable`**, loudly, at the moment of the write: - the ADR-0094 write-through still translates the edit into a metadata save, - but `permission` declares `allowEnvironmentOverlay: false` (ADR-0005's security - row — overlays of the authorization surface would create silent privilege - drift), so the tier gate refuses it and no env-scope overlay is minted. The - supported channel is the one ADR-0086 two-doors always named: **edit the + `permissionSets`. It owns environment-authored sets and assignments — not + package sets. The set **definition** has one authoritative store, the + metadata layer (ADR-0094 D1, ADR-0131 D3): Setup → Access Control → + Permission Sets opens the catalog list + (`/apps/setup/metadata/permission?scope=environment`), and a create or edit + there is a metadata save (`PUT /api/v1/meta/permission/:name`) into the + environment's definition ledger (`sys_metadata`), served from the registry + beside the packages' sets. No row mirrors it: the `sys_permission_set` + object is no longer registered, so no data route serves one. The save + requires `manage_metadata`; under a `group` or `isolated` tenancy posture it + is the platform operator's, and anyone else is refused `403` (ADR-0131 D3). + The name is the item's identity, so there is no rename — clone instead. A + delete cannot remove a set a package ships: its definition stays served from + the package. Subject assignments remain plain config rows, unchanged. +- **Packaged sets are sealed** — an **edit of a set whose definition ships as a + code artifact is refused with `403 NOT_OVERRIDABLE`**, loudly, at the moment + of the write: `permission` declares `allowEnvironmentOverlay: false` + (ADR-0005's security row — overlays of the authorization surface would + create silent privilege drift), so no environment-scope overlay is minted, + and the refusal names the clone (the "Clone" action on the set, or + `POST /api/v1/security/permission-sets/:name/clone`). Narrowing the packaged + set itself takes the channel ADR-0086 two-doors always named: **edit the package and re-publish**. (ADR-0094 D5's 2026-07-14 direction — translate such an edit into a first-class ADR-0005 env-scope overlay — was **retired - on 2026-08-09**; see ADR-0094 D5-R.) A set authored through the data door, + on 2026-08-09**; see ADR-0094 D5-R.) A set authored in the environment, whose definition lives only in `sys_metadata`, rides the still-open - `allowRuntimeCreate` tier and stays editable. A "delete" of a packaged set - through this door still degrades to a **reset** to the shipped declaration — - the admin door can never remove a packaged definition. System / - boot writes carry `isSystem` and bypass it (the ADR-0094 projector's among - them), so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set + `allowRuntimeCreate` tier and stays editable. System / boot writes carry + `isSystem` and pass the door's capability gate, so the platform's own writes + are never self-blocked. That bypass is not local to this gate — the full set of behaviours the flag changes is catalogued in [System Context (`isSystem`)](/docs/permissions/system-context). @@ -412,10 +404,11 @@ 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. +No definition carries the switch, and no row does: the `active` column of the +retired catalog row objects (`sys_permission_set.active`, +`sys_position.active`) decides nothing, and neither object is registered any +more (ADR-0131 D3). Setup's active switch on the Positions and Permission Sets +catalog lists writes the ledger row, through the door below. The ledger row is written through `POST /api/v1/security/_activation/:type/:name`, with `:type` either @@ -604,7 +597,9 @@ The complete, prioritized gap map lives in issue **#2561** (the production The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences` (`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a `requiredPermissions` names a capability registered nowhere — no built-in, no - permission set grants it via `systemPermissions`, no `sys_capability` seed. + `defineCapability` declaration, no permission set grants it via + `systemPermissions`. A `sys_capability` seed row is not counted: no runtime + reader consults one. - **Per-operation `requiredPermissions`** (ADR-0066 ⑤) — **landed**: an object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a `{ read, create, update, delete }` map (read-open / write-gated), enforced diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index c0140e99d57..468f8b5cb93 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -215,9 +215,11 @@ read and the metadata door serve. **No authorization check reads a `sys_capability` row.** Permission-set grants and resource `requiredPermissions` match capability names **as strings**. The boot writes no row for a capability, declared or curated: both are served by -the registry. A row an older database still holds is a catalogue only, and -clearing its `active` flag revokes nothing. Withdraw a capability by -withdrawing the **grant**. +the registry. The `sys_capability` object itself is retired and no longer +registered (ADR-0131 D3): a row an older database still holds is served by no +route and read by nothing, and `os migrate organization-ownership --apply` +reports the authored rows and deletes the seeded copies (ADR-0131 D10). +Withdraw a capability by withdrawing the **grant**. ## `capability` is code-only diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 5fe22db80e1..1573f2780f3 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -26,16 +26,12 @@ export const FieldOpsDelegate = definePermissionSet({ businessUnit: 'Field Operations', // WHERE: this sys_business_unit subtree includeSubtree: true, // default true manageAssignments: true, // user ↔ position rows (sys_user_position) - manageBindings: false, // position ↔ set rows (sys_position_permission_set) - authorEnvironmentSets: false, // may they author environment-owned sets? assignablePermissionSets: ['showcase_contributor', 'showcase_manager'], }, // …and plain CRUD on the RBAC link tables lets the requests through at all. // Both are required: table CRUD with NO scope is refused outright. objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, - sys_position: { allowRead: true }, - sys_permission_set: { allowRead: true }, sys_business_unit: { allowRead: true }, sys_business_unit_member: { allowRead: true }, sys_user: { allowRead: true }, @@ -49,16 +45,18 @@ misconfigured name resolves to an empty subtree and **approves nothing** ## What the runtime gate enforces -Writes to the governed tables (`sys_user_position`, -`sys_position_permission_set`, `sys_user_permission_set`, -`sys_permission_set`, `sys_member`) pass through the `DelegatedAdminGate`: +Writes to the governed tables (`sys_user_position`, `sys_user_permission_set`, +`sys_member`) pass through the `DelegatedAdminGate`. Positions and permission +sets themselves are catalog definitions, not rows (ADR-0131 D3), so the set +above grants no object permission for them: an assignment names a position or +set, and the gate resolves the name in the security catalog. The gate's rules: | Rule | Effect | |---|---| | Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ | | Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it | | Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves** | -| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree. Under a `group` or `isolated` tenancy posture `authorEnvironmentSets` admits nothing at the data door: the platform operator authors the security catalog there (ADR-0131 D3). It still applies under `single` | +| Action flags | `manageAssignments` gates the assignment rows — `sys_user_position`, and direct `sys_user_permission_set` grants. `manageBindings` and `authorEnvironmentSets` gated writes to the binding rows and set rows of the retired `sys_position_permission_set` and `sys_permission_set` objects; neither object is registered any more, so neither flag admits a write. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a `manage_metadata` holder (the platform operator under `group` or `isolated`) and reads no `adminScope` (ADR-0131 D3). The two flags still count in the strict-containment comparison below | | Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) | | Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked | | Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically | @@ -66,11 +64,11 @@ Writes to the governed tables (`sys_user_position`, | Membership is not delegable | `sys_member` writes are tenant-admin-only: no `adminScope` axis expresses org membership, and an admin-grade membership row is auto-elevated to `organization_admin` — writing one would mint a tenant admin. Adding people *is* delegable, through an [invitation](#letting-a-delegate-invite-delegated_admin) | | No scope, no admin | Holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a scoped capability now, not a side effect of table access | -Every rule above is exercised end-to-end by the showcase permission zoo +The showcase permission zoo exercises the gate end-to-end (`packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts`): the in-subtree allowlisted assignment passes with a `granted_by` stamp; the -out-of-subtree anchor, the off-allowlist grant (to self), and the -`manageBindings: false` binding write are each refused. +out-of-subtree anchor and the off-allowlist grant (to self) are each +refused. ## The assignment anchor diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 8d5583c7f9d..795ea1d9cc5 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -238,14 +238,19 @@ export const salesRep = definePosition({ Each name in `permissionSets` is resolved in the environment's catalog when a holder's grants are resolved; a name the catalog does not hold confers nothing. -A `sys_position_permission_set` row confers nothing either: the position's -definition is the only binding that grants. +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. -These writes are **governed** (ADR-0090 D12): tenant-level admins pass +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. Bindings to -the `everyone`/`guest` anchors additionally reject high-privilege sets at the -data layer for *every* caller. +`adminScope`; plain CRUD on the tables grants nothing by itself. A position's +`permissionSets` is part of its definition, so changing it is a metadata write +(`manage_metadata`; the platform operator under `group` or `isolated`). No +caller binds a set to the `everyone` or `guest` anchor: `everyone`'s sets are +derived from the deployment's baseline and the package suggestions an admin +accepted (next section), and the high-privilege gate runs on that derived +definition. Org-admin grants (`sys_member.role` owner/admin → `organization_admin`) are reconciled automatically by a lifecycle hook — you never insert those rows by @@ -317,14 +322,13 @@ export const EastSubsidiaryAdmin = definePermissionSet({ // The scope authorizes WHAT may be administered… adminScope: { businessUnit: 'east', // WHERE: this BU subtree - manageAssignments: true, // user ↔ position rows - manageBindings: true, // position ↔ set rows + manageAssignments: true, // user ↔ position rows, direct grants assignablePermissionSets: ['sales_user', 'support_user'], // WHICH sets }, // …and the CRUD bits let the requests through at all: objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, - sys_position_permission_set: { allowRead: true, allowCreate: true, allowDelete: true }, + sys_user_permission_set: { allowRead: true, allowCreate: true, allowDelete: true }, }, }); ``` @@ -341,12 +345,13 @@ tenant-level. A package ships its own sets as registry items stamped with the owning package (`packageId`), served from the security catalog — the boot writes no row for them, and an upgrade is served as soon as the package registers its new -version; environment-authored sets are never clobbered. The data layer refuses forging package -provenance through the admin door (two-doors separation, evolved by ADR-0094 — -an edit of a set shipped in code is refused; clone it instead, see below), which -is what makes package uninstall well-defined — and enforced: uninstalling a -package (`DELETE /api/v1/packages/:id`) revokes its own sets, their -position/user bindings, and its pending audience-binding suggestions in the +version; environment-authored sets are never clobbered. An environment write +cannot take over a package's set (two-doors separation — an edit of a set +shipped in code is refused; clone it instead, see below), which is what makes +package uninstall well-defined — and enforced: uninstalling a package +(`DELETE /api/v1/packages/:id`) takes its sets and positions out of the +catalog, revokes every `sys_user_permission_set` grant naming one of its sets, +and removes its audience-binding suggestions in the same request (no ghost grants); the uninstall response reports the revocation under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers the route, and it nests the protocol's uninstall report under `persisted`). @@ -359,37 +364,42 @@ publish, discard, roll back, revert, duplicate or adopt catalog items (`publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`). -## One authoritative store — the record is a projection (ADR-0094) +## One authoritative store — the definition (ADR-0094 D1, ADR-0131 D3) A permission-set **definition** has a single authoritative store: the metadata -layer (packaged declarations plus the `sys_metadata` overlay). The queryable -`sys_permission_set` record — what Setup lists and user assignment reads — is a -**pure projection** of that definition, never independently authoritative. -Every non-system write on the record (Setup CRUD, a bulk import, any API that -goes through the data engine) is redirected into a metadata write and the -record is re-derived by an awaited projector, so the two can never drift. This -has three author-visible consequences: +layer — a package's declaration, or a definition authored in this environment +and saved in `sys_metadata` — served by name from the security catalog. +Nothing mirrors it into a row: the `sys_permission_set` record ADR-0094 kept +as a projection is retired, its object is no longer registered, and no data +route serves it (ADR-0131 D3). Setup → Access Control → Permission Sets lists +the catalog (`/apps/setup/metadata/permission?scope=environment`), a grant +names its set by name (`sys_user_permission_set.permission_set`), and a write +goes through the metadata door (`PUT` or `DELETE /api/v1/meta/permission/:name`), +which requires `manage_metadata` — and, under a `group` or `isolated` tenancy +posture, the platform operator. This has three author-visible consequences: - **A set shipped in code cannot be edited in the environment.** That is a set an installed package declares (a `*.permission.ts` file, a stack's `permissionSets`) or a platform default such as `member_default`. Editing it through Setup or the metadata API answers `403 NOT_OVERRIDABLE` and stores - nothing. Clone it to a new name instead (the **Clone** action on the set, - `POST /api/v1/data/sys_permission_set` with a new name, or the clone door - below) and edit the clone: + nothing. Clone it to a new name instead (the **Clone** action on the set, or + the clone door below) and edit the clone: the clone is your organization's own set, and package upgrades keep reaching the original. Every other set (one created in this environment, a clone, or one saved into a writable runtime package) is edited in place: the edit - lands on its one stored definition, and the record follows it. + lands on its one stored definition. - **The API name is immutable after creation.** It is the definition's metadata - identity, so the create form accepts it but the edit form locks it, and a - rename through the data door is rejected. Clone the set to a new name instead. -- **Deleting through the data door depends on where the definition lives.** A - set you created in this environment is removed. A set that ships with an - installed package/app cannot be removed from the environment — "delete" - **resets** it to the shipped definition (the customization overlay is - dropped), and the row remains. Uninstall the owning package to remove a - packaged set entirely. + identity, and the metadata door addresses the set by it, so there is no + rename. Clone the set to a new name instead. +- **A delete depends on where the definition lives.** A set you created in this + environment is removed. A set that ships with an installed package/app cannot + be removed from the environment: its definition stays served from the + package. Uninstall the owning package to remove a packaged set entirely. + +An older database keeps the `sys_permission_set` table and its rows, and +nothing serves or reads them. `os migrate organization-ownership --apply` +(ADR-0131 D10) converts the sets an organization authored in Setup into +environment metadata under `single` and deletes the seeded copies. A cross-package job function needs no hand-authored cross-package set: bind each package's own sets to one **position** (the union model adds). Where a @@ -406,8 +416,7 @@ POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "lab The clone door copies a set's whole definition under a new machine name. It reads the source from the security catalog, where every packaged and -environment-authored set lives, and never from a `sys_permission_set` record, -so it works with no record at all. It saves the copy through the metadata layer +environment-authored set lives. It saves the copy through the metadata layer as a set authored in this environment: no package, and no link back to the set it was copied from, so changes to the source never reach the copy. @@ -441,7 +450,7 @@ package currently declares) in one way: the `OS_METADATA_WRITABLE` escape hatch) answers ahead of the package's definition — the overlay wins forever, regardless of what the package ships next. No door creates such an overlay any more: the metadata door refuses a - save over a packaged set, and so does the data door (clone the set instead). + save over a packaged set (clone the set instead). The boot names every such set in its log. **Remedy:** delete the overlay row. From the release that adds ADR-0048's cold-boot check, a deployment that still holds one does not boot: `os migrate security-catalog-overlays` diff --git a/content/docs/permissions/positions.mdx b/content/docs/permissions/positions.mdx index a7c5ab2ebf6..bc625bf91c9 100644 --- a/content/docs/permissions/positions.mdx +++ b/content/docs/permissions/positions.mdx @@ -7,9 +7,11 @@ description: "Positions are flat groups that hand permission sets to users — t A **position** (岗位) is a named, assignable distribution group: users hold positions (`sys_user_position` rows), a position names the permission sets it distributes in `permissionSets` on its definition, and a user's capability is -the union of every set reached that way plus direct grants. A -`sys_position_permission_set` row grants nothing. Positions carry **no capability -of their own** — they only decide *who gets which sets*. +the union of every set reached that way plus direct grants. The definition is +the only binding: the retired `sys_position_permission_set` junction object is +no longer registered, and a row an older database still holds grants nothing. +Positions carry **no capability of their own** — they only decide *who gets +which sets*. > **Formerly "roles".** ADR-0090 D3 renamed the concept in one step and > reserved the word *role* platform-wide (the D7 publish linter rejects it in @@ -59,7 +61,7 @@ Assigning a position is a plain data row: ```typescript await objectql.object('sys_user_position').insert({ user_id: 'user123', - position: 'sales_manager', // machine name (sys_position.name) + position: 'sales_manager', // the position's name in the security catalog business_unit_id: 'bu_east', // ADR-0090 Addendum: the assignment anchor }); ``` @@ -99,22 +101,28 @@ Two more built-in positions are **implicit** — they are never stored as assignment rows (the runtime rejects such rows) because whole principal classes hold them automatically: -- **`everyone`** — every authenticated user. Binding a permission set to - `everyone` is how an admin authors *default grants* — additive, alongside - the configured baseline (`member_default`); receiving an explicit grant - never costs the baseline (the "fallback cliff" was abolished by D5). - Packages may *suggest* an everyone-binding via a set's `isDefault: true`; - the admin confirms each suggestion — nothing auto-binds. +- **`everyone`** — every authenticated user. Its sets are the deployment's + *default grants*: the configured baseline (`member_default`) plus each + package set an admin holding `manage_metadata` accepted. Receiving an + explicit grant never costs the baseline (the "fallback cliff" was abolished + by D5). Packages *suggest* a default via a set's `isDefault: true`; the + admin confirms each suggestion — nothing auto-binds (see + [Permission Sets](/docs/permissions/permission-sets#package-suggestion--isdefault-adr-0090-d5)). - **`guest`** — every unauthenticated principal, which holds this position - *and nothing else*. Guest bindings face the strictest tier: explicit - objects only, read-only by default (create is the case-by-case exception, - e.g. public form intake), no View/Modify All, no system permissions. - -Both anchors reject **high-privilege bindings at the data layer** — no View/ -Modify All Data, no delete/purge/transfer, no `'*'` wildcard, no system -permissions — no matter who asks (even a platform admin). Grant powerful sets -through ordinary positions instead. Anchor bindings are tenant-level only: no -delegated admin scope can touch them. + *and nothing else*. Its definition is the platform's and names no permission + set, so a guest reaches data only through a surface that authorizes itself + from a declaration — a public form's `publicFormGrant`, a share link, a + public book (see the anonymous-deny gate in + [Authorization](/docs/permissions/authorization#the-enforcement-chain)). + +No caller writes either anchor's definition — a platform admin and every +delegated admin scope included: the platform declares both and derives +`everyone`'s sets ([Authorization](/docs/permissions/authorization)). The +high-privilege gate runs on that derived definition: accepting a set that +carries a bit the `everyone` anchor forbids — View/Modify All Data, +delete/purge/transfer, system permissions — is refused, and a later publish +that adds one is withheld. Grant powerful sets through ordinary positions +instead. --- diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e5f753eea80..79aae3c1e90 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -108,7 +108,7 @@ that silently does not happen. | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | -| 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | +| 11 | Permission-set projection middleware skipped | plugin-security | Lose: nothing on a stock composition — the security plugin no longer registers this middleware, because the `sys_permission_set` object it projected into is retired (ADR-0131 D3); the module is still exported. Where a host registers it, the skip is of the projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | | 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` | @@ -408,9 +408,10 @@ engine's context wrapper). A restatement is a producer's declaration of the shape it will build, never a read, so a new one moves the census's declaration count and nothing on this page — the read population and the anchored rows above stay where they are. The most recent arrival is the scoped seed context threaded into the org-admin permission-set lookup in -`packages/plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read resolves -against the granting organization's own catalog row rather than an -organization-less one (#11670). ⛔ Cited as a FILE deliberately, with no +`packages/plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read is scoped +to the granting organization rather than read organization-less (#11670). It +reads the legacy `sys_permission_set` ids an old grant may carry; that object +is no longer registered (ADR-0131 D3), so the read answers no rows. ⛔ Cited as a FILE deliberately, with no `#symbol`: this page's symbol anchors name the enclosing declaration of an elevation **read**, and a declaration of the shape is not one — so there is no symbol here for a row to name, and inventing one would put a name on the page