Uh oh!
There was an error while loading. Please reload this page.
fix(security): make clone_permission_set carry all five copied facets - #11755
Conversation
The Clone action POSTs its `params` values to the generic data door, so the params list IS the payload. It named two of the six definition facets a `sys_permission_set` row carries, leaving `system_permissions`, `row_level_security` and `tab_permissions` absent from the body; `permissionSetBodyFromRow()` then read each through `parseMaybeJson(undefined, …)` and filled the empty default. Cloning a set that granted `setup.access`, or one carrying RLS policies, produced a clone with none of them — record created, success toast fired, loss discoverable only by diffing the two records. Urgent as of one commit ago: the save door now refuses an in-place edit of a package-declared set AND its refusal names the clone path, so this action is the platform's own recommended remedy. The accept surface does not move — `permissionSetBodyFromRow()` already read all six columns. What changed is what the action SENDS. `admin_scope` is deliberately NOT copied (maintainer ruling 2026-08-24): an ADR-0090 D12 delegated-admin authority on a new org-owned set is a privilege decision, not a field copy. The dialog description now states the exclusion, so it reads as a decision rather than as the same silent drop. Pinned by `packaged-permission-set-lock.test.ts` pin 6, which READS the action's params list to build the payload instead of restating it, and asserts each facet by identity against a non-empty value. Verified red (3 failed / 13 passed) on the unmodified tree, green (16 passed) after the fix. Fixes#11703 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…t holds `check:type-check-debt --re-measure` measures plugin-security with the `**/*.test.ts` exclusion dropped, and there the new pin cost +2 raw errors (11 -> 13) on a shrink-only ledger: tsc infers `richRow`'s object-literal type and DROPS the index signature `permissionSetRowFields()` spreads in, so the exclusion control's direct `.admin_scope` read is a TS2339. The package's own `typecheck` excludes tests, so it stayed green throughout — this layer is the only thing that sees it. Fixed at the source, which is the author's remedy; the ledger is untouched. Re-measured 11, matching the recorded entry exactly, with zero errors in this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 26048680e2d9ad85b10ec95e7ce7fad4d7334c5a && git checkout 26048680e2d9ad85b10ec95e7ce7fad4d7334c5a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a6122bd9d1d651b38ca09097a5e3166d8694f0e 9b7f93c17b7ffc89ce9e8012fad3ef7ea53a0602 && git checkout -B drift-repro 2a6122bd9d1d651b38ca09097a5e3166d8694f0e && git merge --no-ff 9b7f93c17b7ffc89ce9e8012fad3ef7ea53a0602
node scripts/docs-audit/affected-docs.mjs --json 2a6122bd9d1d651b38ca09097a5e3166d8694f0e
|
os-sam
commented
Aug 24, 2026
PM review — passes, pending CI. Clause-② NO, so no review gate: this seat lands it.
Verified independently, three-dot against |
os-sam
commented
Aug 24, 2026
Addendum: docs-drift dispositioned (no action), and two hazards from the report worth the recordDocs drift — no actionFour hand-written pages listed. This diff changes what Clone copies, so the one class that can go false is prose describing what a clone copies. Read with No doc describes what a clone copies. The action's copy behaviour is undocumented, so there is nothing for this change to falsify — and the The one facet mention that surfaced — ⛔ The 4 release-owned pages were read-only and untouched.
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11703
What changed
clone_permission_setPOSTs itsparamsvalues to/api/v1/data/sys_permission_set, so the params list is the payload. It named two of the six definition facets asys_permission_setrow carries —object_permissionsandfield_permissions— leavingsystem_permissions,row_level_securityandtab_permissionsabsent from the body.permissionSetBodyFromRow()then read each one throughparseMaybeJson(undefined, …)and filled the empty default, so cloning a set that grantedsetup.access, or one carrying row-level security policies, produced a clone with none of them: record created, success toast fired, and the missing half discoverable only by diffing the two records.The three now travel, in the same JSON-string shape the two listed columns already used.
Why this was urgent rather than tidy
PR #11702 landed one commit before this branch point. The save door now refuses an in-place edit of a package-declared permission set and its refusal message tells the admin to clone — so this action became the platform's own recommended remedy while it was still dropping three facets. An admin following that instruction lost grants quietly. The failure direction was fail-closed (fewer grants), which is exactly why nobody noticed.
admin_scope— deliberately not copiedRuled by the maintainer, 2026-08-24: putting an ADR-0090 D12 delegated-admin authority onto a brand-new organization-owned set on the admin's behalf is a privilege decision, not a field copy. The action's
descriptionnow states the exclusion and why, so it reads to the admin standing in the dialog as a decision rather than as the same silent drop this card reports:That is a new translatable leaf, so the four
plugin-securitybundles are regenerated with the documentedos i18n extractcommand and the three non-English locales are translated by hand rather than left at the--fill=defaultEnglish seed —check:i18n-coverageholds this package at zero untranslated strings, and an English sentence beside 克隆 / 複製 / Clonar would have been a visible regression regardless of the ratchet.The pin, and why it is shaped this way
packaged-permission-set-lock.test.tsgains pin 6. Its existing pin 3 already drives the data door through the clone path — and stayed green throughout this defect, because it hand-writes the payload. It pins what the server does with a payload, never what the action definition chooses to put in one.So pin 6 assembles the payload by reading the action's params list (
clonePayload()is the clone dialog in miniature:defaultFromRowparams seeded from the source row, inline params carrying what the admin typed,bodyExtramerged in). Editing that params list is what moves this suite — which is the point, since editing that list is how the class got here.Two deliberate choices:
toHaveLength(5)holds constant while two facets swap, and asserting a facet is merely present passes on the[]/{}that is the bug. Every facet is asserted against a named, non-empty value, on both the metadata body that gets enforced and the row columns an admin diffs.admin_scope(asserted first — otherwise "the clone has none" is satisfied by a base that never had one), and the clone still has none.Verification
All of the below on the final commit
9b7f93c(union re-run after the last commit, not before it).Red → green, test-first on an otherwise unmodified tree. The failing pin was written and the expected signature recorded in writing before any source edit — no ablation, so no restore could silently fail.
The predicted signature matched the observed one on all three, including the direction: the payload omits columns and the door fills empty defaults, so this is a wrong-value failure, not a throw. A bare
toThrow()would have been green in both states.Package suites —
@objectstack/plugin-security:Test Files 79 passed (79) · Tests 1512 passed (1512);typecheck(tsc --noEmit) clean.Gates, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon a clean tree after the final commit (no path arguments — the script takes its own change set from the merge base). Each exit code captured before any pipe; each line below is the gate's own verdict:check:changeset-gate-self-testscheck:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck:engine-double-contractOK — 401 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher295 matcher(s) discovered, 295 answer the combinator battery correctly or refuse it loudlycheck:slot-lookupratchet holds: 107 unswept site(s) … none newcheck:query-options-erasureratchet holds: 67 unswept non-test site(s) … none newcheck:published-files69 publishable package(s) of 78 workspace member(s)check:test-source-aliasOK — 72 packages with tests scannedcheck:type-source-resolutionOK — 77 packages with a tsconfig.json scannedcheck:type-check-coverageOK — 65/78 workspace packages type-checkedcheck:type-check-debt--re-measure: OK — 32 ledger entr(ies) re-measured, 1898 raw tsc error(s) total, none above its recorded numbercheck:i18nOK (9 package(s) — all bundles in sync);plugins/plugin-security in sync (4 bundle(s))check:i18n-coverageOK (12 config(s), 657 baselined untranslated string(s), none new)check:objectui-changeset--self-test: all checks passedcheck-adr-0087-registrationthis PR adds no declared-breaking changesetcheck-changeset-no-majorThis diff introduces no major bumpcheck-empty-changesetNo empty-frontmatter changeset introduced by this diffcheck-plugin-teardown-shape63 Plugin implementation(s) … SHRINK-ONLY, baseline fully burned downdocs-audit/check-affected-docspm/release-rehearsal-clone --self-testself-test passedNo ratchet artifact was rewritten — the tree is clean after the full union, and
engine-double-contract.baseline.json(shrink-only) and.pinned.jsonare both untouched. The new pin introduces no new engine double: it reuses this file's existingmakeQl, which already routesupdate/deletethroughassertEngineUpdateDispatch/assertEngineDeleteDispatch.One gate caught a real defect in this PR's own test code, worth recording because the package's own typecheck could not see it.
check:type-check-debt --re-measuremeasuresplugin-securitywith its**/*.test.tsexclusion dropped, and there the first commit cost +2 raw errors (11 → 13) on a shrink-only ledger: tsc infersrichRow()'s object-literal type and drops the index signaturepermissionSetRowFields()spreads in, so the exclusion control's direct.admin_scoperead is aTS2339. Fixed at the source (an explicit return annotation) — the ledger is untouched, and a re-measure reports 11, matching the recorded entry exactly, with zero errors in this file.Scope notes
Clause ② re-verified against the tree, not inherited: the accept surface does not move.
permissionSetBodyFromRow()already read all six columns before this change (packages/plugins/plugin-security/src/permission-set-projection.ts), andPermissionSetSchemaalready declared all six facets. What changed is the payload the action emits. No public surface widens; nopackages/specpath is touched.One inaccuracy in the card, corrected rather than transcribed. The repro sketch names
member_defaultas "a set whosesystem_permissionsis non-empty". Measured on this tree it is not: the platformmember_defaultcarries a largerowLevelSecurity(17+ policies) and no system permissions, and the showcase'sshowcase_member_defaultcarries neither — the ADR-0090 D7 lint hard-blocks system permissions on any everyone-suggested set. The defect is real either way, andmember_defaultis if anything a better repro than the card claimed, since it is the platform baseline set every member holds and a clone of it dropped all 17 policies. The pin therefore uses a fixture carrying all six facets at once, which is the only shape that measures all three added facets in one pass; the reasoning is recorded in the fixture's docblock so the next reader is not sent back to the wrong example.The dialog design question is filed, not fixed.
defaultFromRowparams render as editable inputs, so this change takes the Clone dialog from two prefilled JSON blobs to five. Ruling clause ③ put that in the objectui lane and ruled it must not hold this deliverable; it is filed separately as #11753 with the three candidate shapes and the note that its failure direction — a hand-edited RLS blob that is valid JSON but wrong — is not fail-closed.Generated by Claude Code