Skip to content

Design: authorization model — metadata vs environment-config boundary + cross-package composition #2557

Description

@os-zhuang

Summary

Decide, model-wide, which authorization primitives are package metadata (versioned,
shipped with a package, draft/published, portable across environments) and which are
environment / system-admin configuration
(edited live, environment-specific, and bound
to real users/orgs) — and specify how permissions from different packages compose when
several packages are installed into one environment.

This is a design/ADR task, not a code task yet. The Studio "Access" pillar
(objectstack-ai/objectui#2196) surfaced the gap: opened inside a package design
surface (/studio/:packageId/access), it lists all of the environment's objects
(e.g. "84 objects") and edits environment-level permission sets live, with no package
scope and no composition story. A package should only govern access to its own
objects; the cross-package / assignment concerns belong to the environment admin. The
same class of bug was just fixed for the Interfaces pillar
(objectstack-ai/objectui#2197).

The deliverable is a design (likely a new ADR) that draws the metadata↔config line for
the whole authz model with mainstream-platform precedent, defines cross-package
composition, and lists the resulting spec / bootstrap / Studio / admin changes as a phased
plan.


Deliverable

  1. Classification — for every authz primitive: metadata / config / hybrid, with a
    one-line rationale and the mainstream-platform precedent it follows.
  2. Composition semantics — how grants from multiple installed packages assemble into a
    user's effective access.
  3. Change list (phased) — spec (managedBy classification), bootstrap
    (bootstrapDeclaredPermissions), manifest wiring, Studio Access-pillar scoping, and the
    environment-admin surface. Each phase independently shippable.

Grounded facts (already verified — do not re-derive)

Framework (objectstack-ai/framework):

  • Permission sets are environment-level, not package content. sys_permission_set is
    managedBy: 'config' with no packageId/owning-package field
    (packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:20,
    fields at :125-214). Schema: PermissionSetSchema
    (packages/spec/src/security/permission.zod.ts:108-192), Salesforce-shaped (ADR-0056).
  • Grants are stored inline as flat dicts on the permission-set record:
    objects: Record<objectApiName, ObjectPermission> (permission.zod.ts:120) and
    fields: Record<"object.field", FieldPermission> (:122). ObjectPermission =
    allowRead/Create/Edit/Delete + viewAll/modifyAllRecords + read/writeScope
    (:24-73). One record holds every package's object grants.
  • Object api names are package-namespaced and collision-proof: every object in a
    package is ${namespace}_${shortName} (e.g. crm_account, todo_task); the full
    prefixed name is the physical table name, so two packages cannot collide
    (packages/spec/src/kernel/manifest.zod.ts:142-179, esp. :152 lists permissions
    among consumers of the qualified name, :166 on physical storage). ⭐ This is what makes
    cross-package composition conflict-free at the key level.
  • Runtime resolution is a most-permissive UNION across all of a user's assigned
    permission sets (plugin-security/src/permission-evaluator.ts:64-101; per-object lookup
    falls back to '*' wildcard, :40-51). Assignment resolution:
    packages/core/src/security/resolve-authz-context.ts:84-252 (user→roles→sets→union).
  • No composition/seeding exists today. Platform defaults (admin_full_access,
    organization_admin, member_default) are hardcoded
    (plugin-security/src/objects/default-permission-sets.ts:81-536). Roles declared in a
    package manifest ARE seeded (bootstrap-declared-roles.ts:61-95), but there is no
    bootstrapDeclaredPermissions
    — ObjectStackDefinitionSchema.permissions: PermissionSetSchema[] is declarable but never migrated into sys_permission_set. The
    manifest's permissions field (manifest.zod.ts:254-265) is plugin install-consent
    scopes
    (ADR-0025), not RBAC grants. → A package cannot ship default access for its
    own objects.
  • The platform already has two managedBy axes — this is the existing vocabulary for
    the boundary and the design should build on it, not invent a parallel one:
    • metadata provenance/layer: managedBy: 'package' | 'platform' | 'user'
      (packages/spec/src/system/metadata-persistence.zod.ts:72).
    • object CRUD affordance bucket: managedBy: 'platform' | 'config' | 'system' | 'append-only' | 'better-auth' (packages/spec/src/data/object.zod.ts:408, defaults
      matrix :1061-1100).

objectui (objectstack-ai/objectui) — the triggering surface:

  • <AccessPillar /> is rendered with zero props, so it never receives the route's
    packageId (packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:589-590;
    packageId is available at :333-334).
  • Left rail lists permission sets environment-wide via client.list('permission')
    (StudioDesignSurface.tsx:2349). The embedded matrix loads all objects via
    client.list('object') with no filter (PermissionMatrixEditor.tsx:171) — the "84
    objects" — and all fields via client.list('field') (:208).
  • Save writes the whole permission-set record including its full objects dict
    (PermissionMatrixEditor.tsx:289-291). ⚠️ Data-loss trap: naively filtering the
    matrix rows to the package's objects and then saving "the rendered rows" would delete
    every other package's grants
    from that shared record. Any scoping fix must render only
    this package's object rows and slice-merge on save ({ ...original.objects, ...editedPackageKeys }). The namespaced keys make the package's slice unambiguous.
  • The proven package-scoping mechanism used by every other pillar:
    client.list('object', { packageId }) + client.listDrafts({ packageId, type })
    (Interfaces: StudioDesignSurface.tsx:822-823; Data: :1377-1378).
  • Client already consumes a flattened per-user effective grant at runtime:
    GET /api/v1/auth/me/permissions → { roles, permissionSets, objects, fields }
    (packages/permissions/src/MePermissionsProvider.tsx:56, type :21-41).

Design questions to resolve

Q1 — Classify every primitive as metadata / config / hybrid:
Role (definition), Role hierarchy/parent, PermissionSet/Profile definition,
ObjectPermission grant, FieldPermission (FLS), RLS policy (using/check), Sharing rule,
OWD / object.sharingModel, systemPermissions, tabPermissions,
permission-set ASSIGNMENT (sys_user_permission_set, sys_role_permission_set),
Organization / tenancy, actual users/teams.

Q2 — The DEFINITION↔ASSIGNMENT split. Evaluate the hypothesis that authorization
definitions (what a set/role can do) are metadata, while subject bindings (which real
user/team is assigned) are environment data. This is the near-universal line in mainstream
platforms — confirm it holds here and note exceptions.

Q3 — Composition model. Two shapes; pick one or support both:

  • A. Shared env permission set, packages contribute disjoint object slices — one
    "Contributor" set; package A writes objects.crm_*, package B writes objects.todo_*;
    keys never collide (namespacing) and runtime unions them. Works with the current model
    IF editors respect the slice invariant. Natural reading of "拼装" = each package fills its
    own rows of one matrix. Interacts with the overlay model (ADR-0005).
  • B. Each package ships its own permission sets (managed-package style) — package A
    ships crm_sales_rep, B ships todo_member; admin assigns multiple; runtime unions.
    Requires the missing bootstrapDeclaredPermissions + manifest wiring.

Q4 — Package-shipped default access. Should installing a package grant working default
access to its objects out of the box? If yes: bootstrapDeclaredPermissions + manifest
permissions wiring, and a decision on whether it seeds a package-owned set (Shape B)
or contributes a slice into shared/overlay sets (Shape A via ADR-0005). Reconcile with
the "no silently inert metadata" rule (ADR-0078) — a declarable permissions[] that is
never seeded is exactly the inert-metadata smell.

Q5 — Governance. Does Access editing in the package surface flow through package
draft/publish (ADR-0016/0033) like Data/Interface, or stay live like #2196's banner
("permissions are platform-level … the matrix saves the ACTIVE item directly")? This
follows directly from whether the edited thing is classified as metadata in Q1.

Q6 — Two-door surfaces. Confirm the split: the package Access pillar edits only the
package's object slice (metadata, scoped, draft/published); the environment admin
(metadata-admin) keeps the cross-package all-objects matrix and the assignment UI
(config). Ref builder-ui §3.5 "two doors, one metadata".


Mainstream platforms to reference

Evaluate the classification against how established metadata-driven / low-code platforms
draw the same line, and extract the invariant:

  • Salesforce — permission sets, permission set groups, and (legacy) profiles are
    packageable metadata (Metadata API components, travel in managed/unmanaged packages);
    assignment to users is org data, never packaged; muting permission sets let a
    subscriber org subtract from a managed set without editing it (a composition/overlay
    precedent). Field-Level Security and object CRUD ride on the permission set.
  • ServiceNow — scoped applications + update sets carry ACLs and roles as metadata
    (sys_scope); user↔role assignment (sys_user_has_role) is instance data.
  • Microsoft Power Platform / Dataverse — solutions (managed/unmanaged) carry
    security roles as metadata; env-specific values are externalized via environment
    variables
    and connection references (a clean "config that must not be baked into the
    packaged metadata" precedent); team/user role assignment is data.
  • OutSystems / Mendix — module/app roles are metadata that deploy with the app;
    user→role mapping is runtime/admin.

Expected invariant to confirm: authorization DEFINITIONS travel with the app; SUBJECT
bindings and environment-specific values stay as environment config/data
— with an
overlay/subtract mechanism (Salesforce muting, Dataverse layering, ObjectStack ADR-0005) so
a subscriber environment can adjust packaged definitions without forking them.


Relevant ADRs & code

Framework ADRs: 0006 project-environment-split (v4 — the core split),
0005 metadata-customization-overlay, 0007 settings-manifest-and-kv-store,
0003 package-as-first-class-citizen, 0070 package-first-authoring,
0078 no-silently-inert-metadata, 0028 naming/namespace-isolation,
0048 cross-package-metadata-collision, 0056 permission-model landing (the whole
authz surface, enforce/prove/reconcile), 0084 application-builder IA (Access = matrix).
Design doc: docs/design/builder-ui.md §7 (Access pillar — already defers permission-set
composition as the "concept overload" risk).

Spec/runtime: permission.zod.ts, manifest.zod.ts, object.zod.ts (managedBy),
metadata-persistence.zod.ts (managedBy), plugin-security
bootstrap-declared-roles.ts / default-permission-sets.ts / permission-evaluator.ts,
core resolve-authz-context.ts.

objectui: StudioDesignSurface.tsx (AccessPillar), metadata-admin/PermissionMatrixEditor.tsx,
permissions/src/MePermissionsProvider.tsx.


Acceptance criteria

  • Design/ADR: the metadata↔config classification table (Q1) with rationale + platform
    precedent, and the DEFINITION↔ASSIGNMENT principle (Q2) stated explicitly.
  • Composition semantics decided (Q3) and the package-default-access question answered
    (Q4), reconciled with the overlay model (ADR-0005) and no-inert-metadata (ADR-0078).
  • Governance decision for the package Access surface (Q5) and the two-door split (Q6).
  • Phased implementation plan, each phase independently shippable:
    • P0 (objectui, low-risk) — scope the Access matrix rows to { packageId } +
      slice-merge on save + keep the rail global. Closes the object leak and the
      data-loss trap. Mirrors objectstack-ai/objectui#2197.
    • P1 (framework) — bootstrapDeclaredPermissions + manifest wiring so packages ship
      default access for their own objects (per Q3/Q4).
    • P2 — environment-admin surface owns cross-package composition + assignment; package
      surface owns the package slice, optionally under draft/publish (per Q5/Q6).

Related

Activity

  1. self-assigned this
    on Jul 16, 2026
  2. os-zhuang commented on Jul 16, 2026

    @os-zhuang
    ContributorAuthor

    Re-evaluation against latest code (2026-07-16)

    Re-assessed this design against current main (HEAD 2026-07-16). The design shipped — its full plan (P0/P1/P2, D1–D7) is now in the code, and the metadata↔config thesis has become the load-bearing assumption of the whole permission-model-v2 wave (ADR-0090/0094/0095). ADR-0086 was still marked Proposed, which under-sold reality. PR #3011 flips it to Accepted (substantially implemented) and adds a code-grounded Re-evaluation (2026-07-16) addendum.

    Acceptance criteria — all met, and shipped

    • Classification (Q1/Q2) — done in D1/D2; the DEFINITION↔ASSIGNMENT principle is now the platform's stated line and the reason Profile could be removed (ADR-0090 D2 cites "ADR-0086 D3 made machine-checkable").
    • Composition (Q3) + package default access (Q4) — Shape B chosen and implemented: packageId + provenance on PermissionSetSchema (spec/security/permission.zod.ts:167,178), and bootstrapDeclaredPermissions (plugin-security/src/bootstrap-declared-permissions.ts) seeds stack.permissions as managed_by:'package' — closing the ADR-0078 inert-metadata smell. Composition anchors at the position (union), reconciled with the overlay model.
    • Governance (Q5) + two doors (Q6) — package Access door writes mode:'draft'; env-admin door stays live (and is itself redirected into a metadata overlay by ADR-0094's write-through).
    • Phased plan — P0 done in objectui feat(import): server-side coercion + write-mode for /data/:object/import #2505/tooling: migrate Node version management from nvm to Volta (multi-repo) #2508 (permission-slice.ts + mergePermissionSlice, packageId-scoped matrix); P1 done (D3 + D5 above); P2 two doors shipped, subtract layer landed as a first-class ADR-0005 overlay.

    Two mechanisms the original text describes were overtaken by better realizations

    1. P0's "slice-merge the shared data record" → ADR-0094 pure projection. sys_permission_set is now a derived projection over the metadata layer; the data-loss trap is closed structurally (there is no second writable authority to clobber). The inline objects/fields body shape survived, so the namespaced-key slice invariant still holds.
    2. D4's Salesforce-muting / clone subtract layer → ADR-0005 first-class overlay (ADR-0094 D5, revised 2026-07-14): an env overlay of a package set keeps receiving vendor baseline tightenings and retains the code-vs-overlay diff — strictly better than muting/forking.

    Vocabulary refresh (ADR-0090)

    The D1 table is restated: Position (not Role; flat, no parent — the hierarchy is the BU tree), Profile removed, everyone/guest anchors replace the member_default fallback, assignment tables renamed and time-boxed (ADR-0091), plus adminScope delegated admin (ADR-0090 D12).

    Net: no decision here was reversed. Remaining open items are the ones already marked deferred/evidence-gated: the ADR-0066 explicit-deny precedence layer, permission-set groups, and OWD/sharingModel env-tightening via the overlay path. Recommend closing the design ask; the deferred items live under the #2561 umbrella.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions