diff --git a/.changeset/15204-7b-position-write-through-retired.md b/.changeset/15204-7b-position-write-through-retired.md new file mode 100644 index 00000000000..381e6d90ef2 --- /dev/null +++ b/.changeset/15204-7b-position-write-through-retired.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): the position write-through and the row-only position backfill are deleted, and the boot reference report stops reading position rows (ADR-0131 D3, C3 stage 7b) + +Clause-②: no + +Stage 8 stopped registering the `sys_position` row object, the `sys_position` data-door write-through and the row-only position backfill. This change deletes the modules behind them, `position-write-through` and `position-environment-backfill`. A position is written through the metadata door alone (`PUT /api/v1/meta/position/:name`). No export of the package's entry changes. + +What a deployment observes: + +- **The boot reference report reads no `sys_position` row.** It still lists, per organization, every position assignment and permission-set grant whose name the security catalog does not resolve, and every grant that names no permission set. It no longer has the two position-row lines ("position(s) … have sys_position rows and no definition" and "position name(s) are carried by more than one sys_position row"). No composition provisions that table any more, so those lines could not fire. Legacy position rows are converted and reported by the upgrade ceremony (ADR-0131 D10). +- The Setup-create refusal for a position name the catalog already holds (`409 UNIQUE_VIOLATION`, raised by the deleted write-through) goes with its module. A `sys_position` data-door create cannot reach the engine any more, so nothing raised it. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c1084bb229f..e5f753eea80 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,7 +109,6 @@ that silently does not happen. | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | | 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | -| 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the package door writes rows for definitions that already have their home, untouched (no boot seeder writes a `sys_position` row, ADR-0131 D2). Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | | 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a8be2d22d5c..a84d1ca73c7 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 42 of them — just under a fifth +typed `any` have no type to read, and there are 40 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 228 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **228** | +| 175 write call sites | quoted in the merged changeset | **225** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 228** decidable, **73** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **153 of 225** decidable, **72** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 42 sites reached -through an erased (`any`) receiver, and the 52 that name their object through a +one, and both are counted in the generated tables below: the 40 sites reached +through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 228 (54%) as decidably elevated, with 97 more whose elevation is a +120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 228`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **228** | -| …whose object name is statically decidable | 155 | -| …whose object name is chosen at run time | 73 | +| write call sites on the application surface | **225** | +| …whose object name is statically decidable | 153 | +| …whose object name is chosen at run time | 72 | | …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 167 | +| threading a tenant context | 164 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 120 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 186 | +| receiver carried a readable engine type | 185 | | 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 | 8 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 6 | | object name spelled inline | 103 | -| object name spelled through a `const` | 52 | +| object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 9 | -| object name is some other run-time expression | 64 | +| object name is some other run-time expression | 63 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ 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 `7c01d30c9`. +Measured on 2026-10-13 at `66949b772`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 650 | -| engine-shaped types recognised | 73 | +| tracked non-test sources scanned | 648 | +| engine-shaped types recognised | 72 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | 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 553ee5a9e8a..c5af45e04ff 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 @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 228 | -| Object name statically decidable | 155 | -| Object name chosen at run time | 73 | +| Write call sites | 225 | +| Object name statically decidable | 153 | +| Object name chosen at run time | 72 | | Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 167 | +| Threading a tenant context | 164 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 120 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,12 +90,12 @@ 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 `7c01d30c9`. +Measured on 2026-10-13 at `66949b772`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 650 | -| engine-shaped types recognised | 73 | +| tracked non-test sources scanned | 648 | +| engine-shaped types recognised | 72 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | @@ -164,9 +164,6 @@ Measured on 2026-10-13 at `7c01d30c9`. | `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` | 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` | 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/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 4378b81ddfe..26a94c5d461 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -186,7 +186,7 @@ async function boot(opts: { posture?: 'single' | 'isolated' } = {}): Promise collectCatalogReferences(engine as any, { posture, catalog }), + report: () => collectCatalogReferences(engine as any, { catalog }), async bootstrapped() { for (const handler of hooks.get('kernel:bootstrapped') ?? []) await handler(); }, @@ -229,18 +229,12 @@ const findings = (report: CatalogReferenceReport) => positionAssignments: o.positionAssignments.map((r) => `${r.id}:${r.name}`), permissionSetGrants: o.permissionSetGrants.map((r) => `${r.id}:${r.name}`), unnamedGrants: o.unnamedGrants.map((g) => `${g.id}:${g.permissionSetId}`), - positionsWithoutDefinition: o.positionsWithoutDefinition.map((p) => `${p.name}${p.refusedName ? ' (refused name)' : ''}`), })); describe('[ADR-0131 D4] the catalog reference report — references whose names resolve nowhere, per organization', () => { it('a deployment whose every reference resolves reports nothing: declared, packaged and environment-defined names', async () => { const b = await boot(); await b.protocol.saveMetaItem({ type: 'position', name: 's3_env_lead', item: { name: 's3_env_lead', label: 'Env lead' } }); - await insertRows(b, 'sys_position', [ - { id: 'pos_field', name: FIELD_LEAD.name, label: 'Field lead' }, - { id: 'pos_pkg', name: PKG_POSITION, label: 'Package lead' }, - { id: 'pos_env', name: 's3_env_lead', label: 'Env lead', organization_id: ORG }, - ]); await insertRows(b, 'sys_user_position', [ { id: 'up_declared', user_id: 'usr_a', position: FIELD_LEAD.name, organization_id: ORG }, { id: 'up_pkg', user_id: 'usr_a', position: PKG_POSITION }, @@ -255,7 +249,6 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names expect(report.stopped).toBeUndefined(); expect(report.absent).toEqual([]); expect(findings(report)).toEqual([]); - expect(report.conflictingPositionNames).toEqual([]); }); it('an assignment and a grant whose names resolve nowhere are listed under their own organization, and nowhere else', async () => { @@ -280,18 +273,18 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names { organizationId: null, positionAssignments: ['up_ghost_global:global_ghost'], - permissionSetGrants: [], unnamedGrants: [], positionsWithoutDefinition: [], + permissionSetGrants: [], unnamedGrants: [], }, { organizationId: ORG, positionAssignments: ['up_ghost_own:ghost_lead'], permissionSetGrants: ['g_ghost:ghost_set'], - unnamedGrants: [], positionsWithoutDefinition: [], + unnamedGrants: [], }, { organizationId: OTHER_ORG, positionAssignments: ['up_ghost_other:ghost_lead'], - permissionSetGrants: [], unnamedGrants: [], positionsWithoutDefinition: [], + permissionSetGrants: [], unnamedGrants: [], }, ]); expect(report.organizations[1].positionAssignments[0].userId).toBe('usr_a'); @@ -319,87 +312,8 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names organizationId: ORG, positionAssignments: [], permissionSetGrants: [], unnamedGrants: ['g_row_only:ps_row_only'], - positionsWithoutDefinition: [], }]); }); - - it('under a walled posture, an organization-authored position has no catalog home: listed by name and organization; the organization\'s built-in copies are not', async () => { - const b = await boot({ posture: 'isolated' }); - // The organizations' own copies of the built-ins, as an existing database - // holds them: the retired built-in seeder wrote them per organization, and - // since ADR-0131 D2 no boot writes them (stage 6a). - await insertRows(b, 'sys_position', [ - { id: 'pos_everyone_own', name: 'everyone', label: 'Everyone', managed_by: 'platform', organization_id: ORG }, - { id: 'pos_everyone_other', name: 'everyone', label: 'Everyone', managed_by: 'platform', organization_id: OTHER_ORG }, - { id: 'pos_org_member_own', name: 'org_member', label: 'Organization Member', managed_by: 'platform', organization_id: ORG }, - ]); - await insertRows(b, 'sys_position', [ - { id: 'pos_regional_own', name: 'regional_lead', label: 'Regional lead', organization_id: ORG }, - { id: 'pos_regional_other', name: 'regional_lead', label: 'Regional lead', organization_id: OTHER_ORG }, - { id: 'pos_night_other', name: 'night_lead', label: 'Night lead', organization_id: OTHER_ORG }, - ]); - await insertRows(b, 'sys_user_position', [ - { id: 'up_regional', user_id: 'usr_a', position: 'regional_lead', organization_id: ORG }, - ]); - - const report = await b.report(); - expect(findings(report)).toEqual([ - { - organizationId: ORG, - positionAssignments: ['up_regional:regional_lead'], - permissionSetGrants: [], unnamedGrants: [], - positionsWithoutDefinition: ['regional_lead'], - }, - { - organizationId: OTHER_ORG, - positionAssignments: [], permissionSetGrants: [], unnamedGrants: [], - positionsWithoutDefinition: ['night_lead', 'regional_lead'], - }, - ]); - expect(report.conflictingPositionNames, 'one name on many rows is the walled shape, not a conflict').toEqual([]); - }); - - it('under a walled posture, a definition saved through the metadata door gives the position its catalog home, and it leaves the report', async () => { - const b = await boot({ posture: 'isolated' }); - await insertRows(b, 'sys_position', [ - { id: 'pos_regional_own', name: 'regional_lead', label: 'Regional lead', organization_id: ORG }, - ]); - await insertRows(b, 'sys_user_position', [ - { id: 'up_regional', user_id: 'usr_a', position: 'regional_lead', organization_id: ORG }, - ]); - expect(findings(await b.report()).map((o) => o.positionsWithoutDefinition)).toEqual([['regional_lead']]); - - await b.protocol.saveMetaItem({ - type: 'position', name: 'regional_lead', item: { name: 'regional_lead', label: 'Regional lead' }, actor: 'system', - }); - expect(findings(await b.report())).toEqual([]); - }); - - it('under single, a position name more than one row carries is conflicting — reported, never merged — whether or not the catalog resolves it', async () => { - const b = await boot(); - await insertRows(b, 'sys_position', [ - // Two organizations' Setup rows of one name, agreeing, from before the - // write door refused a second holder. - { id: 'pos_twin_own', name: 'twin_lead', label: 'Twin', organization_id: ORG }, - { id: 'pos_twin_other', name: 'twin_lead', label: 'Twin', organization_id: OTHER_ORG }, - // A package's seeded row, and an organization's Setup row of the same - // name stored before the Setup create refused a package-held name. - { id: 'pos_pkg', name: PKG_POSITION, label: 'Package lead' }, - { id: 'pos_pkg_own', name: PKG_POSITION, label: 'Mine', organization_id: ORG }, - { id: 'pos_alone', name: 'alone_lead', label: 'Alone', organization_id: ORG }, - ]); - await b.bootstrapped(); - - const report = await b.report(); - expect(report.conflictingPositionNames).toEqual([ - { name: PKG_POSITION, organizationIds: [null, ORG], resolves: true }, - // `resolves` for this one is the row-only position backfill's doing, - // which C3 stage 8 unwires, so it is not asserted. - expect.objectContaining({ name: 'twin_lead', organizationIds: [ORG, OTHER_ORG] }), - ]); - const conflictLine = b.logger.error.mock.calls.find(([, , meta]: any[]) => meta?.conflicting); - expect(conflictLine?.[2]?.conflicting?.items?.map((c: any) => c.name)).toEqual([PKG_POSITION, 'twin_lead']); - }); }); describe('[ADR-0131 D4] a removed declaration is reported; re-adding it clears the report', () => { @@ -482,7 +396,7 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', { id: 'up_ghost', user_id: 'usr_a', position: 'ghost_lead', organization_id: ORG }, ]); const catalog = { resolve: vi.fn().mockRejectedValue(new Error('registry offline')), list: vi.fn() }; - const report = await reportCatalogReferences(b.engine as any, { posture: 'single', catalog, logger: b.logger }); + const report = await reportCatalogReferences(b.engine as any, { catalog, logger: b.logger }); expect({ stopped: report.stopped, organizations: report.organizations }).toEqual({ stopped: 'catalog-unreadable', organizations: [], }); @@ -499,7 +413,7 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', return b.engine.find(object, options as EngineQueryOptions); }, }; - const report = await collectCatalogReferences(engine, { posture: 'single', catalog: b.catalog }); + const report = await collectCatalogReferences(engine, { catalog: b.catalog }); expect({ stopped: report.stopped, reading: report.stoppedReading, organizations: report.organizations }) .toEqual({ stopped: 'scan-unreadable', reading: 'sys_user_permission_set', organizations: [] }); }); @@ -508,8 +422,8 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', const engine: CatalogReferenceReportEngine = { getObject: () => undefined, find: vi.fn() }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const catalog = { resolve: vi.fn(), list: vi.fn() }; - const report = await reportCatalogReferences(engine, { posture: 'single', catalog, logger }); - expect(report.absent).toEqual(['sys_user_position', 'sys_user_permission_set', 'sys_position']); + const report = await reportCatalogReferences(engine, { catalog, logger }); + expect(report.absent).toEqual(['sys_user_position', 'sys_user_permission_set']); expect(engine.find).not.toHaveBeenCalled(); expect([logger.info, logger.warn, logger.error].map((f) => f.mock.calls.length)).toEqual([0, 0, 0]); }); diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.ts b/packages/plugins/plugin-security/src/catalog-reference-report.ts index 84ae22cdb77..55b1d17119f 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.ts @@ -26,24 +26,12 @@ * another organization's set row, a set row whose name the catalog does not * resolve (a row-only set) — stay unnamed, and an unnamed grant confers * nothing. - * 4. **Positions with no definition** — `sys_position` rows whose name the - * catalog does not resolve, by name and organization. Under a walled - * posture these are the positions an organization authored in Setup: there - * is no organization-level catalog (ADR-0131 D3), so they have no registry - * home. Under `single` they are the row-only positions the row-only - * position backfill (`position-environment-backfill.ts`) could not define, - * each marked `refusedName` when its name is one the metadata door refuses - * (that class is final for the backfill, which records its verdict and - * stops reporting it). * - * And, under `single` only, where a position name is unique per deployment: - * - * 5. **Conflicting position names** — a name carried by more than one - * `sys_position` row (two organizations' rows, or an organization's row - * beside the row a package or a built-in seeded). Reported, never guessed - * and never merged (ADR-0131 D10, fate 4). Under a walled posture the - * catalog rows are materialized per organization, so one name on several - * rows is the expected shape there, not a conflict. + * It reads no `sys_position` row. The catalog row objects are retired (C3 + * stage 8), so no composition provisions that table, and a Setup-authored + * position row with no definition — or a name several such rows carry — is + * the upgrade ceremony's to convert or report (ADR-0131 D10, C7), never a boot + * report's. * * ## What it does not do * @@ -60,27 +48,23 @@ * raises `AuthzStoreUnavailableError` for a reader that failed), stops the * report: it says which store could not be read and reports no finding past * that point, because an unread answer is never taken for an absent name. A - * composition without one of the three objects reads the others and names the + * composition without one of the two objects reads the other and names the * absent one in the result. * * ## When it runs * * At `kernel:bootstrapped`, from `SecurityPlugin.start`, AFTER the grant-name - * and row-only position backfills registered beside it (handlers run in - * registration order): every `kernel:ready` handler has settled, so every - * code, package and environment definition this boot registers is in the - * catalog, and a name either backfill gives a definition or a grant this boot - * is not reported. A package installed into the running process later is not + * backfill registered beside it (handlers run in registration order): every + * `kernel:ready` handler has settled, so every code, package and environment + * definition this boot registers is in the catalog, and a name the backfill + * gives a grant this boot is not reported. A package installed into the running process later is not * re-judged until the next boot, which is why every line says "at this boot". * - * ADR anchors: ADR-0131 D3 (one catalog home, no organization-level catalog), - * D4 (references by name), D10 (a conflict is reported, never guessed). + * ADR anchors: ADR-0131 D3 (one catalog home), D4 (references by name). */ -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import type { SecurityCatalogReader, SecurityCatalogType } from '@objectstack/core'; import { GRANT_OBJECT, GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD } from './grant-permission-set-name.js'; -import { POSITION_OBJECT, metadataDoorAcceptsPositionName } from './position-write-through.js'; /** The user ↔ position assignment object (ADR-0057 D4). */ export const POSITION_ASSIGNMENT_OBJECT = 'sys_user_position'; @@ -108,8 +92,6 @@ export interface CatalogReferenceReportLogger { } export interface CatalogReferenceReportDeps { - /** The tenancy posture in force: it decides whether a shared position name is a conflict. */ - posture: TenancyPosture; /** The security catalog read every name is resolved through. */ catalog: SecurityCatalogReader; logger?: CatalogReferenceReportLogger; @@ -132,29 +114,12 @@ export interface UnnamedGrant { permissionSetId: string | null; } -/** One position name an organization holds rows of, with no definition in the catalog. */ -export interface PositionWithoutDefinition { - name: string; - /** The metadata door refuses this name, so no definition can ever carry it. */ - refusedName: boolean; -} - /** Everything the report found for one organization; `null` is the organization-less bucket. */ export interface OrganizationCatalogReferences { organizationId: string | null; positionAssignments: UnresolvedReference[]; permissionSetGrants: UnresolvedReference[]; unnamedGrants: UnnamedGrant[]; - positionsWithoutDefinition: PositionWithoutDefinition[]; -} - -/** A position name more than one `sys_position` row carries under `single`. */ -export interface ConflictingPositionName { - name: string; - /** The organization of each row carrying it, `null` for an organization-less row; sorted, repeats kept. */ - organizationIds: Array; - /** Whether the catalog resolves the name (a package, a built-in or the environment holds it). */ - resolves: boolean; } /** Why the report stopped before it judged every reference. */ @@ -164,10 +129,7 @@ export type CatalogReferenceReportStop = /** What one report found. Only organizations with a finding are listed, the organization-less bucket first. */ export interface CatalogReferenceReport { - posture: TenancyPosture; organizations: OrganizationCatalogReferences[]; - /** Under `single` only; always empty under a walled posture. */ - conflictingPositionNames: ConflictingPositionName[]; /** The objects this composition does not provision; their part of the report was not read. */ absent: string[]; /** Set when a read did not happen; no finding is reported past it. */ @@ -267,12 +229,10 @@ function catalogResolver(catalog: SecurityCatalogReader): (type: SecurityCatalog */ export async function collectCatalogReferences( engine: CatalogReferenceReportEngine, - deps: Pick, + deps: Pick, ): Promise { const report: CatalogReferenceReport = { - posture: deps.posture, organizations: [], - conflictingPositionNames: [], absent: [], }; const has = (object: string): boolean => { @@ -282,7 +242,6 @@ export async function collectCatalogReferences( }; const readAssignments = has(POSITION_ASSIGNMENT_OBJECT); const readGrants = has(GRANT_OBJECT); - const readPositions = has(POSITION_OBJECT); const byOrganization = new Map(); const bucket = (organizationId: string | null): OrganizationCatalogReferences => { @@ -293,7 +252,6 @@ export async function collectCatalogReferences( positionAssignments: [], permissionSetGrants: [], unnamedGrants: [], - positionsWithoutDefinition: [], }; byOrganization.set(organizationId, found); } @@ -332,41 +290,12 @@ export async function collectCatalogReferences( } } - if (readPositions) { - // No field projection: the organization column is the one the tenancy - // layer provisions, not one the object declares. - const rows = await scanAll(engine, POSITION_OBJECT); - const organizationsByName = new Map>(); - for (const row of rows) { - const name = nameOf(row.name); - if (name === undefined) continue; - const list = organizationsByName.get(name); - if (list) list.push(organizationOf(row)); - else organizationsByName.set(name, [organizationOf(row)]); - } - const single = !postureEnforcesWall(deps.posture); - for (const name of [...organizationsByName.keys()].sort()) { - const organizationIds = organizationsByName.get(name)!.sort(compareKeys); - const resolved = await resolves('position', name); - if (!resolved) { - for (const organizationId of new Set(organizationIds)) { - bucket(organizationId).positionsWithoutDefinition.push({ - name, refusedName: !metadataDoorAcceptsPositionName(name), - }); - } - } - if (single && organizationIds.length > 1) { - report.conflictingPositionNames.push({ name, organizationIds, resolves: resolved }); - } - } - } } catch (e) { if (!(e instanceof ReportStop)) throw e; report.stopped = e.stop; report.stoppedReading = e.reading; report.stoppedError = errorText(e.reason); report.organizations = []; - report.conflictingPositionNames = []; return report; } @@ -466,53 +395,12 @@ function logReport(report: CatalogReferenceReport, logger?: CatalogReferenceRepo ); } - const positions = perOrganization(report, (o) => o.positionsWithoutDefinition, (items) => ({ - positions: listed(items.map((i) => i.name)), - refusedNames: items.filter((i) => i.refusedName).map((i) => i.name), - })); - if (positions.count > 0) { - const walled = postureEnforcesWall(report.posture); - logError( - logger, - `[security] ${positions.count} position(s), listed by name and organization, have ${POSITION_OBJECT} rows ` + - 'and no definition the security catalog resolves at this boot, so assignments to them distribute no ' + - 'permission set. ' + - (walled - ? 'Under a walled posture a position an organization authored in Setup has no catalog home: the catalog ' + - 'is environment-level (ADR-0131 D3). Fix: define the position in the environment catalog through the ' + - 'metadata door, or remove it and its assignments.' - : 'Under single the row-only position backfill gives such a position its environment definition, except ' + - 'a name the metadata door refuses (refusedNames) or a name whose rows disagree. Fix: rename a refused ' + - 'name in Setup to a lowercase snake_case name; make disagreeing rows agree; or remove the position and ' + - 'its assignments.') + - ' Nothing was changed.', - { count: positions.count, organizations: positions.organizations }, - ); - } - - if (report.conflictingPositionNames.length > 0) { - logError( - logger, - `[security] ${report.conflictingPositionNames.length} position name(s) are carried by more than one ` + - `${POSITION_OBJECT} row under the single posture, where a position name is unique per deployment. Each is ` + - 'reported as conflicting, never guessed and never merged (ADR-0131 D10): an assignment names only the ' + - 'name, so at most one of those rows can be what it means. Nothing was changed. Fix: keep one row per name ' + - '— rename or remove the others in Setup.', - { - count: report.conflictingPositionNames.length, - conflicting: listed(report.conflictingPositionNames.map((c) => ({ - name: c.name, organizationIds: c.organizationIds, resolves: c.resolves, - }))), - }, - ); - } - - const clean = report.organizations.length === 0 && report.conflictingPositionNames.length === 0; - // A composition with none of the three objects has nothing to report on. - if (clean && report.absent.length < 3) { + const clean = report.organizations.length === 0; + // A composition with neither object has nothing to report on. + if (clean && report.absent.length < 2) { logger?.info?.( '[security] catalog reference report: every position assignment and permission-set grant names an item ' + - 'the security catalog resolves at this boot, and no position row is without a definition', + 'the security catalog resolves at this boot', { absent: report.absent }, ); } diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index 70f3c1be9ae..2f5acfb507b 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -42,7 +42,6 @@ const FLOOR = [ 'MaskedValueWriteError', 'ExplainObjectNotFoundError', 'PermissionSetNameConflictError', - 'PositionNameConflictError', ]; /** diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index a857d1c10d5..eaf10992655 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -417,37 +417,6 @@ export class PermissionSetNameConflictError extends Error { } } -/** - * [ruling Q (a), ADR-0131 D3] A Setup create of a position, or a rename into a - * name, that the security catalog already holds a definition of under the - * `single` posture: a second holder of one position name per deployment → - * `409 UNIQUE_VIOLATION` (`position-write-through.ts`). - * - * The wire identity is the one this platform already answers for a second - * `sys_position` row of a name in ONE organization (the engine's unique index, - * reported as `409 UNIQUE_VIOLATION`), and the one - * {@link PermissionSetNameConflictError} answers for the sibling catalog — one - * condition, one envelope, whichever layer catches it. It stamps the same - * named constants, so it adds no second emitter spelling of the code. - */ -export class PositionNameConflictError extends Error { - readonly code = PERMISSION_SET_NAME_CONFLICT_CODE; - readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS; - readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS; - /** The position name that is already held. */ - readonly positionName: string; - constructor(positionName: string, holder?: string) { - super( - `[Security] a position named '${positionName}' already exists in this deployment` - + `${holder ? ` (declared by '${holder}')` : ''}: under the single tenancy posture a position name ` - + 'is unique per deployment, and two holders of one name are never merged. Choose another name, ' - + 'or edit the existing position.', - ); - this.name = 'PositionNameConflictError'; - this.positionName = positionName; - } -} - export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; diff --git a/packages/plugins/plugin-security/src/position-environment-backfill.ts b/packages/plugins/plugin-security/src/position-environment-backfill.ts deleted file mode 100644 index 64e98782319..00000000000 --- a/packages/plugins/plugin-security/src/position-environment-backfill.ts +++ /dev/null @@ -1,453 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The one-time backfill of row-only positions into the environment ledger - * under `single` (ADR-0131 D3, C2 stage S7). - * - * ## What it does - * - * Before the position write-through (`position-write-through.ts`), a position - * created in Setup was a `sys_position` row and nothing else: no definition in - * the environment ledger, so the security catalog read - * (`createSecurityCatalogReader`, `@objectstack/core`) did not resolve it. The - * write-through gives every new Setup position its definition; this pass gives - * one to every position written before it — a ROW-ONLY position, one whose - * name the catalog read does not resolve — so the planned read switch finds - * every position a `single` deployment holds. - * - * For each name some `sys_position` row carries: - * - * 1. **The catalog read decides "row-only".** A name the environment ledger, a - * package or a built-in already declares resolves, and is left alone — - * its definition already has a home, and its rows are the seeders'. - * 2. **A name the metadata door does not accept is a final class.** The door - * refuses a name outside its item-name grammar or `PositionSchema.name`, so - * such a position can never have a definition; it is reported, by count and - * name, and never written (seat re-rule on the C2 card, Q1 = A). It stays a - * row the read switch will report. - * 3. **The rows of one name must agree.** The definition is read from the row - * (`{ name, label, description, delegatable }`); where two rows of one name - * (two organizations of one `single` deployment) disagree on it, nothing is - * written for that name and it is reported: which row's text the definition - * should carry is not this pass's to guess. - * 4. **Written through the metadata door at environment scope** - * (`saveMetaItem`, actor `system`), then verified through the catalog read. - * - * ## Once, and remembered - * - * The verdict is recorded in the deployment ledger `sys_migration` (row id - * {@link POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}; `verified_at: null` and - * `blocking: 0`, the shape of the grant-name backfill beside it) only when the - * pass decided every name: each row-only name was given its definition, or is - * in the final refused-name class. A write that did not land, a definition the - * catalog read does not resolve afterwards, a name whose rows disagree, or a - * read that did not happen leaves the verdict unrecorded, and the next boot - * runs the pass again. A rerun writes nothing a previous run wrote: a name - * that has its definition resolves, and is left alone. - * - * ## When it runs - * - * At `kernel:bootstrapped`, from `SecurityPlugin.start`, beside the grant-name - * backfill: every `kernel:ready` handler has settled (the declared-position and - * built-in seeders among them) and every plugin has registered its - * declarations, so the catalog read sees every holder a name can have. The - * order is load-bearing: an environment definition minted under a name a - * package registers later is refused at the registry's item seam - * (`NAMESPACE_CONFLICT`), so a pass run ahead of the declarations would turn - * a package's registration into a refusal. Under a walled posture the pass - * does not run: an organization's row has no environment home (ADR-0131 D3). - */ - -import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import type { SecurityCatalogReader } from '@objectstack/core'; -import { - POSITION_METADATA_TYPE, - POSITION_OBJECT, - metadataDoorAcceptsPositionName, - positionBodyFromRow, - type PositionMetadataDoor, -} from './position-write-through.js'; - -/** Ledger row id of the one-time position backfill. */ -export const POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID = 'adr-0131-position-environment-backfill'; - -/** Rows per page of the position scan. */ -const SCAN_PAGE_SIZE = 500; - -/** Names printed per category; the count is always complete. */ -const LOGGED_NAME_LIMIT = 50; - -const SYSTEM_CTX = { isSystem: true } as const; - -/** The engine surface the backfill uses — ObjectQL satisfies it as it stands. */ -export interface PositionBackfillEngine { - getObject(name: string): unknown; - find(object: string, options: Record): Promise; - findOne(object: string, options: Record): Promise | null>; - insert(object: string, data: Record, options?: Record): Promise; -} - -/** The kernel logger, as this module uses it. */ -export interface PositionBackfillLogger { - info?: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - /** The kernel logger's shape: message, cause, meta. */ - error?: (message: string, cause?: Error, meta?: Record) => void; -} - -/** Why a pass stopped before it reached a verdict. */ -export type PositionBackfillStop = - /** No `sys_position` object in this composition. */ - | 'objects-absent' - /** No metadata door that can save. */ - | 'door-absent' - /** The position scan could not be read in full. */ - | 'scan-unreadable' - /** The security catalog read did not happen. */ - | 'catalog-unreadable'; - -/** What one pass did. Every list holds position names. */ -export interface PositionBackfillResult { - /** Distinct names the scan found. */ - names: number; - /** Names the catalog read did not resolve before this pass. */ - rowOnly: string[]; - /** Names this pass gave a definition, verified through the catalog read. */ - backfilled: string[]; - /** Names the metadata door does not accept — never written, final. */ - refusedName: string[]; - /** Names whose rows disagree on the definition — not written. */ - conflicting: string[]; - /** Names whose definition write did not land, or does not resolve after it. */ - failed: string[]; - /** Set when the pass stopped before it judged every name. */ - stopped?: PositionBackfillStop; -} - -export interface PositionBackfillDeps { - /** The tenancy posture in force; the pass runs under `single` only. */ - posture: TenancyPosture; - /** S1's security catalog read: "row-only" is a name it does not resolve. */ - catalog: SecurityCatalogReader; - /** The metadata door the definitions are written through. */ - door: unknown; - logger?: PositionBackfillLogger; -} - -/** Thrown inside the pass to stop it; never escapes the module. */ -class BackfillStop extends Error { - constructor(readonly stop: PositionBackfillStop, readonly reason: unknown) { - super(stop); - } -} - -const STOPPED_READING: Record = { - 'objects-absent': 'this composition', - 'door-absent': 'the metadata door', - 'scan-unreadable': POSITION_OBJECT, - 'catalog-unreadable': 'the security catalog', -}; - -const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); - -function logError(logger: PositionBackfillLogger | undefined, message: string, meta: Record): void { - if (logger?.error) logger.error(message, undefined, meta); - else logger?.warn(message, meta); -} - -/** At most {@link LOGGED_NAME_LIMIT} names, and how many more there are. */ -function listed(names: readonly string[]): { names: string[]; more?: number } { - return names.length > LOGGED_NAME_LIMIT - ? { names: names.slice(0, LOGGED_NAME_LIMIT), more: names.length - LOGGED_NAME_LIMIT } - : { names: [...names] }; -} - -function canSave(door: unknown): door is Pick { - return !!door && typeof (door as { saveMetaItem?: unknown }).saveMetaItem === 'function'; -} - -/** Every `sys_position` row, grouped by name, read in full before anything is written. */ -async function scanRowsByName(engine: PositionBackfillEngine): Promise[]>> { - const byName = new Map[]>(); - for (let offset = 0; ; offset += SCAN_PAGE_SIZE) { - let page: unknown; - try { - page = await engine.find(POSITION_OBJECT, { - where: {}, - fields: ['id', 'name', 'label', 'description', 'delegatable'], - orderBy: [{ field: 'id', order: 'asc' }], - limit: SCAN_PAGE_SIZE, - offset, - context: SYSTEM_CTX, - }); - } catch (e) { - throw new BackfillStop('scan-unreadable', e); - } - const rows = Array.isArray(page) ? (page as Record[]) : []; - for (const row of rows) { - const name = row?.name; - if (typeof name !== 'string' || name === '') continue; - const list = byName.get(name); - if (list) list.push(row); - else byName.set(name, [row]); - } - if (rows.length < SCAN_PAGE_SIZE) break; - } - return byName; -} - -async function catalogResolves(catalog: SecurityCatalogReader, name: string): Promise { - try { - const entry = await catalog.resolve(POSITION_METADATA_TYPE, name); - return entry !== undefined && entry.name === name; - } catch (e) { - throw new BackfillStop('catalog-unreadable', e); - } -} - -/** Report what one pass did and could not do — once per category, never once per name. */ -function reportPass(result: PositionBackfillResult, logger?: PositionBackfillLogger): void { - if (result.backfilled.length > 0) { - logger?.info?.( - `[security] ${result.backfilled.length} row-only position(s) now have an environment definition (ADR-0131 D3): ` + - `each was read from its ${POSITION_OBJECT} row, saved through the metadata door at environment scope and ` + - 'resolved through the security catalog read', - { backfilled: listed(result.backfilled) }, - ); - } - if (result.refusedName.length > 0) { - logger?.warn( - `[security] ${result.refusedName.length} position(s) keep no environment definition: their names are not ` + - 'position names the metadata door accepts (lowercase snake_case, starting with a letter, at least two ' + - 'characters), so no definition can carry them. Each still grants from its row today, and the catalog read ' + - 'will not find it. Fix: rename each position in Setup to a lowercase snake_case name — the rename gives it ' + - 'its definition — and re-point the assignments that name it.', - { count: result.refusedName.length, positions: listed(result.refusedName) }, - ); - } - if (result.conflicting.length > 0) { - logError( - logger, - `[security] ${result.conflicting.length} row-only position name(s) were NOT given an environment definition: ` + - `the ${POSITION_OBJECT} rows carrying each name disagree on its label, description or delegatable, and ` + - 'one environment definition cannot carry both. They still grant from their rows today, and would not be ' + - 'found by the catalog read. Fix: make the rows agree (or rename one) in Setup — an edit gives the name its ' + - 'definition; the backfill runs again on the next boot.', - { count: result.conflicting.length, positions: listed(result.conflicting) }, - ); - } - if (result.failed.length > 0) { - logError( - logger, - `[security] ${result.failed.length} row-only position(s) were NOT given an environment definition: the ` + - 'metadata door refused or lost the write, or the catalog read did not resolve it afterwards, while every ' + - 'other line of this backfill reads clean. They still grant from their rows today. The backfill runs again ' + - 'on the next boot; if it fails again, check that the metadata store (sys_metadata) is writable.', - { count: result.failed.length, positions: listed(result.failed) }, - ); - } -} - -/** - * One pass: give every row-only position its environment definition, report - * the rest. A read that did not happen stops the pass; the stop is returned in - * `stopped` and reported, never thrown. - */ -export async function backfillRowOnlyPositions( - engine: PositionBackfillEngine, - deps: Pick, -): Promise { - const result: PositionBackfillResult = { - names: 0, rowOnly: [], backfilled: [], refusedName: [], conflicting: [], failed: [], - }; - const { catalog, door, logger } = deps; - if (!engine?.getObject?.(POSITION_OBJECT)) { - result.stopped = 'objects-absent'; - return result; - } - if (!canSave(door)) { - result.stopped = 'door-absent'; - logger?.warn( - '[security] the row-only position backfill did not run: no metadata door that can save is registered, so ' + - 'positions created in Setup before the environment write-through keep no environment definition. It runs ' + - 'again on the next boot.', - ); - return result; - } - - try { - const byName = await scanRowsByName(engine); - result.names = byName.size; - for (const [name, rows] of [...byName.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { - if (await catalogResolves(catalog, name)) continue; - result.rowOnly.push(name); - if (!metadataDoorAcceptsPositionName(name)) { - result.refusedName.push(name); - continue; - } - const bodies = new Set(rows.map((row) => JSON.stringify(positionBodyFromRow(row)))); - if (bodies.size > 1) { - result.conflicting.push(name); - continue; - } - try { - await door.saveMetaItem({ - type: POSITION_METADATA_TYPE, - name, - item: positionBodyFromRow(rows[0]), - actor: 'system', - }); - } catch (e) { - result.failed.push(name); - if (result.failed.length === 1) { - logError( - logger, - `[security] the row-only position backfill could not save the environment definition of '${name}' ` + - '(ADR-0131 D3): the position keeps granting from its row, and the catalog read does not find it. ' + - 'The count of every such position follows when the pass ends; it runs again on the next boot.', - { name, error: errorText(e) }, - ); - } - continue; - } - if (await catalogResolves(catalog, name)) result.backfilled.push(name); - else result.failed.push(name); - } - } catch (e) { - if (!(e instanceof BackfillStop)) throw e; - result.stopped = e.stop; - logger?.warn( - `[security] the row-only position backfill stopped before it judged every position: ` + - `${STOPPED_READING[e.stop]} could not be read, and an unread answer is never taken for "already declared". ` + - 'No definition was written past that point; the backfill runs again on the next boot.', - { stop: e.stop, error: errorText(e.reason), backfilled: result.backfilled.length }, - ); - } - reportPass(result, logger); - return result; -} - -/** A pass's verdict is final when nothing a later pass could decide differently remains. */ -export function isRecordablePositionBackfillVerdict(result: PositionBackfillResult): boolean { - return result.stopped === undefined && result.failed.length === 0 && result.conflicting.length === 0; -} - -/** What the deployment ledger says about this backfill. */ -export type PositionBackfillLedgerReading = 'recorded' | 'absent' | 'unavailable' | 'unreadable'; - -/** Read the ledger row by its id. Never throws. */ -export async function readPositionBackfillLedger(engine: PositionBackfillEngine): Promise { - try { - if (!engine?.getObject?.(DATA_MIGRATION_FLAG_OBJECT)) return 'unavailable'; - const row = await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID }, - context: SYSTEM_CTX, - }); - return row?.id === POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID ? 'recorded' : 'absent'; - } catch { - return 'unreadable'; - } -} - -/** The ledger row for a decided pass — pure. */ -export function buildPositionBackfillRecord(result: PositionBackfillResult, now: string): DataMigrationFlag { - return { - id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID, - last_run_at: now, - applied_at: now, - verified_at: null, - blocking: 0, - details: JSON.stringify({ - names: result.names, - rowOnly: result.rowOnly.length, - backfilled: result.backfilled.length, - refusedName: result.refusedName.length, - }), - }; -} - -/** - * Insert the ledger row. THROWS on failure — the caller decides what a lost - * record means. - */ -async function persistPositionBackfillRecord(engine: PositionBackfillEngine, flag: DataMigrationFlag): Promise { - const now = flag.last_run_at; - await engine.insert(DATA_MIGRATION_FLAG_OBJECT, { ...flag, created_at: now, updated_at: now }, { context: SYSTEM_CTX }); -} - -export type PositionBackfillStatus = - /** A walled posture: the pass does not apply. */ - | 'not-applicable' - /** The pass decided, and its record landed. */ - | 'ran' - /** The pass decided, but its record did not land (or there is no ledger to land it in). */ - | 'ran-unrecorded' - /** The pass left something a later pass may decide differently — retried on the next boot. */ - | 'undecided' - /** The ledger already records the verdict — nothing was scanned or written. */ - | 'already-run'; - -export interface OneTimePositionBackfillResult { - status: PositionBackfillStatus; - ledger?: PositionBackfillLedgerReading; - /** The pass's own summary, present whenever it ran. */ - backfill?: PositionBackfillResult; -} - -/** - * Run the row-only position backfill under `single` unless the deployment - * ledger already records its verdict, and record the verdict when this pass - * reaches one. A read the pass could not make is reported and returned, never - * thrown; the boot hook that calls this still guards against anything - * unforeseen. - */ -export async function runOneTimePositionEnvironmentBackfill( - engine: PositionBackfillEngine, - deps: PositionBackfillDeps, -): Promise { - if (postureEnforcesWall(deps.posture)) return { status: 'not-applicable' }; - const { logger } = deps; - const ledger = await readPositionBackfillLedger(engine); - if (ledger === 'recorded') return { status: 'already-run', ledger }; - - const backfill = await backfillRowOnlyPositions(engine, deps); - if (!isRecordablePositionBackfillVerdict(backfill)) return { status: 'undecided', ledger, backfill }; - - if (ledger !== 'absent') { - logger?.warn( - `[security] the row-only position backfill reached its verdict but cannot record it: the deployment ledger ` + - `${DATA_MIGRATION_FLAG_OBJECT} is ${ledger === 'unavailable' ? 'not available on this kernel' : 'unreadable'}, ` + - 'so every boot scans the positions again. Nothing is written twice. Compose PlatformObjectsPlugin (it ' + - 'provisions the ledger) to let the verdict be remembered.', - { ledger }, - ); - return { status: 'ran-unrecorded', ledger, backfill }; - } - - const flag = buildPositionBackfillRecord(backfill, new Date().toISOString()); - try { - await persistPositionBackfillRecord(engine, flag); - } catch (e) { - // A concurrent boot that landed the same id first has recorded it. - if ((await readPositionBackfillLedger(engine)) === 'recorded') return { status: 'ran', ledger, backfill }; - // At `error`: the pass's writes stand and every line reads clean, while the - // record that makes it one-time is absent. - logError( - logger, - `[security] the row-only position backfill ran, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed ` + - `(${errorText(e)}). The next boot scans every position again and re-reports what it cannot give a ` + - `definition. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this deployment (it is provisioned by ` + - `PlatformObjectsPlugin) and verify with SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = ` + - `'${POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}'.`, - { error: errorText(e) }, - ); - return { status: 'ran-unrecorded', ledger, backfill }; - } - logger?.info?.( - `[security] the row-only position backfill is recorded in ${DATA_MIGRATION_FLAG_OBJECT} ` + - `(id '${POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}') — later boots will not run it again`, - { id: flag.id, details: flag.details }, - ); - return { status: 'ran', ledger, backfill }; -} diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts deleted file mode 100644 index f1e4ca552fb..00000000000 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ /dev/null @@ -1,583 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The `sys_position` data-door write-through under `single` (ADR-0131 D3, C2 - * stage S7). - * - * ## What it does - * - * ADR-0131 D3 gives the security catalog one home, the environment registry, - * and says of the `single` posture: "an admin who creates or edits a position - * ... in Setup performs an environment metadata write". Before this module a - * Setup create or edit of a position wrote a `sys_position` row and nothing - * else, so the position had no registry home and the catalog read - * (`createSecurityCatalogReader`, `@objectstack/core`) did not resolve it. Every - * non-system data-door write on `sys_position` under `single` now also writes - * the position's DEFINITION through the metadata door, at environment scope: - * - * - **the row first.** The engine executes the row write as before, with every - * check it already makes (the required label, the reserved built-in names, - * one name per organization, the junction's `DELETE_RESTRICTED`). Only a row - * the engine accepted gets a definition. Writing the definition first would, - * for a duplicate name, overwrite the existing definition before the unique - * index refused the row; - * - **then the definition** `{ name, label, description, delegatable }`, read - * back from the written row, so the row and the definition agree by - * construction. `active` and `is_default` are row state (no `PositionSchema` - * key carries them; the standing ruling keeps `active` on the row), so a - * patch touching only them is a row write and nothing else; - * - **a refusal undoes the row.** When the metadata door refuses a create, or - * an update that renames a position, the row write is undone and the door's - * own refusal is the answer: nothing is kept. That is the refusal for a name - * the door does not accept (its item-name grammar, `PositionSchema.name`): - * the data door accepted such names before this stage, and a position named - * that way could never have a registry home (seat re-rule on the C2 card, - * Q1 = A); - * - **rename** saves the new name's definition and deletes the old name's; - * - **delete** deletes the row, then the definition. A definition delete that - * fails is reported at `error`: the declared-position seeder recreates a row - * for every definition the catalog lists at the next boot, so the deleted - * position would come back. - * - * ## One namespace — a name a package or a built-in holds is not taken (C2 stage S10) - * - * Positions hold one name per deployment (ADR-0048 addendum, N.2), and a write - * with no package provenance over a package-held name stays under ADR-0005 - * overlay precedence (N.3): `position` takes no environment overlay - * (`allowEnvironmentOverlay: false`), so the metadata door refuses that save with its - * locked-base refusal (`403 NOT_OVERRIDABLE`). A Setup **create**, or a - * **rename into** such a name, is now answered with that same refusal, and the - * row write is undone — before this stage the write-through stood down and the - * row landed beside the package's (or the platform's) position under the same - * name, while a rename also deleted the renamed position's own definition. - * - * - **asked of its owner, never re-derived**: the verdict is the metadata - * door's `packagedBaseRefusal` (the predicate and the emitter `saveMetaItem` - * uses — the engine registry's artifact provenance, - * `SchemaRegistry.getArtifactItem`), relayed as the door built it, code, - * status and sentence; this module stamps no code of its own. It is asked - * for the verdict alone, so nothing is written to environment metadata for - * such a name, whatever the answer; - * - **after the engine's own checks**: the row first, as for every other write - * here, so a refusal the engine already makes keeps its own answer (the - * reserved built-in identity names `400 VALIDATION_FAILED`, one name per - * organization `409 UNIQUE_VIOLATION`); only a row the engine accepted is - * asked about, and its write is undone when the door refuses the name; - * - **a door without that verdict** (a protocol that does not bring - * `packagedBaseRefusal`) keeps the stand-down below, as the `/automation` - * doors keep theirs. - * - * ## One namespace — a name the environment catalog already holds is not taken either (ruling Q (a)) - * - * Under `single` a position name is unique per DEPLOYMENT (the cutover's - * ruling Q (a): "the write door refuses a second holder"), while the row's own - * index is unique per organization only. So a Setup **create**, or a **rename - * into** a name, that the security catalog already resolves to a definition - * no package or built-in holds (S10 above answers those) — an environment - * definition another organization's row wrote, one an author saved through - * the metadata door, or one an application stack declares in memory — is - * refused, and the row write is undone. Before this, the definition save that - * follows overwrote the existing definition with this row's label and - * description and dropped its `permissionSets`: two holders silently merged - * into one. The refusal is `409 UNIQUE_VIOLATION` (`PositionNameConflictError`) - * — the answer a second row of the same name in one organization already gets - * over HTTP — so an author meets one dialect for "this name is taken". The read is the - * catalog the resolver grants through (`securityCatalogReaderOf`), asked - * before any definition is saved; a catalog that cannot be read refuses (the - * row goes and that failure is the answer), because accepting would merge - * blind. The upgrade ceremony reports a pair that already exists as - * `conflicting` and never merges it (`convertPositionBindingRows`). - * - * ## Where it stands down — the write proceeds exactly as before - * - * - **a system write** (`isSystem`): the seeders and the package door write - * rows for definitions that already have their home; - * - **a walled posture**: under a wall a definition is the operator's - * (ADR-0131 D3), and an organization's row has no environment home to write - * (the walled half is a later stage's); - * - **a kernel whose metadata protocol cannot save and delete**: the legacy - * direct write, as the permission-set write-through does; - * - **an edit that keeps a name a package or a built-in holds, and a delete - * of such a row**: its definition is the package's (or the platform's), and - * the metadata door refuses an environment save over it. The question is - * read from the engine registry's artifact provenance - * (`SchemaRegistry.getArtifactItem`), the same source the door decides - * `NOT_OVERRIDABLE` from (seat re-rule, Q2 = A). Whether the data door admits - * such an edit at all is the system-row gate's call, on the row's provenance - * stamp; - * - **an edit of a row whose name the metadata door does not accept**, with the - * name unchanged: such a row predates this stage, has no definition and can - * have none, so the edit stays a row write (Q1 = A's control). The predicate - * ({@link metadataDoorAcceptsPositionName}) is read off the spec's own item - * name pattern and `PositionSchema.name`, never transcribed. - * - * ## Where it runs - * - * `SecurityPlugin` registers it on `sys_position` AFTER its security - * middleware, so it runs INSIDE it: `assertSystemRowWriteGate`, the - * engine-owned write guard and the CRUD/FLS checks have all passed before a - * write is translated. Unlike the permission-set write-through, the driver - * write is never skipped: every row reader keeps answering exactly as before, - * because the row is still the row the data door wrote. - */ - -import { securityCatalogReaderOf } from '@objectstack/core'; -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; -import { PositionSchema } from '@objectstack/spec/identity'; -import { PositionNameConflictError } from './errors.js'; - -/** The catalog object this module is registered on. */ -export const POSITION_OBJECT = 'sys_position'; -/** The metadata type a position definition is saved as. */ -export const POSITION_METADATA_TYPE = 'position'; - -const SYSTEM_CTX = { isSystem: true } as const; - -/** - * The row columns that are row STATE, never part of a definition — the - * standing ruling keeps `active` on the row, and `is_default` has no key on - * `PositionSchema` either. Identity (`id`) and the readonly provenance and - * timestamp columns are not definition edits either. - */ -const ROW_ONLY_COLUMNS: ReadonlySet = new Set([ - 'id', 'active', 'is_default', 'managed_by', 'created_at', 'updated_at', 'organization_id', -]); - -/** The kernel logger, as this module uses it. */ -export interface PositionWriteThroughLogger { - info?: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - /** The kernel logger's shape: message, cause, meta. */ - error?: (message: string, cause?: Error, meta?: Record) => void; -} - -/** The metadata door, as this module uses it. */ -export interface PositionMetadataDoor { - saveMetaItem(request: { type: string; name: string; item: Record; actor?: string }): Promise; - deleteMetaItem(request: { type: string; name: string; actor?: string }): Promise; - /** - * The door's locked-base verdict without a write: the refusal `saveMetaItem` - * would raise for `(type, name)` on that ground, or `null` - * (`ObjectStackProtocolImplementation.packagedBaseRefusal`). Optional: a door - * without it keeps the stand-down for a package-held name (module note). - * `create` is the save that takes a name for an item of the caller's own: the - * same verdict as `save`, with the remedy a create has. - */ - packagedBaseRefusal?(request: { type: string; name: string; operation: 'save' | 'create' | 'delete' }): Error | null; -} - -export interface PositionWriteThroughDeps { - /** ObjectQL engine handle (row reads, the undo writes, the registry). */ - ql: any; - /** Lazy protocol handle — the protocol service may register after start(). */ - getProtocol: () => unknown; - /** The tenancy posture in force — read per write. */ - getPosture: () => TenancyPosture; - logger?: PositionWriteThroughLogger; -} - -/** - * Is `protocol` a metadata door this module can write through? Both verbs are - * required: a door that can save but not delete would leave a renamed or - * deleted position's definition behind. - */ -export function isPositionMetadataDoor(protocol: unknown): protocol is PositionMetadataDoor { - const p = protocol as Partial | null | undefined; - return !!p && typeof p.saveMetaItem === 'function' && typeof p.deleteMetaItem === 'function'; -} - -/** - * Does a package or a built-in hold this position name? Read from the engine - * registry's artifact provenance — `getArtifactItem` answers only an item a - * code package registered (an environment definition hydrated from - * `sys_metadata` carries no package), which is the lookup the metadata door - * arms `NOT_OVERRIDABLE` from. A registry without that read falls back to the - * by-name read with the same provenance filter the door applies to one. - */ -export function packageHoldsPosition(ql: any, name: string): boolean { - const registry = ql?.registry; - if (!registry) return false; - try { - if (typeof registry.getArtifactItem === 'function') { - return registry.getArtifactItem(POSITION_METADATA_TYPE, name) !== undefined; - } - if (typeof registry.getItem === 'function') { - const item = registry.getItem(POSITION_METADATA_TYPE, name); - const pkg = (item as { _packageId?: unknown } | null | undefined)?._packageId; - return typeof pkg === 'string' && pkg !== '' && pkg !== 'sys_metadata'; - } - } catch { - // An unreadable registry holds nothing we can see; the metadata door - // still refuses a save over a packaged name itself. - } - return false; -} - -/** - * [C2 stage S10, ADR-0048 addendum N.2/N.3] The metadata door's refusal of a - * position taking `name` — its locked-base verdict, asked and never re-derived - * (module note, "One namespace"). `null` when the door would not refuse that - * save on the locked-base ground, or brings no such verdict. - * - * [#22591] Asked as a `create`, because that is what this write is: a create, - * or a rename into `name`, never an edit of the item that holds it (an edit - * that keeps a held name is never asked about: it stands down). The door's verdict - * is the `save` verdict; what the act changes is the remedy its sentence names - * — a name no package or built-in holds, not the source artifact of a position - * this administrator did not write. - */ -function heldPositionNameRefusal(door: PositionMetadataDoor, name: unknown): Error | null { - if (typeof name !== 'string' || name === '' || typeof door.packagedBaseRefusal !== 'function') return null; - return door.packagedBaseRefusal({ type: POSITION_METADATA_TYPE, name, operation: 'create' }); -} - -let cachedPositionNameSchema: { safeParse(v: unknown): { success: boolean } } | null = null; - -/** - * Would the metadata door accept `name` as a position name? Both of the door's - * name checks, each read from its own declaration: the item-name grammar - * (`METADATA_ITEM_NAME_PATTERN`, enforced at `saveMetaItem`) and - * `PositionSchema.name` (the spec validation that runs inside it). Derived, - * never transcribed, so a grammar change in the spec moves this answer with - * it. Resolved on first use: `PositionSchema` is a lazy schema. - */ -export function metadataDoorAcceptsPositionName(name: unknown): boolean { - if (typeof name !== 'string' || !METADATA_ITEM_NAME_PATTERN.test(name)) return false; - cachedPositionNameSchema ??= ((PositionSchema as unknown as { shape?: Record }).shape?.name ?? null); - return cachedPositionNameSchema ? cachedPositionNameSchema.safeParse(name).success : true; -} - -/** - * The position DEFINITION a row carries: the four `PositionSchema` keys the - * row has columns for. `active` and `is_default` are row state and never - * travel; a `null` or blank description is absent, never `null` (the schema - * declares it optional, not nullable). - */ -export function positionBodyFromRow(row: Record): Record { - const body: Record = { - name: row.name, - label: typeof row.label === 'string' && row.label !== '' ? row.label : row.name, - }; - if (typeof row.description === 'string' && row.description !== '') body.description = row.description; - body.delegatable = row.delegatable === true || row.delegatable === 1 || row.delegatable === '1' || row.delegatable === 'true'; - return body; -} - -/** Does this update payload touch the definition at all? */ -function touchesDefinition(patch: Record): boolean { - return Object.keys(patch).some((key) => !ROW_ONLY_COLUMNS.has(key)); -} - -const scalarId = (v: unknown): v is string | number => - (typeof v === 'string' && v !== '') || (typeof v === 'number' && Number.isFinite(v)); - -/** The rows a data-door update or delete targets, read before the write (by id, or by the write's filter). */ -async function readTargetRows(ql: any, opCtx: any): Promise[]> { - const data = opCtx?.data; - const where = opCtx?.options?.where; - let filter: unknown; - if (data && typeof data === 'object' && !Array.isArray(data) && scalarId((data as any).id)) filter = { id: (data as any).id }; - else if (where && typeof where === 'object' && scalarId((where as any).id)) filter = { id: (where as any).id }; - else if (where && typeof where === 'object') filter = where; - else return []; - const rows = await ql.find(POSITION_OBJECT, { where: filter, limit: 1000, context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : []; -} - -async function readRowById(ql: any, id: unknown): Promise | null> { - const rows = await ql.find(POSITION_OBJECT, { where: { id }, limit: 1, context: SYSTEM_CTX }); - return Array.isArray(rows) && rows[0] ? rows[0] : null; -} - -/** Does any row still carry `name` (another organization's, or a declared one)? */ -async function nameStillCarried(ql: any, name: string): Promise { - const rows = await ql.find(POSITION_OBJECT, { where: { name }, limit: 1, context: SYSTEM_CTX }); - return Array.isArray(rows) && rows.length > 0; -} - -/** - * [ruling Q (a)] The refusal of a create, or a rename into `name`, when the - * security catalog already holds a definition of that name (module note, "a - * name the environment catalog already holds"). `null` when it holds none, - * when the name is a package's or a built-in's (S10's - * {@link heldPositionNameRefusal} answers those, first), or when no catalog is - * bound to this engine. A catalog read that fails throws its own loud error, - * and the caller undoes the write. - */ -async function environmentHeldPositionNameRefusal(ql: any, name: unknown): Promise { - if (typeof name !== 'string' || name === '' || packageHoldsPosition(ql, name)) return null; - const catalog = securityCatalogReaderOf(ql); - if (!catalog) return null; - const held = await catalog.resolve(POSITION_METADATA_TYPE, name); - if (!held) return null; - return new PositionNameConflictError(name, held.packageId); -} - -const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); - -function logError(logger: PositionWriteThroughLogger | undefined, message: string, cause: unknown, meta: Record): void { - if (logger?.error) logger.error(message, cause instanceof Error ? cause : undefined, meta); - else logger?.warn?.(message, { ...meta, error: errorText(cause) }); -} - -/** - * Engine middleware: under `single`, write every non-system data-door create, - * edit, rename and delete of a position through to its environment definition, - * and refuse a create or a rename into a name a package or a built-in holds. - * See the module note for the order, the undo, the refusal and where it stands - * down. - */ -export function createPositionWriteThrough( - deps: PositionWriteThroughDeps, -): (opCtx: any, next: () => Promise) => Promise { - const { ql, logger } = deps; - - /** Which of a name's possible definitions this module may write: neither a package's nor the platform's. */ - const environmentOwns = (name: unknown): name is string => - typeof name === 'string' && name !== '' && !packageHoldsPosition(ql, name); - - /** - * [ADR-0131 D3/D4] Save the definition a row carries. `permissionSets` has no - * row column, so the list the stored definition under `carriedFrom` names - * (the row's name before this write) travels into the saved body — a row - * edit never clears a binding the metadata door declared. A registry that - * cannot be read throws, and the caller undoes the row write. - */ - const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string, carriedFrom?: unknown) => { - const item = positionBodyFromRow(row); - const stored = typeof carriedFrom === 'string' && carriedFrom !== '' - ? (ql?.registry?.getItem?.(POSITION_METADATA_TYPE, carriedFrom) as { permissionSets?: unknown } | undefined)?.permissionSets - : undefined; - if (Array.isArray(stored) && stored.length > 0) item.permissionSets = [...stored]; - return door.saveMetaItem({ - type: POSITION_METADATA_TYPE, - name: String(row.name), - item, - ...(actor ? { actor } : {}), - }); - }; - - /** - * Delete `name`'s environment definition once no row carries the name any - * more. A definition the delete leaves behind is reported at `error`: the - * row is already gone and its caller was told so, and the next boot's - * declared-position seeder recreates a row from the definition. - */ - const deleteDefinitionOf = async (door: PositionMetadataDoor, name: string, actor?: string): Promise => { - if (!environmentOwns(name) || !metadataDoorAcceptsPositionName(name)) return; - try { - if (await nameStillCarried(ql, name)) return; - await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name, ...(actor ? { actor } : {}) }); - } catch (e) { - logError( - logger, - `[security] the position '${name}' was removed from ${POSITION_OBJECT}, but its environment definition was ` + - 'NOT deleted (ADR-0131 D3). Nothing looks wrong now: the row is gone and the write answered success. At the ' + - 'next boot the declared-position seeder recreates a row for every definition the catalog lists, so the ' + - 'position comes back and every assignment naming it grants again. Fix: delete the definition through the ' + - `metadata door (DELETE /api/v1/meta/position/${name}) before the next restart.`, - e, - { name }, - ); - } - }; - - /** Undo an insert: the rows this write created go again, under the system context. */ - const undoInsert = async (rows: Record[]): Promise => { - for (const row of rows) { - try { - await ql.delete(POSITION_OBJECT, { where: { id: row.id }, context: SYSTEM_CTX }); - } catch (e) { - logError( - logger, - `[security] a ${POSITION_OBJECT} create was refused by the metadata door, and undoing its row FAILED: the ` + - `position '${String(row.name)}' exists as a row with no environment definition while the caller was told ` + - 'the create was refused. Fix: delete the row by hand, then create the position again under a name the ' + - 'metadata door accepts (lowercase snake_case, and not a name a package or a built-in already holds).', - e, - { id: row.id, name: row.name }, - ); - } - } - }; - - /** Undo an update: every target row gets back the columns the patch wrote. */ - const undoUpdate = async (targets: Record[], patch: Record): Promise => { - for (const pre of targets) { - const restore: Record = { id: pre.id }; - for (const key of Object.keys(patch)) { - if (key !== 'id' && key in pre) restore[key] = pre[key]; - } - try { - await ql.update(POSITION_OBJECT, restore, { context: SYSTEM_CTX }); - } catch (e) { - logError( - logger, - `[security] a ${POSITION_OBJECT} edit was refused by the metadata door, and restoring the row FAILED: the ` + - `row '${String(pre.name)}' keeps the refused edit while the caller was told it was refused, and the row ` + - 'and its environment definition now disagree. Fix: re-apply the previous values to the row by hand.', - e, - { id: pre.id, name: pre.name }, - ); - } - } - }; - - return async (opCtx: any, next: () => Promise): Promise => { - if (opCtx?.object !== POSITION_OBJECT) return next(); - if (opCtx?.context?.isSystem) return next(); - const op = opCtx?.operation; - if (op !== 'insert' && op !== 'update' && op !== 'delete') return next(); - if (postureEnforcesWall(deps.getPosture())) return next(); - const door = deps.getProtocol(); - if (!isPositionMetadataDoor(door)) return next(); - const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; - - if (op === 'insert') { - await next(); - const result = opCtx.result; - const written = (Array.isArray(result) ? result : [result]) - .filter((r: unknown): r is Record => !!r && typeof r === 'object' && scalarId((r as any).id)); - const createdRows: Record[] = []; - for (const created of written) createdRows.push((await readRowById(ql, created.id)) ?? created); - // [S10] A name a package or a built-in holds is not taken: the door's - // refusal is the answer, asked before any definition is saved. A verdict - // the door could not reach (it re-raises anything but its refusal) is - // no answer either: the rows go, and the failure is the answer. - try { - for (const row of createdRows) { - const refusal = heldPositionNameRefusal(door, row.name); - if (refusal) throw refusal; - } - // [ruling Q (a)] …nor a name the environment catalog already holds: - // one holder per name per deployment, never a silent merge. - for (const row of createdRows) { - const refusal = await environmentHeldPositionNameRefusal(ql, row.name); - if (refusal) throw refusal; - } - } catch (e) { - await undoInsert(written); - throw e; - } - const saved: Record[] = []; - for (const row of createdRows) { - if (!environmentOwns(row.name)) continue; - try { - await saveDefinition(door, row, actor); - saved.push(row); - } catch (e) { - // The definitions this write already saved go with their rows. - for (const done of saved) await deleteDefinitionOfInsert(door, done, actor); - await undoInsert(written); - throw e; - } - } - return; - } - - // An update or a delete: the targets are read before the write. - const targets = await readTargetRows(ql, opCtx); - await next(); - if (targets.length === 0) return; - - if (op === 'delete') { - for (const pre of targets) { - if (await readRowById(ql, pre.id)) continue; // the row survived: nothing was deleted - await deleteDefinitionOf(door, String(pre.name), actor); - } - return; - } - - const patch = opCtx.data && typeof opCtx.data === 'object' && !Array.isArray(opCtx.data) ? opCtx.data : null; - if (!patch || !touchesDefinition(patch)) return; - const posts: Array<{ pre: Record; post: Record }> = []; - for (const pre of targets) { - const post = await readRowById(ql, pre.id); - if (post) posts.push({ pre, post }); - } - - // [S10] A rename into a name a package or a built-in holds is not taken: - // the door's refusal (or its failure to answer) is the answer, and the row - // gets its old values back. An edit that keeps such a name stands down below. - try { - for (const { pre, post } of posts) { - const refusal = pre.name !== post.name ? heldPositionNameRefusal(door, post.name) : null; - if (refusal) throw refusal; - } - // [ruling Q (a)] A rename into a name the environment catalog holds is a - // second holder too. - for (const { pre, post } of posts) { - const refusal = pre.name !== post.name ? await environmentHeldPositionNameRefusal(ql, post.name) : null; - if (refusal) throw refusal; - } - } catch (e) { - await undoUpdate(targets, patch); - throw e; - } - - // Every new definition first; the old names' definitions go only once all landed. - const saved: Array<{ pre: Record; post: Record }> = []; - for (const pair of posts) { - const { pre, post } = pair; - const renamed = pre.name !== post.name; - if (!environmentOwns(post.name)) continue; - // An unchanged name the door does not accept: a row that predates this - // stage and can have no definition — the edit stays a row write. - if (!renamed && !metadataDoorAcceptsPositionName(post.name)) continue; - try { - await saveDefinition(door, post, actor, pre.name); - saved.push(pair); - } catch (e) { - await undoUpdate(targets, patch); - // The definitions saved before this one return to their pre-image. - for (const done of saved) await restoreDefinitionAfterUndo(door, done, actor); - throw e; - } - } - for (const { pre, post } of posts) { - if (pre.name !== post.name) await deleteDefinitionOf(door, String(pre.name), actor); - } - }; - - /** Undo of an insert's saved definition: the row is about to go, so its definition goes too. */ - async function deleteDefinitionOfInsert(door: PositionMetadataDoor, row: Record, actor?: string): Promise { - try { - await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(row.name), ...(actor ? { actor } : {}) }); - } catch (e) { - logError( - logger, - `[security] a ${POSITION_OBJECT} create was refused, and undoing the environment definition already saved for ` + - `'${String(row.name)}' FAILED: the definition stays with no row, and the next boot's declared-position ` + - `seeder creates a row for it. Fix: DELETE /api/v1/meta/position/${String(row.name)}.`, - e, - { name: row.name }, - ); - } - } - - /** Undo of an update's saved definition: back to the pre-image, and a renamed one's new name goes. */ - async function restoreDefinitionAfterUndo( - door: PositionMetadataDoor, - pair: { pre: Record; post: Record }, - actor?: string, - ): Promise { - const { pre, post } = pair; - try { - if (pre.name !== post.name) { - await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(post.name), ...(actor ? { actor } : {}) }); - } else { - await saveDefinition(door, pre, actor, pre.name); - } - } catch (e) { - logError( - logger, - `[security] a ${POSITION_OBJECT} edit was refused, and returning the environment definition of ` + - `'${String(post.name)}' to its previous state FAILED: the row is back as it was and the definition is not. ` + - 'Fix: re-save the position in Setup.', - e, - { name: post.name }, - ); - } - } -} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 691eeef3992..7808074eedc 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4540,19 +4540,16 @@ export class SecurityPlugin implements Plugin { // [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 - // (by name and organization), and under `single` every position name more - // than one row carries. A reference that resolves nowhere confers nothing; - // this is what makes that loud. Registered AFTER the two backfills above, - // so it runs after them (handlers run in registration order) and does not - // report a name either one resolved this boot. It writes nothing. Only at - // `kernel:bootstrapped`: run any earlier, it would judge a catalog that is - // still being filled. See `catalog-reference-report.ts`. + // and every grant that names nothing. A reference that resolves nowhere + // confers nothing; this is what makes that loud. Registered AFTER the + // grant-name backfill above, so it runs after it (handlers run in + // registration order) and does not report a name it resolved this boot. + // It writes nothing. Only at `kernel:bootstrapped`: run any earlier, it + // would judge a catalog that is still being filled. See `catalog-reference-report.ts`. if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:bootstrapped', async (): Promise => { try { await reportCatalogReferences(ql as any, { - posture: this.tenancyPosture, catalog: createSecurityCatalogReader({ registry: (ql as any).registry, metadata: this.metadata }), logger: ctx.logger, }); diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 04e0104f698..f0e32a6378e 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -1537,12 +1537,7 @@ export const UNTYPED_RECEIVERS = [ 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',