fix(plugin-security): project env-scope permission-set edits onto sys_permission_set (#2857) - #2867
Merged
Merged
Conversation
…_permission_set (#2857) An environment-scope save('permission', ...) writes only the sys_metadata overlay; the metadata->sys_permission_set projection ran only at boot / on publish (and publish refuses env-authored rows), so the queryable record the admin/Setup surface reads went stale while the layered read showed the edit. Adds the environment door: subscribeEnvPermissionProjection hooks the protocol's post-persistence onMetadataMutation choke point; on an active permission save it re-reads the fresh effective body via the layered read and upsertEnvPermissionSet projects the six facet columns onto the record. Ownership is decided by the RECORD's managed_by (env-authored rows only; package records keep their declared baseline). Mirrors authored-translation-sync's mutation-listener pattern. Tested: 21 unit tests (projection for all six facets, env/package guards, the _packageId-provenance + direct-body-shape regressions, and the mutation wiring via a mock protocol); no regression (153 plugin-security tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
4 tasks
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
added a commit
that referenced
this pull request
Jul 14, 2026
…of the metadata layer (ADR-0094) (#2901) * refactor(plugin-security): make sys_permission_set a pure projection of the metadata layer (ADR-0094) Retire the two-store split-brain behind the #2857 display-freshness class (#2875): the metadata layer (packaged declarations + sys_metadata overlay, merged overlay-wins) is now the ONE authoritative store for permission-set definitions, and the queryable sys_permission_set record is a derived read-model — enforced structurally, not by a subscriber a new write path might forget. - metadata-protocol: new registerMutationProjector(type, fn) — an awaited, best-effort per-type hook run after persistence inside saveMetaItem / publishMetaItem / deleteMetaItem, surfaced as `projectionApplied` on the response. A derived read-model is consistent before the write returns (the #2867 onMetadataMutation subscriber was fire-and-forget). - plugin-security: new permission-set-projection module: * data-door write-through — every non-system CRUD write on sys_permission_set is redirected into the metadata store at the engine middleware choke point (after the two-doors + delegated-admin + CRUD/FLS gates); the record is written only by the projector; * the projector re-reads the layered effective body, upserts/creates the env record (Studio-authored sets finally appear in Setup), retires it when the definition is gone, resets it to the declared body when an overlay tombstone reveals the artifact baseline, and syncs the metadata manager's in-memory `permission` entry so evaluator resolution (registry-first list) can no longer disagree with the display; * boot reconciliation — env overlays project onto records, legacy data-door-only records are backfilled into metadata once, and records that drifted from an existing definition are re-projected (metadata wins; the drift was never enforced); * declared (artifact) baselines are read from the engine SchemaRegistry, skipping runtime shadows and projection echoes, so a deleted runtime-only definition cannot zombie back as "declared". Behavior changes: deleting an artifact-backed set through the data door now RESETS it to its declared body instead of removing the row; renames through the data door are rejected (400); pre-existing record drift shadowed by a metadata definition is discarded loudly at first boot. Follow-up: rejecting env-scope overlays of package-owned sets at authoring time is tracked in #2898. Closes #2875 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * docs(permissions): document the ADR-0094 pure-projection env door in authorization.mdx Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes the split-brain where an environment-scope permission-set edit updates the metadata overlay but not the queryable
sys_permission_setrecord. Fixes #2857.Why
A
sys_permission_sethas two representations:client.save('permission', …)/client.layeredread-write (authoritative; enforcement + ADR-0090 explain read it)./api/v1/data/sys_permission_set, snake_case JSON-string columns, what the admin/Setup surface reads.The camelCase→snake projection (
toRowFields/upsertPackagePermissionSet) runs only at boot and on publish (the package door), and the publish path refuses env-authored rows. So an env-scopesaveMetaItem('permission', …)persists thesys_metadataoverlay (the layered read reflects it) but never touches the record → the admin surface goes stale. Enforcement stays correct (it reads the metadata), so this is display-freshness, but it breaks the loop for the objectui pure-model work (objectstack-ai/objectui#2403: Studio designs → Setup shows read-only).How
Adds the environment door, mirroring the existing
authored-translation-syncmutation-listener pattern:subscribeEnvPermissionProjection(protocol, ql, logger)— hooks the protocol's post-persistenceonMetadataMutationchoke point (wired once inSecurityPlugin.runBootstrap, guarded sorunBootstrap's re-run doesn't double-subscribe).projectEnvPermissionOnMutation— on an active (non-draft)permissionevent, re-reads the fresh effective body viagetMetaItemLayered(the boot-cachedMetadataManagerregistry would hand back a stale declared body for a seeded set), tolerating both the layered envelope ({effective|code}) and a direct body.upsertEnvPermissionSet— projects the six facets (objects→object_permissions, …,adminScope→admin_scope). Ownership is decided by the record'smanaged_by, never the body: the layered read stamps_packageIdprovenance on env-authored sets too, so only the record can distinguish the doors. Env-authored rows (platform/user/absent) are projected; apackage-owned record's baseline is left to boot re-seed / publish.Storage columns unchanged.
Tested
bootstrap-declared-permissions.test.ts): projection for all six facets; env upsert vs package-record refusal; the_packageId-provenance regression (record decides, not body); the direct-body-shape regression (getMetaItemLayeredcan return the body directly — a?? nullfallback would silently drop it); and the mutation wiring via a mock protocol (envelope + direct shapes, draft/non-permission skip, no-onMetadataMutation→ no-op).plugin-securitytests pass.I could not get a clean live end-to-end (start backend → env
save→ assert the record refreshes) in my local isolated worktree backend:require.resolve('@objectstack/plugin-security')fails from the example app, and the dev server loaded a staleplugin-securityvia pnpm/worktree resolution rather than my rebuiltdist, so the running backend never had this code (a marker file placed at the subscriber's first line never appeared across 7 rebuild/restart attempts). This is a worktree/pnpm-resolution artifact of my local setup, not a code issue — the projection + wiring logic is deterministically unit-tested above, and the wiring mirrors the provenauthored-translation-synclistener. Please confirm the live projection under CI / a clean install. (The two bugs the unit tests caught — the over-eager_packageIdguard and the dropped direct-body — were found during this debugging, so it was productive.)Refs
🤖 Generated with Claude Code