Uh oh!
There was an error while loading. Please reload this page.
feat(security,meta): org-scoped presentation authoring capability for tenant org admins - #12705
Conversation
📓 Docs Drift CheckThis PR changes 4 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 133 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 e1d9c785a752e85bdeaeaaeec3fecfbaa8019adf && git checkout e1d9c785a752e85bdeaeaaeec3fecfbaa8019adf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15bf9e859e56862e6ebe7b5c42404de103362457 51bf9893c1dbd2328a9d9b581b00d95932775629 && git checkout -B drift-repro 15bf9e859e56862e6ebe7b5c42404de103362457 && git merge --no-ff 51bf9893c1dbd2328a9d9b581b00d95932775629
node scripts/docs-audit/affected-docs.mjs --json 15bf9e859e56862e6ebe7b5c42404de103362457
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12702
Part of the #12701 epic (hotcrm single-DB multi-org SaaS). Maintainer direction 2026-08-27, quoted verbatim in the epic; the card's Contract / Walls / Acceptance sections are the spec and were not re-litigated.
What this adds
A tenant org admin in a walled posture can author org-scoped overlays for exactly the org-overridable (tier-A) metadata types without holding platform-wide
manage_metadata:PLATFORM_CAPABILITIESentrymanage_org_presentation(scope: 'org'), seeded intosys_capabilityat boot bybootstrapSystemCapabilitieslike its siblings (KNOWN_CAPABILITIESis the same array). Granted by NO shipped permission set: the SaaS operator grants it per deployment (the epic's hotcrm composition card carries the org-admin profile variant), so existing deployments —singleposture included — are unchanged by its existence.metaWriteCapabilityVerdict(meta-write-capability.ts), the capability half of the/metawrite decision beside the existing scope half (organizationIdForMetaWrite). Acceptance conditions: the CANONICAL type declaresallowOrgOverride: true(viadeclaresOrgOverride— registry-derived, no hand-written type list) AND the session has an active organization, which is the very organization every door threads.isSystemandmanage_metadatashort-circuit first, unchanged./metaitem write doors (PUT save, DELETE reset, POST publish, POST rollback) run the shared verdict.POST /meta/_migrate-storeddeliberately staysmanage_metadata-only (an install-wide rewrite is env-wide by definition; a comment at the gate says so)./metaPUT door runs the same shared verdict; its active-organization resolution is hoisted above the gate so authorization and write scope read ONE value. Its_migrate-storedtwin likewise staysmanage_metadata-only.Why publish / reset / rollback beside save: the four doors are one authoring family behind one policy (the gate comments in
rest-server.tscall them "the SAME four lines", deliberately not four ways of demanding a capability), the save door's draft mode makes publish the second half of the Studio save-then-publish loop, and each of the three already threadsorganizationIdForMetaWrite— so the identical conditions bound their blast radius to the caller's own org partition (reset discards only the caller's own org overlay row; rollback restores only a version of it; publish promotes only that partition's draft).One landing-point difference from the card's expected file surface: nothing changed in packages/metadata-protocol. The protocol deliberately owns no capability decision (transport doors do, by design), and its existing
orgScopedWriteRefusal(NOT_OVERRIDABLE, 403) already refuses org-scoped writes of non-tier-A types below the door — the second wall stays where it always was. The five-type identity pin (protocol.org-scoped-write-refused.test.ts) is untouched and still pins exactly five org-overridable types.Walls held
allowOrgOverrideflips; predicates registry-derived end to end.ctx.tenantId, dispatcherresolveActiveOrganizationId); pins assert both the admission threading (the writtenorganizationIdequals the caller's own org, on every door) and smuggling inertness (query/body-supplied organization ids do not move it).PLATFORM_ADMIN_ONLY_CAPABILITIESuntouched — the new capability is deliberately NOT a platform-admin marker, and the spec entry's comment forbids adding it there.FORBIDDEN(that door family's pinned spelling), the dispatcher 403PERMISSION_DENIED; no codes minted. The tier-B refusal sentence is byte-identical to before; the tier-A sentences name the sanctioned path and never the caller's own grants (The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 posture — the message varies only on request/session-derived facts, pinned by asserting the same sentence for different caller shapes).Read path — verified, no change needed
organizationIdForMetaReadthreadsctx.tenantIdfor EVERY caller class at all REST read doors (rest-server.ts— the layered read, the list read, the item read), and the dispatcher reads resolve the active organization the same way; no capability participates in the read-scope decision anywhere. A tenant org admin already reads their own org overlay under org-wins precedence, so this caller class needs no read-side change. The_draftslist gate keeps its own predicate (isObjectSchemaMaskExempt), also unchanged.Verification (all at 51bf989, this branch's head)
scripts/pm/dispatch-gates.mjsderived from this diff (derivation header cites this repo at commit 51bf989; no paths were hand-fed) plus dispatch-namedcheck:durability-log-level,check:filter-alias-parity,check:nul-bytes,check:error-code-casing. Derived-new beyond the dispatch prompt's list, run and green:check:authz-resolver,check:changeset-gate-self-tests,check:dispatcher-error-vocabulary,check:objectql-double-limit,check:objectui-changeset,check:pm-half-states.pnpm --filter @objectstack/spec check:generated— all 14 artifacts up to date (the capability entry is a value, not a schema: no artifact churn,check:api-surfaceincluded).git diff HEAD; no dist rebuild was needed because both gate suites import their door via same-package relative source paths (no package-exports resolution in the loop). Predicted: admission and message-content cases red, every refusal-direction case green, helper suite green as control. Measured exactly that: 4 red in the runtime gate suite, 8 red across the two REST suites (2 admissions + env-wide message + smuggling in the item suite; the 4 per-door admissions in the enumeration suite), 41-test helper control green.bootstrap-system-capabilities.test.tshard-coded the "last two page-truncated names" as indices 6 and 7 of what was an eight-entry curated list, so the ninth entry shifted the truncation and turned its positive control red. Now derived withslice(-2)plus a comment; the full plugin-security suite is green.Single-posture stability reading
"Byte-unchanged" is delivered at the decision level: every caller admitted before is admitted on the same path, every refusal keeps its status and code, and the tier-B refusal sentence — the common case — is byte-identical. The tier-A refusal sentences gained the sanctioned-path clause the card itself requires ("refusals stay loud and name the sanctioned path"), which is the one deliberate text delta; no test pinned the previous sentences.
No out-of-scope findings.
Generated by Claude Code