diff --git a/.changeset/15204-s8-catalog-row-objects-retired.md b/.changeset/15204-s8-catalog-row-objects-retired.md new file mode 100644 index 00000000000..7ed08713e47 --- /dev/null +++ b/.changeset/15204-s8-catalog-row-objects-retired.md @@ -0,0 +1,48 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/platform-objects': minor +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': minor +'@objectstack/plugin-sharing': patch +'@objectstack/lint': patch +'@objectstack/service-settings': patch +--- + +feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + +Clause-②: no (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + +FROM → TO: + +- `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. +- `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. +- `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). +- `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). +- Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. +- An object permission on one of the four names in a permission set grants nothing and can be deleted. + +What else changes: + +- `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. +- A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. +- On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. +- Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. +- On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. +- `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. +- On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + +Removed exports, no replacement: + +- `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. +- `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. +- `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. +- `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). +- `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 7546a0b13d9..a8be2d22d5c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -226,7 +226,7 @@ cannot read, and they are neither in nor out. | write call sites on the application surface | **228** | | …whose object name is statically decidable | 155 | | …whose object name is chosen at run time | 73 | -| …against an object with tenancy ENABLED | 154 | +| …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 167 | | PROVABLY carrying none (options read, no context key) | **8** | @@ -240,9 +240,9 @@ cannot read, and they are neither in nor out. | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 27 | | receiver erased, placed by an `object: string` PARAMETER | 7 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 8 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `ae14c39c5`. +Measured on 2026-10-13 at `7c01d30c9`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 654 | +| tracked non-test sources scanned | 650 | | engine-shaped types recognised | 73 | -| declared objects in the registry | 120 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | {/* END GENERATED: tenant-audit-census */} diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index e3895711ec6..4d28e9bd36c 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -116,7 +116,7 @@ const result = AdminScopeSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) | | **label** | `string` | optional | Display label | -| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) | +| **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) | | **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | | **isDefault** | `boolean` | optional (default: `false`) | [ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 23953ae00a1..9052efb607e 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -54,7 +54,7 @@ Advanced / low-level Better-Auth options | :--- | :--- | :--- | :--- | | **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. | | **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. | -| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | +| **selfRegistrationPermissionSet** | `string` | optional | Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | --- @@ -171,7 +171,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | :--- | :--- | :--- | :--- | | **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. | | **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. | -| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | +| **selfRegistrationPermissionSet** | `string` | optional | Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | ### Nested Shape: `AuthConfig.advanced` diff --git a/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md b/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md index d7340ef913f..7f655be34a9 100644 --- a/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md +++ b/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md @@ -373,7 +373,7 @@ flagged inline below. |---|---|---| | **D1** classification on the provenance axis | **Adopted**; table vocabulary now stale — see "revised table" below | Principle intact. Rows renamed/removed by ADR-0090 (Profile gone, role→position). | | **D2** DEFINITION↔ASSIGNMENT principle | **Adopted, reinforced** | ADR-0090 D2 leans on it to justify removing Profile; ADR-0091 dates the *assignment* rows only (`valid_from`/`valid_until`), confirming they are env data. | -| **D3** `packageId` + provenance `managedBy` on the set | **Implemented** | `packages/spec/src/security/permission.zod.ts#managedBy` (`packageId`, tagged `[ADR-0086 D3]`) + (`managedBy` enum); record fields `package_id`/`managed_by`/`customized` + index on `packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts#managedBy`. | +| **D3** `packageId` + provenance `managedBy` on the set | **Implemented** | `packages/spec/src/security/permission.zod.ts#managedBy` (`packageId`, tagged `[ADR-0086 D3]`) + (`managedBy` enum); record fields `package_id`/`managed_by`/`customized`; the `sys_permission_set` row object that carried them is retired by C3 stage 8 (ADR-0131 D3; `packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts`). | | **D4** Shape B (packages ship own sets; union; shared slices env-only) | **Adopted, reinforced; subtract layer upgraded** | ADR-0090 D9 / Alt #6 reject package-written shared/builtin sets for the same reasons. Composition is now anchored at the **position** ("bind several packages' sets to one position"), ADR-0094 D5. **Subtract layer changed** — see ⚠️ below. | | **D5** `bootstrapDeclaredPermissions` | **Implemented; `isDefault` semantics refined by ADR-0090 D5** | `plugin-security/src/bootstrap-declared-permissions.ts` (invoked at `kernel:ready`, `security-plugin.ts`); seeds `managed_by:'package'` + `package_id`, idempotent/upgrade-aware; closes the ADR-0078 inert-metadata smell the D5 header calls out. `isDefault` no longer means "profile fallback": app-level default sets **auto-bind to the `everyone` position**; package-level default sets materialize a `sys_audience_binding_suggestion` an admin confirms (new object). | | **D6** package Access door under draft/publish | **Implemented** | objectui writes the package door as `mode:'draft', packageId` (`PermissionMatrixEditor.tsx`); env door stays live and, per ADR-0094 D3, that "live" save is itself redirected into a metadata overlay. | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d7a1fe0e1a3..553ee5a9e8a 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -36,7 +36,7 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Write call sites | 228 | | Object name statically decidable | 155 | | Object name chosen at run time | 73 | -| Against a tenancy-enabled object | 154 | +| Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 167 | | Provably carrying none | 8 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `ae14c39c5`. +Measured on 2026-10-13 at `7c01d30c9`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 654 | +| tracked non-test sources scanned | 650 | | engine-shaped types recognised | 73 | -| declared objects in the registry | 120 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | ## Every site @@ -163,10 +163,10 @@ Measured on 2026-10-13 at `ae14c39c5`. | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | undeclared-name | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index cd548be3225..fd33e236ec6 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2560,8 +2560,9 @@ "id": "access-security.packaged-permission-set-lifecycle", "title": "Packaged permission-set lifecycle (#11513) survives ADR-0126 unchanged: locked base at the data door, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it writes a sys_metadata_activation row", "since": "v16", - "status": "active", - "revision": 1, + "status": "retired", + "retiredReason": "C3 stage 8 (ADR-0131 D3) retired the sys_permission_set row object: its data door, its Activate / Deactivate / Clone row actions and its `active` column are gone. A packaged set's base stays locked at the metadata door, a copy is made with POST /api/v1/security/permission-sets/:name/clone, and a set is switched off in the activation ledger (sys_metadata_activation).", + "revision": 2, "priority": "P2", "surface": "mixed", "personas": [ @@ -2646,7 +2647,7 @@ "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", "packages/plugins/plugin-security/src/permission-set-projection.ts (insert-path lock + duplicate 409; the #4669 row-state carve-out ordered BEFORE the update-path lock)", - "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts#bodyExtra (activate/deactivate bodyExtra {active}, clone_permission_set params — the payload contract)", + "packages/runtime/src/domains/permission-set-clone.ts (the clone door that replaced the retired object's clone_permission_set action)", "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" ], @@ -2656,6 +2657,12 @@ "date": "2026-08-26", "change": "new — the landed #11513 surface (data-door lock, clone action, active row-state lifecycle) had no checklist item: readonly-package-locks-studio covers the /meta door on objects and permission-matrix-edit-loop explicitly scopes itself to WRITABLE sets, so the packaged-set data-door lifecycle was uncovered end to end. Authored as the ADR-0126 regression guard with the no-ledger-row wall as its ⭐ clause, per the 2026-08-26 ruling in #12159 (card 已知边界 4): flipping active or cloning must keep writing sys_permission_set state only, never a sys_metadata_activation row", "ref": "#12438" + }, + { + "revision": 2, + "date": "2026-10-12", + "change": "retired — the sys_permission_set row object, its data door and its row actions are retired with C3 stage 8 (ADR-0131 D3)", + "ref": "#15204" } ] }, diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index fe1e66fa19c..328a7687ca7 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -223,7 +223,6 @@ "access-security.sharing-rule-authoring-ui", "access-security.owd-save-gate", "access-security.share-link-capability-tokens", - "access-security.packaged-permission-set-lifecycle", "access-security.platform-owner-email-anchor", "access-security.me-permissions-aggregation-parity", "api-backend.api-methods-verb-gate" diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json index 721b25a7e42..6d948776253 100644 --- a/examples/app-showcase/access-matrix.json +++ b/examples/app-showcase/access-matrix.json @@ -199,26 +199,6 @@ "viewAllRecords": false, "modifyAllRecords": false }, - { - "permissionSet": "showcase_field_ops_delegate", - "object": "sys_permission_set", - "create": false, - "read": true, - "edit": false, - "delete": false, - "viewAllRecords": false, - "modifyAllRecords": false - }, - { - "permissionSet": "showcase_field_ops_delegate", - "object": "sys_position", - "create": false, - "read": true, - "edit": false, - "delete": false, - "viewAllRecords": false, - "modifyAllRecords": false - }, { "permissionSet": "showcase_field_ops_delegate", "object": "sys_user", diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index d728363357a..41412ad0470 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -272,8 +272,8 @@ export default defineStack({ // Security positions: allPositions, permissions: allPermissionSets, - // [ADR-0066 D1] Package-declared authorization capabilities — seeded into - // sys_capability with package provenance (managed_by:'package'). + // [ADR-0066 D1] Package-declared authorization capabilities — served from + // the security catalog under this package (ADR-0131 D3). capabilities: allCapabilities, sharingRules: allSharingRules, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 3800fd7665e..92235e0386f 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -165,7 +165,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/security/capabilities.ts'], notes: - 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), seeded into sys_capability at publish. Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', + 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), served from the security catalog under the package (ADR-0131 D3). Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', }, // ── ai ── diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 62a413f870b..4af023b5427 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -391,8 +391,6 @@ export const FieldOpsDelegatePermissionSet = definePermissionSet({ label: 'Field Ops Delegate Admin', 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 }, diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index 95b33123a45..76487d91cc6 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -8,6 +8,7 @@ import { dirname, join, resolve } from 'node:path'; // Loaded at module top, so the first transform is paid during collection // rather than inside a clocked test body (`check:test-source-alias`). import { AppPlugin } from '@objectstack/runtime'; +import { buildIndexName } from '@objectstack/driver-sql'; import { loadConfig } from './config.js'; import { bootSchemaStack } from './schema-migrate.js'; @@ -24,17 +25,22 @@ import { bootSchemaStack } from './schema-migrate.js'; * * The fixture is cloud#1695's shape, built by the platform rather than by hand: * a host `objectstack.config.ts` composing `SecurityPlugin`, the tables created - * through the migrate path itself, and then `sys_position`'s per-organization - * composite unique swapped back to the pre-#8323 global spelling - * (`uniq_sys_position_name`) — the exact index a deployed control DB was still - * carrying. + * through the migrate path itself, and then a per-organization composite unique + * swapped back to its pre-#8323 global spelling — the drift a deployed control + * DB was carrying. The measured index was `sys_position`'s + * (`uniq_sys_position_name`); C3 stage 8 retired that object (ADR-0131 D3), so + * the swap is now made on the one organization-respelled unique the plugin + * still declares: `sys_audience_binding_suggestion`'s + * `(package_id, permission_set_name, anchor)`, the #8323 declared-index arm of + * the same `replace_unique_index` plan. * * Four things are pinned, and the fourth is an ABSENCE. Composing a host - * config means composing arbitrary code, and `SecurityPlugin` seeds its - * built-in permission sets from `start()`: measured, that produced 14 insert - * attempts against `sys_permission_set` during a run documented as writing - * nothing. `sys_permission_set` staying EMPTY across a plan is what proves the - * declaration-phase composition actually holds. + * config means composing arbitrary code. Measured before the catalog seeders + * retired, `SecurityPlugin.start()` made 14 insert attempts against + * `sys_permission_set` during a run documented as writing nothing. That table + * is retired with its object; the plugin's tables holding exactly the + * fixture's rows across a plan is what still shows the declaration-phase + * composition holds. */ const require_ = createRequire(import.meta.url); @@ -63,6 +69,19 @@ const ARTIFACTLESS_BASELINE_TABLES = [ 'sys_metadata_history', ]; +/** + * The organization-respelled declared unique the drift cases below put back to + * its global spelling: `sys_audience_binding_suggestion`'s + * `(package_id, permission_set_name, anchor)`, declared `unique: 'organization'`. + * Both names are the driver's own (`buildIndexName`), never spelled by hand. + */ +const RESPELLED = { + table: 'sys_audience_binding_suggestion', + listed: ['package_id', 'permission_set_name', 'anchor'], +} as const; +const LEGACY_UNIQUE = buildIndexName(RESPELLED.table, [...RESPELLED.listed], true); +const ORG_UNIQUE = buildIndexName(RESPELLED.table, ['organization_id', ...RESPELLED.listed], true); + describe('os migrate plan/apply compose the deployment\'s own object set (#12938)', () => { let dir: string; let dbFile: string; @@ -147,9 +166,9 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 }; /** - * Put `sys_position` back into the pre-respelling physical shape — the - * per-organization composite retired, the global single-column unique - * present. This is cloud#1695's index, verbatim. + * Put {@link RESPELLED} back into the pre-respelling physical shape — the + * per-organization composite retired, the global unique on the listed + * columns present. cloud#1695's shape, on a table the plugin still declares. * * Idempotent, and called by every test that needs it, so the cases below do * not depend on each other's order. An integration file whose third test only @@ -160,18 +179,20 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 const stack = await bootLikeMigrate(); try { const k = (stack.driver as any).knex; - const present = await indexNames(stack.driver, 'sys_position'); - if (present.includes('uniq_sys_position_organization_id_name')) { - await k.raw('DROP INDEX uniq_sys_position_organization_id_name'); + const present = await indexNames(stack.driver, RESPELLED.table); + if (present.includes(ORG_UNIQUE)) { + await k.raw(`DROP INDEX ${ORG_UNIQUE}`); } - if (!present.includes('uniq_sys_position_name')) { - await k.raw('CREATE UNIQUE INDEX uniq_sys_position_name ON sys_position (name)'); + if (!present.includes(LEGACY_UNIQUE)) { + await k.raw(`CREATE UNIQUE INDEX ${LEGACY_UNIQUE} ON ${RESPELLED.table} (${RESPELLED.listed.join(', ')})`); } - if (await countRows(stack.driver, 'sys_position') === 0) { - await k('sys_position').insert({ - id: 'pos_1', - name: 'org_admin', - label: 'Org Admin', + if (await countRows(stack.driver, RESPELLED.table) === 0) { + await k(RESPELLED.table).insert({ + id: 'abs_1', + package_id: 'com.acme.crm', + permission_set_name: 'sales_readonly', + anchor: 'everyone', + status: 'pending', organization_id: 'org_jia', }); } @@ -196,15 +217,18 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 expect(stack.composition.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); // The number the consumer-side coverage gate reads. Strictly greater than - // the baseline, and by more than one: the security plugin alone brings six - // RBAC tables and the platform floor another four. + // the baseline, and by more than one: the security plugin alone brings + // three tables (its assignment objects and the audience-binding + // suggestions; the catalog row objects retired in C3 stage 8) and the + // platform floor another four. expect(stack.managedTableCount).toBeGreaterThan(ARTIFACTLESS_BASELINE_TABLES.length); const pending = stack.pendingSchemaWork.map((p) => p.table); - // The two tables the issue names by hand, neither of which was reachable - // from a migrate boot at all. - expect(pending).toContain('sys_position'); - expect(pending).toContain('sys_permission_set'); + // The issue named `sys_position` and `sys_permission_set` by hand, neither + // reachable from a migrate boot at all; both objects are retired now + // (ADR-0131 D3), and the security plugin's tables that remain stand in. + expect(pending).toContain('sys_user_position'); + expect(pending).toContain('sys_user_permission_set'); // …and the platform floor `serve` composes unconditionally. expect(pending).toContain('sys_migration'); for (const table of ARTIFACTLESS_BASELINE_TABLES) expect(pending).toContain(table); @@ -225,15 +249,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 try { const drift = await stack.driver!.detectManagedDrift(); const entry = drift.find( - (d) => d.table === 'sys_position' && d.op.type === 'replace_unique_index', + (d) => d.table === RESPELLED.table && d.op.type === 'replace_unique_index', ); expect(entry, 'the legacy platform-wide unique must be planned, not invisible').toBeDefined(); expect(entry!.category).toBe('safe'); expect(entry!.op).toMatchObject({ - table: 'sys_position', - dropIndexNames: ['uniq_sys_position_name'], - createIndexName: 'uniq_sys_position_organization_id_name', - createColumns: ['organization_id', 'name'], + table: RESPELLED.table, + dropIndexNames: [LEGACY_UNIQUE], + createIndexName: ORG_UNIQUE, + createColumns: ['organization_id', ...RESPELLED.listed], }); // The prescription that made this a contract rather than an incomplete report. expect(entry!.message).toContain('os migrate apply'); @@ -249,13 +273,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 try { await stack.driver!.detectManagedDrift(); - // The absence that matters. `SecurityPlugin.start()` bootstraps - // `admin_full_access`, `organization_admin`, `member_default` and the rest; - // fully started, it attempted 14 inserts into this table during a plan. - expect(await countRows(stack.driver, 'sys_permission_set')).toBe(0); - expect(await countRows(stack.driver, 'sys_position')).toBe(1); + // The absence that matters: the security plugin's tables hold exactly + // the fixture's rows. (Fully started, `SecurityPlugin.start()` once made + // 14 insert attempts into `sys_permission_set` during a plan; that seeder + // and that object are retired, ADR-0131 D3.) + expect(await countRows(stack.driver, 'sys_user_permission_set')).toBe(0); + expect(await countRows(stack.driver, 'sys_user_position')).toBe(0); + expect(await countRows(stack.driver, RESPELLED.table)).toBe(1); // The legacy index is still there — a plan proposes, it does not apply. - expect(await indexNames(stack.driver, 'sys_position')).toContain('uniq_sys_position_name'); + expect(await indexNames(stack.driver, RESPELLED.table)).toContain(LEGACY_UNIQUE); } finally { await stack.shutdown(); } @@ -274,10 +300,10 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 expect(applied.some((d) => d.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); - const after = await indexNames(stack.driver, 'sys_position'); - expect(after).toContain('uniq_sys_position_organization_id_name'); - expect(after).not.toContain('uniq_sys_position_name'); - expect(await countRows(stack.driver, 'sys_position')).toBe(1); + const after = await indexNames(stack.driver, RESPELLED.table); + expect(after).toContain(ORG_UNIQUE); + expect(after).not.toContain(LEGACY_UNIQUE); + expect(await countRows(stack.driver, RESPELLED.table)).toBe(1); } finally { await stack.shutdown(); } @@ -464,11 +490,13 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur // being a second, differently-derived number. expect(stack.managedTableCount).toBe(coverage!.examinedObjects); - // The two tables #12938 named by hand as the proof the five-table set was - // wrong. Pre-#13028 this shape reached NEITHER. + // #12938 named `sys_position` and `sys_permission_set` by hand as the proof + // the five-table set was wrong; pre-#13028 this shape reached neither. Both + // objects are retired now (ADR-0131 D3), so the security plugin's tables + // that remain carry the same proof. const pending = stack.pendingSchemaWork.map((p) => p.table); - expect(pending).toContain('sys_position'); - expect(pending).toContain('sys_permission_set'); + expect(pending).toContain('sys_user_position'); + expect(pending).toContain('sys_user_permission_set'); // Full coverage means SILENCE about coverage: the honesty note exists to // mark a shortfall, and one that printed anyway would train readers to @@ -493,7 +521,7 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur // assignment on the driver. If it had reached `initObjects` instead, the // table would exist here. const k = (stack.driver as any).knex; - const exists = await k.schema.hasTable('sys_permission_set'); + const exists = await k.schema.hasTable('sys_user_permission_set'); expect(exists, 'a plan must not create a table on its way to a coverage number').toBe(false); } finally { await stack.shutdown(); diff --git a/packages/cli/src/utils/unmanaged-tables.integration.test.ts b/packages/cli/src/utils/unmanaged-tables.integration.test.ts index 6df211de61e..80a8a0a93fb 100644 --- a/packages/cli/src/utils/unmanaged-tables.integration.test.ts +++ b/packages/cli/src/utils/unmanaged-tables.integration.test.ts @@ -22,7 +22,7 @@ * dialects; this exercises the sqlite one through the driver's own raw * seam, against tables that really exist. * - * ⛔ The NEGATIVE control is the point. `sys_permission_set` (from the composed + * ⛔ The NEGATIVE control is the point. `sys_user_permission_set` (from the composed * plugin) and `sys_secret` (from the platform floor) are put in the database * BEFORE the boot and must NOT be reported, alongside `_objectstack_sequences` * and an application table. A sweep observed only in the presence of an orphan @@ -76,7 +76,10 @@ const OVERRIDING_ENV = [ /** Legitimate, and every one of them must stay OUT of the report. */ const NEGATIVE_CONTROLS = [ - 'sys_permission_set', // declared by the composed host plugin + // [ADR-0131 D3] A table the composed plugin still declares. `sys_permission_set` + // was this control until C3 stage 8 retired its object: its table is now + // declared by nothing, so the sweep is RIGHT to report it. + 'sys_user_permission_set', // declared by the composed host plugin 'sys_secret', // declared by the platform floor '_objectstack_sequences', // the driver's own autonumber ledger 'app_leftovers', // no reserved prefix at all @@ -204,7 +207,7 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204) expect(stack!.composition.servedBoot).toEqual({ mirrored: true }); expect(stack!.managedTableCount).toBeGreaterThan(0); const declared = (stack!.allObjects() as Array<{ name?: string }>).map((o) => o?.name); - expect(declared).toContain('sys_permission_set'); + expect(declared).toContain('sys_user_permission_set'); expect(declared).toContain('sys_secret'); }); diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 04717d4eb9d..896c9e2d156 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -112,7 +112,7 @@ export const ADMIN_STANDING_SURFACE: Readonly + '(`readDisabledCatalogNames`, §6a): a `permission` row of `admin_full_access` whose `active` ' + 'is false un-makes every GRANT-derived platform administrator at once (§6b), with no identity ' + 'table touched; a `position` row switches that position off. Deployment-wide (no tenant ' - + 'column). The `sys_permission_set` and `sys_position` rows are no longer read at all: the ' + + 'column). No catalog row is read — the row objects are retired (ADR-0131 D3, C3 stage 8): the ' + 'set itself — its existence and its body — is the catalog definition (see the ' + '`security-catalog` non-table input). ⚠️ It does NOT un-make a CONFIG-derived administrator ' + '(§6b-config), which never reads a set.', diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f7963f7216f..163db8d015d 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -239,11 +239,6 @@ export { // ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate. export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js'; -// ADR-0049 enforce-or-remove — the `active` flag on the RBAC grant catalogues -// (`sys_permission_set` / `sys_position`). One predicate for the resolver that -// enforces it and the break-glass guard that simulates a write to it. -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 diff --git a/packages/core/src/security/resolve-user-grants-cache.ts b/packages/core/src/security/resolve-user-grants-cache.ts index aead9a48791..fe6006cc34d 100644 --- a/packages/core/src/security/resolve-user-grants-cache.ts +++ b/packages/core/src/security/resolve-user-grants-cache.ts @@ -118,9 +118,6 @@ export const GRANTS_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set([ 'sys_member', 'sys_user_position', 'sys_user_permission_set', - 'sys_position', - 'sys_position_permission_set', - 'sys_permission_set', 'sys_user', // [ADR-0131 D3, ADR-0126 §4] The activation ledger: a position or permission // set switched off there stops granting (`readDisabledCatalogNames`). diff --git a/packages/core/src/security/row-active.ts b/packages/core/src/security/row-active.ts deleted file mode 100644 index 2f46c79e7af..00000000000 --- a/packages/core/src/security/row-active.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The `active` flag on the RBAC grant catalogues — `sys_permission_set` and - * `sys_position` (ADR-0049 enforce-or-remove). - * - * Both objects ship a Deactivate action whose confirmation dialog promises, in - * four locales, that access stops: - * - * > Deactivate this permission set? Existing assignments stay in place but stop - * > granting access until re-activated. - * > Deactivate this position? Users keep their assignment but the position stops - * > granting permissions until re-activated. - * - * ⚠️ The catalogue row's `active` column DECIDES NO GRANT any more (ADR-0131 - * D3, ADR-0126 §4). `resolveUserAuthzGrants` reads deactivation for both types - * from the deployment-level activation ledger, `sys_metadata_activation` - * (`metadata_type` `position` / `permission`), written through - * `POST /api/v1/security/_activation/:type/:name`; it never calls this - * predicate. The Deactivate dialog's promise is kept by that ledger row, not by - * this column. - * - * What still reads the column through this predicate, on `main`: the explain - * engine's "deactivated" label (`plugin-security` `explain-engine.ts`) and the - * security plugin's set loader (`plugin-security` `security-plugin.ts`). The - * last-administrator guard and the sharing-rule position check read the ledger - * instead. The reasoning below is about reading the column at all, and holds - * for each remaining reader. - * - * ## Why the predicate is "explicitly deactivated", not "explicitly active" - * - * Absent means ACTIVE. Only a stored value that really reads false takes the - * grant away. Two measured reasons, and they point the same way: - * - * 1. **Deployed data.** `active` carries `defaultValue: true`, but a row that - * predates the column, arrived through a migration, or was projected with a - * `fields` list that omitted it carries no value at all. Requiring `true` - * would revoke every such row's grants the moment this lands — a silent - * mass revocation nobody asked for, which is the opposite of the one thing - * the Deactivate dialog promises. `isGrantActive` reads absent bounds as - * unbounded for exactly this reason (ADR-0091 D2). - * 2. **Storage shapes.** SQLite stores booleans as 1/0 and presents them back - * as numbers unless the driver knows the column is boolean; the memory - * driver round-trips real booleans; a JSON/text column can hand back - * `'false'`. `row.active === false` alone therefore misses the deactivated - * row on the primary driver — an enforcement hole shaped exactly like the - * bug this predicate closes. Pushing `active: true` into a driver `where` - * has the same problem from the other side AND drops the NULL rows of (1), - * so the filter runs in memory over rows the resolver already fetched: - * zero new queries, identical answer on every driver. - * - * The false-set below is closed on purpose. An unrecognised value (a stray - * string, an object) is NOT a deactivation — nothing writes one, and inventing - * a revocation out of junk data is the failing-open-into-a-lockout direction. - */ - -/** The stored spellings of a deactivated row, across every driver in the tree. */ -const DEACTIVATED_VALUES: readonly unknown[] = [false, 0, '0', 'false']; - -/** A catalogue row that may carry the `active` flag (`sys_permission_set`, `sys_position`). */ -export interface ActivatableRow { - active?: unknown; -} - -/** - * True unless the row carries an `active` column that is explicitly OFF. - * - * The ONE predicate every reader of `sys_permission_set.active` / - * `sys_position.active` uses (the readers are listed in the module doc), so - * they can never disagree about what a deactivated ROW means. It is not the - * grant switch: the resolver reads the activation ledger instead. - */ -export function isRowActive(row: ActivatableRow | null | undefined): boolean { - if (!row) return false; - const value = (row as { active?: unknown }).active; - if (value === undefined || value === null) return true; - return !DEACTIVATED_VALUES.includes(value); -} diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 3795ab480a3..3d62946b709 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3311,12 +3311,12 @@ const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = { 'A `requiredPermissions` entry names a capability the caller must hold. It is a free string, so ' + 'a typo (`mange_users` for `manage_users`) parses and fails CLOSED at run time: the gate denies ' + 'its callers, the safe direction, and nothing tells the author the capability exists nowhere.', - // The four sources are held to the rule by its test, each one silencing it. + // The three sources are held to the rule by its test, each one silencing it. 'A capability is known when it is a built-in platform capability, when the stack declares it ' + - 'with `defineCapability` (`stack.capabilities`, ADR-0066 D1), when a permission set in the ' + - 'stack grants it through `systemPermissions` (granting declares it), or when a ' + - '`sys_capability` seed row ships it. `systemPermissions` itself is never reported: it is the ' + - 'declaration side.', + 'with `defineCapability` (`stack.capabilities`, ADR-0066 D1), or when a permission set in the ' + + 'stack grants it through `systemPermissions` (granting declares it). A seed row is no source: ' + + 'no runtime reader consults one, so ship the capability with `defineCapability` instead. ' + + '`systemPermissions` itself is never reported: it is the declaration side.', // The positions are held to the rule by its test, each fired. 'Judged at every position the key is declared: an object\'s `requiredPermissions` (the array ' + 'form, or each operation of the per-operation map), each field\'s, each object action\'s and ' + diff --git a/packages/lint/src/validate-capability-references.seed-row.test.ts b/packages/lint/src/validate-capability-references.seed-row.test.ts new file mode 100644 index 00000000000..f2b3f8604fa --- /dev/null +++ b/packages/lint/src/validate-capability-references.seed-row.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { validateCapabilityReferences, CAPABILITY_REFERENCE_UNKNOWN } from './validate-capability-references.js'; + +// [ADR-0131 D3, C3 stage 8] A `sys_capability` seed row declares nothing: no +// runtime reader consults one, and the row object is retired. Counting it as a +// source silenced this warning for a capability the runtime never declares, +// while the `requiredPermissions` gate it names failed closed. +describe('capability-reference-unknown — a seed row is no source', () => { + it('still warns on a capability only a sys_capability seed row ships', () => { + const findings = validateCapabilityReferences({ + data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], + }); + expect(findings.map((f) => f.rule)).toEqual([CAPABILITY_REFERENCE_UNKNOWN]); + expect(findings[0].hint).not.toContain('seed row'); + }); + + it('CONTROL: the same capability declared with defineCapability is known', () => { + const findings = validateCapabilityReferences({ + capabilities: [{ name: 'approve_invoice', label: 'Approve', scope: 'org' }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index d6f41108502..c8224e3d824 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -19,8 +19,12 @@ * (`stack.capabilities`) — the explicit, package-provenanced declaration * (ADR-0066 D1), served at runtime by the registry (ADR-0131 D3), * 3. every capability a permission set in this stack GRANTS via - * `systemPermissions` (granting a capability also declares it), and - * 4. any `sys_capability` row shipped as seed data. + * `systemPermissions` (granting a capability also declares it). + * + * A `sys_capability` seed row is NOT a source: no runtime reader consults one + * (ADR-0131 D3 — the catalog is the registry, and the row object is retired), + * so counting it would silence this warning for a capability the runtime never + * declares. * * WARNING, not error: a single package's lint cannot see capabilities declared * by OTHER installed packages, and the reference fails closed at runtime anyway, @@ -93,17 +97,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin for (const ps of recordsOf(stack.permissions)) { for (const cap of asCapArray(ps.systemPermissions)) known.add(cap); } - for (const seed of recordsOf(stack.data)) { - if (seed.object !== 'sys_capability') continue; - for (const rec of Array.isArray(seed.records) ? seed.records : []) { - const name = (rec as AnyRec | null)?.name; - if (typeof name === 'string' && name.length > 0) known.add(name); - } - } const hint = 'Fix the capability name, define it with defineCapability (stack.capabilities), ' + - 'declare it on a permission set’s systemPermissions, ship a sys_capability seed row, ' + + 'declare it on a permission set’s systemPermissions, ' + 'or ignore this if the capability is provided by another installed package ' + '(references fail closed at runtime).'; @@ -114,12 +111,12 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin rule: CAPABILITY_REFERENCE_UNKNOWN, where, path, - // [#22161] One verdict sentence; the four sources of the known set, the + // [#22161] One verdict sentence; the three sources of the known set, the // positions walked and why it only warns is // `os explain capability-reference-unknown`. message: - `requiredPermissions names capability "${cap}", which no built-in, \`defineCapability\`, ` + - `\`systemPermissions\` grant or \`sys_capability\` seed provides, so this gate fails closed`, + `requiredPermissions names capability "${cap}", which no built-in, \`defineCapability\` ` + + `or \`systemPermissions\` grant provides, so this gate fails closed`, hint, }); }; diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 737656013a0..e9f9857892b 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -195,7 +195,7 @@ export const PACKAGED_BASE_REGIME: Readonly = { }, description: { label: "Description", - helpText: "Human-readable description shown in Setup (persisted as sys_permission_set.description)." + helpText: "Human-readable description shown in Setup." }, isDefault: { label: "Is Default", diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 3220450c6ea..6d68e68febe 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -280,6 +280,5 @@ export const en: TranslationData = { // mirrors the literal in the page metadata, as above. sys_user_detail: { label: 'User' }, sys_organization_detail: { label: 'Organization' }, - sys_position_detail: { label: 'Position' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index cd3af11bfb5..69a00e76c01 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2303,7 +2303,7 @@ export const esESMetadataForms: NonNullable = }, description: { label: "Descripción", - helpText: "Descripción legible que se muestra en Setup (se guarda como sys_permission_set.description)." + helpText: "Descripción legible que se muestra en Setup." }, isDefault: { label: "Predeterminado", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index bb48dc87b67..9f763fbb114 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -184,5 +184,4 @@ export const esESSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 16de6d4e247..e5f300a5e38 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -190,6 +190,5 @@ export const esES: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: 'Usuario' }, sys_organization_detail: { label: 'Organización' }, - sys_position_detail: { label: 'Posición' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index dd86c222b20..be3ce88e1db 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2303,7 +2303,7 @@ export const jaJPMetadataForms: NonNullable = }, description: { label: "説明", - helpText: "Setup に表示される説明文(sys_permission_set.description として保存されます)。" + helpText: "Setup に表示される説明文。" }, isDefault: { label: "既定の権限セット", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index d3063192df3..14a7015babb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -184,5 +184,4 @@ export const jaJPSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 07eb8248660..5acbe5e69b9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -188,6 +188,5 @@ export const jaJP: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: 'ユーザー' }, sys_organization_detail: { label: '組織' }, - sys_position_detail: { label: 'ポジション' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 55a8b66a9a0..819e8008915 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2303,7 +2303,7 @@ export const zhCNMetadataForms: NonNullable = }, description: { label: "描述", - helpText: "在 Setup 中展示的可读描述(持久化为 sys_permission_set.description)。" + helpText: "在 Setup 中展示的可读描述。" }, isDefault: { label: "默认权限集", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 5229f05412a..b2ddf8c3661 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -184,5 +184,4 @@ export const zhCNSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index ba01a9eb04f..98ed30cb272 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -196,6 +196,5 @@ export const zhCN: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: '用户' }, sys_organization_detail: { label: '组织' }, - sys_position_detail: { label: '岗位' }, }, }; diff --git a/packages/platform-objects/src/pages/index.ts b/packages/platform-objects/src/pages/index.ts index 702841fc1a1..459be24c2a7 100644 --- a/packages/platform-objects/src/pages/index.ts +++ b/packages/platform-objects/src/pages/index.ts @@ -2,4 +2,3 @@ export { SysOrganizationDetailPage } from './sys-organization.page.js'; export { SysUserDetailPage } from './sys-user.page.js'; -export { SysPositionDetailPage } from './sys-position.page.js'; diff --git a/packages/platform-objects/src/pages/sys-position.page.ts b/packages/platform-objects/src/pages/sys-position.page.ts deleted file mode 100644 index 0a9db276f11..00000000000 --- a/packages/platform-objects/src/pages/sys-position.page.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Page } from '@objectstack/spec/ui'; - -/** - * sys_position — Record Detail Page (slotted, default for ALL sys_position - * records) - * - * **Audience**: admins managing capability distribution from Setup. - * - * A position (岗位, ADR-0090 D3) is the flat distribution group: users hold - * it (`sys_user_position`), and it binds permission sets - * (`sys_position_permission_set`). This page makes both edges manageable - * as PURE SDUI — two `record:related_list` tabs with Add pickers; no - * bespoke React: - * - * - **Holders** — who holds this position. `sys_user_position.position` - * stores the position's MACHINE NAME, so the list keys on - * `relationshipValueField: 'name'` (the generic name-keyed-junction - * affordance this page motivated). Assigning here inserts an ordinary - * assignment row; the ADR-0090 D12 delegated-admin gate governs the - * write and its denial reason surfaces in the Add dialog. The - * `business_unit_id` column is the assignment-level BU anchor - * (ADR-0090 Addendum); `valid_from`/`valid_until` columns join it when - * ADR-0091 L1 lands. - * - **Permission Sets** — what the position distributes - * (`sys_position_permission_set`, ordinary id-keyed junction). Binding - * is guarded by BOTH the audience-anchor gate (D5/D9 — high-privilege - * sets never bind to everyone/guest) and the D12 gate. - * - * Header/actions fall through to the synthesizer, so the object's declared - * row actions (activate/deactivate/clone/set-default) keep working. - */ -export const SysPositionDetailPage: Page = { - name: 'sys_position_detail', - label: 'Position', - type: 'record', - object: 'sys_position', - template: 'default', - kind: 'slotted', - isDefault: true, - - regions: [], - - slots: { - tabs: { - type: 'page:tabs', - properties: { - // `tabStyle`, not `type` (#6776) — see sys-organization.page.ts. - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Holders', 'zh-CN': '任职人员', 'ja-JP': '在任者', 'es-ES': 'Titulares' }, - icon: 'users', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_user_position', - relationshipField: 'position', - relationshipValueField: 'name', - columns: ['user_id', 'business_unit_id', 'granted_by', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Holders', 'zh-CN': '任职人员', 'ja-JP': '在任者', 'es-ES': 'Titulares' }, - add: { - picker: { - object: 'sys_user', - labelField: 'name', - }, - linkField: 'user_id', - label: { en: 'Assign user', 'zh-CN': '分配用户', 'ja-JP': 'ユーザーを割り当て', 'es-ES': 'Asignar usuario' }, - }, - }, - }, - ], - }, - { - label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - icon: 'lock', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_position_permission_set', - relationshipField: 'position_id', - columns: ['permission_set_id', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - add: { - picker: { - object: 'sys_permission_set', - labelField: 'label', - }, - linkField: 'permission_set_id', - label: { en: 'Bind permission set', 'zh-CN': '绑定权限集', 'ja-JP': '権限セットをバインド', 'es-ES': 'Vincular conjunto de permisos' }, - }, - }, - }, - ], - }, - ], - }, - }, - - // No Chatter feed on an RBAC primitive. - discussion: [], - }, -}; diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index 04e20c0fb58..b48b9c7cd73 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -9,7 +9,7 @@ `plugin-security` hooks into the ObjectQL pipeline and applies authorization on every read and write: -1. **Resolve permission sets** — expand the user's positions and direct grants against `SysPermissionSet` metadata. +1. **Resolve permission sets** — expand the user's positions and direct grants against the permission sets the security catalog serves by name. 2. **Check object CRUD** — `allowRead`, `allowCreate`, `allowEdit`, `allowDelete`. 3. **Inject RLS** — compile row-level policy expressions into query filters. 4. **Mask fields** — remove non-readable fields from results; flag non-editable fields on writes. @@ -74,18 +74,21 @@ In CLI / dev-server mode the `OS_MULTI_ORG_ENABLED` environment variable (defaul | `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across the held permission sets (most-permissive merge). | | `RLSCompiler` | class | Compiles RLS expressions into ObjectQL filter AST. | | `FieldMasker` | class | Strips non-readable fields and identifies non-editable ones. | -| `SysPosition`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. | +| `SysUserPosition`, `SysUserPermissionSet` | objects | Assignment objects registered by the plugin. | ## System objects +Positions, permission sets and capabilities are not objects: they live in the security catalog, fed by package declarations and by environment metadata saved through `/api/v1/meta/position`, `/api/v1/meta/permission` and `/api/v1/meta/capability` (ADR-0131 D3). A position names its permission sets in its definition's `permissionSets`. + The plugin contributes these system objects to the kernel: | Object | Purpose | |:---|:---| -| `sys_position` | Position (岗位) definitions — the flat permission-set distribution layer (ADR-0090 D3). | -| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions and a delegated-admin `admin_scope` (ADR-0090 D12). | +| `sys_user_position` | Assigns a position (岗位) to a user, by the position's name (ADR-0090 D3). | +| `sys_user_permission_set` | Grants a permission set to a user directly, by the set's name (ADR-0131 D4). | +| `sys_audience_binding_suggestion` | A package's install-time suggestion to grant one of its permission sets to the `everyone` anchor, and the decision on it (ADR-0090 D5/D9). | -Assignment tables (position ↔ user, position ↔ permission_set, user ↔ permission_set) are registered alongside and governed by the delegated-admin and audience-anchor gates. +The assignment objects are governed by the delegated-admin and audience-anchor gates. ## RLS expression language diff --git a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts index 0a49255aa80..994f4ec530b 100644 --- a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts @@ -37,11 +37,12 @@ */ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; -// SysCapability carries curated translations already present in the bundles; it -// must stay in this list so `os i18n extract` keeps emitting it (dropping it -// here silently deletes those strings on the next run — the sys_audit_log -// incident). Enforced by src/translations/bundle-ownership.test.ts. -import { SysPosition, SysCapability, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition } from '../src/objects/index.js'; +// Every object the plugin owns must stay in this list so `os i18n extract` +// keeps emitting it (dropping one here silently deletes its strings on the next +// run — the sys_audit_log incident). Enforced by +// src/translations/bundle-ownership.test.ts. [C3 stage 8] The four catalog row +// objects are retired (ADR-0131 D3), and their strings with them. +import { SysUserPermissionSet, SysUserPosition } from '../src/objects/index.js'; import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -62,7 +63,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; * precision: the annotated type is the function's own return type. */ const config: ObjectStackDefinition = defineStack({ - objects: [SysPosition, SysCapability, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition] as any, + objects: [SysUserPermissionSet, SysUserPosition] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index edf2a164d98..56166db7690 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -76,7 +76,6 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -450,6 +449,12 @@ export function shouldReplayBootstrapFor(opCtx: { */ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; +/** Whether a grant still carries the legacy id a release before the name column wrote (ADR-0131 D10). */ +function hasLegacySetId(row: any): boolean { + const id = row?.permission_set_id; + return id !== undefined && id !== null && String(id) !== ''; +} + /** * Who ALREADY holds the unscoped platform-admin grant (commit 1c83ca226) — the * one rule both {@link bootstrapPlatformAdmin}'s `already_have_admin` guard and @@ -528,20 +533,26 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * nothing yet, and a by-name scan alone would answer "nobody", promote the * oldest user and hand them the seeded records: the second grant this function * exists to refuse. So leg C, only when A and B found nobody, reaches the - * grants that name NOTHING through the set row's id, and an unscoped human one - * comes back as `unnamedHolder`. It only ever RESTRICTS: the bootstrap - * withholds the promotion on it, and it is never a holder — never an - * `adminUserId`, never a claim target ({@link findExistingPlatformAdmin}). - * Leg C runs only when the deployment still carries a legacy `admin_full_access` - * row (`adminPsId`): a database with no such row has no grant written before - * the name column existed, and the by-name legs answer alone. + * unscoped human grants that name NOTHING and still carry a legacy + * `permission_set_id`, and one comes back as `unnamedHolder`. It only ever + * RESTRICTS: the bootstrap withholds the promotion on it, and it is never a + * holder — never an `adminUserId`, never a claim target + * ({@link findExistingPlatformAdmin}). + * + * [C3 stage 8] Leg C asks for ANY such grant, not for grants of the admin set's + * row: the catalog row object is retired, so no read can say which set a legacy + * id pointed at. An unnamed grant of another set therefore withholds the + * promotion too, until the organization-ownership migration's verified + * id-to-name rewrite (ADR-0131 D10) names it — the stricter reading, since the + * one it cannot tell apart may be the administrator's. A database that holds no + * unnamed grant (every fresh one) answers by name alone. */ async function findPlatformAdminGrantHolder( ql: any, - adminPsId: string | undefined, + reachUnnamed: boolean, ): Promise<{ holder: any | undefined; - /** Leg C: an unscoped human grant of the admin set row that names no set yet (restricts only). */ + /** Leg C: an unscoped human grant that names no set yet and carries a legacy id (restricts only). */ unnamedHolder: any | undefined; adminGrantRowsExamined: number; truncated: boolean; @@ -599,13 +610,13 @@ async function findPlatformAdminGrantHolder( holder = await scan({ [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME }, isUnscopedHumanHolder); } - // Leg C — the grants that name nothing yet, through the set row's id; it + // Leg C — the grants that name nothing yet and carry a legacy id; it // restricts only (see the function note). let unnamedHolder: any | undefined; - if (!holder && adminPsId) { + if (!holder && reachUnnamed) { unnamedHolder = await scan( - { permission_set_id: adminPsId }, - (r) => grantSetNameOf(r) === undefined && isUnscopedHumanHolder(r), + { [GRANT_SET_NAME_FIELD]: null }, + (r) => grantSetNameOf(r) === undefined && hasLegacySetId(r) && isUnscopedHumanHolder(r), ); } @@ -650,7 +661,7 @@ export async function findExistingPlatformAdmin( } // [ADR-0131 D4] By name alone: leg C (the legacy id reach) only restricts // and never names a holder, so this question needs no catalog row. - const { holder } = await findPlatformAdminGrantHolder(ql, undefined); + const { holder } = await findPlatformAdminGrantHolder(ql, false); return holder?.user_id ? String(holder.user_id) : undefined; } @@ -709,19 +720,12 @@ export async function bootstrapPlatformAdmin( return { adminPromoted: false, reason: 'objectql_unavailable' }; } - // 1. [ADR-0131 D3/D4] No catalog row is written: the platform's default - // sets are definitions this plugin ships on its manifest, served from - // the registry, and a grant names its set. The one read left is the - // LEGACY `admin_full_access` row an older release seeded, whose id is how - // leg C of {@link findPlatformAdminGrantHolder} reaches a grant written - // before the name column existed. ⛔ "Refused" is not "absent": a read - // that did not answer cannot say no such grant exists, so the promotion - // is withheld below rather than risk minting a second administrator. - let legacyAdminRowUnreadable = false; - const legacyAdminRows = await tryFind( - ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1, undefined, undefined, - () => { legacyAdminRowUnreadable = true; }, - ); + // 1. [ADR-0131 D3/D4] No catalog row is written or read: the platform's + // default sets are definitions this plugin ships on its manifest, served + // from the registry, and a grant names its set. [C3 stage 8] The catalog + // row object is retired, so a grant written before the name column + // existed is reached by leg C of {@link findPlatformAdminGrantHolder} + // through the grant table alone. // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): @@ -749,23 +753,6 @@ export async function bootstrapPlatformAdmin( if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { return { adminPromoted: false, reason: 'admin_permission_set_missing' }; } - if (legacyAdminRowUnreadable) { - // [#22169] The set's row table did not answer, so the legacy leg cannot ask - // whether an unnamed grant already makes someone the administrator; no - // promotion is made on a question this pass could not ask. Said through - // the sink derivation, so it is heard with no logger injected. - reportThroughSink( - logger, - '[security] platform-admin promotion WITHHELD: the legacy admin_full_access sys_permission_set row ' + - 'could not be read, so this pass cannot ask whether a grant written before the name column existed ' + - 'already makes someone the platform administrator. Nothing was written; the next pass whose read ' + - 'answers decides.', - { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, - ); - return { adminPromoted: false, reason: 'admin_permission_set_unreadable' }; - } - const adminPsId: string | undefined = - typeof legacyAdminRows[0]?.id === 'string' && legacyAdminRows[0].id !== '' ? legacyAdminRows[0].id : undefined; // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // @@ -778,7 +765,7 @@ export async function bootstrapPlatformAdmin( unnamedHolder, adminGrantRowsExamined, truncated: adminGrantScanTruncated, - } = await findPlatformAdminGrantHolder(ql, adminPsId); + } = await findPlatformAdminGrantHolder(ql, true); // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and @@ -821,20 +808,20 @@ export async function bootstrapPlatformAdmin( }; } - // [ADR-0131 D4] An unscoped grant of the admin set row that names no set yet - // — on an upgraded deployment's first boot, its platform administrator's - // grant, which the name backfill reaches only at `kernel:bootstrapped`. It is - // read as an administrator who may already exist: promoting now would mint - // the second grant {@link findPlatformAdminGrantHolder} exists to refuse. It - // names no `adminUserId` — it restricts, it confers nothing — so the seed - // claim waits for a boot that reads the grant by its name. + // [ADR-0131 D4] An unscoped grant that names no set yet — on an upgraded + // deployment, possibly its platform administrator's grant, written before + // the name column existed. It is read as an administrator who may already + // exist: promoting now could mint the second grant + // {@link findPlatformAdminGrantHolder} exists to refuse. It names no + // `adminUserId` — it restricts, it confers nothing — so the seed claim waits + // for a boot that reads the grant by its name. if (!walled && unnamedHolder) { const withheld = - '[security] platform-admin promotion WITHHELD: an unscoped admin_full_access grant exists whose ' - + 'permission-set name (sys_user_permission_set.permission_set) is not written yet, so this pass ' - + 'cannot read it as the holder and will not mint a second one. The one-time grant-name backfill ' - + 'names it at kernel:bootstrapped; the next pass reads it by name. If it stays unnamed, the ' - + 'backfill\'s own report says why.'; + '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + + 'so this pass will not mint a second one. The organization-ownership migration\'s id-to-name rewrite ' + + '(ADR-0131 D10) names it; the next pass after that reads it by name.'; if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts index 0287a152e7b..ef1c2cafc76 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts @@ -5,20 +5,22 @@ * data-plane permission rows (ADR-0086 D3, #2747). * * ADR-0090 D5 promises: "uninstalling the package (removing its sets by - * `packageId`) revokes it everywhere at once. No ghost grants." The - * `package_id`/`managed_by` provenance columns (and the `package_id` index on - * `sys_permission_set`) exist precisely for this query; this module is the - * wiring that consumes them. Registered with the protocol's uninstall-cleanup - * seam (the mirror of the publish materializer) so `deletePackage` triggers it - * without the protocol layer learning `sys_permission_set`'s shape. + * `packageId`) revokes it everywhere at once. No ghost grants." Registered + * with the protocol's uninstall-cleanup seam so `deletePackage` (and + * install-local's uninstall door) trigger it without the protocol layer + * learning the grant table's shape. * - * Scope — provenance rules identical to the seeder's (ADR-0086 D4): - * - ONLY rows `managed_by: 'package'` with `package_id` = the uninstalled - * package are touched. Env-authored sets (`platform`/`user`/absent) and - * other packages' sets are never removed, even on a name collision. - * - Bindings referencing a removed set (`sys_position_permission_set`, - * `sys_user_permission_set`) are deleted first, so no dangling grant rows - * survive and re-resolution never sees a half-removed state. + * Scope: + * - [ADR-0131 D3/D4, C3 stage 8] The package's permission sets are the names + * it declared in the security catalog. The uninstalling door captures them + * before it withdraws the package (`declared` on the cleanup's arguments, + * `captureUninstallDeclared` in `@objectstack/metadata-protocol`), and every + * grant naming one of them is revoked. No catalog row is read: the row + * objects are retired, and the package withdrawal already took the + * definitions themselves out of the catalog. A grant written before the + * name column existed names nothing, so it cannot be reached here; the + * organization-ownership migration's id-to-name rewrite (ADR-0131 D10) + * owns it. * - `sys_audience_binding_suggestion` rows for the package are removed in * every status: with the sets gone, confirmed/dismissed history points at * nothing, and a fresh reinstall should re-prompt (D5 — admin confirms). @@ -82,11 +84,11 @@ async function deleteRows( } export interface PackagePermissionCleanupOutcome { - /** Package-owned sys_permission_set rows removed. */ + /** Always 0 since C3 stage 8: a permission set is a catalog definition, and no row is removed. */ sets: number; - /** sys_position_permission_set rows referencing those sets. */ + /** Always 0 since C3 stage 8: a position's sets are part of its definition, and no binding row exists. */ positionBindings: number; - /** sys_user_permission_set rows referencing those sets. */ + /** sys_user_permission_set rows naming those sets. */ userGrants: number; /** sys_audience_binding_suggestion rows for the package (any status). */ suggestions: number; @@ -123,41 +125,11 @@ export async function cleanupPackagePermissions( // consequence. const refusals = createSeedWriteRefusals(); - // Provenance-scoped: only the package door's own rows (ADR-0086 D4). - // `managed_by` is filtered in JS — a multi-column where on the readonly - // provenance columns doesn't match through the engine's query layer - // (verified empirically), while the single-column package_id filter does. - const sets = (await tryFind(ql, 'sys_permission_set', { package_id: packageId })) - .filter((r) => r?.managed_by === 'package'); - - // Bindings first — a set row must never outlive its grants in reverse. - for (const set of sets) { - if (!set?.id) continue; - out.positionBindings += await deleteRows( - ql, 'sys_position_permission_set', - await tryFind(ql, 'sys_position_permission_set', { permission_set_id: set.id }), - refusals, - ); - // [ADR-0131 D4] A grant names its set BY NAME, so the grants of this set - // are the rows naming it; a grant written before the name column existed - // is still reached through its legacy id, to revoke only. - const grants = new Map(); - const byName = typeof set.name === 'string' && set.name !== '' - ? await tryFind(ql, 'sys_user_permission_set', { permission_set: set.name }) - : []; - for (const g of [...byName, ...(await tryFind(ql, 'sys_user_permission_set', { permission_set_id: set.id }))]) { - if (g?.id && !grants.has(String(g.id))) grants.set(String(g.id), g); - } - out.userGrants += await deleteRows(ql, 'sys_user_permission_set', [...grants.values()], refusals); - } - out.sets = await deleteRows(ql, 'sys_permission_set', sets, refusals); - // [ADR-0131 D3/D4, ADR-0090 D5] The package's sets are served from the - // registry, so no row above stands for them: the grants naming them are - // reached by the names the uninstall door captured before it withdrew the - // package. A name belongs to one holder (the registry refuses a second at - // install), so every grant naming it was a grant of this package's set. - // Position bindings have no name column; a legacy one is reached above. + // registry, so no row stands for them: the grants naming them are reached by + // the names the uninstall door captured before it withdrew the package. A + // name belongs to one holder (the registry refuses a second at install), so + // every grant naming it was a grant of this package's set. for (const name of new Set(declared?.permission ?? [])) { if (typeof name !== 'string' || name === '') continue; out.userGrants += await deleteRows( @@ -201,20 +173,19 @@ export async function cleanupPackagePermissions( `package — those rows SURVIVE the uninstall, and nothing else reports it: the package door ` + `has already answered "success" and the count it reported is a count of the rows that were ` + `actually removed. ADR-0090 D5 promises that removing a package's sets "revokes it ` + - `everywhere at once. No ghost grants." Where a sys_position_permission_set or ` + - `sys_user_permission_set row survived, that promise is BROKEN and the grant is still live: ` + - `every principal holding it keeps the uninstalled package's permissions until the row goes. ` + - `Where a sys_permission_set row survived, the set remains resolvable by name and a reinstall ` + - `collides with it. What the store actually said is in the query engine's "Delete operation ` + + `everywhere at once. No ghost grants." Where a sys_user_permission_set row survived, that ` + + `promise is BROKEN and the grant is still live should the name ever resolve again: a reinstall, ` + + `or another holder of the same name, hands its permissions straight back to every principal ` + + `holding it. What the store actually said is in the query engine's "Delete operation ` + `failed" entries logged just before this one. Remedy: re-run the uninstall once the store ` + - `accepts writes again — this sweep is idempotent and re-selects by package_id, so a second ` + + `accepts writes again — this sweep is idempotent and re-selects by name, so a second ` + `pass removes whatever the first could not. Until then, treat the package as still ` + `installed for authorization purposes.`, { packageId, refused: out.refused, refusals: refusals.report() }, ); } - if (out.sets + out.positionBindings + out.userGrants + out.suggestions > 0) { - logger?.info?.('[security] package permission rows revoked on uninstall — removed by package_id, so no grant outlives the package (ADR-0090 D5)', { + if (out.userGrants + out.suggestions > 0) { + logger?.info?.('[security] package permission rows revoked on uninstall — every grant naming a set the package declared, so no grant outlives the package (ADR-0090 D5)', { packageId, ...out, }); } diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 2a5cac45496..78bf8fba4af 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -30,9 +30,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/manifest.ts b/packages/plugins/plugin-security/src/manifest.ts index 140cb1091e0..76c6d90ed37 100644 --- a/packages/plugins/plugin-security/src/manifest.ts +++ b/packages/plugins/plugin-security/src/manifest.ts @@ -9,11 +9,7 @@ */ import { - SysPermissionSet, - SysPosition, - SysCapability, SysUserPermissionSet, - SysPositionPermissionSet, SysUserPosition, SysAudienceBindingSuggestion, defaultPermissionSets, @@ -22,13 +18,16 @@ import { export const SECURITY_PLUGIN_ID = 'com.objectstack.plugin-security'; export const SECURITY_PLUGIN_VERSION = '1.0.0'; -/** Security objects owned by plugin-security. */ +/** + * Security objects owned by plugin-security. + * + * [ADR-0131 D3] The catalog itself — positions, permission sets, capabilities + * and the position → set binding — is no object: it lives in the security + * catalog (the environment registry). What stays here are the assignments and + * grants that name catalog items, and the audience-binding suggestions. + */ export const securityObjects = [ - SysPosition, - SysCapability, - SysPermissionSet, SysUserPermissionSet, - SysPositionPermissionSet, SysUserPosition, SysAudienceBindingSuggestion, ]; diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index a84f32fdc46..26a158db074 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -472,9 +472,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // accept, remove-member, transfer, …) rather than raw CRUD. ...denyWritesOnManagedObjects(), // RBAC tables — read-only to prevent privilege escalation. - sys_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - sys_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - sys_position_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, // [ADR-0131 D7] The compliance ledger, WITHOUT the superuser bits: the diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts index 054c188cecc..56df94508eb 100644 --- a/packages/plugins/plugin-security/src/objects/index.ts +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -9,11 +9,7 @@ * live in `@objectstack/plugin-sharing`. */ -export { SysPosition } from './sys-position.object.js'; -export { SysCapability } from './sys-capability.object.js'; -export { SysPermissionSet } from './sys-permission-set.object.js'; export { SysUserPermissionSet } from './sys-user-permission-set.object.js'; -export { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; export { SysUserPosition } from './sys-user-position.object.js'; export { SysAudienceBindingSuggestion } from './sys-audience-binding-suggestion.object.js'; export { defaultPermissionSets } from './default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts deleted file mode 100644 index 47d4ccf9c6f..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * sys_capability — Capability definition registry (ADR-0066 D1). - * - * Promotes authorization *capabilities* from bare strings to first-class - * records. A capability is layer 1 of the ADR-0066 three-way separation - * (capability / assignment / requirement): "what can be done" - * (`manage_users`, `manage_platform_settings`, `export_data`, …). The - * platform/packages DEFINE capabilities; admins EXTEND them in Setup. - * - * `PermissionSet.systemPermissions[]` (assignment) and a resource's - * `requiredPermissions[]` (requirement) reference a capability **by name** — - * so this table is the catalog/definition, NOT the grant. Existing string - * capabilities are back-compat seeded as rows with the same `name`, so all - * current references keep resolving (no migration). - * - * Named `sys_capability` (not `sys_permission` as the ADR loosely floats) to - * avoid collision with `sys_permission_set` and to match the "capability" - * vocabulary used throughout ADR-0066. - * - * @namespace sys - */ -export const SysCapability = ObjectSchema.create({ - name: 'sys_capability', - label: 'Capability', - pluralLabel: 'Capabilities', - icon: 'badge-check', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants/admins may add custom rows - // (created via UI / API) but the schema itself is locked. - protection: { - lock: 'no-overlay', - reason: 'Capability registry schema is platform-defined — see ADR-0066 / ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - // [#8535] `active` is deliberately NOT highlighted. It is a catalogue flag with - // no authorization effect (see the field's own comment), and record-header - // prominence next to `scope`/`managed_by` is itself a claim that it belongs to - // the authorization posture. Demoting it is half the fix; rewording the dialog - // is the other half — a truthful dialog under a field still presented as - // first-class tells the admin the flag matters after all. - highlightFields: ['label', 'name', 'scope', 'managed_by'], - - actions: [ - { - name: 'activate_capability', - label: 'Activate', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_capability/{id}', - bodyExtra: { active: true }, - successMessage: 'Capability activated', - refreshAfter: true, - }, - { - name: 'deactivate_capability', - label: 'Deactivate', - icon: 'circle-off', - // [#8535] Was `danger`. A danger variant is a claim in itself — it tells the - // admin the click has consequences proportionate to a security control. This - // one writes a catalogue column and nothing else. - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_capability/{id}', - bodyExtra: { active: false }, - // [#8535] This used to read: "Deactivate this capability? Grants and resource - // requirements that reference it stop resolving until re-activated." No code - // path has ever enforced that. `PermissionEvaluator.getSystemPermissions()` - // unions `permissionSets[].systemPermissions` — plain strings — and - // `requiredPermissions` is compared against that string set; neither loads a - // `sys_capability` row. The two production readers of the table are both - // seeders (`bootstrap-system-capabilities.ts`, - // `bootstrap-declared-capabilities.ts`), which WRITE `active: true` on insert - // and never read it back. - // - // The direction of that falsehood was the dangerous one: an admin withdrawing - // a capability was told in a confirmation dialog that the withdrawal took - // effect, and it silently did not — the escalation is what they believed they - // had prevented. ADR-0049 enforce-or-remove; the maintainer ruled (2026-08-13) - // that enforcement is NOT the answer here — putting the registry on the - // authorization hot path is an architectural change (caching, fail-closed - // semantics, org-authored rows influencing platform capabilities) that needs - // its own designed card if capability lifecycle management ever earns real - // pull. So the claim is withdrawn instead, and the dialog now states the - // non-effect explicitly rather than merely omitting the promise: an admin who - // remembers the old wording has to be told it was wrong, not left to infer it. - confirmText: - 'Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.', - successMessage: 'Capability deactivated', - refreshAfter: true, - }, - ], - - // [#8535] `active` was a column in ALL THREE views. It is now shown only in - // `all_capabilities` — the full-catalogue view, where a catalogue attribute - // genuinely belongs and stays observable and filterable for the admin who sets - // it. The two SCOPED views (`platform`, `org`) are the ones an admin works in - // to reason about who can do what, and a column sitting next to `managed_by` - // there reads as part of the authorization posture. Dropping it from all three - // was rejected as the opposite error: a flag the product lets you set but never - // lets you see is its own kind of dishonest surface. - listViews: { - platform: { - type: 'grid', - name: 'platform', - label: 'Platform', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by'], - filter: [{ field: 'scope', operator: 'equals', value: 'platform' }], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - org: { - type: 'grid', - name: 'org', - label: 'Organization', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by'], - filter: [{ field: 'scope', operator: 'equals', value: 'org' }], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_capabilities: { - type: 'grid', - name: 'all_capabilities', - label: 'All', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'scope', 'managed_by', 'active'], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - description: 'Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access).', - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Classification ─────────────────────────────────────────── - scope: Field.select({ - label: 'Scope', - required: true, - defaultValue: 'platform', - description: 'platform = a platform-wide power; org = scoped to an organization.', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'org', label: 'Organization' }, - ], - group: 'Classification', - }), - - managed_by: Field.select({ - label: 'Managed By', - required: true, - defaultValue: 'admin', - description: 'platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'Classification', - }), - - // [ADR-0066 D1 / ADR-0086 D3] Owning package for a capability DECLARED by a - // package via `defineCapability` (absent = platform-curated or admin-created). - // Populated by bootstrapDeclaredCapabilities; makes package uninstall/upgrade - // of a capability well-defined and gives the derived-from-systemPermissions - // back-door an explicit, attributable replacement. - package_id: Field.text({ - label: 'Owning Package', - required: false, - readonly: true, - maxLength: 255, - description: 'Package that ships this capability (absent = platform-curated or admin-created).', - group: 'Classification', - }), - - // ── Status ─────────────────────────────────────────────────── - // [#8535] Catalogue flag, NOT an enforcement switch. It carried no - // `description` at all, which is how the `deactivate_capability` dialog - // became the only place its meaning was stated — and that statement was - // false. The semantics are declared here now, negative half included, so the - // field documents its own inertness at the point an author or an admin meets - // it. If capability lifecycle ever becomes enforceable it arrives as a - // designed feature with its own card (maintainer ruling, 2026-08-13), and - // this comment plus the description are what must change with it. - active: Field.boolean({ - label: 'Active', - defaultValue: true, - description: - 'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.', - group: 'Status', - }), - - // ── System ─────────────────────────────────────────────────── - id: Field.text({ - label: 'Capability ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [ADR-0120 D1, #8323] `'organization'`, NOT bare `true`. - // - // Admins EXTEND this registry in Setup (`managed_by: 'admin'`, `scope: - // 'org'`), so a capability name is one holder per organization, not one - // across the installation. Bare `true` on a DECLARED index is the - // positional spelling of `'global'` (listed columns verbatim), which made - // `name` an installation-wide key: an organization could probe whether ANY - // other organization — or the platform seed — already held a name, by - // reading 409-vs-201 on a row it has no permission to see. - // - // Platform-seeded rows carry no organization, and the organization key part - // is NULL-safe (`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so - // they remain unique among themselves. - // - // [#8470] This comment used to end "…and `bootstrapSystemCapabilities`' - // upsert-by-name is unaffected". That was WRONG, and the correction belongs - // next to the index that unmasked it. Widening the key to per-organization - // made the two-row state (platform row + an org's row, same `name`) - // REACHABLE, and the seeder's `find({ name }, limit: 1)` had no way to say - // which of the two it meant. The index is not the defect and must not be - // narrowed back — that would reinstate #8323's cross-tenant existence - // oracle. The seeder now scopes its curated lookup to `managed_by: - // 'platform'` + `organization_id: null`, i.e. exactly the bucket this key - // part keeps a singleton. - { fields: ['name'], unique: 'organization' }, - { fields: ['scope'] }, - { fields: ['active'] }, - // [ADR-0086 D3] uninstall/upgrade query: "this package's own capabilities". - { fields: ['package_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts deleted file mode 100644 index b9ac9972ca5..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ /dev/null @@ -1,365 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { P } from '@objectstack/spec'; - -/** - * sys_permission_set — System Permission Set Object - * - * Named groupings of fine-grained permissions. - * Permission sets can be bound to positions or granted directly to users - * for granular access control. - * - * @namespace sys - */ -export const SysPermissionSet = ObjectSchema.create({ - name: 'sys_permission_set', - label: 'Permission Set', - pluralLabel: 'Permission Sets', - icon: 'lock', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows - // (created via UI / API) but the schema itself is locked. - protection: { - lock: 'no-overlay', - reason: 'RBAC schema is platform-defined — see ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Named permission groupings for fine-grained access control', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - highlightFields: ['label', 'name', 'active'], - - // Custom actions — permission sets are templates bound to positions or - // users (via sys_position_permission_set / sys_user_permission_set). The - // sysadmin operations that don't live on the parent-detail tabs are - // lifecycle (activate/deactivate without losing assignments) and - // clone (build a new permset by tweaking an existing one). Both hit - // the generic data CRUD endpoint — managedBy: 'config' permits it. - actions: [ - { - name: 'activate_permission_set', - label: 'Activate', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_permission_set/{id}', - bodyExtra: { active: true }, - successMessage: 'Permission set activated', - refreshAfter: true, - }, - { - name: 'deactivate_permission_set', - label: 'Deactivate', - icon: 'circle-off', - variant: 'danger', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_permission_set/{id}', - bodyExtra: { active: false }, - confirmText: 'Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.', - successMessage: 'Permission set deactivated', - refreshAfter: true, - }, - { - // [commit 5cb62d88b] ⭐ THIS PARAMS LIST *IS* THE PAYLOAD. Every definition facet a - // clone should carry has to be named below: the action POSTs its param - // VALUES to the generic data door, so a column that is not a param is - // simply absent from the body — `permissionSetBodyFromRow()` then reads - // it through `parseMaybeJson(undefined, …)` and fills the EMPTY default - // (`[]` / `{}`). An omission here is therefore not a missing input, it is - // a silent grant loss: the clone is created, the success toast fires, and - // the difference is discoverable only by diffing the two records. - // - // That was live until commit 5cb62d88b — three of the six facets - // (`system_permissions`, `row_level_security`, `tab_permissions`) were - // never listed, so cloning a set carrying system permissions or RLS - // produced a clone with none of them. Fail-closed, and therefore quiet. - // It became urgent the moment the save door started REFUSING in-place - // edits of package-declared sets and naming THIS action as the remedy - // (`packaged-permission-set-lock.ts`, maintainer ruling 2026-08-24). - // - // `packaged-permission-set-lock.test.ts` pin 6 READS this list rather - // than restating it, so adding a facet column to the object surfaces - // here rather than silently shipping a clone that drops it. - name: 'clone_permission_set', - label: 'Clone', - icon: 'copy', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'POST', - target: '/api/v1/data/sys_permission_set', - bodyExtra: { active: true }, - // The explanatory line under the clone dialog's title. Its second - // sentence is the RULED exclusion (2026-08-24): `admin_scope` — an - // ADR-0090 D12 delegated-admin authority — is deliberately NOT copied, - // because putting one on a brand-new set on the admin's behalf is a - // privilege decision, not a field copy. It is stated HERE, where the - // admin is standing when the clone happens, because an UNEXPLAINED - // omission is the same silent drop commit 5cb62d88b fixed, merely ruled. - description: - 'Copies this set\'s permissions into a new organization-owned set you can edit. ' - + 'Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.', - successMessage: 'Permission set cloned', - refreshAfter: true, - params: [ - { name: 'label', label: 'New Display Name', type: 'text', required: true }, - // [#8554] The clone dialog is the exact moment an admin types a NEW - // name, so the scope has to be right here — a bare "Unique" tells the - // author the name is taken installation-wide when it is not. - { name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' }, - // `description` is prose, not a permission facet: it stays editable - // (renaming a clone's description is legitimate), while the five JSON - // facets below are declared `carryOver` — commit 0e4e51b0a's - // non-editable carry-over. Copied verbatim, shown read-only, never - // offered as a prefilled JSON textarea an admin could hand-mangle into - // a clone that grants MORE than its base. - { field: 'description', defaultFromRow: true }, - { field: 'object_permissions', defaultFromRow: true, carryOver: true }, - { field: 'field_permissions', defaultFromRow: true, carryOver: true }, - // [commit 5cb62d88b] The three facets the clone silently dropped. Same - // JSON-string shape as the two above: `permissionSetRowFields()` - // writes all five with `JSON.stringify`, and the data door parses all - // five back — the accept surface did not move, only what is SENT. - { field: 'system_permissions', defaultFromRow: true, carryOver: true }, - { field: 'row_level_security', defaultFromRow: true, carryOver: true }, - { field: 'tab_permissions', defaultFromRow: true, carryOver: true }, - // ⛔ `admin_scope` is deliberately absent — see `description` above. - ], - }, - ], - - listViews: { - active: { - type: 'grid', - name: 'active', - label: 'Active', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'description', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - inactive: { - type: 'grid', - name: 'inactive', - label: 'Inactive', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: false }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_permsets: { - type: 'grid', - name: 'all_permsets', - label: 'All', - data: { provider: 'object', object: 'sys_permission_set' }, - // [ADR-0094] Surface provenance + the customized flag so admins can tell - // a packaged set (and whether they've overlaid it) from an env set. - columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - // [#8554] "unique per organization", not bare "unique". The bare wording - // described the installation-wide index this card removed. - description: - 'Machine name for the permission set, unique per organization. This is the set’s ' + - 'metadata identity (ADR-0094) and cannot be changed after creation — the data door ' + - 'rejects a rename; clone the set to a new name instead.', - // [ADR-0094] The name is the metadata key the record projects from, so it - // is immutable once the record exists. `record.id` is server-assigned: - // absent on the create form (editable), present on edit (locked). The - // data-door write-through independently rejects a rename (400), so this - // is the matching UI affordance rather than the only guard. - readonlyWhen: P`record.id != null && record.id != ''`, - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Permissions ────────────────────────────────────────────── - object_permissions: Field.textarea({ - label: 'Object Permissions', - required: false, - description: 'JSON-serialized object-level CRUD permissions', - group: 'Permissions', - }), - - field_permissions: Field.textarea({ - label: 'Field Permissions', - required: false, - description: 'JSON-serialized field-level read/write permissions', - group: 'Permissions', - }), - - system_permissions: Field.textarea({ - label: 'System Permissions', - required: false, - description: 'JSON-serialized array of system capability names (e.g. ["setup.access","studio.access","manage_users"])', - group: 'Permissions', - }), - - row_level_security: Field.textarea({ - label: 'Row-Level Security', - required: false, - description: 'JSON-serialized array of row-level security policies (USING/CHECK clauses)', - group: 'Permissions', - }), - - tab_permissions: Field.textarea({ - label: 'Tab Permissions', - required: false, - description: 'JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)', - group: 'Permissions', - }), - - admin_scope: Field.textarea({ - label: 'Delegated Admin Scope', - required: false, - description: - '[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, ' + - 'manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the ' + - 'user a SCOPED administrator within the declared business-unit subtree; enforced by the ' + - 'delegated-admin write gate.', - group: 'Permissions', - }), - - // ── Status ─────────────────────────────────────────────────── - active: Field.boolean({ - label: 'Active', - defaultValue: true, - group: 'Status', - }), - - // ── Provenance (ADR-0086 D3) ───────────────────────────────── - package_id: Field.text({ - label: 'Owning Package', - required: false, - readonly: true, - maxLength: 255, - description: - 'Package that ships this permission set (absent = environment-authored). ' + - 'Set on rows an earlier release seeded for a package\'s set; no current writer sets it.', - group: 'Provenance', - }), - - // [A4 #2920] Unified provenance tri-state — platform / package / admin — - // shared verbatim with sys_capability and sys_position so an admin reads one - // vocabulary across all three RBAC catalogs. Converted from free `text` to a - // constrained `select` so the value is a labelled, i18n-able badge. - // Back-compat: legacy env rows carry 'user' (== admin). No runtime path - // branches on 'user' (every read keys on 'package' / 'platform', both - // unchanged), so those rows stay semantically correct. The boot normalizer - // that rewrote them to 'admin' (`normalizeManagedByVocab`) retired with - // ADR-0131 C3 stage 6b-2, so such rows keep 'user'. - managed_by: Field.select({ - label: 'Managed By', - required: false, - readonly: true, - defaultValue: 'admin', - description: - "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + - "permission sets and positions all share: 'platform' = shipped by the " + - "platform; 'package' = a row an earlier release seeded for a package's set " + - "(read-mostly for admins); 'admin' = created/owned in this environment by an administrator " + - "(live-edited). Legacy rows may carry 'user' (== admin).", - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'Provenance', - }), - - // [ADR-0094] TRUE when this package-owned set is currently shadowed by an - // environment overlay (customized in this env, away from its shipped - // baseline). Projector-maintained; deleting the overlay (reset) clears it. - // Only meaningful on `managed_by:'package'` rows. - customized: Field.boolean({ - label: 'Customized', - defaultValue: false, - readonly: true, - description: - 'This packaged permission set has an environment customization overlay (ADR-0094). ' + - 'Reset it (delete through the data door) to return to the shipped baseline.', - group: 'Provenance', - }), - - // ── System ─────────────────────────────────────────────────── - id: Field.text({ - label: 'Permission Set ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [#8554] Scope spelled EXPLICITLY (ADR-0120 D1). On a DECLARED index bare - // `unique: true` is the positional spelling of `'global'` — the listed - // columns verbatim — so this was an installation-wide key on a tenant-scoped - // object. Measured live before the fix, two organizations and the same name: - // org_jia 201 / org_yi 409 UNIQUE_VIOLATION / org_yi unused name 201 / - // org_yi's own GET on the colliding name 0 rows. A per-value refusal on a - // row the caller cannot read is the #8323 cross-tenant existence oracle, and - // two tenants could not both name a set `sales_readonly`. - { fields: ['name'], unique: 'organization' }, - { fields: ['active'] }, - // ADR-0086 D3 — uninstall/upgrade query: "this package's own sets". - { fields: ['package_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts deleted file mode 100644 index 67270085094..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { F } from '@objectstack/spec'; - -/** - * sys_position_permission_set — Position ↔ PermissionSet binding. - * - * Allows administrators to compose a `sys_position` from one or more - * `sys_permission_set` rows. At request time, the runtime resolver - * (`resolveExecutionContext`) collects every permission set bound to - * the user's positions via this table and injects their names into - * `ExecutionContext.permissions[]` for downstream RBAC evaluation. - * - * Writes are guarded twice (both gates unconditional, ADR-0090): - * the audience-anchor gate (D5/D9 — no high-privilege set on - * `everyone`/`guest`) and the delegated-admin gate (D12 — non-tenant-admin - * writers need an adminScope whose allowlist covers the bound set). - * - * @namespace sys - */ -export const SysPositionPermissionSet = ObjectSchema.create({ - name: 'sys_position_permission_set', - label: 'Position Permission Set', - pluralLabel: 'Position Permission Sets', - icon: 'shield-plus', - isSystem: true, - // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: - // `suggested-audience-bindings.ts` inserts a binding with `context: callerCtx` - // (deliberately not isSystem). The bucket default is full CRUD, so no - // `userActions` block is needed — the DelegatedAdminGate is the authz. - managedBy: 'system-data', - description: 'Binds a permission set to a position.', - // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the - // registry's designate-only pass stamped `nameField: 'id'` (the first - // title-eligible field), so a renderer honouring ADR-0079's order (an - // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-security-assignment-display-title.test.ts` holds the two to the same text. - displayNameField: 'display_title', - nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{position_id} → {permission_set_id}', - highlightFields: ['position_id', 'permission_set_id'], - - fields: { - id: Field.text({ - label: 'Binding ID', - required: true, - readonly: true, - description: 'UUID of the position-permission-set binding.', - }), - - // [ADR-0079] The record title (`nameField` above). A formula is computed on - // read and has no stored column. It reads only this row's own columns — - // the two foreign keys, never a field of the looked-up records — and - // neither is hidden, permission-guarded or masked on this object, so the - // title carries nothing the declared read path withholds. Both are - // required, so the expression needs no null guard. - display_title: Field.formula({ - label: 'Title', - returnType: 'text', - expression: F`record.position_id + ' → ' + record.permission_set_id`, - description: 'Record title: the position and the permission set bound to it (computed on read)', - }), - - position_id: Field.lookup('sys_position', { - label: 'Position', - required: true, - description: 'Foreign key to sys_position.', - }), - - permission_set_id: Field.lookup('sys_permission_set', { - label: 'Permission Set', - required: true, - description: 'Foreign key to sys_permission_set.', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - }, - - indexes: [ - { fields: ['position_id', 'permission_set_id'], unique: 'global' }, - { fields: ['position_id'] }, - { fields: ['permission_set_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts deleted file mode 100644 index a81b163ba8a..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ /dev/null @@ -1,412 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js'; - -/** - * [#15972] The CEL predicate behind the `reserved_identity_name` rule below, - * assembled here so the object literal reads as one line and the two halves of - * the condition can each carry their own note. - * - * ⚠️ `&&` order is load-bearing. CEL absorbs an error on one side of `&&` when - * the other side is `false`, so putting the reserved-name test FIRST means the - * provenance test is only ever evaluated for a write that already spells a - * reserved name. Every other name short-circuits out before `managed_by` is - * read at all — which is what keeps the negative control (every other name - * still writes) independent of whatever `managed_by` happens to hold. - */ -const RESERVED_IDENTITY_NAME_CONDITION = - `record.name in ${reservedIdentityNamesCelList()}` - // The framework's OWN catalog rows are the exception, and the only one: - // `bootstrapBuiltinRoles` seeds exactly these names, per organization, - // stamped `managed_by: 'platform'` under an `isSystem` context. `system` is - // the legacy pre-A4 spelling of the same provenance — kept in lockstep with - // `SYSTEM_ROW_PROVENANCE` (security-plugin.ts), which maps BOTH to "the - // platform" — so a legacy row is not frozen by an invariant that lands after - // it. ⛔ `package` / `config` are deliberately NOT exempt: a stack that - // declares a position named `org_admin` is repurposing a built-in identity, - // which is the same defect arriving through the supply chain, and - // `bootstrapDeclaredPositions` stamps no provenance at all (the row defaults - // to `admin`), so it is refused here like any other tenant-grade write. - // - // A tenant cannot reach the exemption by CLAIMING it: `managed_by` is - // `readonly`, and the admin-door provenance gate refuses a payload that - // spells `platform`/`package` outright. - + ` && !(record.managed_by in ['platform', 'system'])`; - -/** - * sys_position — Position definitions (ADR-0090 D3). - * - * A position (岗位) is the flat capability-DISTRIBUTION group: users hold - * positions (`sys_user_position`), positions bind permission sets - * (`sys_position_permission_set`). Positions carry no capability of their - * own and no hierarchy — the visibility tree lives on `sys_business_unit`. - * - * @namespace sys - */ -export const SysPosition = ObjectSchema.create({ - name: 'sys_position', - label: 'Position', - pluralLabel: 'Positions', - icon: 'shield', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows - // (created via UI / API) but the schema itself is locked. - // ADR-0068 D3: position-DEFINITION authority follows the isolation boundary. - // Framework-reserved built-in identities (platform_admin / org_*) and the - // ADR-0090 D9 audience anchors (everyone / guest) are seeded with - // `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') and - // MUST NOT be repurposed by a tenant; ad-hoc position definitions in a shared - // cross-tenant kernel namespace are forbidden. - protection: { - lock: 'no-overlay', - reason: 'RBAC schema is platform-defined — see ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Position definitions for capability distribution (ADR-0090)', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - highlightFields: ['label', 'name', 'managed_by', 'active', 'is_default'], - - // Custom actions — positions drive capability distribution and are edited - // rarely but require the four high-frequency sysadmin affordances every IdP - // (Salesforce, ServiceNow, Okta) ships: activate/deactivate (lifecycle - // without losing assignments), mark default (auto-assign to new users), - // and clone (template for new positions). All operations hit the generic - // data CRUD endpoint exposed by `apiEnabled` — no custom server route - // required because `managedBy: 'config'` allows direct mutation. - actions: [ - { - name: 'activate_position', - label: 'Activate Position', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { active: true }, - successMessage: 'Position activated', - refreshAfter: true, - }, - { - name: 'deactivate_position', - label: 'Deactivate Position', - icon: 'circle-off', - variant: 'danger', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { active: false }, - confirmText: 'Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.', - successMessage: 'Position deactivated', - refreshAfter: true, - }, - { - name: 'set_default_position', - label: 'Set as Default', - icon: 'star', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { is_default: true }, - confirmText: 'Make this the default position for new users? Existing users are unaffected.', - successMessage: 'Default position updated', - refreshAfter: true, - }, - { - // Clone — POST a new sys_position row pre-filled from the source. The - // dialog asks only for the new API name / label so the operator - // can rename atomically; description is carried over via defaultFromRow. - // (Until #9885 this also copied the retired `permissions` column — a - // copy of a value nothing ever wrote; see the retirement note at the - // field group below. A clone does NOT copy permission-set bindings: - // `sys_position_permission_set` rows are runtime state, bound in Setup.) - name: 'clone_position', - label: 'Clone Position', - icon: 'copy', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'POST', - target: '/api/v1/data/sys_position', - bodyExtra: { is_default: false, active: true }, - successMessage: 'Position cloned', - refreshAfter: true, - params: [ - { name: 'label', label: 'New Display Name', type: 'text', required: true }, - // [#8468] The clone dialog is where an admin types a NEW position name, - // so it is the one place the scope has to be right at the moment of - // authoring: the name must be free within THIS organization, not across - // the installation. - { name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' }, - { field: 'description', defaultFromRow: true }, - ], - }, - ], - - listViews: { - active: { - type: 'grid', - name: 'active', - label: 'Active', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'is_default', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - default_positions: { - type: 'grid', - name: 'default_positions', - label: 'Default', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'description', 'active'], - filter: [{ field: 'is_default', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - custom: { - type: 'grid', - name: 'custom', - label: 'Custom', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'active', 'updated_at'], - filter: [{ field: 'is_default', operator: 'equals', value: false }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_positions: { - type: 'grid', - name: 'all_positions', - label: 'All', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'active', 'is_default', 'updated_at'], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - // [#8468] "unique per organization", not "unique". The bare wording - // asserted the installation-wide reading the declared index accidentally - // materialized, so the accident reached admins as contract. - description: - 'Machine name for the position, unique per organization ' + - '(e.g. sales_manager, hr_specialist)', - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Configuration ──────────────────────────────────────────── - // RETIRED (#9885, ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-20): `permissions` — a "JSON-serialized array of permission - // strings" textarea — was declared here while NO producer ever wrote it - // and NO runtime path ever read it (the object-scoped census read all - // sys_position-naming files; the bootstrap writers set label / - // description / managed_by / active / is_default only). Grants reach a - // position ONLY through permission-set bindings — - // `sys_position_permission_set` rows, resolved by position `name` in - // resolve-authz-context — so the declared column told an author (human - // or AI) that direct position-level permission strings are a platform - // capability; they are not. Re-declaring the column without a runtime - // reader in the same change is the exact defect the ruling removed. - // An undeclared field in a write is refused loudly by the engine's - // schema preflight (400 INVALID_FIELD); existing physical columns are - // untouched (ADR-0045 schema sync is additive). - - // ── Status ─────────────────────────────────────────────────── - active: Field.boolean({ - label: 'Active', - defaultValue: true, - group: 'Status', - }), - - is_default: Field.boolean({ - label: 'Default Position', - defaultValue: false, - description: 'Automatically assigned to new users', - group: 'Status', - }), - - // [ADR-0091 D3] Opt-in to self-service delegation of duty (职务代理). When - // true, a holder may assign this position to a delegate — time-boxed, - // reasoned, dual-audited — without being a delegated administrator. Admin- - // ish positions (those distributing an adminScope set) MUST NOT set this; - // the D7 lint and the D12 gate reject that combination. - delegatable: Field.boolean({ - label: 'Delegatable', - defaultValue: false, - description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.', - group: 'Status', - }), - - // ── System ─────────────────────────────────────────────────── - // [A4 #2920] Unified provenance tri-state — platform / package / admin — - // shared verbatim with sys_capability and sys_permission_set. Converted from - // free `text` to a constrained `select`. `platform` = a framework-reserved - // built-in identity position (seeded by bootstrapBuiltinRoles, read-only); - // `package` = stack/package-declared; `admin` = tenant-created in Setup. - // Back-compat: legacy rows may carry system (== platform) / config (== package) - // / user (== admin); the boot normalizer heals them to the canonical vocab, and - // the system-row write gate (SYSTEM_ROW_PROVENANCE, security-plugin.ts) guards - // both vocabularies — keep it in lockstep with any change here (#2926 ①). - // Built-in (`platform`) rows self-heal on the next bootstrap upsert. - managed_by: Field.select({ - label: 'Managed By', - readonly: true, - defaultValue: 'admin', - description: - 'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' + - 'permission sets and positions all share: platform = framework built-in ' + - '(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' + - 'may carry system (== platform) / config (== package) / user (== admin).', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'System', - }), - - id: Field.text({ - label: 'Position ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [ADR-0120 D1, #8468] `'organization'`, NOT bare `true`. - // - // Positions are admin-authored (`managed_by: 'admin'` by default, created - // in Setup and by the `clone_position` action above), and the object takes - // no `tenancy` opt-out, so `organization_id` is injected. A position name - // is therefore one holder per ORGANIZATION, not one across the whole - // installation. - // - // Bare `true` on a DECLARED index is the positional spelling of `'global'` - // — the listed columns VERBATIM (see `normalizeDeclaredIndex`, which - // prepends the organization key part only for the explicit spelling). It is - // NOT the field-level `true`, which has meant per-organization since #3696; - // that divergence is "the #4986 trap" named in - // `packages/lint/src/data-model-rules.ts`, and this declaration was the - // third instance of it in the platform's own objects. - // - // Measured on a real engine before the fix (two organizations, same name): - // org_jia POST name=probe_pos_xtenant → 201 - // org_yi POST the SAME name → 409 UNIQUE_VIOLATION - // org_yi POST an unused name → 201 - // org_yi GET that name → total 0 - // A per-value refusal on a row the caller cannot read is a cross-tenant - // existence oracle, plus a dead end: the second organization could never - // name a position `sales_manager` if any other organization already had. - // - // The maintainer ruling of 2026-08-13 settles the family: an admin-authored - // name on a tenant-scoped object is scoped per organization. The hierarchy - // counter-argument does not apply here in any case — positions are - // deliberately FLAT (ADR-0090 D3, finalizing ADR-0057 D5); there is no - // `parent_id` on this object and no position tree to share a namespace. - // - // Platform-seeded rows (`bootstrapBuiltinRoles`, `managed_by: 'platform'`) - // carry no organization, and the organization key part is NULL-safe - // (`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so they remain - // unique among THEMSELVES and the bootstrap upsert-by-name is unaffected. - { fields: ['name'], unique: 'organization' }, - { fields: ['active'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, - - // ── [#15972] Reserved built-in identity names ──────────────────── - // - // The prose at the head of this file has said since ADR-0068 that the - // framework-reserved built-in identities "MUST NOT be repurposed by a - // tenant". ⚠️ That was a COMMENT, not a gate — the names stayed writable, - // and every defence against a tenant-minted `platform_admin` was a READER - // choosing to consult the capability rung rather than the name. This array - // is the enforcement of the sentence that was already here. - // - // Why the object's `validations`, and not a gate in the security plugin: - // the plugin's name gates (the curated-capability one, the provenance one) - // guard the ADMIN DOOR. A rule declared here is evaluated by the ENGINE - // (`objectql`'s rule validator) on insert, by-id update and multi-row - // update, so the data API, the seeders and metadata import are all covered - // by ONE refusal carrying ONE code (`VALIDATION_FAILED`) — which is what - // stops the two doors answering the same condition with two vocabularies. - // - // ⚠️ An INVARIANT, not a transition gate (see `ScriptValidationSchema`): the - // predicate is re-evaluated against the merged record on every write, so a - // row that ALREADY spells a reserved name is refused on any edit until it is - // renamed — frozen, not bricked, and deliberately so on a security invariant. - // Nothing here rewrites such a row; the read-only census - // (`scripts/measure-reserved-identity-name-census.mjs`) is what reports them. - validations: [ - { - // `cross_field`, not `script`: the condition genuinely reads two columns - // (the name AND its provenance), and only this variant carries `fields`, - // which is what attaches the violation to `name` instead of `_record` so - // a form can point at the offending input. - type: 'cross_field', - name: 'reserved_identity_name', - label: 'Reserved built-in identity name', - description: - 'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own ' - + 'catalog seed may spell one; a tenant- or package-authored position must choose another name.', - fields: ['name', 'managed_by'], - condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION }, - severity: 'error', - message: reservedIdentityNameMessage('name'), - }, - ], -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index f39648826c5..1dbc575dd51 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -76,17 +76,23 @@ export const SysUserPermissionSet = ObjectSchema.create({ // [ADR-0131 D10] The grant's former id reference to a catalog row. No // writer fills it and no reader confers anything through it: a grant names // its set by `permission_set` below. Optional, and kept only until D10 - // drops the column with its data (C7b); the one-time name backfill reads - // it on grants written before the name column existed. - permission_set_id: Field.lookup('sys_permission_set', { + // drops the column with its data (C7b), whose verified rewrite reads it on + // grants written before the name column existed. + // + // [C3 stage 8] Plain text, no longer a lookup: the catalog row object it + // pointed at is retired. Same name, same stored ids, same two indexes + // below, and the same `varchar(255)` a lookup is stored in, so the column + // itself does not change. + permission_set_id: Field.text({ label: 'Permission Set (legacy id)', required: false, - // [#15204 B2] Off the forms: it grants nothing, so an editable lookup + maxLength: 255, + // [#15204 B2] Off the forms: it grants nothing, so an editable id // beside `permission_set` only invites a meaningless value. The column // stays until its data is dropped (ADR-0131 D10). hidden: true, description: - '[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. ' + + '[ADR-0131 D10] Legacy id of a retired permission-set catalog row, dropped with its data by a later stage. ' + 'Not written and not read to grant anything: the grant names its set by permission_set.', }), diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 7c399f10d5b..b433e436bbf 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -340,8 +340,8 @@ export class PackagedPermissionSetLockedError extends Error { + (operation === 'insert' ? `Choose a different name for your set, or clone '${name}' (the "Clone" action on the permission ` + `set) and edit the clone.` - : `Clone it instead (the "Clone" action on the permission set, or POST /api/v1/data/sys_permission_set ` - + `with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + : `Clone it instead (the "Clone" action on the permission set, or POST ` + + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + `flowing to '${name}' untouched.`), ); this.name = 'PackagedPermissionSetLockedError'; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5a4a3cb5dd0..691eeef3992 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -41,9 +41,6 @@ import { unregisterGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; -import { createPositionWriteThrough } from './position-write-through.js'; -import { createWalledCatalogDataDoorRefusal, WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; -import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { reportCatalogReferences } from './catalog-reference-report.js'; import { explainAccessForAsker, @@ -59,11 +56,6 @@ import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/securi import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; -import { - createPermissionSetWriteThrough, - registerPermissionSetProjection, - reconcilePermissionSetProjection, -} from './permission-set-projection.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; @@ -159,7 +151,6 @@ import { readDeploymentOrgScopingEntitlement, type DeploymentOrgScopingEntitlementReading, } from '@objectstack/core'; -import { SysPositionDetailPage } from '@objectstack/platform-objects/pages'; import { securityObjects, securityDefaultPermissionSets, @@ -1534,26 +1525,24 @@ export class SecurityPlugin implements Plugin { ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, objects: securityObjects, - // [ADR-0090] SDUI detail page for sys_position — Holders (assignments, - // name-keyed junction) + Permission Sets (bindings) as pure - // record:related_list declarations; no bespoke UI. - pages: [SysPositionDetailPage], // Permission sets ride along on the manifest so the metadata service // can resolve them by name when SecurityPlugin middleware queries // `metadata.list('permissions')`. permissions: this.bootstrapPermissionSets, // ADR-0029 D7 — contribute the RBAC entries into the Setup app's - // `group_access_control` slot. This plugin owns these objects (K2), so it - // ships their menu too; when the plugin is absent the entries don't appear. + // `group_access_control` slot. This plugin owns the security catalog, so + // it ships its menu too; when the plugin is absent the entries don't + // appear. [ADR-0131 D3/D7] Each entry opens Setup's list of the registry + // type in its environment scope — the catalog has no object behind it. navigationContributions: [ { app: 'setup', group: 'group_access_control', priority: 100, items: [ - { id: 'nav_positions', type: 'object', label: 'Positions', objectName: 'sys_position', icon: 'shield-check' }, - { id: 'nav_capabilities', type: 'object', label: 'Capabilities', objectName: 'sys_capability', icon: 'badge-check' }, - { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, + { id: 'nav_positions', type: 'url', label: 'Positions', url: '/apps/setup/metadata/position?scope=environment', icon: 'shield-check' }, + { id: 'nav_capabilities', type: 'url', label: 'Capabilities', url: '/apps/setup/metadata/capability?scope=environment', icon: 'badge-check' }, + { id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment', icon: 'lock' }, ], }, ], @@ -4355,75 +4344,13 @@ export class SecurityPlugin implements Plugin { ); } - // [ADR-0131 D3] Under a wall the security catalog is the platform - // operator's: a non-system write on `sys_position`, `sys_permission_set` or - // `sys_capability` by anyone below the `PLATFORM_ADMIN` rung is refused - // `403 PERMISSION_DENIED`, with the metadata door's own verdict and - // sentence. Registered AFTER the security middleware (a caller who may not - // write the table keeps its CRUD refusal) and BEFORE the permission-set - // write-through below (a refused write is never translated into a metadata - // save). Inert under `single`. See `walled-catalog-data-door.ts`. - { - const walledCatalogDataDoor = createWalledCatalogDataDoorRefusal({ getPosture: () => this.tenancyPosture }); - for (const object of Object.keys(WALLED_CATALOG_OBJECT_TYPES)) { - ql.registerMiddleware(walledCatalogDataDoor, { object }); - } - } - - // [ADR-0094] Data-door write-through: every non-system CRUD write on - // `sys_permission_set` is redirected into the metadata store (the ONE - // authoritative store for definitions); the record is projector-owned. - // Registered AFTER the security middleware, so it runs INSIDE it — the - // two-doors gate, the delegated-admin gate, and the CRUD/FLS checks have - // all passed before a write is translated. Kernels without a capable - // metadata protocol pass through to the legacy direct write (single - // store — no split brain to prevent). - // - // [#20013] Wrapped so the security middleware can tell a write the door - // executed itself from one it passed on: the wrapper hands the door a - // `next` that records the pass-on, and a write that returns without it is - // recorded in `writesExecutedByDataDoor`. Observed, not declared — the - // door's own code is unchanged. - const permissionSetWriteThrough = createPermissionSetWriteThrough({ - ql, - metadata, - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - logger: ctx.logger, - }); - ql.registerMiddleware( - async (opCtx: any, next: () => Promise) => { - let passedOn = false; - await permissionSetWriteThrough(opCtx, async () => { - passedOn = true; - await next(); - }); - if (!passedOn && opCtx && typeof opCtx === 'object') writesExecutedByDataDoor.add(opCtx); - }, - { object: 'sys_permission_set' }, - ); - - // [ADR-0131 D3, C2 stage S7] Under `single`, a non-system create, edit, - // rename or delete of a position also writes its DEFINITION through the - // metadata door at environment scope — the row first, then the - // definition; a refused definition undoes the row. Registered AFTER the - // security middleware, so it runs INSIDE it. A create or a rename into a - // name a package or built-in holds answers the metadata door's own - // refusal (C2 stage S10). Stands down for a walled posture, an edit that - // keeps or a delete of such a name, and a kernel without a capable - // metadata protocol. See `position-write-through.ts`. - ql.registerMiddleware( - createPositionWriteThrough({ - ql, - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - getPosture: () => this.tenancyPosture, - logger: ctx.logger, - }), - { object: 'sys_position' }, - ); + // [ADR-0131 D3, C3 stage 8] No data-door write-through and no walled + // catalog data-door refusal is registered: the catalog objects are + // retired, so no `sys_position`, `sys_permission_set` or `sys_capability` + // write reaches the engine at all. A position, permission set or + // capability is written through the metadata door alone, which registers + // it in the security catalog itself and, under a wall, refuses a tenant + // with the same verdict (`walledCatalogWriteVerdict`). // Defer platform admin bootstrap until all plugins finish starting — // sys_user / sys_user_permission_set objects must be registered (by @@ -4432,12 +4359,6 @@ export class SecurityPlugin implements Plugin { // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); - // [ADR-0094] Guard so the env-projection wiring runs exactly once even - // though runBootstrap re-runs (e.g. after the first user insert) — - // registerMutationProjector replaces idempotently, but the legacy - // onMetadataMutation fallback appends listeners, and re-wiring that would - // project each save N times. - let envProjectionWired = false; // [#22801 → A] Same once-guard for the `permission` mutation listener // that re-derives `everyone` (registered in `runBootstrap`). let everyoneMutationWired = false; @@ -4520,24 +4441,14 @@ export class SecurityPlugin implements Plugin { await this.declareEveryone(ctx.logger); return { success: true, - removed: r.sets + r.positionBindings + r.userGrants + r.suggestions, + removed: r.userGrants + r.suggestions, }; }, ); } - // [ADR-0094] Environment door — the `permission` mutation projector. - // The protocol AWAITS it inside saveMetaItem / publishMetaItem / - // deleteMetaItem, so the sys_permission_set record (and the metadata - // manager's in-memory entry, which the evaluator's registry-first - // list('permission') resolution reads) already reflects a Studio - // save when it returns — no projection race. Falls back to the - // fire-and-forget onMetadataMutation subscription (#2857/#2867) on - // protocols that predate registerMutationProjector. - if (!envProjectionWired) { - envProjectionWired = registerPermissionSetProjection(protocol, { - ql, metadata: this.metadata, logger: ctx.logger, - }); - } + // [ADR-0131 D3, C3 stage 8] No `permission` mutation projector: the + // metadata door's own registry write-through puts a saved set in the + // security catalog, and no row mirrors it. // [#3050] OWD posture authoring gate — pre-persistence veto on // runtime-authored `object` bodies: env-tighten-only over packaged // declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11). @@ -4555,17 +4466,6 @@ export class SecurityPlugin implements Plugin { // non-packaged names keep the documented hatch behavior. One // spelling of "package-declared", two doors, one refusal. registerPackagedPermissionSetLockGate(protocol, ql); - // [ADR-0094 D4] Converge record ↔ metadata: project env overlays - // onto records (creating missing ones), backfill legacy data-door - // creations into the metadata store once, and heal drifted records - // from the effective body (metadata wins). Idempotent per boot. - try { - await reconcilePermissionSetProjection(protocol, { - ql, metadata: this.metadata, logger: ctx.logger, - }); - } catch (e) { - ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); - } // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce // 式)」, item 3] The DETECTION READING for sets that were already // silently forked before the save door was locked: count + NAMES, @@ -4638,37 +4538,6 @@ export class SecurityPlugin implements Plugin { void runGrantNameBackfill(); } - // [ADR-0131 D3, C2 stage S7] Give every row-only position — written in - // Setup before the write-through above existed — its environment - // definition, once per `single` deployment, recorded in `sys_migration`. - // At `kernel:bootstrapped` for the reason the grant-name backfill gives, - // and one more: an environment definition minted ahead of a package's - // declaration of the same name would refuse that package. See - // `position-environment-backfill.ts`. - const runPositionBackfill = async (): Promise => { - try { - let door: unknown = null; - try { door = (ctx as any).getService?.('protocol') ?? null; } catch { door = null; } - await runOneTimePositionEnvironmentBackfill(ql as any, { - posture: this.tenancyPosture, - catalog: createSecurityCatalogReader({ registry: (ql as any).registry, metadata: this.metadata }), - door, - logger: ctx.logger, - }); - } catch (e) { - ctx.logger.warn( - '[security] the row-only position backfill did not run — positions created in Setup before the ' + - 'environment write-through keep no environment definition until a later boot runs it', - { error: (e as Error)?.message }, - ); - } - }; - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('kernel:bootstrapped', runPositionBackfill); - } else { - void runPositionBackfill(); - } - // [ADR-0131 D3/D4] The catalog reference report: per organization, every // assignment and grant whose name the security catalog does not resolve, // every grant that names nothing, every position row with no definition diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index e2cbd494c0b..d7314fdec3e 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const enObjects: NonNullable = { - sys_position: { - label: "Position", - pluralLabel: "Positions", - description: "Position definitions for capability distribution (ADR-0090)", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the position, unique per organization (e.g. sales_manager, hr_specialist)" - }, - description: { - label: "Description" - }, - active: { - label: "Active" - }, - is_default: { - label: "Default Position", - help: "Automatically assigned to new users" - }, - delegatable: { - label: "Delegatable", - help: "ADR-0091 D3: holders may self-service delegate this position, time-boxed." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: platform = framework built-in (read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows may carry system (== platform) / config (== package) / user (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - id: { - label: "Position ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - default_positions: { - label: "Default" - }, - custom: { - label: "Custom" - }, - all_positions: { - label: "All" - } - }, - _actions: { - activate_position: { - label: "Activate Position", - successMessage: "Position activated" - }, - deactivate_position: { - label: "Deactivate Position", - confirmText: "Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.", - successMessage: "Position deactivated" - }, - set_default_position: { - label: "Set as Default", - confirmText: "Make this the default position for new users? Existing users are unaffected.", - successMessage: "Default position updated" - }, - clone_position: { - label: "Clone Position", - successMessage: "Position cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "Capability", - pluralLabel: "Capabilities", - description: "Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access)." - }, - description: { - label: "Description" - }, - scope: { - label: "Scope", - help: "platform = a platform-wide power; org = scoped to an organization.", - options: { - platform: "Platform", - org: "Organization" - } - }, - managed_by: { - label: "Managed By", - help: "platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - package_id: { - label: "Owning Package", - help: "Package that ships this capability (absent = platform-curated or admin-created)." - }, - active: { - label: "Active", - help: "Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing." - }, - id: { - label: "Capability ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - platform: { - label: "Platform" - }, - org: { - label: "Organization" - }, - all_capabilities: { - label: "All" - } - }, - _actions: { - activate_capability: { - label: "Activate", - successMessage: "Capability activated" - }, - deactivate_capability: { - label: "Deactivate", - confirmText: "Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.", - successMessage: "Capability deactivated" - } - } - }, - sys_permission_set: { - label: "Permission Set", - pluralLabel: "Permission Sets", - description: "Named permission groupings for fine-grained access control", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the permission set, unique per organization. This is the set’s metadata identity (ADR-0094) and cannot be changed after creation — the data door rejects a rename; clone the set to a new name instead." - }, - description: { - label: "Description" - }, - object_permissions: { - label: "Object Permissions", - help: "JSON-serialized object-level CRUD permissions" - }, - field_permissions: { - label: "Field Permissions", - help: "JSON-serialized field-level read/write permissions" - }, - system_permissions: { - label: "System Permissions", - help: "JSON-serialized array of system capability names (e.g. [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "Row-Level Security", - help: "JSON-serialized array of row-level security policies (USING/CHECK clauses)" - }, - tab_permissions: { - label: "Tab Permissions", - help: "JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "Delegated Admin Scope", - help: "[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the user a SCOPED administrator within the declared business-unit subtree; enforced by the delegated-admin write gate." - }, - active: { - label: "Active" - }, - package_id: { - label: "Owning Package", - help: "Package that ships this permission set (absent = environment-authored). Set on rows an earlier release seeded for a package's set; no current writer sets it." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = a row an earlier release seeded for a package's set (read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited). Legacy rows may carry 'user' (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - customized: { - label: "Customized", - help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." - }, - id: { - label: "Permission Set ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - inactive: { - label: "Inactive" - }, - all_permsets: { - label: "All" - } - }, - _actions: { - activate_permission_set: { - label: "Activate", - successMessage: "Permission set activated" - }, - deactivate_permission_set: { - label: "Deactivate", - confirmText: "Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.", - successMessage: "Permission set deactivated" - }, - clone_permission_set: { - label: "Clone", - description: "Copies this set's permissions into a new organization-owned set you can edit. Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.", - successMessage: "Permission set cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - } - }, sys_user_permission_set: { label: "User Permission Set", pluralLabel: "User Permission Sets", @@ -307,7 +34,7 @@ export const enObjects: NonNullable = { }, permission_set_id: { label: "Permission Set (legacy id)", - help: "[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." + help: "[ADR-0131 D10] Legacy id of a retired permission-set catalog row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." }, permission_set: { label: "Permission Set", @@ -354,35 +81,6 @@ export const enObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "Position Permission Set", - pluralLabel: "Position Permission Sets", - description: "Binds a permission set to a position.", - fields: { - id: { - label: "Binding ID", - help: "UUID of the position-permission-set binding." - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "Position", - help: "Foreign key to sys_position." - }, - permission_set_id: { - label: "Permission Set", - help: "Foreign key to sys_permission_set." - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - } - }, sys_user_position: { label: "User Position", pluralLabel: "User Positions", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 9e7c04cca4e..1434a33c7e5 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const esESObjects: NonNullable = { - sys_position: { - label: "Puesto", - pluralLabel: "Puestos", - description: "Definiciones de puesto para la distribución de capacidades (ADR-0090)", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Nombre técnico del puesto, único por organización (p. ej. sales_manager, hr_specialist)." - }, - description: { - label: "Descripción" - }, - active: { - label: "Activo" - }, - is_default: { - label: "Puesto predeterminado", - help: "Se asigna automáticamente a los nuevos usuarios." - }, - delegatable: { - label: "Delegable", - help: "ADR-0091 D3: quienes la ostentan pueden delegar esta posición por autoservicio, con límite temporal." - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro, en el único vocabulario platform / package / admin que comparten capacidades, conjuntos de permisos y puestos: platform (integrado) / package (declarado) / admin (creado por el inquilino).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - id: { - label: "ID de puesto" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - active: { - label: "Activo" - }, - default_positions: { - label: "Predeterminado" - }, - custom: { - label: "Personalizado" - }, - all_positions: { - label: "Todos" - } - }, - _actions: { - activate_position: { - label: "Activar puesto", - successMessage: "Puesto activado" - }, - deactivate_position: { - label: "Desactivar puesto", - confirmText: "¿Desactivar este puesto? Los usuarios con el puesto conservan su asignación, pero el puesto deja de otorgar permisos hasta que se vuelva a activar.", - successMessage: "Puesto desactivado" - }, - set_default_position: { - label: "Establecer como predeterminado", - confirmText: "¿Convertir este en el puesto predeterminado para los nuevos usuarios? Los usuarios existentes no se ven afectados.", - successMessage: "Puesto predeterminado actualizado" - }, - clone_position: { - label: "Clonar puesto", - successMessage: "Puesto clonado", - params: { - label: { - label: "Nuevo nombre para mostrar" - }, - name: { - label: "Nuevo nombre de API", - helpText: "Nombre de máquina snake_case, único por organización" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "Capacidad", - pluralLabel: "Capacidades", - description: "Definiciones de capacidades de autorización referenciadas por nombre desde conjuntos de permisos y requisitos de recursos.", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Clave única de capacidad referenciada por systemPermissions / requiredPermissions (p. ej. manage_users, setup.access)." - }, - description: { - label: "Descripción" - }, - scope: { - label: "Ámbito", - help: "platform = potestad de toda la plataforma; org = limitada a una organización.", - options: { - platform: "Plataforma", - org: "Organización" - } - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro: platform / package (distribuido, no eliminable por el usuario) / admin (creado en Configuración).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - package_id: { - label: "Paquete propietario", - help: "Paquete que distribuye esta capacidad (ausente = curada por la plataforma o creada por un administrador)." - }, - active: { - label: "Activo", - help: "Indicador de catálogo/visibilidad para filtrar y revisar. NO tiene ningún efecto sobre la autorización: las concesiones de los conjuntos de permisos y los requiredPermissions de los recursos comparan los nombres de capacidad como cadenas y nunca leen esta fila, por lo que desmarcarlo no revoca nada." - }, - id: { - label: "ID de capacidad" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - platform: { - label: "Plataforma" - }, - org: { - label: "Organización" - }, - all_capabilities: { - label: "Todos" - } - }, - _actions: { - activate_capability: { - label: "Activar", - successMessage: "Capacidad activada" - }, - deactivate_capability: { - label: "Desactivar", - confirmText: "¿Desactivar esta capacidad? Es solo un indicador de catálogo: marca la fila como inactiva para filtrar y revisar en Setup. La autorización NO se ve afectada: los conjuntos de permisos que la conceden, y los recursos que la requieren, la comparan por nombre y se siguen resolviendo exactamente igual que antes.", - successMessage: "Capacidad desactivada" - } - } - }, - sys_permission_set: { - label: "Conjunto de permisos", - pluralLabel: "Conjuntos de permisos", - description: "Agrupaciones de permisos con nombre para un control de acceso detallado", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Nombre técnico del conjunto de permisos, único por organización." - }, - description: { - label: "Descripción" - }, - object_permissions: { - label: "Permisos de objeto", - help: "Permisos CRUD a nivel de objeto serializados en JSON." - }, - field_permissions: { - label: "Permisos de campo", - help: "Permisos de lectura/escritura a nivel de campo serializados en JSON." - }, - system_permissions: { - label: "Permisos del sistema", - help: "Array serializado en JSON de nombres de capacidades del sistema (p. ej. [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "Seguridad a nivel de fila", - help: "Array serializado en JSON de políticas de seguridad a nivel de fila (cláusulas USING/CHECK)" - }, - tab_permissions: { - label: "Permisos de pestañas", - help: "Mapa serializado en JSON de la visibilidad de las pestañas de la app (visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "Alcance de administración delegada", - help: "[ADR-0090 D12] AdminScope serializado en JSON: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Poseer este conjunto convierte al usuario en administrador ACOTADO dentro del subárbol de unidad de negocio declarado; lo aplica la barrera de escritura de administración delegada." - }, - active: { - label: "Activo" - }, - package_id: { - label: "Paquete propietario", - help: "Paquete que distribuye este conjunto de permisos (ausente = creado en el entorno). Lo rellena bootstrapDeclaredPermissions; hace que la desinstalación/actualización del paquete quede bien definida." - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro, en el único vocabulario platform / package / admin que comparten capacidades, conjuntos de permisos y puestos: platform (distribuido) / package (empaquetado) / admin (creado en el entorno).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - customized: { - label: "Personalizado", - help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." - }, - id: { - label: "ID de conjunto de permisos" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - active: { - label: "Activo" - }, - inactive: { - label: "Inactivo" - }, - all_permsets: { - label: "Todos" - } - }, - _actions: { - activate_permission_set: { - label: "Activar", - successMessage: "Conjunto de permisos activado" - }, - deactivate_permission_set: { - label: "Desactivar", - confirmText: "¿Desactivar este conjunto de permisos? Las asignaciones existentes se mantienen, pero dejan de otorgar acceso hasta que se vuelva a activar.", - successMessage: "Conjunto de permisos desactivado" - }, - clone_permission_set: { - label: "Clonar", - description: "Copia los permisos de este conjunto en un nuevo conjunto propiedad de la organización que puedes editar. El alcance de administración delegada no se copia: concédelo deliberadamente en el nuevo conjunto si lo necesita.", - successMessage: "Conjunto de permisos clonado", - params: { - label: { - label: "Nuevo nombre para mostrar" - }, - name: { - label: "Nuevo nombre de API", - helpText: "Nombre de máquina snake_case, único por organización" - } - } - } - } - }, sys_user_permission_set: { label: "Conjunto de permisos de usuario", pluralLabel: "Conjuntos de permisos de usuario", @@ -354,35 +81,6 @@ export const esESObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "Conjunto de permisos de puesto", - pluralLabel: "Conjuntos de permisos de puesto", - description: "Vincula un conjunto de permisos a un puesto.", - fields: { - id: { - label: "ID de vinculación", - help: "UUID de la vinculación puesto-conjunto de permisos." - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "Puesto", - help: "Clave foránea a sys_position." - }, - permission_set_id: { - label: "Conjunto de permisos", - help: "Clave foránea a sys_permission_set." - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - } - }, sys_user_position: { label: "Puesto de usuario", pluralLabel: "Puestos de usuario", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index 608ee0175cd..6d5c033bc79 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index d540be40ba6..fc0b17d2c6b 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const jaJPObjects: NonNullable = { - sys_position: { - label: "ポジション", - pluralLabel: "ポジション", - description: "ケイパビリティ配分のためのポジション定義(ADR-0090)", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "ポジションのマシン名(組織ごとに一意。例: sales_manager、hr_specialist)" - }, - description: { - label: "説明" - }, - active: { - label: "有効" - }, - is_default: { - label: "デフォルトポジション", - help: "新規ユーザーに自動的に割り当てられます" - }, - delegatable: { - label: "委任可能", - help: "ADR-0091 D3: 保持者はこのポジションを期限付きでセルフサービス委任できます。" - }, - managed_by: { - label: "管理元", - help: "レコードの出所(ケイパビリティ・権限セット・ポジションで共通の platform / package / admin の語彙):platform(組み込み)/ package(宣言済み)/ admin(テナント作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - id: { - label: "ポジション ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - active: { - label: "有効" - }, - default_positions: { - label: "デフォルト" - }, - custom: { - label: "カスタム" - }, - all_positions: { - label: "すべて" - } - }, - _actions: { - activate_position: { - label: "ポジションを有効化", - successMessage: "ポジションが有効化されました" - }, - deactivate_position: { - label: "ポジションを無効化", - confirmText: "このポジションを無効化しますか?このポジションを持つユーザーの割り当ては維持されますが、再度有効化するまで権限の付与は停止されます。", - successMessage: "ポジションが無効化されました" - }, - set_default_position: { - label: "デフォルトに設定", - confirmText: "このポジションを新規ユーザーのデフォルトポジションにしますか?既存のユーザーには影響しません。", - successMessage: "デフォルトポジションを更新しました" - }, - clone_position: { - label: "ポジションを複製", - successMessage: "ポジションを複製しました", - params: { - label: { - label: "新しい表示名" - }, - name: { - label: "新しい API 名", - helpText: "snake_case マシン名(組織ごとに一意)" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "ケイパビリティ", - pluralLabel: "ケイパビリティ", - description: "権限セットおよびリソース要件から名前で参照される認可ケイパビリティ定義。", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "systemPermissions / requiredPermissions から参照される一意のケーパビリティキー(例: manage_users、setup.access)。" - }, - description: { - label: "説明" - }, - scope: { - label: "スコープ", - help: "platform = プラットフォーム全体の権限、org = 組織単位に限定。", - options: { - platform: "プラットフォーム", - org: "組織" - } - }, - managed_by: { - label: "管理元", - help: "レコードの出所:platform / package(配布物、ユーザー削除不可)/ admin(設定で作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - package_id: { - label: "所有パッケージ", - help: "このケーパビリティを提供するパッケージ(未設定はプラットフォーム標準または管理者作成)。" - }, - active: { - label: "有効", - help: "絞り込みと確認のためのカタログ/表示用フラグです。認可には一切影響しません。権限セットの付与もリソースの requiredPermissions も、ケーパビリティ名を文字列として照合し、この行を読むことはないため、これを解除しても何も取り消されません。" - }, - id: { - label: "ケーパビリティ ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - platform: { - label: "プラットフォーム" - }, - org: { - label: "組織" - }, - all_capabilities: { - label: "すべて" - } - }, - _actions: { - activate_capability: { - label: "有効化", - successMessage: "ケーパビリティを有効化しました" - }, - deactivate_capability: { - label: "無効化", - confirmText: "このケーパビリティを無効化しますか?これはカタログ上のフラグにすぎず、Setup での絞り込みと確認に使われます。認可には影響しません。このケーパビリティを付与する権限セットも、これを要求するリソースも、名前で照合されるため、これまでどおり解決され続けます。", - successMessage: "ケーパビリティを無効化しました" - } - } - }, - sys_permission_set: { - label: "権限セット", - pluralLabel: "権限セット", - description: "細かいアクセス制御のための権限グループ", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "権限セットのマシン名(組織ごとに一意)" - }, - description: { - label: "説明" - }, - object_permissions: { - label: "オブジェクト権限", - help: "JSON シリアライズされたオブジェクトレベルの CRUD 権限" - }, - field_permissions: { - label: "フィールド権限", - help: "JSON シリアライズされたフィールドレベルの読み取り/書き込み権限" - }, - system_permissions: { - label: "システム権限", - help: "システムケーパビリティ名のJSONシリアライズ配列(例: [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "行レベルセキュリティ", - help: "行レベルセキュリティポリシーのJSONシリアライズ配列(USING/CHECK 句)" - }, - tab_permissions: { - label: "タブ権限", - help: "アプリのタブ表示のJSONシリアライズマップ(visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "委任管理スコープ", - help: "[ADR-0090 D12] JSON シリアライズされた AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。このセットを保持すると、宣言されたビジネスユニット配下に限定されたスコープ付き管理者になります。委任管理の書き込みゲートで強制されます。" - }, - active: { - label: "有効" - }, - package_id: { - label: "所有パッケージ", - help: "この権限セットを提供するパッケージ(未設定は環境で作成)。bootstrapDeclaredPermissions が設定し、パッケージのアンインストール/アップグレードの挙動を明確にします。" - }, - managed_by: { - label: "管理元", - help: "レコードの出所(ケイパビリティ・権限セット・ポジションで共通の platform / package / admin の語彙):platform(配布)/ package(パッケージ)/ admin(環境作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - customized: { - label: "カスタマイズ済み", - help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" - }, - id: { - label: "権限セット ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - active: { - label: "有効" - }, - inactive: { - label: "無効" - }, - all_permsets: { - label: "すべて" - } - }, - _actions: { - activate_permission_set: { - label: "有効化", - successMessage: "権限セットが有効化されました" - }, - deactivate_permission_set: { - label: "無効化", - confirmText: "この権限セットを無効化しますか?既存の割り当ては維持されますが、再度有効化するまでアクセスの付与は停止されます。", - successMessage: "権限セットが無効化されました" - }, - clone_permission_set: { - label: "複製", - description: "このセットの権限を、編集可能な組織所有の新しい権限セットにコピーします。委任管理スコープはコピーされません。新しいセットに必要な場合は、個別に付与してください。", - successMessage: "権限セットを複製しました", - params: { - label: { - label: "新しい表示名" - }, - name: { - label: "新しい API 名", - helpText: "snake_case マシン名(組織ごとに一意)" - } - } - } - } - }, sys_user_permission_set: { label: "ユーザー権限セット", pluralLabel: "ユーザー権限セット", @@ -354,35 +81,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "ポジション権限セット", - pluralLabel: "ポジション権限セット", - description: "権限セットをポジションにバインドします。", - fields: { - id: { - label: "バインド ID", - help: "ポジション権限セットバインドの UUID。" - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "ポジション", - help: "sys_position への外部キー。" - }, - permission_set_id: { - label: "権限セット", - help: "sys_permission_set への外部キー。" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - } - }, sys_user_position: { label: "ユーザーポジション", pluralLabel: "ユーザーポジション", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index 51efd73cf9b..37812fced2f 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 4859358e9c9..1488d2650b4 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const zhCNObjects: NonNullable = { - sys_position: { - label: "岗位", - pluralLabel: "岗位", - description: "用于分发授权能力的岗位定义(ADR-0090)", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "岗位的机器名称,在每个组织内唯一(例如 sales_manager、hr_specialist)" - }, - description: { - label: "描述" - }, - active: { - label: "启用" - }, - is_default: { - label: "默认岗位", - help: "自动分配给新用户" - }, - delegatable: { - label: "可委派", - help: "ADR-0091 D3:持有者可自助委派该岗位,且有时限。" - }, - managed_by: { - label: "管理来源", - help: "记录来源(能力、权限集与岗位共用同一套 platform / package / admin 取值):platform(平台内置)/ package(应用包声明)/ admin(租户创建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - id: { - label: "岗位 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - active: { - label: "启用" - }, - default_positions: { - label: "默认" - }, - custom: { - label: "自定义" - }, - all_positions: { - label: "全部" - } - }, - _actions: { - activate_position: { - label: "激活岗位", - successMessage: "岗位已激活" - }, - deactivate_position: { - label: "停用岗位", - confirmText: "确定要停用此岗位吗?拥有该岗位的用户仍保留其分配,但在重新激活之前该岗位将不再授予权限。", - successMessage: "岗位已停用" - }, - set_default_position: { - label: "设为默认", - confirmText: "将此岗位设为新用户的默认岗位吗?现有用户不受影响。", - successMessage: "已更新默认岗位" - }, - clone_position: { - label: "克隆岗位", - successMessage: "已克隆岗位", - params: { - label: { - label: "新显示名称" - }, - name: { - label: "新 API 名称", - helpText: "snake_case 机器名称,在每个组织内唯一" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "能力", - pluralLabel: "能力", - description: "授权能力定义,由权限集和资源需求按名称引用。", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "唯一的能力键,由 systemPermissions / requiredPermissions 按名称引用(如 manage_users、setup.access)。" - }, - description: { - label: "描述" - }, - scope: { - label: "范围", - help: "platform = 平台级权能;org = 限定于某个组织。", - options: { - platform: "平台", - org: "组织" - } - }, - managed_by: { - label: "管理来源", - help: "记录来源:platform / package(随包发布,不可用户删除)/ admin(在设置中创建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - package_id: { - label: "所属应用包", - help: "发布该能力的应用包(为空表示平台内置或管理员创建)。" - }, - active: { - label: "启用", - help: "目录/可见性标记,用于筛选和查看。它对授权没有任何影响:权限集的授予与资源的 requiredPermissions 都按字符串匹配能力名称,从不读取该行,因此清除它不会撤销任何权限。" - }, - id: { - label: "能力 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - platform: { - label: "平台" - }, - org: { - label: "组织" - }, - all_capabilities: { - label: "全部" - } - }, - _actions: { - activate_capability: { - label: "激活", - successMessage: "已启用能力" - }, - deactivate_capability: { - label: "停用", - confirmText: "停用该能力?这只是目录标记,用于在 Setup 中筛选和查看。授权不受影响——授予该能力的权限集,以及要求该能力的资源,都按名称匹配,仍会照常解析。", - successMessage: "已停用能力" - } - } - }, - sys_permission_set: { - label: "权限集", - pluralLabel: "权限集", - description: "用于精细化访问控制的命名权限分组", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "权限集的机器名称,在每个组织内唯一" - }, - description: { - label: "描述" - }, - object_permissions: { - label: "对象权限", - help: "对象级 CRUD 权限的 JSON 序列化内容" - }, - field_permissions: { - label: "字段权限", - help: "字段级读写权限的 JSON 序列化内容" - }, - system_permissions: { - label: "系统权限", - help: "系统能力名称的 JSON 序列化数组(例如 [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "行级安全", - help: "行级安全策略的 JSON 序列化数组(USING/CHECK 子句)" - }, - tab_permissions: { - label: "标签页权限", - help: "应用标签页可见性的 JSON 序列化映射(visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "委派管理范围", - help: "[ADR-0090 D12] JSON 序列化的 AdminScope:{ businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。持有该权限集会使用户成为所声明业务单元子树内的「受限范围」管理员,由委派管理写入门禁强制执行。" - }, - active: { - label: "启用" - }, - package_id: { - label: "所属应用包", - help: "发布该权限集的应用包(为空表示环境内编写)。由 bootstrapDeclaredPermissions 填充,使应用包的卸载/升级行为有明确定义。" - }, - managed_by: { - label: "管理来源", - help: "记录来源(能力、权限集与岗位共用同一套 platform / package / admin 取值):platform(平台发布)/ package(应用包发布)/ admin(环境自建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - customized: { - label: "已定制", - help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" - }, - id: { - label: "权限集 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - active: { - label: "启用" - }, - inactive: { - label: "停用" - }, - all_permsets: { - label: "全部" - } - }, - _actions: { - activate_permission_set: { - label: "激活", - successMessage: "权限集已激活" - }, - deactivate_permission_set: { - label: "停用", - confirmText: "确定要停用此权限集吗?现有分配仍将保留,但在重新激活之前将不再授予访问权限。", - successMessage: "权限集已停用" - }, - clone_permission_set: { - label: "克隆", - description: "将此权限集的权限复制到一个新的、由组织拥有且可编辑的权限集。委派管理范围不会被复制——如果新权限集需要,请单独在其上授予。", - successMessage: "已克隆权限集", - params: { - label: { - label: "新显示名称" - }, - name: { - label: "新 API 名称", - helpText: "snake_case 机器名称,在每个组织内唯一" - } - } - } - } - }, sys_user_permission_set: { label: "用户权限集", pluralLabel: "用户权限集", @@ -354,35 +81,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "岗位权限集", - pluralLabel: "岗位权限集", - description: "将权限集绑定到岗位。", - fields: { - id: { - label: "绑定 ID", - help: "岗位-权限集绑定记录的 UUID。" - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "岗位", - help: "指向 sys_position 的外键。" - }, - permission_set_id: { - label: "权限集", - help: "指向 sys_permission_set 的外键。" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - } - }, sys_user_position: { label: "用户岗位", pluralLabel: "用户岗位", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index f97c9467f5f..6444f5e4e3a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts deleted file mode 100644 index 003a885f3f6..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the - * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). - * - * Measured before it on this same harness shape (a real `ObjectQL` over a real - * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the - * organization-scoping service): an organization-bound administrator holding - * the superuser wildcard created a position at the data door, and it landed - * as a row of that organization. Nothing judged a catalog create by posture. - * - * Pinned here, per posture: - * - `isolated` and `group`: the tenant's create, update and delete of a - * position, a permission set and a capability are refused with the ADR-0112 - * envelope `403 PERMISSION_DENIED`, and nothing is stored; - * - the CONTROL: the same tenant still assigns a catalog position to a member - * of its organization; - * - the platform operator rung and a system write are admitted; - * - `single`: the same tenant's create is accepted, unchanged. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { resolveThrownHttpError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; - -import { SecurityPlugin } from './security-plugin.js'; -import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysCapability } from './objects/sys-capability.object.js'; -import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; - -const ORG = 'org_a'; - -/** The fallback every authenticated caller resolves: it grants nothing here. */ -const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; - -/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -type Posture = 'single' | 'isolated' | 'group'; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(posture: Posture) { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.walled-catalog-data-door', - name: 'Walled catalog data door', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysCapability, SysMember, SysMetadataActivation, - ], - } as any); - await engine.syncSchemas(); - for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { - if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); - } - engines.push(engine); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata: { - get: async (type: string, name: string) => - type === 'permission' - ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async () => [MEMBER_DEFAULT, QA_ADMIN], - }, - ...(posture === 'single' - ? {} - : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), - }; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - hook: vi.fn(), - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - const walled = posture !== 'single'; - const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; - // The catalog the tenant may assign: one position, declared in the registry. - engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, - 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); - // Rows the tenant will try to change: one per catalog object. - await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); - await engine.insert('sys_permission_set', { - id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, - }, { context: system } as any); - await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); - // The member an assignment names: a member of the organization it is scoped to. - await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, - { context: { isSystem: true, tenantId: ORG } } as any); - - const tenant = walled - ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } - : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; - return { engine, tenant, system }; -} - -async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { - try { - await run; - return { ok: true }; - } catch (e) { - const r = resolveThrownHttpError(e); - return { ok: false, code: r.code, status: r.status }; - } -} - -const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; - -async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); - return Array.isArray(rows) ? rows.length : 0; -} - -for (const posture of ['isolated', 'group'] as const) { - describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { - it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); - expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); - expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); - }); - - it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) - .toEqual(REFUSED); - const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; - expect(row?.label).toBe('Existing'); - expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); - }); - - it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) - .toEqual({ ok: true }); - }); - - it('the platform operator rung and a system write are admitted', async () => { - const { engine, tenant, system } = await boot(posture); - const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) - .toEqual({ ok: true }); - }); - - it('a tenant rung below the operator is refused like a context that carries none', async () => { - const { engine, tenant } = await boot(posture); - const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) - .toEqual(REFUSED); - }); - }); -} - -describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { - it('the same administrator creates a position and a permission set', async () => { - const { engine, tenant } = await boot('single'); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); - }); -}); - -describe('the judged objects', () => { - it('are exactly the three security catalog objects — assignment objects are never judged', () => { - expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', - }); - }); -}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 7d173f74220..c8ca7f2e048 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -439,10 +439,7 @@ export class SharingService implements ISharingService { 'sys_user', 'sys_organization', 'sys_member', - 'sys_position', - 'sys_permission_set', 'sys_user_permission_set', - 'sys_position_permission_set', ...(options.bypassObjects ?? []), ]); } diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index f45a7e15ce6..a6a6aa6da57 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -120,7 +120,7 @@ const manifest = { label: 'Self-registration permission set', required: false, description: - 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + 'Name of the permission set granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', visible: "${data.audience_posture === 'email_domain' || data.audience_posture === 'open'}", }, diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index f6c8feddcba..e64b3b5130e 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -179,7 +179,7 @@ export const en: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'Self-registration permission set', - help: 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + help: 'Name of the permission set granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', }, password_min_length: { label: 'Minimum password length' }, password_max_length: { label: 'Maximum password length', help: 'Guards against denial-of-service via very long password hashing.' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 541893918b8..bbf4861e571 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -146,7 +146,7 @@ export const esES: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'Conjunto de permisos de autorregistro', - help: 'Nombre del sys_permission_set que recibe cada usuario autorregistrado (declarar member_default explícitamente es válido; admin_full_access se rechaza).', + help: 'Nombre del conjunto de permisos que recibe cada usuario autorregistrado (declarar member_default explícitamente es válido; admin_full_access se rechaza).', }, password_min_length: { label: 'Longitud mínima de contraseña' }, password_max_length: { label: 'Longitud máxima de contraseña', help: 'Un límite superior protege frente a la denegación de servicio por el hasheo de contraseñas muy largas.' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index a3fc0989896..cfe30048bea 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -146,7 +146,7 @@ export const jaJP: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'セルフ登録の権限セット', - help: '各セルフ登録ユーザーに付与される sys_permission_set 名(member_default の明示宣言は可。admin_full_access は拒否されます)。', + help: '各セルフ登録ユーザーに付与される権限セット名(member_default の明示宣言は可。admin_full_access は拒否されます)。', }, password_min_length: { label: 'パスワードの最小文字数' }, password_max_length: { label: 'パスワードの最大文字数', help: '非常に長いパスワードのハッシュ化によるサービス拒否を防ぎます。' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 0ffa332cfde..808117d28f9 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -288,7 +288,7 @@ export const zhCN: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: '自助注册权限集', - help: '每个自助注册用户获得的 sys_permission_set 名称(可显式声明 member_default;admin_full_access 会被拒绝)。', + help: '每个自助注册用户获得的权限集名称(可显式声明 member_default;admin_full_access 会被拒绝)。', }, password_min_length: { label: '密码最小长度' }, password_max_length: { label: '密码最大长度', help: '防止超长密码哈希导致的拒绝服务。' }, diff --git a/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts b/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts new file mode 100644 index 00000000000..bce42c314ac --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (C3 stage 8) — the four security-catalog row objects are retired. +// Registered because a client of their data and REST routes, a permission set +// granting object permissions on them, or a seed row targeting one must move to +// the security catalog instead. +export const entry: SemanticMigration = { + id: 'catalog-row-objects-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_position, sys_permission_set, sys_position_permission_set and sys_capability objects — their ' + + 'data and REST routes, their Setup object pages, object permissions naming them, and seed rows targeting them', + replacement: + 'read and write the security catalog through the metadata door: `GET|PUT|DELETE /api/v1/meta/permission/:name` ' + + 'and `/api/v1/meta/position/:name` (a position names its sets in `permissionSets`), read-only ' + + '`GET /api/v1/meta/capability`; copy a set with `POST /api/v1/security/permission-sets/:name/clone`. ' + + 'Declare a capability with `defineCapability` (`stack.capabilities`) instead of a `sys_capability` seed row, ' + + 'and drop object permissions on the four names — they grant nothing', + reason: + 'ADR-0131 D3: positions, permission sets and capabilities have one home, the environment registry, fed by ' + + 'package declarations and by environment metadata saved through the metadata door. The row objects that ' + + 'mirrored them were read by nothing that authorizes, so they are no longer registered; their physical ' + + 'tables and rows stay until the organization-ownership migration (ADR-0131 D10) takes them. ADR-0087.', + acceptanceCriteria: + 'No client calls `/api/v1/data/` on one of the four names, no permission set grants object permissions on ' + + 'them, no seed dataset targets them, and every position, permission set and capability the deployment ' + + 'relies on is served by `GET /api/v1/meta//:name`.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 9452e6b4d95..a9959cf8406 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -16,8 +16,10 @@ export const entry: SemanticMigration = { + 'caller below the platform operator (an ADR-0090 D12 delegate whose scope authors environment ' + 'sets included): PUT, DELETE, publish and rollback of /meta/position, /meta/permission and ' + '/meta/capability on @objectstack/rest, and PUT on the runtime dispatcher; the permission-set ' - + 'clone door (POST /security/permission-sets/:name/clone); and a data-door insert, update or ' - + 'delete on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security', + + 'clone door (POST /security/permission-sets/:name/clone). A data-door insert, update or delete ' + + 'on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security was ' + + 'refused the same way only while those row objects were registered; catalog-row-objects-retired ' + + 'retires them, so the data door now answers 404 OBJECT_NOT_FOUND to every caller', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' @@ -37,7 +39,8 @@ export const entry: SemanticMigration = { + '(403 PERMISSION_DENIED on the runtime dispatcher), the message names the posture and the ' + 'manage_metadata capability, and another organization\'s holder of that set resolves exactly ' + 'what it resolved before. A data-door create on sys_position or sys_permission_set by the same ' - + 'caller answers 403 PERMISSION_DENIED and stores nothing. The platform operator saves the same ' + + 'caller answers 404 OBJECT_NOT_FOUND (the row objects are retired by catalog-row-objects-retired) ' + + 'and stores nothing. The platform operator saves the same ' + 'item with 200, the same organization owner still assigns a catalog permission set to its own ' + 'member with 201, and on a single deployment a manage_metadata holder saves catalog items ' + 'exactly as before. Items saved before this change keep their bytes.', diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index f683384c52f..4fd006c5280 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -36,7 +36,7 @@ export const permissionForm = defineForm({ // them was the Source tab's free-text JSON. `description` is persisted on // sys_permission_set and shown in Setup; the other three are the ADR-0086 // D3 provenance pair plus the ADR-0090 D5 baseline flag. - { field: 'description', type: 'textarea', colSpan: 2, helpText: 'Human-readable description shown in Setup (persisted as sys_permission_set.description).' }, + { field: 'description', type: 'textarea', colSpan: 2, helpText: 'Human-readable description shown in Setup.' }, { field: 'isDefault', type: 'boolean', colSpan: 1, helpText: 'App baseline for the everyone position (ADR-0090 D5): an app-level set is auto-bound at boot; a package-level set becomes an install-time suggestion an admin confirms. Default false.' }, { field: 'managedBy', type: 'select', colSpan: 1, helpText: 'Record provenance (ADR-0086 D3): who owns this set across upgrades.', options: [ { label: 'Package (upgrade-owned metadata)', value: 'package' }, diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 39d9891774b..20fa165653d 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -827,7 +827,7 @@ export const PermissionSetSchema = lazySchema(() => strictObject( * stripped at parse (the ADR-0078 §3 inverse-drift class); the `.strict()` * gate surfaced the gap and it is now declared. */ - description: z.string().optional().describe('Human-readable description shown in Setup (persisted as sys_permission_set.description)'), + description: z.string().optional().describe('Human-readable description shown in Setup'), /** * [ADR-0086 D3] Owning package for a package-shipped permission set diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 8f0d17fed93..a92454d61b5 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -447,7 +447,7 @@ export const AudienceConfigSchema = lazySchema(() => z.object({ * receive the platform-admin set). */ selfRegistrationPermissionSet: z.string().min(1).optional().describe( - 'sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; ' + + 'Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; ' + 'refused for invite_only. admin_full_access is refused.', ), }).superRefine((value, ctx) => { diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 34fedab9a3b..e22d4abc04c 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -105,10 +105,6 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly console.log(m.sweep('../os-keyed-provenance').counts))" + * - Checking the ref out and running the gate there may fail with + * `MODULE_NOT_FOUND` (measured 2026-08-29, when the PR that wrote the first + * record also added this gate). Run TODAY's instrument AGAINST a tree + * instead; `sweep` is exported and takes a root for exactly this: + * + * git worktree add --detach ../os-keyed-provenance "$TREE" + * node --input-type=module -e "import('./scripts/check-keyed-text-bounds.mjs') + * .then((m) => console.log(m.sweep('../os-keyed-provenance').counts))" + * + * - Against the ref ALONE the instrument reads the base's census, which + * differs from the record by exactly what the writing PR changed. The record + * itself reproduces on the landing commit (or on the ref with that PR's diff + * applied). * * All five counts are source-only, so where the tree is available the - * re-derivation is cheap: no install and no build. Re-derived at `MEASURED.ref` - * on 2026-08-29: 113/118/255/594/151 -- exactly the record, which is the - * measurement that rules out "the original count was written wrong". + * re-derivation is cheap: no install and no build. The first record + * (113/118/255/594/151, ref `fa5d137ab0`, written by a PR whose diff declared no + * object) re-derived at its ref on 2026-08-29 -- exactly the record, which is + * the measurement that ruled out "the original count was written wrong". The + * current record is C3 stage 8's: 111/116/236/592/148, re-derived on 2026-10-13 + * on the stage-8 tree (ref `6d7f4df3c9` plus the stage-8 diff). Against the ref + * alone the instrument reads 115/120/248/612/152. The difference is the four + * retired security-catalog object files (-4 files, -4 objects, -12 index + * entries, -21 text fields, -5 keyed columns), less one text field the same diff + * adds: `sys_user_permission_set.permission_set_id` became `Field.text` and is + * keyed by its unique index (+1 text field, +1 keyed column). * * ⚠️ A SHALLOW clone cannot do this at all -- `MEASURED.ref` predates the * default checkout window, so the commit is simply absent. That is why the @@ -378,21 +394,27 @@ const ALLOWLIST_KINDS = new Set(['unboundable', 'pending']); // make visible in a diff. const MEASURED = Object.freeze({ - // The commit this census describes. ⚠️ It is this gate's PR BASE, not a tree - // the gate ever ran in -- see the header for the recipe that actually - // reproduces it, and for why that distinction is not a detail. Immutable, so - // the record stays reproducible forever even as `main` moves away from it. - // ⛔ Never repoint it without re-running all five counts against the new ref. - ref: 'fa5d137ab0', - files: 113, - objects: 118, - indexEntries: 255, - textFields: 594, - keyedTextColumns: 151, + // The base of the PR that last wrote this census; the census is that base + // plus the PR's diff. ⚠️ Not a tree the five counts hold on by itself -- see + // the header for the recipe that actually reproduces it, and for why that + // distinction is not a detail. Immutable, so the record stays reproducible + // forever even as `main` moves away from it. + // ⛔ Never repoint it without re-running all five counts against the new tree. + // + // [C3 stage 8] Re-measured, all five, when the four security-catalog row + // objects retired (ADR-0131 D3): net 4 files, 4 objects, 12 index entries, 20 + // text fields and 4 keyed columns fewer (the header itemises it). The census is + // the stage-8 tree's, the ref its base. + ref: '6d7f4df3c9', + files: 111, + objects: 116, + indexEntries: 236, + textFields: 592, + keyedTextColumns: 148, }); const MIN_FILES = 105; const MIN_OBJECTS = 110; -const MIN_INDEX_ENTRIES = 235; +const MIN_INDEX_ENTRIES = 210; const MIN_TEXT_FIELDS = 550; const MIN_KEYED_TEXT_COLUMNS = 140; @@ -1763,11 +1785,14 @@ export function selfTest() { + 'Every other site interpolates it, so a count and its tree cannot be edited apart', ownCode.split(MEASURED.ref).length - 1 === 1, `${ownCode.split(MEASURED.ref).length - 1} code occurrence(s) of the ref literal`); - const provDrifted = provenanceLine({ - files: 112, objects: 117, indexEntries: 251, textFields: 589, keyedTextColumns: 148, - }); + // A census just below the record, derived from it, so a re-measure moves it too. + const drifted = { + files: MEASURED.files - 1, objects: MEASURED.objects - 1, indexEntries: MEASURED.indexEntries - 4, + textFields: MEASURED.textFields - 5, keyedTextColumns: MEASURED.keyedTextColumns - 3, + }; + const provDrifted = provenanceLine(drifted); t('PROVENANCE — a passing run shows the census it read AND the census the floors came from', - provDrifted.includes('112/117/251/589/148') + provDrifted.includes(FLOORS.map(([key]) => drifted[key]).join('/')) && provDrifted.includes(FLOORS.map(([key]) => MEASURED[key]).join('/')) && provDrifted.includes(FLOORS.map(([, min]) => min).join('/')) && provDrifted.includes(MEASURED.ref), provDrifted); diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 0ca3b874695..cccca4aa004 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 87, - "inReach": 52, + "registered": 83, + "inReach": 48, "outOfReach": 35 }, "reasonTotals": { @@ -140,13 +140,6 @@ "tenantField": "organization_id", "reasons": [] }, - { - "name": "sys_capability", - "file": "packages/plugins/plugin-security/src/objects/sys-capability.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_comment", "file": "packages/plugins/plugin-audit/src/objects/sys-comment.object.ts", @@ -472,13 +465,6 @@ "managedBy: 'better-auth'" ] }, - { - "name": "sys_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_platform_setting", "file": "packages/platform-objects/src/system/sys-platform-setting.object.ts", @@ -488,20 +474,6 @@ "systemFields.tenant: false" ] }, - { - "name": "sys_position", - "file": "packages/plugins/plugin-security/src/objects/sys-position.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, - { - "name": "sys_position_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_presence", "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts", diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 74f027b4d31..04e0104f698 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -1531,6 +1531,18 @@ export const UNTYPED_RECEIVERS = [ engine: true, what: '`ql: any` seed helper writing `schema.name` -- a runtime object name off the registered schema', }, + { + file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', + receiver: 'ql', + engine: true, + what: '`ql: any` writing `sys_permission_set` -- a catalog row object retired by C3 stage 8 (ADR-0131 D3), so the name is no longer a declared object; the module is unwired and deleted with stage 7a', + }, + { + file: 'packages/plugins/plugin-security/src/position-write-through.ts', + receiver: 'ql', + engine: true, + what: '`ql: any` writing `POSITION_OBJECT` (`sys_position`) -- a catalog row object retired by C3 stage 8 (ADR-0131 D3); the module is unwired and deleted with stage 7b', + }, { file: 'packages/services/service-settings/src/settings-service-plugin.ts', receiver: 'eng',