Repository navigation
Commit 6729e10
feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) (#22266)
Part of #15207
Clause-②: no (narrowing)
The claim (`6055795594`) declared the value `yes` with the narrowing arm
and asked the dev to measure the built declaration closure. Measured:
`check:api-surface` on the rebuilt `@objectstack/spec` reports the
public surface unchanged, and no package in this diff adds an export, a
published key or an accepted value; the diff only narrows (the ledger
refuses `organization_id`, and `organization_admin`'s ledger grant loses
its superuser bits). So the value is `no`, the arm stays `(narrowing)`,
and the changeset carries the same line. It is the reading item (1)
declared for the same shape. Item (4) of #15207 is not built here, so
the card stays open.
## Scope: item (2) only
ADR-0131 D7: the audit ledger may hold rows about deployment-level
actions, so "the organization an audit row is *about* becomes a plain
attribution field under a name the tenant-field resolver does not claim,
never the tenancy anchor", and the object "is governed by object
permission, not by the wall". The seat's ruling is option A of report
`6042515710`. Item (1) landed as #22107 and item (3) as #22166. No
stored row moves here (ADR-0131 D14): the column's fate is C7's
(#15211), below.
**Patch round** (seat ruling `6058257824`, which widens the claim's file
surface):
- the changeset now states the `single`-posture consequence;
- a global settings change's `config_change` row carries no `tenant_id`;
- the inert `organization_id` stamps are removed in
`platform-admin-standing-audit.ts` and `config-change-audit.ts`, and the
predating comments are corrected, including
`managed-object-write-denies.ts`' docblock.
The open question on a `single` deployment holding more than one
organization is ruled A (ADR-0131 D8 and §1.2(3)).
## What changes
- **`sys_audit_log`**
(`plugin-audit/src/objects/sys-audit-log.object.ts`) declares
`systemFields: { tenant: false }`. The registry injects no
`organization_id`, and a new table is provisioned without it.
`tenant_id` (lookup to `sys_organization`) is unchanged and is now the
only organization column; its help text says what it is. The four
translation bundles and the README follow.
- **The plugin-audit writers** (`audit-writers.ts`, `read-audit.ts`,
`auth-event-audit.ts`) keep stamping `tenant_id` as before and drop
their conditional `organization_id` stamp, which the registered schema
can no longer satisfy. No fallback read or write of the retired column
remains (D14).
- **The settings writer**
(`service-settings/src/config-change-audit.ts`): a GLOBAL-scope change
is a deployment-level action about no organization, so its
`config_change` row carries no `tenant_id`, whatever organization the
writing session has active. Tenant- and user-scope changes keep the
writer's organization. Its `organization_id` field probe and stamp are
gone. The `SettingsAuditSink.tenantId` TSDoc in
`settings-service.types.ts` says the same.
- **The platform-admin standing writer**
(`plugin-security/src/platform-admin-standing-audit.ts`): the
`declaresOrganizationId` input and its stamp are gone, and the call site
in `bootstrap-platform-admin.ts` with them. `tenant_id` stays NULL by
ruling, and its comment block now reads against the column-less ledger.
No behaviour moves: the registered ledger declares no such column after
this PR.
- **`managed-object-write-denies.ts`'s docblock** no longer cites the
ledger as reached by the wildcard's superuser bits. `organization_admin`
names it explicitly, without them.
- **The read scope, option A**
(`plugin-security/src/objects/default-permission-sets.ts`):
- a platform row policy `sys_audit_log_org`: `tenant_id ==
current_user.organization_id`, operation `select`;
- spread into `organization_admin` (and so its derived no-bypass
variant), `viewer_readonly` and `member_default`. A set that holds no
policy for an object leaves it unfiltered, so the policy goes where the
shipped reads are. `viewer_readonly`'s wildcard reads the ledger.
`member_default` is the baseline every authenticated human holds, so a
ledger read an application set grants is scoped too. This is the
placement `scimProjectionRowScope` already uses;
- an explicit `sys_audit_log` entry in `organization_admin`: read only,
with no `viewAllRecords` / `modifyAllRecords`;
- stripped under `single` by the existing provenance rule (ADR-0105 D3),
with no new code.
- **Retention** (`objectql/src/lifecycle/lifecycle-service.ts`).
`tenantWindowsFor` now returns the partition column with the windows. It
is `organization_id` where the object has a provisioned one (unchanged).
Otherwise it is the ledger's attribution field, from a one-row,
name-keyed table `ATTRIBUTION_PARTITION_COLUMNS` (`sys_audit_log` →
`tenant_id`), honoured only where the author really declares the field.
The reaper and the archiver name only that column.
- **`view_all_audit_log`** (`spec/src/security/capabilities.ts`): the
description and its comment are re-premised on the attribution field and
the row scope. `eval-user.zod.ts` lists the name only and restates
nothing.
- **ADR-0087**: the D3 entry
`18.sys-audit-log-organization-column-retired.ts`, one step-18 rationale
fragment, and the regenerated `registry.ts`.
- **Censuses**: `scripts/platform-object-tenancy-census.json`
regenerated by its own tool (in reach 50 → 49, out 34 → 35,
`systemFields.tenant: false` 9 → 10). The tenant-audit and
system-context censuses are green and did not move.
## Premise readings (each measured before code)
- **P1, holds, with one refinement.** The ledger holds rows about
deployment-level actions with no organization:
- `platform_admin_standing_change`: `plugin-security`
`bootstrap-platform-admin.ts` through `buildPlatformAdminStandingRow`,
`tenant_id` always NULL, by ruling;
- `import`: `plugin-auth` `admin-import-users.ts`, the run-level row of
a platform-admin endpoint, no `tenant_id`;
- administrative `create` / `update` on `sys_user`: `plugin-auth`
`admin-user-endpoints.ts`, platform-admin endpoints, no `tenant_id`;
- `config_change`: `service-settings` `config-change-audit.ts`.
Refinement: it stamped the writing context's organization, so a
global-scope key written by a session with an active organization
carried that organization. Ruled into this PR (`6058257824`): a
global-scope change now carries no `tenant_id`.
- **P2, holds, measured** through the real permission compiler (a real
`SecurityPlugin` over a real `ObjectQL` and SQL driver, with the shipped
sets). With the explicit entry removed and the policy kept, an
organization admin under `isolated` reads every organization's rows.
Mechanism: `systemFields.tenant === false` makes `meta.tenancyDisabled`
true, so `posturePermits` holds in `computeLayeredRlsFilter` and the
wildcard's superuser bypass skips Layer 1.
- **P3, holds.** `security-plugin.ts#collectRLSPolicies` drops a policy
when `!this.orgScopingEnabled && isPlatformTenantPolicy(policy)`.
`orgScopingEnabled` is `postureEnforcesWall(this.tenancyPosture)`. The
provenance set is `PLATFORM_TENANT_POLICY_KEYS` in
`platform-tenant-policies.ts`, built from the shipped sets' policies
whose `using` names `current_user.organization_id`. The new policy is in
that set (pinned).
- **P4.** Writers that stamp `tenant_id`:
- the record mirror (`audit-writers.ts`): the record's organization,
else the session's;
- the record-view writer (`read-audit.ts`): the record's organization,
else the session's;
- the sign-in writer (`auth-event-audit.ts`): the session's
organization;
- the settings writer (`config-change-audit.ts`): the writing context's
organization, and none for a global-scope change since this round.
Each stamped the injected column with the same value. Explicit NULL: the
platform-admin standing writer. Not stamped: the two `plugin-auth`
administrative writers. Writers that update existing rows:
`stored-metadata-body-migration.ts` and the CLI's
`audit-metadata-bodies` rewrite `old_value` / `new_value` only. A row
with no `tenant_id` matches no organization under the new policy. Under
a wall it is served to platform administrators only; under `single` it
is served to every ledger reader.
## Who reads what (measured; rows `a1` about org A, `b1` about org B,
`d1` about no organization)
| posture | caller | before | after |
|---|---|---|---|
| `isolated` | organization admin of A | `a1` | `a1` |
| `isolated` | viewer of A | not measured | `a1` |
| `isolated` | platform admin (active org A) | `a1` | `a1 b1 d1` |
| `isolated` | platform admin, no active organization | not measured |
`a1 b1 d1` |
| `isolated` | organization admin, no active organization | not measured
| none |
| `single` (one organization) | organization admin, both variants | `a1
d1` | `a1 d1` |
| `group` (member of A and B, active A) | organization admin | `a1 b1` |
`a1` |
| `group` | platform admin | `a1 b1` | `a1 b1 d1` |
| `single` holding two organizations | organization admin, viewer,
platform admin | `a1 d1` | `a1 b1 d1` |
| `isolated` | organization admin of A, reading a global settings change
(`g1`, no `tenant_id`) and a tenant-scope change about A (`t1`) | not
measured | `t1` |
| `isolated` | platform admin, the same two rows | not measured | `g1
t1` |
The `group` and two-organization `single` rows are readings, not pins.
Under `group` the policy scopes to the ACTIVE organization, not the
membership union; the seat accepted that as the fail-closed direction. A
`single` deployment that holds two organizations boots today, reported
at boot. Before this change, the SQL driver's native organization arm
still narrowed the ledger there; with no column it cannot, and the
policy is stripped under `single`. The seat ruled this A (ADR-0131 D8,
§1.2(3)), and the changeset states it with its remedy, a walled posture.
## Pins (refused and still-accepted case each)
- **Organization scope**
(`plugin-security/src/sys-audit-log-row-scope.test.ts`):
- an organization admin of A reads `a1` and not `b1` or `d1`;
- CONTROL: the same read with the policy removed from every set serves
all three;
- the explicit entry is load-bearing: without it, the bypass reads all
three;
- a viewer is scoped the same way;
- a global settings change's row (`config_change` on
`sys_platform_setting`, no `tenant_id`) is not served to an organization
admin, and a tenant-scope change about its organization is. The platform
admin reads both.
- **The settings writer**
(`service-settings/src/config-change-audit.test.ts`):
- the `#8145 … WIRES the generic sink` case on a global manifest,
written by a session with `org_1` active, asserts `tenant_id` null and
no `organization_id`;
- CONTROL: a tenant-scope write keeps `org_1`.
- **Platform scope**: a platform admin reads all three, the
deployment-level row included. CONTROL: before the change, the wall hid
`d1` (and `b1`) from the platform admin.
- **`single`**: the policy is a platform tenant policy by provenance,
and both organization-admin variants read exactly what the pre-change
read served.
- **The write**
(`plugin-audit/src/objects/sys-audit-log-attribution.test.ts`, a real
kernel with SQLite):
- a deployment-level row with no `tenant_id` is written with no refusal;
- a write still naming `organization_id` is refused `INVALID_FIELD` /
400;
- a filter on it is refused `INVALID_FILTER` / 400;
- the record mirror stamps the record's organization into `tenant_id`
and writes no `organization_id`.
- **Retention**
(`objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts`,
a real `ObjectQL` registry):
- the reaper and the archiver partition a tenant's override on
`tenant_id`, and the global pass keeps the NULL rows;
- no `INVALID_FILTER`, so a tenant's override no longer stops the
table's reap;
- CONTROLS: the ledger without the field and another column-less object
carrying a `tenant_id` both run one global pass; the ledger with the
injected column partitions on `organization_id`.
- **DDL**: the injection plan carries no `organization_id`. The
provisioned SQLite table (introspected after a real schema sync) has
`tenant_id` and no `organization_id`. CONTROL: an ordinary object on the
same sync has the column.
## Reverse verification (one-off, `scripts/ablation-replace.mjs`, each
restore read blob == HEAD with `git diff HEAD` empty)
- **A.** Delete the `sys_audit_log_org` policy literal (anchor 1 → 0).
Exactly four red: the organization-admin pin, the viewer pin, the
provenance pin and the `member_default` roster pin. 28 green, the
controls and the platform-admin pins included.
- **B.** Delete `organization_admin`'s explicit ledger entry. Exactly
the organization-admin pin red; 8 green, the viewer pin included.
- **C.** Delete the `ATTRIBUTION_PARTITION_COLUMNS` row. Exactly the
reap and archive pins red; 4 green.
- **D.** Make the settings writer stamp `tenant_id: entry.tenantId ??
null` for every scope again. Exactly the global `WIRES` case red; 16
green, the tenant-scope control included.
The subjects are imported by relative source path, so no `dist` is in
the path.
## Fate for C7's inventory (#15211, ADR-0131 D10 fate 1)
`sys_audit_log.organization_id`: **drop the column, once its values are
confirmed in `tenant_id`; report the rows where they differ.** By the
writer census, every writer that stamped the column stamped the same
value into `tenant_id` (or NULL into both). The check is NULL-safe: a
row differs when exactly one of the two is NULL, or both are set and
unequal. A zero count means `os migrate apply --allow-destructive` drops
the orphan the boot drift report already names. Any other count is
listed with the row ids, never guessed and never dropped. Until then,
schema sync is additive and the column stays as an orphan that nothing
reads or writes.
## Files outside the claim's file surface
- `packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts`.
Its arming control read the ledger's `organization_id`. It is re-keyed
to `tenant_id`, its prose names the row scope, and one test title says
"superuser read bypass" for "wall bypass". With it, all 13 ledger
dogfood files pass.
- In the patch round, beyond the files `6058257824` names, two changes
the named edits require:
- `service-settings/src/settings-service.types.ts`: the
`SettingsAuditSink.tenantId` TSDoc restated the removed stamp;
- `plugin-security/src/bootstrap-platform-admin.ts`: the one caller
passing the removed `declaresOrganizationId` input.
- Within the claimed packages but not named in the claim: plugin-audit's
three writer test files, translation bundles and README;
plugin-security's `rbac-objects.test.ts` roster pin; objectql's
federated reader census (`federated-injected-column-readers.test.ts`),
whose `#reap` / `#archiveObject` rows go because those passes now name
only the column `tenantWindowsFor` returns.
## Verification (at `b865914be5`)
The patch round touched `service-settings` and `plugin-security` (source
and tests), the changeset, and no objectql, spec or plugin-audit source.
The objectql and spec runs were taken in round one, at `ef87292b75`,
whose files they read are unchanged since.
- service-settings: 38 files / 642 passed. plugin-security: 179 files /
3749 passed / 45 skipped. plugin-audit: 41 files / 649 passed. objectql
(round one): 382 files / 7531 passed.
- spec: `--project local`, 625 files / 18661 passed; `--project repo`
`step18-rationale-merge` + `conversions-major18-merge`, 21 passed;
`check:generated` 15 of 15 up to date; `check:api-surface` unchanged.
- dogfood: the 13 files that touch the ledger, 93 passed, re-run at this
head.
- Typecheck exit 0 for service-settings and plugin-security at this
head; for plugin-audit, objectql, spec and dogfood in round one. Test
layers are included.
- Gates: `dispatch-gates --commands` (no paths) derives 104 families at
this head. All 104 ran with their exit codes recorded, and the `--ran`
reconciliation reads a derived zero NOT-MEASURED.
- Lint, a proven narrowing: `eslint --no-inline-config --format json`
over the 31 changed `.ts` files gives 31 results, 0 errors, 0 warnings.
The population is read from `eslint.config.mjs`'s `packages/**` and
`**/*` globs. That config enables no type-aware linting, so an untouched
file's verdict cannot move. The full `pnpm lint` is CI's.
## Acceptance notes
- Out of reach of the row policy: an application set that grants the
superuser read bypass on the ledger (`viewAllRecords` on it or on a
wildcard) skips Layer 1, as it does on every object the wall does not
cover. No example app ships such a grant.
- The organization row scope is the active organization's under `group`,
and stripped under `single`. Both are ruled; the changeset states each,
with the remedy for a multi-organization `single` deployment.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2c52e43 commit 6729e10
34 files changed
Lines changed: 1065 additions & 257 deletions
File tree
- .changeset
- packages
- objectql/src
- lifecycle
- plugins
- plugin-audit
- src
- objects
- translations
- plugin-security/src
- objects
- qa/dogfood/test
- services/service-settings/src
- spec/src
- migrations
- entries/semantic
- security
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
Lines changed: 3 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
149 | 149 | | |
150 | 150 | | |
151 | 151 | | |
152 | | - | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
153 | 155 | | |
154 | 156 | | |
155 | 157 | | |
156 | 158 | | |
157 | 159 | | |
158 | 160 | | |
159 | 161 | | |
160 | | - | |
161 | | - | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
170 | 162 | | |
171 | 163 | | |
172 | 164 | | |
| |||
548 | 540 | | |
549 | 541 | | |
550 | 542 | | |
551 | | - | |
552 | | - | |
553 | 543 | | |
554 | 544 | | |
555 | 545 | | |
| |||
Lines changed: 209 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
0 commit comments