Repository navigation
Commit 50b5e03
fix(service-storage,plugin-audit): a write refusal on a parent the caller cannot read names nothing (#21769)
Fixes #21755
Clause-②: no
## What this changes
Two parent-derived write gates refuse an update or a delete by a caller
who neither wrote the row nor can edit its parent record:
- the attachment gate on `sys_attachment`
(`installAttachmentAccessHooks`, `@objectstack/service-storage`);
- the comment gate on `sys_comment` (`installCommentAccessHooks`,
`@objectstack/plugin-audit`). This is the sibling check the card asked
for. It was measured to have the same shape, so it is fixed here too.
The named refusal carries the parent record's object and id in its
message, and the parent's object in the envelope. For a caller who
cannot read the parent, the read door answers "not found" for the row
itself, so the write door named what the read door hides. The by-id
write pre-image check in plugin-security already refuses such a write
first for every principal its row filter binds: an `org_member` under
the shipped ownership floor. A principal it does not bind, such as a
session outside the floor's domain, got the gate's named refusal
instead.
This follows triage's ruled direction (comment 5981792733):
- **A caller who cannot read the parent** now gets the platform's
not-visible refusal: `PERMISSION_DENIED`, 403, and the
`record_access_denied` sentence from the shared Operation Message
Catalog. That is the pre-image check's own answer. No parent identity
appears in the message, `details`, `developerMessage` or the envelope
`object`. The door fills in the route's object, as it does for the
pre-image check.
- **A caller who can read the parent but may not edit it** keeps the
named refusal: `ATTACHMENT_DELETE_DENIED` for an attachment delete, and
`RECORD_NOT_ACCESSIBLE` for an attachment update and for a comment
update or delete.
- **Who may write does not change.** The new branch only replaces a
refusal the caller was already getting. The uploader and author shortcut
and the parent-editor limb are untouched. The gate asks whether the
caller can read the parent only for a row it is about to refuse.
- **A comment whose thread names no record** (an unparseable
`thread_id`) is read by nobody, because the read middleware drops it. A
non-author's write on it now gets the not-visible refusal too, and the
thread value is no longer echoed back. This is the same class in the
same function.
### How "can the caller read the parent" is decided
The gate uses the evaluator the read door already uses, not a second
one:
- **Attachment kit.** The read-visibility middleware's inline
caller-scoped parent probe moves into `resolveReadableParentIds`, beside
`attachmentParentOf` (the rule for a row that names no readable parent).
The middleware and the write gate both call it. The middleware's
behaviour is unchanged, and its tests pass unedited.
- **Comment kit.** The write gate calls the existing shared
`resolveReadableParentIds`, the evaluator the comment read middleware
and the activity read gate already use.
Both strip the operation-private keys from the caller's envelope, as the
read probe always did.
### The refusal's producer
plugin-security's `PermissionDeniedError` cannot be reached from either
package: neither depends on plugin-security, and the spec contract was
off-limits for this card. The sentence's producer can be reached, and it
is the one used: `renderOperationMessage({ messageKey:
'record_access_denied' })` from `@objectstack/spec/system`, rendered
through the same locale and deployment-override ladder. plugin-sharing
and plugin-approvals already render their refusals this way. The
envelope carries the pre-image check's standard code and status
(`PERMISSION_DENIED`, 403, `name: 'PermissionDeniedError'`), so every
door's existing permission-denied branch answers it. The door pin below
compares the two answers field by field, so they cannot drift apart
unnoticed.
Both installers take an optional fourth argument, a lazily resolved i18n
lookup, which each plugin wires in `start()`. The operator's half, the
sentence naming the parent, is logged server-side at `warn`, as the
pre-image check logs its own.
## The nonexistent-id comparison (triage's second pin)
Measured at the REST data door on this head, for a session outside the
floor's domain that holds the delete grants:
| Write | Parent unreadable (this PR) | Id that does not exist |
|---|---|---|
| delete | 403 `PERMISSION_DENIED` | 404 `RECORD_NOT_FOUND` |
| update | 403 `PERMISSION_DENIED` | 404 `RECORD_NOT_FOUND` |
The two answers differ. The platform's write-door doctrine is the by-id
write pre-image check. It answers a target that is gone and a target
that is hidden with the same refusal: 403 `PERMISSION_DENIED`
(`record_access_denied`). This PR gives the unreadable case that answer.
The nonexistent id answers 404 for this principal class because the
pre-image check does not bind it (no row filter applies), so the
engine's not-found gate answers first.
The `org_member` path shows the same split on update: 403 for an
unreadable parent, 404 for an id that does not exist. It does not show
it on delete, where both answer 403. Per the ruling, that residual split
is class-level and outside this card, and it is reported to the
dispatcher for its own card. It carries no parent identity.
## Tests (head `56d3cdfc`)
- `pnpm --filter @objectstack/service-storage test`: 42 files, 650 tests
passed.
- `pnpm --filter @objectstack/plugin-audit test`: 39 files, 630 tests
passed.
- `pnpm --filter @objectstack/service-storage typecheck`, `pnpm --filter
@objectstack/plugin-audit typecheck` (both include
`check:test-typecheck: OK`) and `pnpm --filter @objectstack/dogfood
typecheck`: all exit 0.
- Dogfood, against the rebuilt `dist/` of both packages: the new door
pin plus the six existing attachment and comment dogfood files. 7 files,
63 tests passed, 1 skipped. The skipped one is the existing cross-tenant
block, gated on `organizationsAvailable`.
**New pins**
- **Unit, in each package**, in a block titled "a refusal on a parent
the caller cannot read names nothing". It covers both verbs:
- The full envelope: `code`, `status`, `statusCode`, `name`, and the
message equal to the catalog sentence.
- No `object`, `details` or `developerMessage` on the error, and no
parent identity anywhere on it.
- A reader who may not edit keeps the named code.
- The uploader, author and parent editor still pass, and the read probe
is never asked for them.
- The probe is one caller-scoped engine read of the parent, without the
operation-private keys.
- A row that names no parent, the degraded mode with no sharing service,
the caller's locale, a deployment override, and a failing i18n lookup.
- The same cases through a wired `ObjectQL`.
- **Door**, in the new
`packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts`.
It boots the same stack twice: once outside the floor's domain, and once
org-bound as the reference.
- It first proves each boot's principal domain and grants with
`assertArmed`.
- For each of {attachment, comment} × {delete, update}, it compares the
outside caller's answer field by field with the pre-image check's answer
to an `org_member`.
- It asserts that the body contains neither the parent's object nor its
id, and that the row survived.
- A reader who may not edit keeps `ATTACHMENT_DELETE_DENIED` and
`RECORD_NOT_ACCESSIBLE`.
**Red before the fix.** Run against `dist/` built from the base: 4
failed, 2 passed. The failures were the four outside-caller cells, each
answering the named refusal with the parent in the message and the
envelope. The two reader cells passed.
**Ablations.** Each mutation went through `scripts/ablation-replace.mjs`
and put the named refusal back where the not-visible one now is.
- **Attachment gate, unit:** 8 failed, 52 passed. Restore proved: the
blob equals HEAD and `git diff HEAD` is empty.
- **Comment gate, unit:** 8 failed, 44 passed. Restore proved the same
way.
- **Door, both gates mutated at once:**
- After the mutated rebuild, `ablation-dist-preflight` found the marker
in the built files of both packages. The door pin then failed 4 and
passed 2.
- The restore was proved by blob against HEAD.
- After the rebuild, preflight `--absent` showed the marker gone from
all built files and the tree clean. The door pin then passed 6 of 6.
- The mutated service-storage build failed its DTS step with TS6133,
because the mutation left a parameter unused. That happened after the JS
was emitted, and the preflight proved the marker was in the JS the suite
consumes.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 97 commands, and every one exited 0
on this head. Two of them, `check:skill-examples` and
`check:dual-build-cjs-loads`, first answered PREREQUISITE NOT MET (exit
3) until the package dists they read were built. The `--ran`
reconciliation: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN, with a
recorded exit code for every command.
**Lint, narrowed and declared.** `eslint --no-inline-config --format
json` on the seven changed `.ts` files reported 7 files, 0 errors and 0
warnings.
- **Population:** the `**/*.{ts,…}` and `packages/**/*.{ts,…}` blocks of
`eslint.config.mjs` cover all seven, and all seven were reported, so
none is ignored.
- **Invariance:** the config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict of a file
it does not touch.
The full `pnpm lint` is left to CI.
**Fixture triage of existing cases.**
- Cases whose subject is the named refusal or the edit verdict now
declare the parent readable, and keep their codes.
- The degraded-mode cases and the dangling-thread case pinned exactly
the branch this changes, so their expected code moves to
`PERMISSION_DENIED`.
- The wired stub drivers learn a lone `$in` operator value. Every other
operator value still throws.
**No regression in the parent-editor delete alternate.** The alternate
match that landed just before this keeps its pins green, unedited, and
so does the attachments permission-matrix dogfood file.
## Docs and changeset
- `content/docs/permissions/attachments-access.mdx`: the
`PERMISSION_DENIED` row of the delete table said the refusal happens
"before the attachment gate runs". That is now only true for the
principals the pre-image check binds, so the row is rewritten. One
sentence adds that an update follows the same rule.
- `.changeset/21755-attachment-refusal-not-visible.md`: `patch` for both
packages.
## Acceptance notes
- **Error class.** `PermissionDeniedError` lives in plugin-security, so
the two gates build its envelope from the standard code and the catalog
sentence instead of throwing the class. A not-visible refusal producer
in a lower shared package would let them throw the one class. Noted, not
filed. Taker: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4331a6b commit 50b5e03
9 files changed
Lines changed: 1206 additions & 78 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins/plugin-audit/src
- qa/dogfood/test
- services/service-storage/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | | - | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
75 | 79 | | |
76 | 80 | | |
77 | 81 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
272 | 272 | | |
273 | 273 | | |
274 | 274 | | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
275 | 285 | | |
276 | 286 | | |
277 | 287 | | |
| |||
Lines changed: 300 additions & 13 deletions
Large diffs are not rendered by default.
Lines changed: 113 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
37 | 42 | | |
38 | 43 | | |
39 | 44 | | |
| |||
61 | 66 | | |
62 | 67 | | |
63 | 68 | | |
| 69 | + | |
64 | 70 | | |
65 | 71 | | |
| 72 | + | |
66 | 73 | | |
67 | 74 | | |
68 | 75 | | |
| |||
189 | 196 | | |
190 | 197 | | |
191 | 198 | | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
192 | 254 | | |
193 | 255 | | |
194 | 256 | | |
| |||
295 | 357 | | |
296 | 358 | | |
297 | 359 | | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
298 | 366 | | |
299 | 367 | | |
300 | 368 | | |
301 | 369 | | |
302 | 370 | | |
| 371 | + | |
303 | 372 | | |
304 | 373 | | |
305 | 374 | | |
| |||
412 | 481 | | |
413 | 482 | | |
414 | 483 | | |
| 484 | + | |
415 | 485 | | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
416 | 516 | | |
417 | 517 | | |
418 | 518 | | |
419 | 519 | | |
420 | 520 | | |
421 | 521 | | |
422 | 522 | | |
423 | | - | |
424 | | - | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
425 | 530 | | |
426 | 531 | | |
427 | 532 | | |
| |||
433 | 538 | | |
434 | 539 | | |
435 | 540 | | |
436 | | - | |
| 541 | + | |
437 | 542 | | |
438 | | - | |
439 | | - | |
440 | | - | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
441 | 548 | | |
442 | 549 | | |
443 | 550 | | |
| |||
0 commit comments