Permissions

Why permission in AuraBoot is layered, not stacked

Most application frameworks ship with one permission model. Some choose roles. Some choose access control lists. Some choose attribute predicates. Each of those models is sufficient for a slice of real-world needs, and insufficient for the whole.

A real enterprise rule sounds like this:

"A sales manager can see all opportunities owned by their organization branch, can edit only those above $50,000 that they personally created, and must never see the discount column unless they belong to the finance role."

That one sentence touches function-level access, record-level visibility, organizational hierarchy, attribute-level conditions, and field-level masking at the same time. No single permission paradigm covers it. If a framework offers only roles, the rule gets buried in handler code. If it offers only attribute policies, every list query becomes an expression evaluation exercise that humans cannot reason about during an audit.

AuraBoot resolves this by layering five independent permission paradigms behind one evaluation contract. Each layer answers one question. The contract decides the order in which the questions are asked and how the answers combine. The principal is uniform across all five layers — the tenant member, not the bare user.

This document explains each layer, the evaluation order, the naming convention for permission codes, and a worked enterprise scenario.

The five layers

LayerParadigmQuestion it answersExample
L1 — RBACRole-basedCan this member invoke this function at all?A sales_manager role grants model.crm_opportunity.update.
L2 — ReBACRelation-basedHas this specific record been explicitly shared with this member?The owner of an opportunity shares it with a peer for read and edit.
L3 — Org data scopeOrganizational hierarchyWhich records are this member allowed to see based on where they sit in the org tree?A regional manager sees their branch and all sub-branches; an individual contributor sees only their own records.
L4 — ABAC policyAttribute predicatesDo the record's attributes and the principal's context satisfy the policy parameters?Approval allowed only when amount <= 50000 and created_by == self.
L5 — Field-levelPer-field visibilityWhich fields can this member read, write, or see masked?The discount field is hidden for sales roles and visible only to finance.

The layers are independent in concept but composed in evaluation. None of them try to do another layer's job. Roles do not encode org hierarchy. Org scope does not encode field masking. This separation is what keeps each layer auditable.

Principal: the tenant member

The principal that AuraBoot evaluates against is not the user account. It is the tenant_member — the relationship that connects a user identity to one specific tenant with one specific set of role bindings and one specific position in the organizational tree.

ab_user (Identity — who can log in)
 └─ ab_tenant_member (Access — the permission principal)
      ├── ab_user_role (member_id -> role_id)
      │     └── ab_role
      │           ├── ab_role_permission  (functional grants)
      │           └── ab_role_data_scope  (org scope per resource)
      └── mt_org_employee (HR — position in the org tree)
            ├── department
            └── position

This matters for three reasons.

One identity, many memberships. A single user can be a member of multiple tenants. The same human can be tenant_admin in their own workspace and a viewer in a partner's workspace. Permission evaluation must always carry the tenant context, never the bare user identity.

Roles attach to access, not to identity. Granting a role does not change who a user is; it changes what one of their memberships can do. Revoking the role does not log the user out — it narrows that membership's surface.

The HR position is on the member, not on the user. Organizational data scope (Layer 3) reads the member's mt_org_employee record. That is what allows "this branch and all sub-branches" to mean something concrete.

Every API entry point — the dynamic CRUD controller, command dispatch, audit log writers, the agent runtime — resolves the principal once at the request boundary and passes a memberId through the evaluation chain. Code paths that bypass this resolution are forbidden; the platform enforces it through the request-scope context holder.

Layer 1: RBAC (role-based)

RBAC answers the most coarse-grained question: does this member's role set grant this functional permission at all?

Permission codes in AuraBoot follow a strict three-segment convention:

module.resource.action
  • module — the plugin or platform module (crm, inv, platform, rbac, tenant, automation, ...)
  • resource — the model code or platform resource being controlled
  • action — the operation (read, create, update, delete, import, export, plus model-specific verbs derived from custom commands such as approve or qualify)

For example: model.crm_lead.read, model.crm_opportunity.update, module.platform.dashboard.read.

Permissions are stored as a three-level tree:

LevelCode shapeMeaning
1 — Modulemodule.<moduleCode>Grouping node per plugin / platform module
2 — Resourcemodel.<modelCode>One node per business model
3 — Actionmodel.<modelCode>.<action>Fine-grained operation; this is what grants attach to

Action nodes are generated automatically at model publish time. AuraBoot reads the model's command definitions and derives the action set: read is always added; create / update / delete are added when matching command exec types exist; custom command verbs (state transitions, custom actions) contribute their own action node. There is no blanket manage permission and no implicit grant. If a model has no delete command, the model.<x>.delete action simply does not exist.

Roles bind to action-level permissions through ab_role_permission. Menu visibility, command dispatch authorization, and DSL page access all consult this same RBAC layer first.

If RBAC denies, evaluation short-circuits with a 403 immediately. The other layers are not consulted. This is what keeps unauthorized callers from causing expensive scope evaluation or attribute matching.

Layer 2: ReBAC (relation-based)

RBAC operates on the function. It does not know about a specific record. ReBAC fills that gap.

When a record is owned by member A, it is normal for A to share access with member B explicitly — to delegate, collaborate, or hand over. That share is a relation, not a role. AuraBoot stores it in ab_record_share:

POST /api/record-share
     body: { resourceCode, recordPid, subjectType: "member"|"role"|"dept", subjectId, permissionMask: "read,update" }

ReBAC is evaluated after RBAC and before organizational scope. The reason is important: a record that has been explicitly shared with a member is, by intent, a deliberate exception to the normal scope rules. If member B's org scope would normally hide the record, but the owner has explicitly shared it, the share wins. ReBAC short-circuits scope evaluation with ALLOW.

A typical scenario: an account executive is on vacation, hands off a critical opportunity to a peer in another region, and shares the record with read and edit. The peer is outside the original org scope. They still see and act on that one record. They do not gain visibility to any other record in that region.

ReBAC is not a backdoor — RBAC is still required upstream. A member with no model.<x>.update permission cannot edit a shared record. Sharing extends visibility, not capability.

Layer 3: Organizational data scope

Once RBAC has granted the function and ReBAC has not produced an explicit allow, Layer 3 determines which records in the visible set this member can act on.

This is the layer that encodes "see your branch, not your peer's branch." It is keyed by the member's position in the organizational tree, resolved through mt_org_employee.

AuraBoot ships five built-in scope types (from most to least permissive):

ScopeMeaning
ALLAll records the tenant can see
DEPT_AND_SUBThe member's department and every descendant department
DEPTThe member's department only
SELFRecords created by or assigned to this member
NONENo records visible (most restrictive)

The scope is stored per role per resource in ab_role_data_scope, which means the same role can grant ALL on one model and SELF on another. When a member holds multiple roles, the system applies a merge strategy (typically max — the most permissive wins, configurable per resource).

The evaluator transforms scope into a SQL predicate that is appended to every list query and every record fetch. There is no Java-side filtering after the fact. The query that hits the database is already narrowed to the visible set. This is what keeps list endpoints fast even when scope rules are non-trivial.

A member outside scope for a record receives the same 403 they would receive if RBAC had denied. The audit log records which layer denied — operators investigating a "missing data" report can tell the difference between "they lack the role" and "they lack the org scope" without reading code.

Layer 4: ABAC (attribute-based)

The first three layers answer can this member act on this kind of record, on this specific record, and within their org scope. Layer 4 answers a question none of them can: do the record's attributes, combined with the principal's context, satisfy the policy parameters attached to this grant?

ABAC is stored in two parts:

  • ab_permission.policy_schema — declares which parameters this permission accepts and their operators. For example, maxAmount with operator <=, or allowedStatuses with operator in.
  • ab_role_permission.conditions — the actual parameter values assigned when a role is granted a permission. For example, maxAmount=50000 and allowedStatuses=["draft", "submitted"].

At evaluation time, the policy evaluator compares record field values against the policy parameters. Multi-role merging follows the schema: numeric <= bounds take the maximum; enum in lists are unioned.

A realistic example: the sales_manager role on model.crm_opportunity.approve is granted with parameters maxAmount=50000 and createdBySelf=true. A manager attempts to approve an opportunity for $80,000 they did not create. RBAC passes (they have the action). ReBAC has nothing to say. Org scope passes (the record is in their branch). ABAC fails: amount > maxAmount. The action is denied, and the denial reason is policy.maxAmount.exceeded — not a generic 403.

ABAC is the layer that prevents enterprises from creating dozens of near-duplicate roles. One role, parameterized.

Layer 5: Field-level visibility

The four layers above gate whether a member can act. Layer 5 gates what they see when they do.

Field-level visibility is declared in the DSL field definition:

{
  "code": "salary",
  "extraProps": {
    "fieldPermission": {
      "view": ["hr_manager", "finance"],
      "edit": ["hr_manager"]
    }
  }
}

When a list or detail response is assembled, the dynamic data service inspects the member's roles and applies one of two read outcomes per field:

OutcomeBehavior
visibleField returned normally
hiddenField omitted from the payload entirely

Edit-side, the same field permission set drives form rendering. A field not in the edit list becomes disabled; a field a member cannot view does not appear on the form. (Field-level masking — returning, say, phone as ***-****-{last4} instead of omitting the field — is an enterprise extension; see below.)

Field-level visibility never changes the pass/deny decision of layers 1–4. A successful read returns the record minus the masked or hidden fields. A successful write rejects payload keys that the member is not entitled to write. This separation is deliberate: hiding a column does not change whether the row exists, only what is shown.

Evaluation order and short-circuit semantics

The layers are evaluated in a fixed order:

Step 1: RBAC          — does the role set grant the function?
        deny -> 403 (final)
Step 2: ReBAC         — is this record explicitly shared with the member?
        allow -> skip Step 3 (org scope)
Step 3: Org scope     — is the record within the member's data scope?
        out of scope -> 403 (final)
Step 4: ABAC policy   — do attributes satisfy the policy parameters?
        violation -> 403 (final)
Step 5: Field-level   — apply masking, hiding, and per-field write rules
        (never produces a deny by itself)

Three rules of the contract are worth highlighting:

RBAC denial is final and cheap. It happens before any record is loaded, before any scope predicate is built, before any policy is evaluated. Authenticated but unauthorized callers never trigger expensive computation.

ReBAC is the only layer that can bypass a later layer. An explicit share skips Step 3. It does not skip RBAC or ABAC. A shared record still needs the function permission and still respects the policy parameters.

Every denial knows which layer denied. The evaluator returns a PermissionExplanation containing every step's decision and the reason. This explanation is what the explain endpoint returns, what the audit log records, and what enables an operator to debug "why can't user X see record Y" without reading the code.

Permission code naming and governance

Permission codes are not strings developers invent at will. They are governed.

Format. Every code must match module.resource.action. Examples: model.crm_lead.read, module.platform.dashboard.read, module.rbac.role.update. Three segments. Lowercase. Dots only.

Registration. Every code must be registered in one of two places:

  • The platform bootstrap template (default-bootstrap.json — platform-wide codes)
  • A plugin's permissions.json (plugin-scoped codes, imported when the plugin is published)

There is no third path. Codes referenced from Java annotations, from commands.json, from menu definitions, or from frontend resource maps must resolve to a registered code.

Generation for business models. When a plugin publishes a model, the platform auto-generates the corresponding module.*, model.<code>, and model.<code>.<action> nodes. The action set comes from the model's commands; there is no manage umbrella.

Strict gate. A CI gate walks the bootstrap registration, every plugin's permissions.json, the Java annotation constants, the frontend resource maps, and the DSL files. Any unresolved reference fails the build. This gate catches the most common production incident in permission systems: a code is referenced somewhere (a Java annotation constant, for instance) with a slightly different string than the registered version, and no role can possibly hold it, producing a silent 403 for every user. The gate eliminates the entire class.

Java annotation enforcement. Controllers use @RequirePermission(MetaPermission.MODEL_READ) rather than bare strings. The constants resolve to registered codes; the gate validates that resolution at build time.

Worked example

The motivating sentence from the opening:

A sales manager can see all opportunities owned by their organization branch, can edit only those above $50,000 that they personally created, and must never see the discount column unless they belong to the finance role.

Here is how the five layers express this without writing any custom permission code.

Step 1 — RBAC. Create a sales_manager role and grant:

  • model.crm_opportunity.read — to see the records
  • model.crm_opportunity.update — to edit them

If a member holds neither, they receive 403 on the list endpoint and the detail endpoint. No further layers are evaluated.

Step 2 — ReBAC. No special configuration. If a peer manager shares an opportunity from another branch explicitly, the share will let our sales manager see and edit that one record even when org scope would normally hide it. This is the intended exception path.

Step 3 — Org data scope. On the sales_manager role, for the crm_opportunity resource, set scope to DEPT_AND_SUB. The platform reads the member's mt_org_employee.department_id and appends an IN (this department and descendants) clause to every query. The branch and all its sub-branches are visible. Peer branches are not.

Step 4 — ABAC policy. On the grant sales_manager -> model.crm_opportunity.update, attach conditions maxAmount=50000 and createdBySelf=true. The platform's policy schema for update declares these parameters with their operators. At evaluation, an opportunity for $80,000 fails the amount check; an opportunity created by a colleague fails the principal check. The read action carries no such conditions, so visibility is broader than write capability — which is exactly the sentence's intent.

Step 5 — Field-level. On the discount field of the opportunity model, set:

{
  "code": "discount",
  "extraProps": {
    "fieldPermission": {
      "view": ["finance"],
      "edit": ["finance"]
    }
  }
}

A sales manager reading an opportunity receives the row with discount omitted from the payload. A finance role receives it. The list endpoint, the detail endpoint, and the form all honor the same rule.

The sales manager never wrote a custom handler. Operators never reviewed bespoke code. The rule is metadata. The audit log will report exactly which layer enforced each denial.

Enterprise extensions

:::note[Enterprise extensions] The five-layer model is fully open source. The enterprise edition extends it across the layers:

  • Richer ABAC policy expressions — a policy expression engine that goes beyond the OSS operator vocabulary (<=, >=, in, not_in), supporting time windows, cross-field comparisons, and aggregations over the principal's recent activity.
  • Field-level cryptographic masking — server-side encryption envelopes for masked fields, so masking is enforced at storage rather than only at the response boundary.
  • Cross-organization scope delegation — temporary or rule-based scope grants across org boundaries (for example a project-based virtual department) without modifying the underlying HR tree.
  • Permission policy export and audit replay — export a tenant's full permission policy as a versioned artifact, diff two versions, and replay historical evaluations against a candidate policy to verify intended behavior before rollout. :::

Next steps

  • Command Pipeline — how permission evaluation slots into the command execution contract
  • Plugin Manifest — how plugins declare their permission codes alongside models, menus, and pages
  • Agent Readiness — how the same permission contract governs AI agents acting on behalf of a member