Repository navigation
Commit c7a60e1
fix(plugin-security): explain credits read depth, not sharing, for a row only the depth admits (#21754)
Fixes #21726
Clause-②: no
The explain answer now produces `decidedBy: 'depth'`, a member the
published `decidedBy` enum (`packages/spec/src/security/explain.zod.ts`,
about `:331`-`:341`) already declares. No access decision changes.
## What changed
All runtime changes are in
`packages/plugins/plugin-security/src/explain-engine.ts`, in the
record-grained pass (`applyRecordAttribution`).
- **A row only read depth admits now explains as `decidedBy: 'depth'`.**
The decision tree ranks `depth` with `sharing`: after a decisive bypass,
after a share, and before `rls`. These are the two layers that widen a
private baseline past its owner.
- **The `depth` layer carries a `record` block on record-grained
requests.** It is `admitted` when the read depth admits the row, and it
names the depth and, below `org`, the owner it reached. It is `excluded`
when the owner is outside the depth. It is `not_evaluated` with a reason
when the depth adds nothing (`own`, a non-private baseline, no record),
when the depth cannot be separated (a share admits the row too, below
`org`), or when the depth is not in play (an on-behalf-of read, a
write).
- **The sharing layer reports `admitted` only when a share or rule
matched.** For a row that only the depth admits, it is `not_evaluated`,
and its detail says that no share grants the row and that read depth
already admits it.
- **The sharing detail states a grant only when one exists.** "N
share(s) attached; access is granted for this record." is kept for a
share that names the caller. When the sharing filter admits a row with
no such share, the detail names the part of the sharing service that let
it through.
- `visible` comes from the same disjunction as before. `depthAdmitsRow`
is the part of the old `sharingMatches === true` admission that the
sharing layer no longer claims, so `sharingAdmitted || depthAdmitsRow`
equals the old `sharingAdmitted`.
## How the depth admission is judged (measured, not re-derived)
The sharing read filter is one predicate with two grants: the
owner-match, which read depth widens, OR-ed with the ids of the caller's
own shares. Measured in plugin-sharing's `buildReadFilter`: at `org`
depth it returns `null` and composes no owner-match at all. Below `org`
it returns the owner-match over the depth's owners, OR-ed with `id IN
(granted ids)` when the caller holds shares.
The engine does not parse that shape and does not call the filter a
second time. It reads facts the pass already holds:
- the depth the `depth` layer published (`depthScope`);
- ownership;
- the share rows naming the caller (the grant disjunct's population);
- whether the filter matched.
Two cases are deliberately not credited to the depth. On an on-behalf-of
read, `security-plugin.ts` hands the filter the delegated context with
no depth stamped. Below `org`, a `null` filter means the sharing service
imposes no owner-match on that object.
## Precedence when a share and the depth both admit
This case is not named on the card. `decidedBy`'s published contract
decides it: "the layer whose `record.outcome` excluded it, or the last
one to admit it" (`explain.zod.ts` about `:390`, and the `.describe` at
`:342`). In pipeline order, depth comes before sharing, so a row a share
admits keeps `decidedBy: 'sharing'`. That is also the answer before this
change. The hand-written explain page
(`content/docs/permissions/explain.mdx`) does not describe record
verdict precedence, so no docs sentence became false.
## Tests
Tests were added or updated in `plugin-security`:
- `explain-engine.test.ts`, unit harness:
- org depth and unit depth explain as `depth`, with a depth record
block;
- an owner outside the unit is excluded by both layers;
- a criteria rule's share still explains as `sharing` with exactly "1
share(s) attached; access is granted for this record.";
- share plus depth, at org and at unit;
- the two guards (a null filter below org, and an on-behalf-of read).
- `explain-engine.test.ts`, enumeration: `decidedBy` is read from
`ExplainDecisionSchema` in `@objectstack/spec/security`. The test fails
by name for any member that no scenario produces and no ledger entry
explains. Today `principal` and `fls` are ledgered with reasons, and
every other member is produced by a scenario that drives its branch.
- `explain-read-verdict-inputs.test.ts`, real SecurityPlugin plus real
SharingService:
- decider cells: depth for org and unit readers; sharing for a manual
share, a rule-sourced share row, and share plus depth; owd_baseline for
the owner;
- the depth record block;
- the rule row's exact detail.
The existing test that asserted the sharing layer `admitted` an
org-depth row was re-judged and replaced: it pinned the behaviour this
change removes. Each cell still asserts explain `visible` equals the
find's outcome.
- `explain-dependency-fault.test.ts`: the "a null filter is an answer,
not a fault" test now asserts `decidedBy: 'depth'`, with the filter
published and judged. A faulted layer publishes neither.
Runs at head `8998a647`:
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2` (full package): 164 files, 3562 passed, 45 skipped, 0
failed.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. This
includes `check:test-typecheck` over `tsconfig.test.json`, which
compiles the test layer.
## Ablations (each red, then restored and proven: blob equals HEAD, `git
diff HEAD` empty)
- A0, the whole file put back at the merge base (`ff29410e`): 15 failed
out of 161 in the three explain files. Examples: `expected 'sharing' to
be 'depth'`, and `expected '0 share(s) attached; access is grante…' to
contain …`.
- A1, `depthAdmitsRow` forced false: 9 red. These are every depth
decider cell and the `'depth' is produced` enumeration case.
- A2, the detail names a grant without a share: 2 red (the null-filter
and on-behalf-of guards' detail assertions).
- A3, the on-behalf-of guard removed: 1 red.
- A4, the null-below-org guard removed: 1 red.
- A5, the `fls` ledger entry renamed: 1 red, with `decidedBy member
'fls' is produced by no branch and carries no ledger reason`.
The subject resolves from `src` (relative imports), so no dist rebuild
was involved.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 66 commands from this branch, and
all 66 ran at `8998a647` with exit 0. `check:i18n` and
`check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit
3). Each was re-run green after its stated build (`turbo` cache hits).
The `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
Lint was narrowed to the 4 touched `.ts` files. ① They are in the
population of `eslint.config.mjs`'s `packages/**` objects
(`--print-config` resolves for each). ② `--format json` reports 4 files,
0 errors, 0 warnings. ③ The config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move any
untouched file's verdict.
## Acceptance notes
- **The write path has the same attribution shape and is left as it
is.** For `update`/`delete`, the row verdict is the sharing service's
per-record gate, which judges write depth, ownership and shares
together. A write admitted by write depth still reads `decidedBy:
'sharing'`, with the gate detail naming ownership or a share. The new
`depth` record block on a write says that this report does not separate
the write depth. Carrier: none named.
- **The owner's row keeps its sharing-layer detail**, "Caller owns the
record — visible without a share." It still decides as `owd_baseline`,
which was already the case.
---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1c3a4d9 commit c7a60e1
5 files changed
Lines changed: 398 additions & 25 deletions
File tree
- .changeset
- packages/plugins/plugin-security/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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
359 | 359 | | |
360 | 360 | | |
361 | 361 | | |
362 | | - | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
363 | 365 | | |
364 | | - | |
| 366 | + | |
| 367 | + | |
365 | 368 | | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
366 | 372 | | |
367 | 373 | | |
368 | 374 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
594 | 594 | | |
595 | 595 | | |
596 | 596 | | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
597 | 769 | | |
598 | 770 | | |
599 | 771 | | |
| |||
0 commit comments