diff --git a/.changeset/24530-settings-audit-global-rung-sibling.md b/.changeset/24530-settings-audit-global-rung-sibling.md new file mode 100644 index 00000000000..76b1ba02f9e --- /dev/null +++ b/.changeset/24530-settings-audit-global-rung-sibling.md @@ -0,0 +1,20 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + +Clause-②: yes (narrowing) + + + +**BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + +- **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. +- **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. +- **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + +**The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 17a6c38d3f0..f4daf3476a2 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 40 of them — just under a fifth +typed `any` have no type to read, and there are 41 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 **52 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 232 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 | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 232** decidable, **74** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 95 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 40 sites reached +one, and both are counted in the generated tables below: the 41 sites reached through an erased (`any`) receiver, and the 51 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 -128 of 231 (55%) as decidably elevated, with 95 more whose elevation is a +129 of 232 (56%) as decidably elevated, with 95 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 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 232`, 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" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | -| …whose object name is statically decidable | 157 | +| write call sites on the application surface | **232** | +| …whose object name is statically decidable | 158 | | …whose object name is chosen at run time | 74 | -| …against an object with tenancy ENABLED | 156 | +| …against an object with tenancy ENABLED | 157 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 172 | | 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 | 52 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 128 | +| threading a decidably ELEVATED (`isSystem`) context | 129 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 191 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 106 | +| object name spelled inline | 107 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 67 | @@ -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-19 at `ebe85c6d3`. +Measured on 2026-10-19 at `030483e7f`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 117 | +| declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | {/* END GENERATED: tenant-audit-census */} 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 9522c1e6add..d26e4b18034 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 | 231 | -| Object name statically decidable | 157 | +| Write call sites | 232 | +| Object name statically decidable | 158 | | Object name chosen at run time | 74 | -| Against a tenancy-enabled object | 156 | +| Against a tenancy-enabled object | 157 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 172 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 52 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 128 | +| Threading a decidably elevated context | 129 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 95 | @@ -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-19 at `ebe85c6d3`. +Measured on 2026-10-19 at `030483e7f`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 117 | +| declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | ## Every site @@ -229,6 +229,7 @@ Measured on 2026-10-19 at `ebe85c6d3`. | `packages/services/service-settings/src/config-change-audit.ts` | `insert` | `sys_audit_log` | enabled | context, elevation undecidable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | options unreadable | 2 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_platform_setting_audit` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | elevated | 1 | diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 962d40dde4b..6c75a439f1b 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -633,7 +633,7 @@ "title": "Settings hub round-trip: a value saves, PERSISTS, reaches an observable consumer, audits, env-locks, tests honestly, and stores secrets as handles served MASKED — anonymous denied", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -680,7 +680,7 @@ "evidence": "the before/after consumer output" }, { - "clause": "every write appends a sys_setting_audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash)", + "clause": "every write appends a per-key audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash): in sys_setting_audit for a tenant- or user-scope key, in the deployment-level sys_platform_setting_audit for a global-scope key (ADR-0131 D7)", "oracle": "api", "verify": "GET /api/v1/data/sys_setting_audit shows the row for localization.timezone (SettingsServicePlugin.buildAuditWriter)", "evidence": "the audit row" @@ -784,7 +784,8 @@ { "revision": 1, "date": "2026-08-08", "change": "initial — settings hub round-trip with persistence + observable-consumer effect (honoring the ai.json form-is-not-effect caveat), sys_setting_audit, env-lock server refusal, honest test action, sys_secret handle-not-plaintext, anonymous deny", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "secret clauses strengthened from negative-only ('never returns the plaintext', which omission and a vanished key also satisfy) to the POSITIVE contract #7522/PR #7554 pinned at the route level: set→mask / unset→null presence-preserving, masking across values..value AND every cascadeChain entry, source/locked/lockedReason unchanged, both specifier flavours, plus the distinct echoed-mask-PUT-is-a-no-op round-trip clause and the in-process-path-is-NOT-masked guard", "ref": "#7573" }, { "revision": 3, "date": "2026-08-18", "change": "corrected the open-framework namespace count from 10 to 11 in the fixture list, step 0 and the per-namespace clause. builtinSettingsManifests holds 10, but lifecycle is registered separately by ObjectQLPlugin at kernel:ready (packages/objectql/src/lifecycle/lifecycle-settings.ts, 5 specifiers) and is served alongside them — so a runner enumerating only the manifests directory undercounts, and the clause could never be satisfied as written (#9453 CF-7)", "ref": "#9386" }, - { "revision": 4, "date": "2026-10-16", "change": "steps[1], [2], [3], [5] and [12] and acceptance[7] (clause, verify) and [11] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Four premises had moved: the `os dev --seed-admin` dev seed writes the host zone into localization.timezone at tenant scope (#23175), so the source is 'tenant' before any PUT and the badge flip needs a key the seed did not write (#23939 used default_country); the analytics date bucket has no stock observable, so the effect step names the two consumers #23939 used (auth/me/localization and a rendered datetime); global-scope secrets live in sys_platform_setting.value_enc, not sys_setting; and an anonymous caller meets the ADR-0056 D2 floor first, 401 UNAUTHENTICATED on both routes, not 403 SETTINGS_FORBIDDEN. acceptance[0].clause, [1].clause and [3].verify follow the same premises: the source reads the scope (on a dev-seeded boot it never read 'default', so there is no flip to see), the effect clause names the two stock consumers instead of the analytics bucket, and the badge is read on a key whose source was 'default' before the save. Not loosened: anonymous must still be refused on both routes with no namespace enumerated, and a secret must still never be plaintext", "ref": "#24036" } + { "revision": 4, "date": "2026-10-16", "change": "steps[1], [2], [3], [5] and [12] and acceptance[7] (clause, verify) and [11] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Four premises had moved: the `os dev --seed-admin` dev seed writes the host zone into localization.timezone at tenant scope (#23175), so the source is 'tenant' before any PUT and the badge flip needs a key the seed did not write (#23939 used default_country); the analytics date bucket has no stock observable, so the effect step names the two consumers #23939 used (auth/me/localization and a rendered datetime); global-scope secrets live in sys_platform_setting.value_enc, not sys_setting; and an anonymous caller meets the ADR-0056 D2 floor first, 401 UNAUTHENTICATED on both routes, not 403 SETTINGS_FORBIDDEN. acceptance[0].clause, [1].clause and [3].verify follow the same premises: the source reads the scope (on a dev-seeded boot it never read 'default', so there is no flip to see), the effect clause names the two stock consumers instead of the analytics bucket, and the badge is read on a key whose source was 'default' before the save. Not loosened: anonymous must still be refused on both routes with no namespace enumerated, and a secret must still never be plaintext", "ref": "#24036" }, + { "revision": 5, "date": "2026-10-19", "change": "acceptance[2].clause re-pointed: the settings audit trail's global rung moved to the deployment-level sys_platform_setting_audit (ADR-0131 D7, ruling record 6257510973 Q4 A), so 'every write appends a sys_setting_audit row' stopped being true for a global-scope key. The clause now names both tables by scope; its verify reads localization.timezone, a tenant-scope key, whose row is still in sys_setting_audit. Not loosened: every write still appends exactly one per-key row", "ref": "#24530" } ] }, { @@ -2869,7 +2870,7 @@ "title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -2881,7 +2882,7 @@ "requires": [ "the settings service wired with a crypto provider and the sys_secret store, which is the stock wiring: SettingsServicePlugin binds both on kernel:ready (the same requirement platform-core.settings-hub-roundtrip names)", "an encrypted settings specifier to write: mail.api_key (type 'password', encrypted: true, mail.manifest.ts), written with provider 'resend' so the manifest's validator accepts the patch", - "plugin-audit mounted, so the generic ledger (sys_audit_log) exists. The per-key ledger (sys_setting_audit) is the settings service's own and is always there" + "plugin-audit mounted, so the generic ledger (sys_audit_log) exists. The per-key ledger is the settings service's own and is always there: sys_platform_setting_audit for a global-scope key, sys_setting_audit for a tenant- or user-scope key" ], "knownGaps": [ "the no-keyed-digest arm is not reachable on a stock boot. A host that builds the settings service with only a CryptoAdapter, or a crypto provider that refuses a keyed digest, records the write with NO fingerprint (sys_setting_audit.new_hash null, the bare encrypted marker in sys_audit_log) and warns once per key, and the write itself still lands. Score that arm from the unit pin (automated.ref); ⛔ never build a custom host to reach it", @@ -2890,7 +2891,7 @@ }, "steps": [ "generate a fresh random value V1 for this run (never a value used anywhere else). As admin, PUT /api/settings/mail { provider: 'resend', api_key: V1, from_email: 'ops@example.com' }", - "read the per-key ledger: GET /api/v1/data/sys_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash)", + "read the per-key ledger: GET /api/v1/data/sys_platform_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash). mail is a GLOBAL-scope namespace, and the per-key ledger of the global rung is the deployment-level sys_platform_setting_audit (ADR-0131 D7, packages/services/service-settings/src/settings-service-plugin.ts#settingAuditObjectFor); a tenant- or user-scope key's row lands in sys_setting_audit", "read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_platform_setting' — mail is a GLOBAL-scope namespace, and a global-scope value's row names the tenant-less table it lives in, packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_GLOBAL_OBJECT_NAME; a tenant-scope key's row names 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)", "OFFLINE, with no server key: compute the unkeyed digests a leak would carry — the FNV-1a 32 of V1's canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex, which is the unkeyed digest this settings service writes for a non-secret key (packages/services/service-settings/src/crypto-adapter.ts#digest), and, belt and braces, the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) — and search both captured rows, every field, for each of them and for V1 itself", "write V1 again, then a different value V2, then V1 a third time; re-read both ledgers and compare the fingerprints of the three writes", @@ -2899,7 +2900,7 @@ ], "acceptance": [ { - "clause": "a secret-valued setting's write records on BOTH ledgers a fingerprint of the keyed shape hmac-sha256 followed by 64 hex digits: sys_setting_audit.new_hash, and the digest inside sys_audit_log's config_change new_value (inside the encrypted marker). Neither row carries the value", + "clause": "a secret-valued setting's write records on BOTH ledgers a fingerprint of the keyed shape hmac-sha256 followed by 64 hex digits: the per-key ledger's new_hash (sys_platform_setting_audit for the global mail key), and the digest inside sys_audit_log's config_change new_value (inside the encrypted marker). Neither row carries the value", "oracle": "api", "verify": "new_hash matches the shape 'hmac-sha256:' + 64 lowercase hex; the config_change row's new_value.digest is the encrypted marker wrapping that same keyed digest; neither row, in any field, contains V1", "evidence": "both captured rows, quoted in full" @@ -2917,9 +2918,9 @@ "evidence": "the three fingerprints per ledger" }, { - "clause": "a reset records no fingerprint: the reset row carries new_hash null on sys_setting_audit, and the config_change row carries no new_value", + "clause": "a reset records no fingerprint: the reset row carries new_hash null on the per-key ledger (sys_platform_setting_audit for the global mail key), and the config_change row carries no new_value", "oracle": "api", - "verify": "the reset rows: sys_setting_audit action 'reset' with new_hash null; sys_audit_log config_change new_value null", + "verify": "the reset rows: sys_platform_setting_audit action 'reset' with new_hash null; sys_audit_log config_change new_value null", "evidence": "the two reset rows" }, { @@ -2969,6 +2970,12 @@ "date": "2026-10-16", "change": "steps[2], [3] and [6], acceptance[1].clause and [4].verify and negative[0] corrected (checklist-accuracy finding of QA run #23939, wave #23878). The non-secret digest is FNV-1a 32 of canonical JSON ('fnv32:'), not SHA-256 (crypto-adapter.ts digest), so the step-7 control computed with SHA-256 never matched and could not prove the offline search would find an unkeyed digest. The control and the secret search now compute the same algorithm — FNV-1a 32 first, SHA-256 kept as a second net — and the generic-ledger row for global-scope mail names sys_platform_setting, not sys_setting. Not loosened: the search now covers the algorithm the service actually uses, so a leaked unkeyed digest is caught where the old search could miss it", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "fixtures.requires[2], steps[1] and acceptance[0].clause, [3] (clause, verify) re-pointed: mail is a global-scope namespace, and the settings audit trail's global rung moved to the deployment-level sys_platform_setting_audit (ADR-0131 D7, ruling record 6257510973 Q4 A). Global-rung rows an 18.0 install wrote stay in sys_setting_audit until the upgrade ceremony moves them. Not loosened: the same keyed-digest, no-value and reset rules are scored on the same rows", + "ref": "#24530" } ] }, diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 7fdc69b9b37..5789ca74f56 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -625,6 +625,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ ], }, { object: 'sys_setting_audit', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, + { + object: 'sys_platform_setting_audit', + census: PMAIN, + fate: 'column-drop', + citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the settings audit trail's global rung (ruling record 6257510973, Q4 A)`, + }, { object: 'sys_secret', census: P1, fate: 'attribution', citation: `re-verification 6018712820: sys_secret is tenant-attributed by one producer (moved here from C6); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_flow_credential', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts index 49ca728424e..8461ed39aeb 100644 --- a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; -import { SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret } from './system/index.js'; /** * [ADR-0131 D7] The deployment-level plumbing this package declares carries no @@ -36,6 +36,8 @@ const DEPLOYMENT_LEVEL = [ ['sys_job_queue', SysJobQueue], ['sys_migration', SysMigration], ['sys_migration_journal', SysMigrationJournal], + // [ruling record 6257510973, Q4 A] The settings audit trail's global rung. + ['sys_platform_setting_audit', SysPlatformSettingAudit], ] as const; describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-only reads', () => { diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 81886729fe2..95839163f25 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -16,6 +16,8 @@ export { SysPlatformSetting } from './sys-platform-setting.object.js'; export { SysSecret } from './sys-secret.object.js'; export { SysMetadataActivation } from './sys-metadata-activation.object.js'; export { SysSettingAudit } from './sys-setting-audit.object.js'; +// [ADR-0131 D7] The settings audit trail's global rung, tenant-less. +export { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; export { diff --git a/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts new file mode 100644 index 00000000000..65cbd7e9c48 --- /dev/null +++ b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_platform_setting_audit — the settings audit trail's GLOBAL rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + * + * The same departure `sys_setting`'s global rung made for `sys_platform_setting`: + * a write of a global-scope settings key is deployment-level, so its audit row + * names no organization and lives here, in a table with no organization column. + * Tenant and user rows stay in `sys_setting_audit`, each stamped with the + * organization whose value changed. + * + * Same row shape as `sys_setting_audit`, so one reader reads both tables the + * same way; `scope` is always `global` here. + * + * ## No organization column, governed by object permission + * + * `systemFields: { tenant: false }` — the one writer (`SettingsService`, through + * the settings audit writer) attributes no row to an organization. With no + * column there is no tenant wall, so who may read is object permission (D7): + * the same platform-only capability `sys_platform_setting` requires. A manifest + * whose keys sit at the global rung already requires it to read or write them + * through the settings door, so the door, the setting and its trail agree. + * + * ## The 18.0 rows + * + * An 18.0 install wrote its global-rung audit rows into `sys_setting_audit`, + * stamped with the Default Organization. They stay there until the upgrade + * ceremony moves them here; a reader of the global rung's history reads both. + * + * Append-only: the settings audit writer is the only writer, and the generic + * data API offers reads only. + * + * @namespace sys + */ +export const SysPlatformSettingAudit = ObjectSchema.create({ + name: 'sys_platform_setting_audit', + label: 'Platform Setting Audit Entry', + pluralLabel: 'Platform Setting Audit', + icon: 'history', + isSystem: true, + managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level: NO tenant column, and reads are platform-only. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], + description: 'Append-only audit trail for SettingsService mutations of global-scope keys.', + highlightFields: ['namespace', 'key', 'action', 'actor_id', 'created_at'], + listViews: { + recent: { + type: 'grid', + name: 'recent', + label: 'Recent', + columns: ['created_at', 'namespace', 'key', 'action', 'actor_id', 'source'], + sort: [{ field: 'created_at', order: 'desc' }], + }, + }, + + fields: { + id: Field.text({ + label: 'ID', + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + readonly: true, + description: 'When the mutation was recorded.', + }), + + namespace: Field.text({ + label: 'Namespace', + required: true, + maxLength: 128, + }), + + key: Field.text({ + label: 'Key', + required: true, + maxLength: 128, + }), + + scope: Field.select( + [{ label: 'Global', value: 'global' }], + { + label: 'Scope', + required: true, + defaultValue: 'global', + description: 'Cascade layer the row was written to — always the global rung here.', + }, + ), + + action: Field.select( + [ + { label: 'Set', value: 'set' }, + { label: 'Reset', value: 'reset' }, + { label: 'Lock', value: 'lock' }, + { label: 'Unlock', value: 'unlock' }, + { label: 'Rotate', value: 'rotate' }, + ], + { + label: 'Action', + required: true, + description: 'Mutation kind.', + }, + ), + + actor_id: Field.lookup('sys_user', { + label: 'Actor', + description: 'User who performed the mutation; null for system jobs.', + }), + + source: Field.select( + [ + { label: 'UI', value: 'ui' }, + { label: 'API', value: 'api' }, + { label: 'Migration', value: 'migration' }, + { label: 'Import', value: 'import' }, + { label: 'System', value: 'system' }, + ], + { + label: 'Source', + required: true, + defaultValue: 'api', + description: 'Mutation entry-point.', + }, + ), + + reason: Field.text({ + label: 'Reason', + description: 'Free-text justification provided by the actor (optional).', + }), + + old_hash: Field.text({ + label: 'Old Hash', + readonly: true, + maxLength: 128, + description: 'Digest of the previous value. Never the plaintext. Null when previously unset.', + }), + + new_hash: Field.text({ + label: 'New Hash', + readonly: true, + maxLength: 128, + description: 'Digest of the new value. Never the plaintext. Null on reset.', + }), + + encrypted: Field.boolean({ + label: 'Encrypted', + defaultValue: false, + description: 'True when the field carries secret material (rotation is interesting).', + }), + + request_id: Field.text({ + label: 'Request ID', + maxLength: 128, + description: 'Correlates with sys_audit_log / tracing.', + }), + }, + + indexes: [ + { fields: ['namespace', 'created_at'], unique: false }, + { fields: ['actor_id', 'created_at'], unique: false }, + ], + + enable: { + trackHistory: false, + // [ADR-0103] Engine-owned: appended by the settings service (SYSTEM_CTX), + // never via the generic data API. Reads stay open for compliance review. + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts new file mode 100644 index 00000000000..312ab07145c --- /dev/null +++ b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D7 · 18.x step 2] The settings audit trail's global rung is the +// deployment-level `sys_platform_setting_audit` (ruling record 6257510973, +// Q4 A) — the EXPAND step, on the booted showcase, composed the way `os serve` +// composes it: the audit plugin and the ownership boot guard. +// +// Two boots over one SQLite file: +// +// A. A fresh install (R1). Through the real settings door, an operator saves +// a tenant setting and an encrypted global setting. The new write shape: +// the global setting's history row is in the sibling, with no +// organization column; the tenant setting's stays in `sys_setting_audit`, +// stamped. +// B. The same database with an 18.0-shaped row added — what an 18.0 install +// wrote: the global setting's history row in `sys_setting_audit`, stamped +// with the Default Organization. HEAD boots over it (the boot guard does +// not refuse); the global rung's history is served from both tables, +// selected by SCOPE in `sys_setting_audit` (the 18.0 row is stamped, so a +// NULL-organization selector would miss it); and once the stack stops the +// 18.0 row is still there, stamped (expand-first: only the upgrade +// ceremony moves it). +// +// This file builds its own 18.0-shaped row; the 18.x line's upgrade harness +// boots a whole 18.0 database, and this step runs it once it lands. +// +// NOT eligible for the shared showcase project: it writes org-wide settings and +// boots twice over its own file. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime'; +import type { VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +/** A placeholder, sealed by the platform like any other value; it opens nothing. */ +const AI_KEY = 'expand-18x-not-a-real-api-key'; +const WORKSPACE = 'Expand 18x Workspace'; +const SYSTEM = { context: { isSystem: true } }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.records ?? body?.data ?? [])); + +function boot(databaseFile: string): Promise { + return bootShowcase({ databaseFile, extraPlugins: [new AuditPlugin(), new OrganizationOwnershipBootGuardPlugin()] }); +} + +/** One table's rows, read from the file past every engine. */ +function tableRows(file: string, table: string): Array> { + const db = new DatabaseSync(file, { readOnly: true }); + try { + return db.prepare(`SELECT * FROM "${table}"`).all() as Array>; + } finally { + db.close(); + } +} + +describe('[18.x step 2] the settings audit trail\'s global rung is deployment-level — expand-first', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-expand-18x-')); + const file = join(dir, 'expand.db'); + let stack: VerifyStack | undefined; + let organizationId = ''; + + beforeAll(async () => { + stack = await boot(file); + const tok = await stack.signIn(); + const orgs = rowsOf(await (await stack.apiAs(tok, 'GET', '/auth/organization/list')).json()); + const org = orgs.find((o: any) => o?.slug === 'default') ?? orgs[0]; + organizationId = String(org?.id ?? ''); + expect(organizationId, 'the boot created the Default Organization').not.toBe(''); + expect((await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationId })).status).toBe(200); + + const put = (ns: string, body: unknown) => + stack!.raw(`/api/settings/${ns}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, + body: JSON.stringify(body), + }); + const branding = await put('branding', { workspace_name: WORKSPACE }); + expect(branding.status, await branding.clone().text()).toBe(200); + const ai = await put('ai', { gateway_api_key: AI_KEY }); + expect(ai.status, await ai.clone().text()).toBe(200); + await stack.stop(); + stack = undefined; + }, 240_000); + + afterAll(async () => { + await stack?.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('A. the global setting\'s history row is in the sibling, unstamped; the tenant setting\'s stays in sys_setting_audit, stamped', () => { + // The boot's own global-rung writes (the auth plugin records its API-key + // policy start) land here too, so the operator's row is found by its key. + const platform = tableRows(file, 'sys_platform_setting_audit'); + expect(platform.filter((r) => r.namespace === 'ai')).toEqual([ + expect.objectContaining({ key: 'gateway_api_key', scope: 'global', action: 'set' }), + ]); + for (const r of platform) expect(Object.keys(r), `sys_platform_setting_audit ${r.id}`).not.toContain('organization_id'); + + const audit = tableRows(file, 'sys_setting_audit'); + expect(audit.filter((r) => r.scope === 'global')).toEqual([]); + expect(audit.filter((r) => r.namespace === 'branding' && r.key === 'workspace_name')).toEqual([ + expect.objectContaining({ scope: 'tenant', organization_id: organizationId }), + ]); + }); + + describe('B. the same database with an 18.0-shaped global audit row', () => { + const LEGACY_AUDIT = 'expand18x_legacy_global_audit'; + let bootError: unknown; + + beforeAll(async () => { + // What an 18.0 install wrote: the global rung's history in + // sys_setting_audit, stamped with the Default Organization. + const db = new DatabaseSync(file); + try { + db.prepare( + 'INSERT INTO "sys_setting_audit" ("id", "organization_id", "namespace", "key", "scope", "action", "source", "new_hash", "encrypted", "created_at") ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ).run(LEGACY_AUDIT, organizationId, 'ai', 'gateway_api_key', 'global', 'set', 'api', 'hmac-sha256:18.0', 1, '2026-10-19T00:00:00.000Z'); + } finally { + db.close(); + } + try { + stack = await boot(file); + } catch (error) { + bootError = error; + } + }, 240_000); + + it('boots: nothing about the sibling reads as the v17 shape', () => { + expect(bootError, String((bootError as Error | undefined)?.stack ?? bootError)).toBeUndefined(); + expect(stack).toBeDefined(); + }); + + it('the global rung\'s history is served from both tables: the 18.0 row selected by scope, and the new one', async () => { + const ql = await stack!.kernel.getServiceAsync('objectql'); + const where = { namespace: 'ai', key: 'gateway_api_key', scope: 'global' }; + const legacy = rowsOf(await ql.find('sys_setting_audit', { where }, SYSTEM)); + const current = rowsOf(await ql.find('sys_platform_setting_audit', { where }, SYSTEM)); + expect(legacy.map((r) => r.id)).toEqual([LEGACY_AUDIT]); + expect(legacy[0].organization_id).toBe(organizationId); + expect(current).toHaveLength(1); + + // And through the data API, as the platform admin reads each grid. + const tok = await stack!.signIn(); + for (const [object, id] of [['sys_setting_audit', LEGACY_AUDIT], ['sys_platform_setting_audit', String(current[0].id)]] as const) { + const res = await stack!.apiAs(tok, 'GET', `/data/${object}`); + expect(res.status, object).toBe(200); + expect(rowsOf(await res.json()).map((r: any) => r.id), object).toContain(id); + } + }); + + it('the 18.0 row keeps its place and its organization once the stack stops — nothing moved or re-stamped', async () => { + await stack?.stop(); + stack = undefined; + expect(tableRows(file, 'sys_setting_audit').find((r) => r.id === LEGACY_AUDIT)).toEqual( + expect.objectContaining({ organization_id: organizationId, scope: 'global' }), + ); + }); + }); +}); diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index 4b427c9e273..b0b158404a0 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -40,7 +40,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -232,6 +232,8 @@ async function bootPlugin(opts: BootOptions = {}) { engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [ADR-0131 D7, Q4 A] The settings audit trail's global rung. + engine.registry.registerObject(SysPlatformSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as // a boot does (platform-objects owns it). @@ -308,8 +310,15 @@ async function bootPlugin(opts: BootOptions = {}) { /** Every `sys_audit_log` row the write path asked the engine to insert. */ ledgerRows: () => ledgerInserts.map((i) => i.row), ledgerOpts: () => ledgerInserts.map((i) => i.opts), - /** REAL `sys_setting_audit` rows, read out of the driver's store. */ - settingAuditRows: () => [...rowsOf('sys_setting_audit').values()], + /** + * REAL settings-audit rows of this file's manifest, read out of the + * driver's store. It is `scope: 'global'`, so they are + * `sys_platform_setting_audit` rows — the audit trail's global rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + */ + settingAuditRows: () => [...rowsOf('sys_platform_setting_audit').values()], + /** REAL `sys_setting_audit` rows — the tenant and user rungs' trail. */ + scopedSettingAuditRows: () => [...rowsOf('sys_setting_audit').values()], /** * REAL settings rows of this file's manifest. It is `scope: 'global'`, so * they are `sys_platform_setting` rows — the global rung's own store @@ -453,17 +462,19 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', // 2. Dual-write: the settings-specific ledger is UNCHANGED // --------------------------------------------------------------------------- -describe('#8145 — dual-write: `sys_setting_audit` keeps its rows', () => { +describe('#8145 — dual-write: the settings audit trail keeps its rows (the global rung in `sys_platform_setting_audit`)', () => { it('one write leaves exactly one row on EACH ledger', async () => { const boot = await bootPlugin({ userId: 'usr_admin', tenantId: 'org_1' }); await boot.service.setMany('branding_test', { workspace_name: 'ObjectStack' }, boot.writeCtx); - // The presence half. This is a REAL row in the REAL `sys_setting_audit` - // object, so the assertion cannot pass emptily on a fixture that never wrote - // there — which is exactly how an "existing behaviour unchanged" pin goes - // green for the wrong reason. + // The presence half. This is a REAL row in the REAL settings audit trail — + // `sys_platform_setting_audit`, the global rung's (ADR-0131 D7, Q4 A) — + // so the assertion cannot pass emptily on a fixture that never wrote there, + // which is exactly how an "existing behaviour unchanged" pin goes green for + // the wrong reason. const settingAudit = boot.settingAuditRows(); + expect(boot.scopedSettingAuditRows()).toHaveLength(0); expect(settingAudit).toHaveLength(1); expect(settingAudit[0]).toMatchObject({ namespace: 'branding_test', @@ -622,6 +633,8 @@ describe('#8145 — a refused write emits NO config_change row', () => { engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [ADR-0131 D7, Q4 A] The settings audit trail's global rung. + engine.registry.registerObject(SysPlatformSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as // a boot does (platform-objects owns it). @@ -660,7 +673,8 @@ describe('#8145 — a refused write emits NO config_change row', () => { // The #8026 pre-flight refuses the WHOLE batch before the write loop, so the // plain sibling key is neither persisted nor audited — a half-audited batch // would be worse than no audit at all. - const settingAudit = () => [...rowsOf('sys_setting_audit').values()]; + // The manifest is global, so its trail is the global rung's (Q4 A). + const settingAudit = () => [...rowsOf('sys_platform_setting_audit').values()]; expect(ledgerRows).toHaveLength(0); expect(settingAudit()).toHaveLength(0); expect([...rowsOf('sys_setting').values()]).toHaveLength(0); diff --git a/packages/services/service-settings/src/manifest.test.ts b/packages/services/service-settings/src/manifest.test.ts index ef2b40a6c60..4a191d53a51 100644 --- a/packages/services/service-settings/src/manifest.test.ts +++ b/packages/services/service-settings/src/manifest.test.ts @@ -4,14 +4,16 @@ import { describe, it, expect } from 'vitest'; import { settingsObjects } from './manifest.js'; describe('service-settings manifest objects (#4270)', () => { - it('owns exactly the K/V store, its global rung and its audit trail', () => { + it('owns exactly the K/V store, its global rung and its audit trail, split the same way', () => { // [ADR-0131 D7] `sys_platform_setting` is the cascade's global rung. It // registers WITH `sys_setting` because the resolver reads both on every - // resolution. + // resolution. The audit trail splits the same way (ruling record + // 6257510973, Q4 A): `sys_platform_setting_audit` is its global rung. expect(settingsObjects.map((o: any) => o.name)).toEqual([ 'sys_setting', 'sys_platform_setting', 'sys_setting_audit', + 'sys_platform_setting_audit', ]); }); diff --git a/packages/services/service-settings/src/manifest.ts b/packages/services/service-settings/src/manifest.ts index 3842426f5cc..896d616c411 100644 --- a/packages/services/service-settings/src/manifest.ts +++ b/packages/services/service-settings/src/manifest.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings'; export const SETTINGS_PLUGIN_VERSION = '0.1.0'; @@ -15,6 +15,10 @@ export const SETTINGS_PLUGIN_VERSION = '0.1.0'; * the resolver reads both on every resolution: a kernel with one and not the * other would answer a cascade with a rung missing. * + * The audit trail splits the same way (ruling record 6257510973, Q4 A): + * `sys_platform_setting_audit` takes the global rung's rows, with no + * organization column, and `sys_setting_audit` keeps the tenant and user rows. + * * `sys_secret` is deliberately NOT here (#4270): its producers span domains * (encrypted settings here, the engine's `secret`-field encryption, the * datasource credential binder) and the engine fails closed when the store @@ -22,7 +26,7 @@ export const SETTINGS_PLUGIN_VERSION = '0.1.0'; * infrastructure, present with or without this service (cf. `sys_migration`, * #4243). This service remains a producer/consumer via its secret store. */ -export const settingsObjects: any[] = [SysSetting, SysPlatformSetting, SysSettingAudit]; +export const settingsObjects: any[] = [SysSetting, SysPlatformSetting, SysSettingAudit, SysPlatformSettingAudit]; /** Manifest header shared by compile-time config and runtime registration. */ export const settingsPluginManifestHeader = { diff --git a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts new file mode 100644 index 00000000000..93a4f35712a --- /dev/null +++ b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7 · ruling record 6257510973, Q4 A] The 18.x expand step for the + * settings audit trail: its global rung is the deployment-level + * `sys_platform_setting_audit`. + * + * Pinned over a real `ObjectQL` engine and the real platform objects, under the + * `single` posture with one organization — the posture an 18.0 install runs — + * so the engine's ADR-0131 D9 stamp is live in this rig: the control shows it + * stamping an organization-less system insert into `sys_setting_audit`, so an + * unstamped sibling row is the sibling's shape, not a rig that stamps nothing. + * + * Two halves, as the 18.x line's expand rule asks: + * + * - **The new write shape.** A global-rung audit row goes to + * `sys_platform_setting_audit`, with no organization; a tenant-rung row + * stays in `sys_setting_audit`, stamped. + * - **The dual read, on 18.0-shaped rows.** An 18.0 install wrote its + * global-rung audit rows into `sys_setting_audit`, stamped with the Default + * Organization. The global rung's history is selected by SCOPE in that + * table (never by a NULL organization, which those rows do not have) beside + * the sibling; nothing re-stamps or moves them — that is the upgrade + * ceremony's. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { + SysPlatformSetting, + SysPlatformSettingAudit, + SysSecret, + SysSetting, + SysSettingAudit, +} from '@objectstack/platform-objects/system'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin, buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.settings-deployment-level-expand'; +const DEFAULT_ORG = 'org_default'; +const SYS = { context: { isSystem: true } } as const; + +const GLOBAL_MANIFEST: SettingsManifest = { + namespace: 'relay_probe', + version: 1, + label: 'Relay (global)', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + specifiers: [{ type: 'password', key: 'api_key', label: 'API key', required: false, encrypted: true }], +}; + +const TENANT_MANIFEST: SettingsManifest = { + namespace: 'tenant_vault', + version: 1, + label: 'Tenant vault', + scope: 'tenant', + specifiers: [{ type: 'password', key: 'token', label: 'Token', required: false, encrypted: true }], +}; + +/** The minimal organization object the engine's D9 probe asks for. */ +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +interface Write { + object: string; + data: Record; + options: Record | undefined; +} + +/** A Map-backed driver that also records each create, with the options the engine handed it. */ +function makeRecordingDriver() { + const store = new Map>>(); + const writes: Write[] = []; + let nextId = 0; + const copy = (r: Record) => ({ ...r }); + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k === '$and') return (v as any[]).every((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record, options?: Record) { + writes.push({ object, data: { ...data }, options }); + nextId += 1; + const id = (data.id as string) ?? `row_${nextId}`; + // What a SQL driver persists: the engine's tenant stamp lands in the + // organization column when the record does not already carry one. + const tenantId = options?.tenantId; + const row = { ...data, id, ...(tenantId && data.organization_id === undefined ? { organization_id: tenantId } : {}) }; + rowsOf(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return copy(next); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[], options?: Record) { + return Promise.all(rows.map((r) => this.create(object, r, options))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany() { return 0; }, + async deleteMany() { return 0; }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf, writes }; +} + +async function boot() { + const engine = new ObjectQL(); + const { driver, rowsOf, writes } = makeRecordingDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [SysSetting, SysPlatformSetting, SysSettingAudit, SysPlatformSettingAudit, SysSecret, ORG_OBJECT]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } + engine.setTenancyPostureProvider(() => 'single'); + rowsOf('sys_organization').set(DEFAULT_ORG, { id: DEFAULT_ORG, name: 'Default Organization' }); + + const cryptoProvider = new LocalCryptoProvider(); + const svc = new SettingsService({ + env: {}, + engine: wrapEngineAsSettingsEngine(engine as any), + cryptoProvider, + secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), + auditWriter: buildSettingAuditWriter(engine as any, { error: (m) => { throw new Error(m); } }), + }); + svc.registerManifest(GLOBAL_MANIFEST); + svc.registerManifest(TENANT_MANIFEST); + const rows = (object: string) => [...rowsOf(object).values()]; + const seed = (object: string, row: Record) => rowsOf(object).set(String(row.id), { ...row }); + const writesTo = (object: string) => writes.filter((w) => w.object === object); + return { engine, svc, cryptoProvider, rows, seed, writesTo }; +} + +describe('[18.x expand · Q4 A] the settings audit trail\'s new write shape, under single with the Default Organization', () => { + it('control: the rig stamps an organization-less system insert into a tenant-column object', async () => { + const { engine, rows } = await boot(); + await engine.insert('sys_setting_audit', { + namespace: 'n', key: 'k', scope: 'tenant', action: 'set', source: 'api', encrypted: false, + created_at: new Date().toISOString(), + }, SYS); + expect(rows('sys_setting_audit')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); + }); + + it('a tenant value: the audit row is stamped in sys_setting_audit, and the sibling takes nothing', async () => { + const { svc, rows } = await boot(); + await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + expect(rows('sys_setting_audit')).toEqual([ + expect.objectContaining({ namespace: 'tenant_vault', key: 'token', scope: 'tenant', organization_id: DEFAULT_ORG }), + ]); + expect(rows('sys_platform_setting_audit')).toEqual([]); + }); + + it('a global value: the audit row goes to sys_platform_setting_audit, unstamped, and sys_setting_audit takes nothing', async () => { + const { svc, rows, writesTo } = await boot(); + await svc.setMany('relay_probe', { api_key: 're_123' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + const platformAudit = rows('sys_platform_setting_audit'); + expect(platformAudit).toEqual([ + expect.objectContaining({ namespace: 'relay_probe', key: 'api_key', scope: 'global', action: 'set', encrypted: true }), + ]); + expect(platformAudit[0]!.organization_id ?? null).toBeNull(); + expect(writesTo('sys_platform_setting_audit')[0]?.options?.tenantId).toBeUndefined(); + expect(rows('sys_setting_audit')).toEqual([]); + }); +}); + +describe('[18.x expand · Q4 A] the dual read, on 18.0-shaped rows', () => { + it('the global rung\'s history reads both tables: the 18.0 row in sys_setting_audit and the new one in the sibling', async () => { + const { engine, svc, seed } = await boot(); + // The 18.0 shape: a global-rung audit row in sys_setting_audit, stamped + // with the Default Organization by the 18.0 engine. + seed('sys_setting_audit', { + id: 'aud_18', organization_id: DEFAULT_ORG, namespace: 'relay_probe', key: 'api_key', scope: 'global', + action: 'set', source: 'api', actor_id: null, encrypted: true, new_hash: 'hmac-sha256:18', + created_at: '2026-10-19T00:00:00.000Z', + }); + await svc.setMany('relay_probe', { api_key: 're_456' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + const where = { namespace: 'relay_probe', key: 'api_key', scope: 'global' }; + const legacy = (await engine.find('sys_setting_audit', { where }, SYS)) as any[]; + const current = (await engine.find('sys_platform_setting_audit', { where }, SYS)) as any[]; + expect(legacy).toEqual([expect.objectContaining({ id: 'aud_18', organization_id: DEFAULT_ORG, new_hash: 'hmac-sha256:18' })]); + expect(current).toHaveLength(1); + expect(current[0]!.organization_id ?? null).toBeNull(); + expect(current[0]!.new_hash).not.toBe('hmac-sha256:18'); + }); +}); diff --git a/packages/services/service-settings/src/settings-engine-bind-window.test.ts b/packages/services/service-settings/src/settings-engine-bind-window.test.ts index be2318ba021..5a3f10ba664 100644 --- a/packages/services/service-settings/src/settings-engine-bind-window.test.ts +++ b/packages/services/service-settings/src/settings-engine-bind-window.test.ts @@ -55,7 +55,7 @@ import { describe, expect, it } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -233,9 +233,12 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { engine.registerDriver(driver, true); await engine.init(); // [#21516] The settings service writes its audit row through - // `sys_setting_audit`; the engine refuses a name its registry does not hold, - // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // `sys_setting_audit` (or, for the global rung, `sys_platform_setting_audit` + // — ADR-0131 D7, Q4 A); the engine refuses a name its registry does not hold, + // so the harness registers them beside the two it already did, as a boot does. + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit, SysPlatformSettingAudit]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } const probe = new ReadyHookFromInitPlugin(); const kernel = new LiteKernel({ logger: { level: 'error' } as never }); @@ -251,9 +254,10 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { return { kernel, engine, svc, probe, rowsOf, // The probe manifests are `scope: 'global'`, so their rows are the global - // rung's: `sys_platform_setting` (ADR-0131 D7). + // rung's: `sys_platform_setting`, and its trail `sys_platform_setting_audit` + // (ADR-0131 D7; ruling record 6257510973, Q4 A). settingRows: () => [...rowsOf('sys_platform_setting').values()], - auditRows: () => [...rowsOf('sys_setting_audit').values()], + auditRows: () => [...rowsOf('sys_platform_setting_audit').values()], }; } @@ -376,9 +380,12 @@ describe('engine-less callers outside the window observe nothing new', () => { engine.registerDriver(driver, true); await engine.init(); // [#21516] The settings service writes its audit row through - // `sys_setting_audit`; the engine refuses a name its registry does not hold, - // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // `sys_setting_audit` (or, for the global rung, `sys_platform_setting_audit` + // — ADR-0131 D7, Q4 A); the engine refuses a name its registry does not hold, + // so the harness registers them beside the two it already did, as a boot does. + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit, SysPlatformSettingAudit]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } const svc = new SettingsService({ env: {}, engineBindPending: true }); svc.registerManifest(probeManifest); diff --git a/packages/services/service-settings/src/settings-read-door.pin.test.ts b/packages/services/service-settings/src/settings-read-door.pin.test.ts index 328c919f91e..a0648d69505 100644 --- a/packages/services/service-settings/src/settings-read-door.pin.test.ts +++ b/packages/services/service-settings/src/settings-read-door.pin.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import { SettingsService } from './settings-service.js'; import { SETTINGS_READ_DOOR_OBJECTS, registerSettingsReadDoor, settingsReadDoorMiddleware } from './settings-read-door.js'; @@ -102,7 +102,7 @@ beforeEach(async () => { const memory = makeMemoryDriver(); engine.registerDriver(memory.driver, true); await engine.init(); - for (const o of [SysSetting, SysSettingAudit, SysPlatformSetting]) { + for (const o of [SysSetting, SysSettingAudit, SysPlatformSetting, SysPlatformSettingAudit]) { engine.registry.registerObject(o as any, '@objectstack/platform-objects'); } const svc = new SettingsService({ env: {} }); @@ -128,6 +128,12 @@ beforeEach(async () => { namespace, key: 'k', value: 'v', value_enc: null, encrypted: false, locked: false, locked_reason: null, }, SYS); ids[namespace].sys_platform_setting = String((platform as any).id); + const platformAudit = await engine.insert('sys_platform_setting_audit', { + namespace, key: 'k', scope: 'global', action: 'set', source: 'api', actor_id: null, + old_hash: null, new_hash: 'h', encrypted: false, request_id: null, reason: null, + created_at: new Date().toISOString(), + }, SYS); + ids[namespace].sys_platform_setting_audit = String((platformAudit as any).id); } }); diff --git a/packages/services/service-settings/src/settings-read-door.ts b/packages/services/service-settings/src/settings-read-door.ts index 224e9a95fe5..70ecaba7cfe 100644 --- a/packages/services/service-settings/src/settings-read-door.ts +++ b/packages/services/service-settings/src/settings-read-door.ts @@ -9,14 +9,15 @@ * The settings door (`GET /api/settings/:namespace`) refuses a caller who lacks * the manifest's `readPermission` (`SettingsService.assertPermitted`). The same * values, and their audit trail, are rows of `sys_setting`, - * `sys_setting_audit` and `sys_platform_setting`, and each of those objects - * exposes `get` / `list` on the generic data API as a diagnostic grid. That + * `sys_setting_audit`, `sys_platform_setting` and `sys_platform_setting_audit`, + * and each of those objects exposes `get` / `list` on the generic data API as a + * diagnostic grid. That * door applied the OBJECT's grants only — never the namespace's — so a * principal the settings door refused read the namespace's rows there instead. * * ## The seam * - * An engine middleware the settings plugin registers for exactly those three + * An engine middleware the settings plugin registers for exactly those four * objects. It ANDs a `namespace` predicate into the operation's `where` for * every READ (`find`, `findOne`, `count`, `aggregate`) — a filter, never a * pass over the result, so a count, an aggregate and a page see exactly the @@ -29,7 +30,7 @@ * * - a SYSTEM context — the settings service's own plumbing and every other * platform reader that elevates on purpose; - * - writes — the three objects expose no write on the data API, and the + * - writes — the four objects expose no write on the data API, and the * settings door owns every write. * * A context that is not system and carries no capability holds none, so it @@ -44,6 +45,7 @@ export const SETTINGS_READ_DOOR_OBJECTS: readonly string[] = Object.freeze([ 'sys_setting', 'sys_setting_audit', 'sys_platform_setting', + 'sys_platform_setting_audit', ]); /** The engine verbs that read rows — the four that carry a `where` to scope. */ diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index 19e43841ebe..0f3f75cf5b2 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -6,8 +6,10 @@ * `sys_setting_audit` history row — belong to the organization the setting * row does: the caller's, for a `tenant` or `user` value. A `global` value is * deployment-level (its row lives in `sys_platform_setting`, which has no - * organization), so neither row names one; whether such rows may stay in - * these two tenant-column objects is a question this file does not answer. + * organization), so neither row names one. Its history row goes to + * `sys_platform_setting_audit`, the audit trail's deployment-level global rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A); the secret's fate is the + * 18.x ceremony's. */ import { randomBytes } from 'node:crypto'; @@ -16,7 +18,12 @@ import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { mailSettingsManifest } from './manifests/mail.manifest.js'; -import { buildSettingAuditWriter } from './settings-service-plugin.js'; +import { + buildSettingAuditWriter, + PLATFORM_SETTING_AUDIT_OBJECT, + SETTING_AUDIT_OBJECT, + settingAuditObjectFor, +} from './settings-service-plugin.js'; import type { SettingsSecretStore } from './settings-service.types.js'; /** A tenant-scoped namespace carrying one encrypted value. */ @@ -77,13 +84,15 @@ describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\' }); }); -describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s organization', () => { +describe('[ADR-0131 D3/D7] the settings audit writer: tenant rows stamped in sys_setting_audit, global rows to the sibling', () => { function engine() { const inserted: Array> = []; + const objects: string[] = []; return { inserted, + objects, async findOne() { return { id: 'usr-1' }; }, - async insert(_object: string, row: Record) { inserted.push({ ...row }); return row; }, + async insert(object: string, row: Record) { objects.push(object); inserted.push({ ...row }); return row; }, }; } const entry = { @@ -91,14 +100,29 @@ describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s orga actorId: 'usr-1', oldHash: null, newHash: 'hmac-sha256:x', encrypted: true, }; - it('writes organization_id when the entry names one, and no organization_id when it does not', async () => { + it('a tenant or user row goes to sys_setting_audit with its organization; a global row to sys_platform_setting_audit with none', async () => { const eng = engine(); const writer = buildSettingAuditWriter(eng as any, { error: (m) => { throw new Error(m); }, warn: (m) => { throw new Error(m); } }); await writer.write({ ...entry, organizationId: 'org-1' }); + await writer.write({ ...entry, scope: 'user', organizationId: 'org-1' }); await writer.write({ ...entry, scope: 'global' }); + // An entry that names an organization at the global rung still writes none: + // the sibling has no organization column to hold it. + await writer.write({ ...entry, scope: 'global', organizationId: 'org-1' }); - expect(eng.inserted[0]).toMatchObject({ key: 'token', organization_id: 'org-1' }); - expect('organization_id' in eng.inserted[1]).toBe(false); + expect(eng.objects).toEqual(['sys_setting_audit', 'sys_setting_audit', 'sys_platform_setting_audit', 'sys_platform_setting_audit']); + expect(eng.inserted[0]).toMatchObject({ key: 'token', scope: 'tenant', organization_id: 'org-1' }); + expect(eng.inserted[1]).toMatchObject({ key: 'token', scope: 'user', organization_id: 'org-1' }); + expect(eng.inserted[2]).toMatchObject({ key: 'token', scope: 'global' }); + expect('organization_id' in eng.inserted[2]).toBe(false); + expect('organization_id' in eng.inserted[3]).toBe(false); + }); + + it('routes by rung through one predicate: global to the sibling, tenant and user to sys_setting_audit', () => { + expect(settingAuditObjectFor('global')).toBe(PLATFORM_SETTING_AUDIT_OBJECT); + expect(settingAuditObjectFor('tenant')).toBe(SETTING_AUDIT_OBJECT); + expect(settingAuditObjectFor('user')).toBe(SETTING_AUDIT_OBJECT); + expect([PLATFORM_SETTING_AUDIT_OBJECT, SETTING_AUDIT_OBJECT]).toEqual(['sys_platform_setting_audit', 'sys_setting_audit']); }); it('reports a lost history row at error once with the consequence, and on a reduced sink through warn', async () => { diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 54cfab51a24..958f95af684 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -254,7 +254,8 @@ export class SettingsServicePlugin implements Plugin { if (!registerSettingsReadDoor(engine, this.service!)) { ctx.logger?.warn?.( 'SettingsServicePlugin: the objectql engine offers no middleware seam, so the data API ' + - 'read of sys_setting / sys_setting_audit / sys_platform_setting is NOT gated by each ' + + 'read of sys_setting / sys_setting_audit / sys_platform_setting / sys_platform_setting_audit ' + + 'is NOT gated by each ' + "settings namespace's readPermission — only by the objects' own grants.", ); } @@ -548,10 +549,35 @@ export class SettingsServicePlugin implements Plugin { } } +/** The settings audit trail's tenant and user rungs (organization NOT NULL). */ +export const SETTING_AUDIT_OBJECT = 'sys_setting_audit'; + +/** + * The settings audit trail's global rung, with no organization column + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + */ +export const PLATFORM_SETTING_AUDIT_OBJECT = 'sys_platform_setting_audit'; + /** - * Phase 3: append-only writer for `sys_setting_audit`. Failures here - * MUST NOT abort the settings write, so all calls are wrapped in a - * try/catch and reported through the plugin logger. + * Which table an audit row of this cascade rung goes to: the global rung's to + * the deployment-level sibling, the tenant and user rungs' to + * `sys_setting_audit` — the split `sys_setting` / `sys_platform_setting` makes + * for the values themselves. + * + * An 18.0 install wrote global-rung rows into `sys_setting_audit`; they stay + * there until the upgrade ceremony moves them, so a reader of the global rung's + * history reads both tables. + */ +export function settingAuditObjectFor(scope: string): string { + return scope === 'global' ? PLATFORM_SETTING_AUDIT_OBJECT : SETTING_AUDIT_OBJECT; +} + +/** + * Phase 3: append-only writer for the settings audit trail — `sys_setting_audit` + * for the tenant and user rungs, `sys_platform_setting_audit` for the global + * rung ({@link settingAuditObjectFor}). Failures here MUST NOT abort the + * settings write, so all calls are wrapped in a try/catch and reported through + * the plugin logger. * * [#8145] The settings-SHAPED half of the dual write. Its sibling — * {@link buildConfigChangeAuditSink} — records the same event on the @@ -588,16 +614,17 @@ export function buildSettingAuditWriter( // Under the opt-in below the engine no longer checks that `actor_id` // names a user, so the writer keeps that refusal; this `catch` reports // it exactly as it reported the engine's (see assertUserReferenceResolves). + const object = settingAuditObjectFor(entry.scope); await assertUserReferenceResolves( (id) => eng.findOne(USER_OBJECT, { where: { id }, fields: ['id'] }, { context: { isSystem: true } }), { - object: 'sys_setting_audit', + object, field: 'actor_id', - label: registeredLabel(eng, { object: 'sys_setting_audit', field: 'actor_id' }, 'Actor'), + label: registeredLabel(eng, { object, field: 'actor_id' }, 'Actor'), }, entry.actorId ?? null, ); - await eng.insert('sys_setting_audit', { + const row = { namespace: entry.namespace, key: entry.key, scope: entry.scope, @@ -610,15 +637,25 @@ export function buildSettingAuditWriter( request_id: entry.requestId ?? null, reason: entry.reason ?? null, created_at: new Date().toISOString(), + }; + // The explicit system opt-in, as the settings row write carries: the + // settings audit is the platform's own ledger, written after the + // settings door already authorized the change, and not a write that + // may rely on a missing principal to pass the security middleware's + // principal-less hand-off (ADR-0096 D5). One literal object per call + // site, so each write names the table it lands in. + if (object === PLATFORM_SETTING_AUDIT_OBJECT) { + // [ADR-0131 D7] A global value is deployment-level: its row names no + // organization, in the sibling that has no organization column. + await eng.insert('sys_platform_setting_audit', row, { bypassTenantAudit: true, context: { isSystem: true } }); + } else { // [ADR-0131 D3/D9] The organization whose value changed — the - // settings row's own owner. A global value names none. - ...(entry.organizationId ? { organization_id: entry.organizationId } : {}), - // The explicit system opt-in, as the settings row write carries: - // `sys_setting_audit` is the platform's own ledger, written after - // the settings door already authorized the change, and not a - // write that may rely on a missing principal to pass the security - // middleware's principal-less hand-off (ADR-0096 D5). - }, { bypassTenantAudit: true, context: { isSystem: true } }); + // settings row's own owner. + await eng.insert('sys_setting_audit', { + ...row, + ...(entry.organizationId ? { organization_id: entry.organizationId } : {}), + }, { bypassTenantAudit: true, context: { isSystem: true } }); + } } catch (err: any) { // A durability loss, not a functional one: the settings write already // landed and everything keeps looking normal, while the per-key @@ -630,7 +667,7 @@ export function buildSettingAuditWriter( reportedDurabilityLoss = true; report( cause + - ' — settings writes keep succeeding, but their sys_setting_audit history rows are NOT persisted ' + + ' — settings writes keep succeeding, but their settings audit history rows are NOT persisted ' + 'and nothing retries them. Fix the cause named here (the actor must be a sys_user; the row must ' + 'carry the organization its setting belongs to).', ); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index e3a1e5290c8..cfeb5fe9fed 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1331,7 +1331,8 @@ export class SettingsService { /** * The `namespace` predicate the generic read door ANDs onto a principal's * read of a settings store (`sys_setting`, `sys_setting_audit`, - * `sys_platform_setting`), given the capabilities the principal holds — or + * `sys_platform_setting`, `sys_platform_setting_audit`), given the + * capabilities the principal holds — or * `null` when it may read every namespace there is. * * The settings door has always applied a namespace's `readPermission` diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 92ed48d1c68..ecb09446119 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -285,8 +285,10 @@ export interface SettingsSecretStore { } /** - * Append-only writer for the `sys_setting_audit` object — Phase 3 - * audit trail. Distinct from {@link SettingsAuditSink} (which writes the + * Append-only writer for the settings audit trail — Phase 3. The tenant and + * user rungs' rows go to `sys_setting_audit`; the global rung's go to the + * deployment-level `sys_platform_setting_audit` (ADR-0131 D7), the way the + * values split between `sys_setting` and `sys_platform_setting`. Distinct from {@link SettingsAuditSink} (which writes the * generic `sys_audit_log` `config_change` row) so audit consumers can subscribe * to settings activity without scanning the firehose. * @@ -387,7 +389,7 @@ export interface SettingsServiceOptions { secretStore?: SettingsSecretStore; /** Audit sink. When undefined, writes still succeed but are not logged. */ audit?: SettingsAuditSink; - /** Phase 3 dedicated writer for `sys_setting_audit`. */ + /** Phase 3 dedicated writer for the settings audit trail. */ auditWriter?: SettingsAuditWriter; /** * `process.env`-like map. Defaults to `process.env`. Injected so diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 86f8c8d6d41..abf7978f0c7 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -73,6 +73,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly