Repository navigation
Commit db3fee3
fix(plugin-sharing): a plain member's own share-link list is self-scoped (ADR-0111) (#21403)
Fixes #21328
Clause-②: no
## What was wrong
`GET /api/v1/share-links` refused every plain member: a caller holding
`member_default` and no `sys_share_link` grant was refused with no
filter, with an object filter, and with any record. The console's Share
dialog loads this list when it opens, so it failed for that whole class
of user on every record. Both share-link doors already force `createdBy`
to the caller, because ADR-0111's surface table rules the list
self-scoped. But `ShareLinkService.listLinks` read `sys_share_link`
under the caller's context, and that read needs an object-level grant
the member baseline does not carry. An admin's list answered.
Measured through this PR's route pin (showcase boot via
`@objectstack/verify`). At the merge base `ee75aae1a`, and again with
the elevation ablated at `8682b7b24`, the plain member's six list cases
answered **500** with code `PERMISSION_DENIED`. The admin's list
answered 200 with exactly the admin's own link. The Acceptance notes
explain why this composition answers 500 where the card's hosted
composition answered 403.
## The change (the triage direction quoted on the card)
- **One creator rule.** `isLinkCreator(row, context)` in
`share-link-service.ts` holds when the caller has a non-empty user
identity and the row's `created_by` equals it. `revokeLink` and
`listLinks` both read this rule, and there is no second creator test.
- **`listLinks`.** The read runs under the system context only when the
creator filter passes that rule, which means the filter names the
caller. In that case the `created_by` constraint is the caller's
identity, written server-side, and each returned row must pass
`isLinkCreator` before it leaves. Every other shape keeps the caller's
context: no creator filter, another user as creator, an empty or absent
identity, and an admin listing someone else's links. A system caller
keeps its bypass. `listLinks` keeps its single `isSystem` read, so the
system-context census stays at 114.
- **Projection unchanged.** The engine strips both `internal` columns
under any context. The token comes back through the existing privileged
accessor, and the password hash never does (route pin `[secret]`).
- ⛔ **No permission-set edit.** The member still holds no
`sys_share_link` grant (route pins `[persona]` and `[no-grant]`).
### What this deliberately changes beyond the list
- **`revokeLink`'s creator check now refuses a caller with no user
identity.** Before, `undefined === undefined` let an identity-less,
non-system caller revoke a link whose `created_by` the driver omitted.
Neither HTTP door reaches that case, because both answer 401 first. The
new behaviour is pinned fail-closed. The creator, non-creator and
ADR-0111 D8 record-manager paths are unchanged, and their existing pins
pass untouched.
- **`share-link-enforcement-context.test.ts`.** The pin `listLinks reads
under the same whole envelope` read the envelope off the
`sys_share_link` read. The route's list is always the caller's own, and
that list is now the system read, so the pin now observes the envelope
at the `listLinks` call. It checks that every key the resolver produced
arrives, using the same comparison as the `createLink` pin, and asserts
that the read is the self-scoped one. The property the file pins is
unchanged: the route hands enforcement the whole envelope (ruling A,
commit 8e13ca8).
- **`@objectstack/spec`.** The `IShareLinkService.listLinks` doc comment
said every listing is read under `context`. It now describes the
self-scoped own list. This is a doc comment only.
## Tests (head `8682b7b24`)
- **Route pin, real composition.**
`packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts` has 11
cases, and all 11 pass. A plain member lists exactly their own links,
both with no filter and with the Share dialog's object and record
filter. Member B's links and the admin's sit on the same record and stay
absent. A `createdBy` query naming B is ignored. A plain member with no
links gets `[]`. The row carries its token and no `password_hash`. The
admin's list is unchanged, and an anonymous caller gets 401
`UNAUTHENTICATED`.
- **Service pins.** `share-link-service.test.ts` and
`share-link-enforcement-context.test.ts` pass 52 of 52. The service pins
cover:
- a member's own list;
- the record filter;
- a foreign creator and an unfiltered list, both refused under the
caller's context with `PERMISSION_DENIED` / 403;
- no identity (absent, and empty), which never takes the elevated path;
- the admin path;
- the system bypass;
- a dropped `created_by` predicate, which still leaks no foreign row;
- revoke and list reading one rule.
- **Package runs.** `pnpm --filter @objectstack/plugin-sharing test`
passes 928 tests in 38 files, and its `typecheck` is OK.
`@objectstack/spec` passes `typecheck`, and its `test` passes 17529
tests in 598 files. `@objectstack/dogfood` passes `typecheck`, and its
tsc program includes the new test.
- **Ablations, service level.** Each leg ran from the committed head
through `scripts/ablation-replace.mjs`, and each restore was proven to
leave the blob equal to HEAD with `git diff HEAD` empty.
| Mutation | Red | Green |
|:--|:--|:--|
| Identity check removed from `isLinkCreator` | 3: no-identity list,
no-identity revoke, system bypass | 49 |
| Server-side constraint removed (identity `where` and creator
post-filter) | 6, including other creators' links on the same record |
46 |
| Identity `where` only removed | 1 | 51 |
| Creator post-filter only removed | 1 (the dropped-predicate pin) | 51
|
| Elevation removed | 5: the own-list pins and the envelope-call pin |
47 |
- **Ablation, route level.** The elevation was removed and
plugin-sharing rebuilt. `ablation-dist-preflight` found the marker in 2
dist files. Result: 6 red (the own, empty, secret and foreign cases, at
500 `PERMISSION_DENIED`) and 5 green (persona, no-grant, fixture, admin,
anonymous, which the test header says sit still). For the restore leg,
plugin-sharing was rebuilt, the marker was absent from dist, and all 11
passed. The first route mutation (a `false &&` guard) was
constant-folded out of dist. The preflight caught that before any test
ran, so it measured nothing, and the leg was redone with a marker the
bundler keeps.
- **Gates.** `dispatch-gates --commands` at this head derives 89
commands, and all exit 0. `check:dual-build-cjs-loads` first exited 3
(`PREREQUISITE NOT MET`, because unrelated packages had not been built).
Those packages were built from the turbo cache, and the rerun exited 0.
The `--ran` reconciliation reports 89 derived, 89 run and 0 NOT
MEASURED, a figure derived from the recorded exit codes.
- **Lint (narrowed).** eslint `--no-inline-config` over the 5 changed TS
files reports 0 errors and 0 warnings in all 5 files, and the config
resolves for each. The narrowing is safe because the config enables no
type-aware rules, and its local plugins read only their baselines. So
this diff cannot move the verdict on any untouched file.
## Docs
`content/docs/**` (outside `releases/` and `references/`) and
`skills/**` hold no sentence this change makes false. It makes true
ADR-0111's surface-table entry for share links, "list is self-scoped",
for every signed-in caller.
## Acceptance notes
- **Which door served.** In this composition, plugin-sharing's
`registerShareLinkRoutes` serves `/api/v1/share-links`, not the runtime
`/share-links` domain. The error status shows it: that door answers 500
where the dispatcher's mapper answers 403. The runtime domain forwards
`createdBy` as the caller's id and the full envelope to the same
`listLinks`, so the service fix covers both doors. The runtime door has
no separate route pin here.
- **Finding, for the seat to file (a defect of a different class, not
fixed here).** The plugin door's catch maps the security middleware's
`PERMISSION_DENIED` refusal (which carries `statusCode` 403 and no
`status`) to HTTP 500. It is still reachable after this PR: `POST
/api/v1/share-links` by a plain member, on an object they cannot read,
answered 500 with code `PERMISSION_DENIED` on the showcase boot.
- **The own list is not organization-walled.** The self-scoped read runs
under the system context, so it sits outside the tenant wall, as
`revokeLink`'s creator read already does. Share-link rows are inserted
under the system context with `organization_id` null (measured on the
showcase boot). A caller's own list therefore spans organizations in the
environment's database, the same reach `revokeLink` already gives the
creator. This follows the ruled direction. It is an observation only,
with no wrong answer measured.
- **No ADR anchor added.** There is no `scripts/adr-anchors` entry for
`share-link-service.ts`. The ADR-0111 id stays in the code. An anchor
would be outside the claimed surface, and no one is set to carry it.
- **Out of scope.** Who may mint a link (#21329) is not addressed here.
- **Base not merged.** `origin/main` has moved since the merge base.
None of the upstream changes touch this PR's six files.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 45efcfa commit db3fee3
6 files changed
Lines changed: 512 additions & 12 deletions
File tree
- .changeset
- packages
- plugins/plugin-sharing/src
- qa/dogfood/test
- spec/src/contracts
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
174 | | - | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
175 | 178 | | |
176 | 179 | | |
177 | 180 | | |
| |||
268 | 271 | | |
269 | 272 | | |
270 | 273 | | |
271 | | - | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
272 | 283 | | |
273 | | - | |
| 284 | + | |
| 285 | + | |
274 | 286 | | |
275 | 287 | | |
276 | 288 | | |
277 | 289 | | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
278 | 306 | | |
279 | 307 | | |
280 | | - | |
281 | | - | |
| 308 | + | |
282 | 309 | | |
283 | 310 | | |
284 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
451 | 451 | | |
452 | 452 | | |
453 | 453 | | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 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 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
454 | 618 | | |
455 | 619 | | |
456 | 620 | | |
| |||
0 commit comments