Skip to content

feat(security,meta): org-scoped presentation authoring capability for tenant org admins - #12705

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12702-org-presentation-capability
Aug 27, 2026
Merged

feat(security,meta): org-scoped presentation authoring capability for tenant org admins#12705
os-zhuang merged 2 commits into
mainfrom
claude/issue-12702-org-presentation-capability

Conversation

@claude

@claudeclaudeBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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:

  • packages/spec — new curated PLATFORM_CAPABILITIES entry manage_org_presentation (scope: 'org'), seeded into sys_capability at boot by bootstrapSystemCapabilities like its siblings (KNOWN_CAPABILITIES is 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 — single posture included — are unchanged by its existence.
  • packages/metadata-core — new metaWriteCapabilityVerdict (meta-write-capability.ts), the capability half of the /meta write decision beside the existing scope half (organizationIdForMetaWrite). Acceptance conditions: the CANONICAL type declares allowOrgOverride: true (via declaresOrgOverride — registry-derived, no hand-written type list) AND the session has an active organization, which is the very organization every door threads. isSystem and manage_metadata short-circuit first, unchanged.
  • packages/rest — the four /meta item write doors (PUT save, DELETE reset, POST publish, POST rollback) run the shared verdict. POST /meta/_migrate-stored deliberately stays manage_metadata-only (an install-wide rewrite is env-wide by definition; a comment at the gate says so).
  • packages/runtime — the dispatcher /meta PUT 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-stored twin likewise stays manage_metadata-only.
  • Route-ledger notes updated on both transports; changeset included (four published packages, minor).

Why publish / reset / rollback beside save: the four doors are one authoring family behind one policy (the gate comments in rest-server.ts call 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 threads organizationIdForMetaWrite — 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

  • No allowOrgOverride flips; predicates registry-derived end to end.
  • No tier-B reach, no env-wide writes, no foreign-org writes through the new capability. The doors derive the organization from the caller's own session only (REST ctx.tenantId, dispatcher resolveActiveOrganizationId); pins assert both the admission threading (the written organizationId equals the caller's own org, on every door) and smuggling inertness (query/body-supplied organization ids do not move it).
  • ADR-0126 surfaces untouched. PLATFORM_ADMIN_ONLY_CAPABILITIES untouched — the new capability is deliberately NOT a platform-admin marker, and the spec entry's comment forbids adding it there.
  • Refusal envelopes reuse existing codes: REST answers 403 FORBIDDEN (that door family's pinned spelling), the dispatcher 403 PERMISSION_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's details to the client, so positions / permissionSets reach the browser on the /data transport #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

organizationIdForMetaRead threads ctx.tenantId for 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 _drafts list gate keeps its own predicate (isObjectSchemaMaskExempt), also unchanged.

Verification (all at 51bf989, this branch's head)

  • Full suites, all green: spec 435 files / 11541 tests · runtime 196 / 2887 · rest 151 / 2501 · metadata-core 13 / 218 · plugin-security 86 / 1561 · lint 81 / 2300.
  • Typecheck green for spec / metadata-core / rest / runtime. Coverage note, measured not assumed: metadata-core and rest DO compile the new and edited test files (tsc --listFiles: 1 and 2 hits respectively); runtime's tsconfig excludes the test layer repo-wide (pre-existing posture under the type-check debt ratchet), so the runtime test additions are exercised by vitest execution, not tsc.
  • Gate union, 25 commands, all exit 0 — "UNION RESULT: 0 failing gate(s) of 25 at 51bf989". The set is the 21 families scripts/pm/dispatch-gates.mjs derived from this diff (derivation header cites this repo at commit 51bf989; no paths were hand-fed) plus dispatch-named check: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-surface included).
  • Reverse verification, direction predicted before running: the two door files were reverted to the base commit 15bf9e8 (tests and the metadata-core helper kept), with the mutation proven on disk (verdict anchor count 0 in both files) and the restore proven by anchor counts (7 and 4), blob-hash equality with HEAD, and an empty 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.
  • One collateral test fix: bootstrap-system-capabilities.test.ts hard-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 with slice(-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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-core, @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 28 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/metadata-core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 15bf9e859e56862e6ebe7b5c42404de103362457.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/index.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 133 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 15bf9e859e56862e6ebe7b5c42404de103362457packageMentionDocs.

Which tree this was computed on

This run read content/docs from e1d9c785a752e85bdeaeaaeec3fecfbaa8019adf — the merge of head 51bf9893c1dbd2328a9d9b581b00d95932775629 into base 15bf9e859e56862e6ebe7b5c42404de103362457, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 15bf9e859e56862e6ebe7b5c42404de103362457 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 12:59
@os-zhuang
os-zhuang enabled auto-merge August 27, 2026 12:59
@os-zhuang
os-zhuang added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 15eb2c9Aug 27, 2026
41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12702-org-presentation-capability branch August 27, 2026 13:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Org-scoped presentation customization authority: a tenant org admin authors tier-A overlays without platform-wide manage_metadata

2 participants

@os-zhuang@claude