Repository navigation
Commit 79c35d4
fix(service-settings)!: settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission (#22295)
Fixes #22261
Clause-②: no (narrowing)
Executes option A as ruled on the card. This body stays at the level of
classes, positions and functions, as the card asks; the detailed
measurement went to the dispatching seat privately.
## What changes
### `SettingsService`
(`packages/services/service-settings/src/settings-service.ts`)
The service reads and writes `sys_setting` under its own system context,
and that context names no organization. So no driver tenant scope and no
organization wall reaches those calls. The organization now travels in
the service's own query and row identity.
- **Row identity.** `rowIdentity` keys a `tenant` or `user` row by the
identity `sys_setting` declares, organization included. `setMany` writes
every such row with the caller's organization
(`SettingsContext.tenantId`). A `global` row is unchanged: it lives in
`sys_platform_setting` and has no organization column.
- **Reads.** `loadScopedRows` filters explicitly, in the query itself,
by the caller's organization plus rows stored with no organization. The
in-memory store applies the same reach (`OrganizationReach`). The global
rung (`loadGlobalRows`) is unchanged.
- **Cascade.** `preferredRow` makes the tenant and user rungs take the
caller organization's own row ahead of an organization-less one. The
lock pre-flight in `setMany` reads the same row, so a lock applies only
where the cascade reads.
- **Refusal.** Under a walled posture (`group` / `isolated`), a
tenant-scope write that names no organization is refused whole, before
anything is written. It reuses the vocabulary the ownerless user-key
refusal already has: `SettingsValidationError`, `code:
SETTINGS_VALIDATION`, HTTP 400 at the settings routes, one field entry
per key with `code: invalid_value` and `constraint: { scope: 'tenant'
}`. A reset is refused the same way.
- **Posture.** The posture comes from the `tenancy` service. The plugin
passes it through `bindEngine` (`tenancyPosture`), read the same way its
HTTP door reads it. The service only asks when the caller names no
organization.
### The generic read door (`settings-read-door.ts`, registered by
`SettingsServicePlugin`)
- An engine middleware registered by object name on `sys_setting`,
`sys_setting_audit` and `sys_platform_setting`. It ANDs a `namespace`
predicate into every non-system read (`find`, `findOne`, `count`,
`aggregate`). It is a filter, not a pass over the result, so counts,
aggregates and pages see exactly the rows a list returns.
- The predicate is `SettingsService.namespaceReadScope`. It uses the
same `requiredCapability` table the settings door enforces. A namespace
with no registered manifest reads at the default capability,
`setup.access`.
- **Seam (H4).** The seam sits inside `service-settings`. No file in
`objectql`, `runtime` or `plugin-security` is edited.
### Census page
`content/docs/permissions/system-context.mdx` gains row 18b for the
middleware's `isSystem` read. `check:system-context-census` requires a
row for every elevation read site. The counts were regenerated with
`pnpm gen:system-context-census`.
## Posture `single`
The default organization keeps the answers it had. These are pinned in
`settings-organization-isolation.pin.test.ts`:
- a value stored before rows carried an organization is still read by
the default organization;
- the default organization's new write is read by itself and by a
process-wide reader that names no organization;
- a reset reads back the default for both;
- an organization-less tenant-scope write is not refused under `single`,
nor where no posture is reported.
## Existing rows (H3)
No stored row is rewritten, as the dispatch fences. What stored rows
carry today, and the decision that follows from it, went to the seat
privately.
## Tests
Every run in this table is on HEAD `61911cf17a`, after
`service-settings` was rebuilt and its `dist/` was proven to carry the
HEAD source.
| Suite | Result |
|:--|:--|
| `pnpm --filter @objectstack/service-settings exec vitest run
--maxWorkers=2` | 41 files, 752 passed |
| `settings-organization-isolation.pin.test.ts` (part of the suite
above) | 18 passed |
| `settings-read-door.pin.test.ts` (part of the suite above) | 27 passed
|
| `test/settings-organization-isolation.dogfood.test.ts`, real stack
over HTTP, two organizations under a non-degraded `isolated` posture | 6
passed |
| `single`-posture HTTP regression: the existing dogfood files that
write and read settings (`settings-config-change-audit`,
`analytics-timezone`, `audit-log-parent-read-gate`) | 3 files, 14 passed
|
| `pnpm --filter @objectstack/service-settings typecheck` | exit 0 |
| `pnpm --filter @objectstack/dogfood typecheck` | exit 0 |
### Over HTTP, with a positive control per refusal
- Each organization sets and reads its own tenant-scope value. One
organization's write and its reset leave the other's value unchanged. A
`global` value is read by both.
- An organization-less tenant-scope write under the walled posture
answers `400` with `error.code` `SETTINGS_VALIDATION` and a field entry
`invalid_value`, and writes nothing. Control: the identical write from
inside an organization answers 200.
- The data-API read of `sys_setting` hides a namespace's row from a
principal lacking that namespace's `readPermission`: the list returns 0
rows and the by-id read answers 404. Control 1: the same principal reads
its own row of a namespace whose capability it holds. Control 2: a
holder of the withheld capability reads the withheld row (200).
### Ablations
Each ablation started from a committed fix, restored from `HEAD` under a
trap, and was proven restored by blob hash and an empty `git diff HEAD`.
All ablations ran at `e1407dc55f`, except the two dogfood rows, which
ran at `672e54e08e`.
| Mutation | Result |
|:--|:--|
| `settings-service.ts` set to the base commit | isolation pin 11 red, 5
green. The 5 that stay green are the regression guards: the global row,
and the three `single` cases plus its no-refusal control. |
| `namespaceReadScope` answers no predicate | read-door pin 19 red, 8
green. The 8 green: system context, registration by name, writes
untouched, refusal of a read with no query. |
| `preferredRow` made positional | isolation pin 2 red (both preference
cases) |
| Dogfood, service and plugin set to the base commit, package rebuilt,
`ablation-dist-preflight --absent` passed | 4 of 6 red. Green: the
guard, and the global row read by both. Restored, rebuilt, marker
present again: 6 of 6 green. |
| Dogfood, only the door predicate disabled, plant proven in `dist/` |
only the read-door case red (1 of 6). Restored, rebuilt, `--absent`
passed, tree clean. |
### Gates
- **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 94 commands at HEAD `61911cf17a`.
All 94 were run and exit 0, plus `check:settings-bind-window` as
dispatched.
- **Reconciliation.** `--ran` reports 94 derived, 94 run, 0
NOT-MEASURED, 0 UNRUN.
- **Fixed on the way.** `check:system-context-census` went red on the
first pass for the new read site. It is green after the row-18b edit.
- **Lint, narrowed.** The 9 changed `.ts` files lint with 0 errors and 0
warnings at `61911cf17a` (`eslint --no-inline-config --format json`).
The repository runs no type-aware lint, so this change cannot move an
untouched file's lint verdict. The full lint run is CI's.
- **Base.** The branch sits on `c8bb3c8d`. `origin/main` gained 4
commits since, and none of them touches `service-settings`. CI on the
merge ref is the arbiter.
## Acceptance notes
- **Changeset**: one `@objectstack/service-settings` `minor` changeset,
declared breaking, with ADR-0087 disposition `not-required
(no-migration-prescription)`. `check-adr-0087-registration` and
`check-changeset-no-major` are green.
- **Fence**:
- `settings-service.types.ts` changes only `SettingsRow` (the
`organization_id` row-shape field). PR #22266 edits a different region
of it.
- `settings-service-plugin.ts` is touched for wiring only: the posture
source and the read-door registration.
- Two existing test fixtures were triaged. `settings-getmany.test.ts`
rows now spell `organization_id: null` the way a real driver returns it.
`settings-routes.test.ts` passes the reach its private `loadRows` call
now takes.
- **Out of scope, not changed here**:
- The organization attribution of the settings-specific audit trail rows
belongs to #15207's family (audit ledgers); that card remains open.
- **For later**: `sys-setting.object.ts` (platform-objects) quotes a
`loadRows` comment sentence this change retires. The quote is prose
only; no gate reads it.
> Seat's append (`domain:services` seat 1, #6021), carried verbatim from
the dev's round-2 report `6060893787`; the dev never edits a PR body.
## Round 2
- **Merge.** `origin/main` `d1dbe70ebd` is merged with a merge commit
(`dcbf66b41a`), with no rebase and no force-push. The only conflict was
the derived counts on `content/docs/permissions/system-context.mdx`.
Both rows are kept, 18b from this branch and 23d from main, and the
counts were regenerated with `pnpm gen:system-context-census`.
`check:system-context-census` is green: 118 elevation read sites in 20
packages across 55 files.
- **Re-verified at `dcbf66b41a`.** All results below follow a rebuild of
what the merge touched.
| Check | Result |
|:--|:--|
| `service-settings` suite | 41 files, 752 passed |
| `settings-organization-isolation.dogfood.test.ts` | 6 passed |
| single-posture settings dogfood files | 3 files, 14 passed |
| typecheck of `service-settings` and `dogfood` | exit 0 |
- **Gates.** `dispatch-gates --commands`, run with no paths, derives 94
commands at `dcbf66b41a` with no stale-tree warning. All 94 were run,
plus `check:settings-bind-window`. `--ran` reports 94 derived, 94 run, 0
NOT-MEASURED and 0 UNRUN. Two gates first refused with exit 3 on unbuilt
packages; they were re-run after those packages were built and exit 0.
- **Measurement.** Under the isolated posture, with the real tenant wall
in the composition, a non-system read of `sys_setting` or
`sys_setting_audit` by one organization's administrator does not return
another organization's organization-stamped row. The harness is a
temporary test, removed after the run.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6729e10 commit 79c35d4
11 files changed
Lines changed: 1318 additions & 55 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- qa/dogfood/test
- services/service-settings/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
| 13 | + | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| |||
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| 118 | + | |
118 | 119 | | |
119 | 120 | | |
120 | 121 | | |
| |||
140 | 141 | | |
141 | 142 | | |
142 | 143 | | |
143 | | - | |
| 144 | + | |
144 | 145 | | |
145 | 146 | | |
146 | 147 | | |
| |||
283 | 284 | | |
284 | 285 | | |
285 | 286 | | |
286 | | - | |
287 | | - | |
| 287 | + | |
| 288 | + | |
288 | 289 | | |
289 | 290 | | |
290 | 291 | | |
| |||
357 | 358 | | |
358 | 359 | | |
359 | 360 | | |
360 | | - | |
| 361 | + | |
361 | 362 | | |
362 | 363 | | |
363 | 364 | | |
364 | | - | |
365 | | - | |
| 365 | + | |
| 366 | + | |
366 | 367 | | |
367 | | - | |
368 | | - | |
369 | | - | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
370 | 371 | | |
371 | 372 | | |
372 | 373 | | |
| |||
430 | 431 | | |
431 | 432 | | |
432 | 433 | | |
433 | | - | |
| 434 | + | |
434 | 435 | | |
435 | 436 | | |
436 | 437 | | |
| |||
0 commit comments