Repository navigation
Commit 454bbb6
fix(rest,runtime): datasource metadata writes require the same capability as the datasource admin door (#21148)
Fixes #21124
Clause-②: no
## What changes
Writing a `datasource` definition through `/api/v1/meta` now requires
`manage_platform_settings`. That is the capability the datasource admin
door (`POST /api/v1/datasources`, `PATCH` and `DELETE
/api/v1/datasources/:name`, `DATASOURCE_ADMIN_CAPABILITY` in
`admin-routes.ts`) already requires for the same create, update and
remove. It is the write-side twin of the read admission #21119 added.
- **One predicate, beside the read one.** `metaTypeWriteRefusal` over
`META_TYPE_WRITE_CAPABILITIES` in
`packages/rest/src/meta-item-read-gate.ts`, next to
`metaTypeReadRefusal`. It judges every write verb (`PUT`, `POST`,
`PATCH`, `DELETE`) whose `:type` folds to `datasource` (the plural
spelling included). It answers `403 PERMISSION_DENIED` with a message
naming the capability, or `401 UNAUTHENTICATED` with no identity. It has
no `isSystem` arm, for the reason the read predicate gives.
- **Asked at each transport's single `/meta` entry, before any write.**
`RestServer`'s guarded registrar asks it right after the read admission.
It judges the route's declared verb, so it covers the save (each mode, a
draft included), the reset, `/publish` and `/rollback`. The runtime
dispatcher's `handleMetadataRequest` asks it at the same point. A
refused caller writes nothing, and the answer is the same whether or not
the name exists.
- **The door's own authoring admission still runs after it**, unchanged.
A datasource write therefore needs `manage_platform_settings` and
`manage_metadata` both.
- **`external_catalog` has a read row but no write row.** Its own write
door (`POST /datasources/:name/external/refresh-catalog`) requires
`FEDERATION_WRITE_CAPABILITY` (`manage_metadata`), which every `/meta`
write door already asks. A row names the capability the type's own write
door requires: matched, never minted.
Every other metadata type and every read route is unchanged.
## Tests
- `packages/rest/src/meta-type-write-capability.test.ts`, three
batteries:
1. The predicate: verbs judged and not judged, folding, holder admitted,
authoring-only / member / unrelated-grant refused, 401 with no identity,
the `external_catalog` non-row pinned against
`FEDERATION_WRITE_CAPABILITY`.
2. Every write door read off the route table (`PUT`/`DELETE
/:type/:name`, `POST /publish`, `POST /rollback`, plus `PUT
?mode=draft`), over a real in-memory store. Each refused caller gets
`403 PERMISSION_DENIED` with the store deep-equal to before and zero
protocol calls. The refusal is byte-identical for a present and an
absent name. A holder still saves, creates, publishes, rolls back and
resets. The capability alone does not write (the authoring admission
still refuses). An unlisted type is still written by the authoring
caller.
3. Agreement with the admin door over one grant store through
`resolveAuthzContext`: every principal `/meta` admits to write a
datasource is admitted by `POST /datasources`. Non-vacuous: the
operator-author passes both, and the authoring-only principal passes
neither.
-
`packages/runtime/src/domains/meta-type-write-capability-parity.test.ts`:
`RestServer` and the dispatcher give the same answer for `PUT
/meta/datasource[s]/:name` (present and absent name). Both stores are
unchanged and no protocol call is made. On the dispatcher,
`POST`/`PATCH`/`DELETE` on a datasource path (list and item shapes) are
judged at the entry. A holder writes through both, and an unlisted type
is unchanged.
**Results.** Measured on head `583f706fef` (`git rev-parse --short HEAD`
after the last commit, which is the merge of `origin/main` at
`c6954d6d09`), unless a row says otherwise:
- New and landed type-level batteries plus the every-type org-scope
suites: `@objectstack/rest` (`meta-type-write-capability`,
`meta-type-read-capability`, `rest-server-meta-org-scope-url-spelling`,
`rest-server-meta-write-org-scope`) 4 files, 122 passed.
`@objectstack/runtime` (`meta-type-write-capability-parity`,
`meta-type-read-capability-parity`, `meta-write-org-scope`) 3 files, 52
passed.
- Full package suites (`vitest run --project local`), run at
`7cea2eeb76` before the second merge of `origin/main`: rest 4767 passed
with 2 failing, and runtime 4314 passed with 1 failing. All three
failures were the every-type org-scope fixtures. Their authorized caller
held only `manage_metadata`, so its `datasource` write is now refused
before it reaches the protocol. The fixture now also holds
`manage_platform_settings` (commit `c21920aacd`), so the scope assertion
still covers `datasource`, and those suites are green as the row above
shows. The incoming `origin/main` commits touch no `/meta` seam. The
overlap that does exist (the datasource admin door's record check) is
covered by the agreement battery, re-run at `583f706fef`.
- `pnpm --filter @objectstack/rest typecheck` exited 0 and `pnpm
--filter @objectstack/runtime typecheck` exited 0, both at `7cea2eeb76`;
runtime's test-layer debt ledger holds unchanged.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the
change set derived from the merge base) gave 62 families. All 62 were
run at `583f706fef` with exit codes recorded. 61 exited 0.
`check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs
every package built), so it is NOT MEASURED here and is left to CI. As a
declared narrowing, `require()` of the CJS `require` targets of the two
packages this PR changes (`packages/rest/dist/index.cjs`,
`packages/runtime/dist/index.cjs`) loads, and the rest one exports
`metaTypeWriteRefusal`. `dispatch-gates --ran` reconciles to 62 derived,
61 run, 1 NOT MEASURED, 0 UNRUN, exit 0.
- Lint (a proven narrowing, not the repo-wide run, which belongs to CI):
`eslint --no-inline-config --format json` over the changed `.ts` files
reported 11 files, 0 errors and 0 warnings. That is a superset of this
PR's 9 `.ts` files. `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, no typed rules), so this diff cannot move
the verdict on any untouched file.
**Ablation (one-off, from committed head `81a64491f3`, via
`scripts/ablation-replace.mjs` under an EXIT trap).** Leg A neutralised
the `RestServer` call site, and `meta-type-write-capability.test.ts`
went red: 9 failed / 43. Leg B neutralised the dispatcher call site, and
`meta-type-write-capability-parity.test.ts` went red: 7 failed / 13.
Each leg was restored to its HEAD blob with `git diff HEAD` empty. The
direction was the expected one: the authoring-only caller was admitted
and the store changed. The dispatcher's member legs stay green under leg
B because that transport's existing authoring admission already refuses
a member. Only the authoring-only caller depends on the new gate, and
those legs went red.
## Acceptance notes
- `external_catalog` writes stay at `manage_metadata` by the
matched-capability rule above. Raising them would mint a policy that
type's own write door does not have.
- Doors outside `/meta` that write metadata keep their own admission and
are not changed here (for example package install, `POST /packages`).
- `POST /meta/_migrate-stored` has no `:type` segment and is not judged
by the predicate. It stays `manage_metadata`-only, as ruled.
- While the gates ran, the `turbo` CLI from the dev-dependency bump
merged from `main` appended its managed `turborepo-agent-rules` block to
`AGENTS.md` in this worktree. It was restored to HEAD and is not part of
this PR. Reported separately.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 336e191 commit 454bbb6
10 files changed
Lines changed: 740 additions & 5 deletions
File tree
- .changeset
- packages
- rest/src
- runtime/src
- domains
| 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 | |
|---|---|---|---|
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
121 | 126 | | |
122 | 127 | | |
123 | 128 | | |
| |||
127 | 132 | | |
128 | 133 | | |
129 | 134 | | |
| 135 | + | |
130 | 136 | | |
131 | 137 | | |
132 | 138 | | |
133 | 139 | | |
134 | 140 | | |
| 141 | + | |
135 | 142 | | |
136 | 143 | | |
137 | 144 | | |
| |||
159 | 166 | | |
160 | 167 | | |
161 | 168 | | |
| 169 | + | |
162 | 170 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1684 | 1684 | | |
1685 | 1685 | | |
1686 | 1686 | | |
1687 | | - | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
1688 | 1690 | | |
1689 | 1691 | | |
1690 | 1692 | | |
| |||
1761 | 1763 | | |
1762 | 1764 | | |
1763 | 1765 | | |
| 1766 | + | |
| 1767 | + | |
| 1768 | + | |
| 1769 | + | |
| 1770 | + | |
| 1771 | + | |
| 1772 | + | |
| 1773 | + | |
| 1774 | + | |
| 1775 | + | |
| 1776 | + | |
| 1777 | + | |
| 1778 | + | |
| 1779 | + | |
| 1780 | + | |
| 1781 | + | |
| 1782 | + | |
| 1783 | + | |
| 1784 | + | |
| 1785 | + | |
| 1786 | + | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
| 1792 | + | |
| 1793 | + | |
| 1794 | + | |
| 1795 | + | |
| 1796 | + | |
| 1797 | + | |
| 1798 | + | |
| 1799 | + | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
| 1805 | + | |
| 1806 | + | |
| 1807 | + | |
| 1808 | + | |
| 1809 | + | |
| 1810 | + | |
| 1811 | + | |
| 1812 | + | |
| 1813 | + | |
| 1814 | + | |
| 1815 | + | |
| 1816 | + | |
| 1817 | + | |
| 1818 | + | |
| 1819 | + | |
| 1820 | + | |
| 1821 | + | |
| 1822 | + | |
| 1823 | + | |
| 1824 | + | |
| 1825 | + | |
| 1826 | + | |
| 1827 | + | |
| 1828 | + | |
| 1829 | + | |
| 1830 | + | |
| 1831 | + | |
| 1832 | + | |
| 1833 | + | |
| 1834 | + | |
| 1835 | + | |
| 1836 | + | |
| 1837 | + | |
| 1838 | + | |
| 1839 | + | |
| 1840 | + | |
| 1841 | + | |
| 1842 | + | |
| 1843 | + | |
| 1844 | + | |
| 1845 | + | |
| 1846 | + | |
| 1847 | + | |
| 1848 | + | |
| 1849 | + | |
| 1850 | + | |
| 1851 | + | |
| 1852 | + | |
| 1853 | + | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
| 1864 | + | |
| 1865 | + | |
| 1866 | + | |
| 1867 | + | |
| 1868 | + | |
| 1869 | + | |
1764 | 1870 | | |
1765 | 1871 | | |
1766 | 1872 | | |
| |||
0 commit comments