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 / Dataversesolutions (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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions