Appearance
Shared RBAC Architecture
This guide explains the shared RBAC foundation used by Cabloy Basic and Cabloy Start: the Vona a-rbac runtime, its extension boundary, and the Resource permission projection consumed by Zova.
Use this page when you need to author, extend, debug, or verify RBAC across the backend/frontend boundary. For Start's operational role, grant, Department, and policy-editor workflow, read RBAC Authorization after this guide.
WARNING
RBAC authority is enforced on the backend. ModelResource.permissions, $passport.checkPermission(...), table-action visibility, and SSR-rendered capability state are browser UX projections. None of them authorize a direct API request.
The shortest accurate mental model
text
@Passport.rbac route metadata
→ shared a-rbac action catalog and guard
→ edition/application policy resolver
→ request-local decision and typed scope access
→ protected backend action and persisted-data checks
same composed guards
→ Resource permission projection endpoint
→ OpenAPI permission DTO
→ ModelSdk / ModelResource
→ ModelPassport / action visibilityThe two paths intentionally share a decision source, but they have different purposes:
| Path | Purpose | Authority |
|---|---|---|
Guard + rbacScope | Admit a request and constrain persisted data | Backend authority |
| Permission projection + matcher | Avoid offering browser actions that will fail | Frontend UX only |
What is shared and what is application policy
The shared a-rbac module does not prescribe a role table, grant table, Department model, Admin UI, or systemAdmin convention. It provides a reusable runtime contract:
- catalog protected route actions;
- run the RBAC guard and validate a decision;
- bind that decision to the current request;
- expose typed data-scope operations to controllers;
- emit resolver and invalidation events; and
- derive a minimized browser permission matcher from an allowed scope.
An edition or application supplies the policy facts and resolver. Cabloy Start's admin-rbac module is one specimen: it resolves roles, grants, and Department facts, persists policy revisions, and provides an administration UI. Those are Start policy choices, not requirements of a-rbac.
1. Opt in explicitly and obtain a stable action identity
A controller action participates in dynamic RBAC only when it carries @Passport.rbac(...) metadata:
typescript
@Web.get()
@Passport.rbac({ dataScope: true })
async select(...) {
// consume the scope below
}The shared RBAC catalog scans registered controller routes for this explicit metadata. An ordinary route does not become a policy action merely because a Resource or frontend UI exposes it.
Each catalogued action has a stable identity:
text
<controllerBeanFullName>#<action>This identity is policy-facing. It is more stable and precise than a displayed menu item, a Resource name, or an HTTP path that might be mounted differently.
Action inheritance
An action can inherit another action's policy inside the same controller. The catalog resolves that relationship centrally and fails closed for:
- a missing target;
- a self-reference;
- an inheritance cycle; or
- an invalid cross-controller alias.
Backend policy inheritance is not the same operation as a frontend permissionHint.actionInherit. The former selects the action policy guarded on the server; the latter selects an emitted action key used by a UI renderer. Keep both metadata paths aligned, but do not assume their similar names make them equivalent automatically.
Representative shared sources
vona/src/suite-vendor/a-vona/modules/a-user/src/lib/passport.ts—Passport.rbac(...)guard facade.vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacCatalog.ts— route discovery, canonical keys, and inheritance validation.vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/lib/rbac.ts— action-key and request-decision helpers.
2. Guard execution and the policy-resolver boundary
GuardRbac executes for a protected route. Its job is not to query a particular role/grant schema; it orchestrates a valid decision:
- clear a prior RBAC decision from the current request context;
- find the current route in the shared catalog;
- merge the catalog/decorator options for that action;
- ask the configured scope adapter whether the subject is unrestricted;
- if not unrestricted, emit
a-rbac:resolvePolicyfor application policy code; - validate that the returned decision is allowed, well formed, and bound to the expected action; and
- store only that validated decision on the current context.
The decision is request-local capability state, stored through the SymbolRbacDecision helpers. It is not a process-global flag, a browser claim, or reusable state for another route.
text
GuardRbac
→ catalog action
→ unrestricted adapter branch
or
a-rbac:resolvePolicy listener
→ validate action-bound decision
→ set request-local decisionExtension contracts
The shared event contracts make policy implementation replaceable:
| Extension point | Shared responsibility | Application responsibility |
|---|---|---|
a-rbac:resolvePolicy | Ask for a decision for a catalog action | Resolve roles, grants, ownership, organizational facts, or another policy model |
| Scope adapter | Ask whether the subject is unrestricted; derive trusted create values | Define unrestricted subjects and trusted business fields |
a-rbac:policyInvalidated | Provide a common invalidation signal | Advance/clear mutable policy state and dependent caches |
The shared role-membership listener bridges a-user:roleMembershipChanged to a-rbac:policyInvalidated. An application that mutates additional policy facts must emit invalidation through its normal durable mutation path.
3. A passing guard is not complete row authorization
After guard admission, a controller must consume the current typed scope. The common injection surface is @Arg.rbacScopeCurrent(); framework extraction obtains app.bean.rbacScope.current() for the exact request action.
| Operation | Required scope operation | Why |
|---|---|---|
| List/select | where(callerWhere) | Preserve the caller filter while adding server scope |
| View/update/delete | checkEntry(entry) | Check the persisted target, not a client claim |
| Bulk mutation | checkEntries(entries) | Ensure every intended target is inside scope |
| Create | ownerValues() | Derive trusted owner/scope fields instead of accepting widening input |
For an atomic multi-ID Resource command, have the command DTO validate its request-list maximum and use a-rbac to validate that the list is nonempty and has no duplicate normalized identities. Then load every requested record through the ordinary instance-scoped model boundary. Require complete one-for-one resolution before calling checkEntries(entries) and before any mutation. This treats missing, soft-deleted, and other-instance records uniformly as absent; do not use an unscoped query merely to distinguish them. Run the full preflight and mutation inside the domain transaction.
a-rbac exposes this.bean.rbacResourceBulk.entries(...) for the reusable integrity and authorization sequence. Pass the facade a fresh, ordinary instance-scoped loader (rather than a cache-backed lookup) from the domain transaction. It preserves the requested order, invokes checkEntries(...) only after complete normal-scoped loading, and emits localized a-rbac scoped errors for invalid ID lists and absent entries.
A canonical Resource bulk-delete controller shape is:
typescript
@Web.post('bulk/delete')
@Api.body(z.null())
@Core.transaction()
@Passport.rbac({ dataScope: true, actionInherit: 'delete' })
async deleteBulk(
@Arg.body() command: DtoRecordDeleteBulk,
@Arg.rbacScopeCurrent() rbacScopeCurrent: IRbacScopeAccess,
) {
const entries = await this.bean.rbacResourceBulk.entries(
command.ids,
ids => this.scope.model.record.select({ where: rbacScopeCurrent.where({ id: ids }) }),
rbacScopeCurrent,
);
await this.scope.service.record.deleteBulk(entries.map(entry => entry.id));
}The DTO must require ids and declare an operation-appropriate maximum. This controller shape makes POST <resource>/bulk/delete compatible with the generic Resource mutation surface while keeping the data-scope preflight and delete in one transaction. A frontend table may hide or disable a selected action as a usability aid, but it must never replace this server-side validation.
A representative list shape is:
typescript
@Web.get()
@Passport.rbac({ dataScope: true })
async select(@Arg.rbacScopeCurrent() rbacScopeCurrent: IRbacScopeAccess) {
return await this.scope.service.record.select({
where: rbacScopeCurrent.where(this.ctx.request.query.where),
});
}The details vary by Resource contract, but the security rules do not:
- caller filters are structurally ANDed with the server predicate;
- permitted scope terms are OR alternatives;
- unrestricted access still keeps the ordinary caller filter; and
- missing, denied, malformed, or action-mismatched request decisions fail closed.
For create operations, never trust a browser-supplied owner or organizational field merely because the browser previously displayed a matching action. Use ownerValues() and validate the persisted result through the same server policy model.
Representative shared sources
vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/guard.rbac.tsvona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacScope.tsvona/src/suite-vendor/a-cabloy/modules/a-rbac/src/types/scope.tsvona/src/suite-vendor/a-vona/modules/a-web/src/lib/decorator/arguments.ts
4. Resource permission projection reuses real guards
The frontend does not receive a parallel grant database. BeanPermission builds a Resource permission snapshot by evaluating the real route guards for the current Passport.
The endpoint path is owned by the Home Base permission controller/service. For a Resource name, BeanPermission:
- maps the Resource to its controller and cached routes;
- omits configured ignored actions (the default excludes
select); - applies the Passport admission precheck: public/authenticated, account-active, and activation state;
- runs the registered
retrievePermissionActionextension point; - evaluates the actual composed guards for the target action; and
- returns an ordinary boolean or an RBAC projection.
Target-route guard simulation
To evaluate a target action without executing its controller, the permission bean temporarily:
- installs the target route as
ctx.route; - sets
ctx.innerAccess = false; - clears any existing request-local RBAC decision;
- calls the real
composeGuards(...)chain; - converts
false,401, and403to a denied permission; and - restores the previous route, inner-access value, and the prior decision's own-property state in
finally.
Unexpected errors are not silently treated as authorization outcomes; they propagate. This isolation is essential: a permission query must not leak or corrupt a decision from the surrounding request action.
Boolean actions and RBAC actions
A permitted ordinary guarded action becomes true; a denied action becomes false. A permitted RBAC action asks the current typed scope for a browser projection:
text
boolean
or
{
key: string,
allowed: boolean,
matcher: { mode: 'all' }
| { mode: 'any', rules: [{ field, values }] }
}permissionProjection() returns all for unrestricted or non-data-scoped access. A restricted decision becomes normalized field/value rules, such as a trusted owner field or a configured organizational field. A restricted decision with no usable matcher rules fails with 403; it does not become a permissive empty projection.
The contract deliberately excludes routes, guard options, raw grant expressions, and policy topology. It is still authorization-derived data: treat projected field names and values as a minimized UX contract, not as information that must remain secret from the current authorized browser.
Representative shared sources
vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.tsvona/src/suite-vendor/a-vona/modules/a-permission/src/dto/permissions.tsxvona/src/suite-vendor/a-vona/modules/a-openapi/src/types/permissions.tsvona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacScope.ts
5. Cache topology and server freshness
Permission projection has three distinct backend cache shapes:
| Result | Cache identity | Why |
|---|---|---|
| Resource snapshot | Resource + current user | The returned action map can be user-specific |
| Ordinary action | Resource + action + sorted unique role IDs | Non-RBAC guard results can be shared by role set |
| RBAC action | Resource + action + current user + sorted unique role IDs | Resolved terms can include user-specific values, such as ownership |
BeanPermission.clearAllCaches() clears all three permission caches. Do not collapse an RBAC result to a role-only cache: two users can hold the same roles but receive different normalized owner/scope matcher values.
Server-side invalidation must follow durable policy mutation. The shared runtime provides a-rbac:policyInvalidated; Start's policy listener is one specimen that advances its policy revision and schedules permission-cache clearing after database commit. A Basic/custom policy implementation must provide equivalent resolver and invalidation wiring for its own mutable facts.
NOTE
Server invalidation refreshes future server permission evaluations. It is not a push channel into already-open browsers.
6. DTO, SDK, and Resource ownership
The permission DTO is emitted through the backend/OpenAPI contract. Generated Home API code owns the generated operation surface, while the generic Resource runtime fetches permissions through ModelSdk.getPermissions(resource) so every Resource can use the same infrastructure.
text
GET /permission/:resource
→ IOpenapiPermissions
→ ModelSdk.getPermissions(resource)
→ ModelResource.permissions
→ page/action render scopeModelResource is selector-scoped by Resource name and owns Resource bootstrap, schemas, query/mutation state, and permissions. Pages, tables, and entry toolbars consume this resource-owned reactive surface instead of creating unrelated permission requests.
Read Model Resource Owner Pattern for ModelResource ownership and reuse. This guide owns the projection producer, transport, matcher, and freshness semantics.
7. Frontend permission decision order
ModelPassport.checkPermission(...) evaluates a permission hint and Resource snapshot in this order:
permissionHint.public === trueallows immediately.- Resolve
permissionHint.actionInherit ?? actionName. - With no resolved action, permit the action-based check.
- Nil permissions or
permissions === falsedeny;permissions === trueallows. - If the resolved action exists in
permissions.actions, use that entry:- boolean entries are final;
- RBAC objects go to
matchPermissionAction(...).
- Only when the selected action entry is absent, try legacy
roleIdsandroleNamesfallback. - Otherwise deny.
An explicit denied action therefore wins over legacy role fallback. A public/actionless hint is UI metadata, not a backend bypass: the eventual API route still runs its own Passport/RBAC guards.
Matcher semantics
matchPermissionAction(...) is deliberately fail closed:
| Projection/data condition | Frontend result |
|---|---|
allowed !== true, missing matcher, malformed rule, or empty any rules | Deny |
{ mode: 'all' } | Allow |
Valid any matcher, no currentData | Allow coarse capability/preflight |
| One record | At least one rule must match that record |
| Multiple records | Every record must match at least one rule |
| Empty record array | Deny |
Rules compare String(record[field]) with string values. With no row data, a valid restricted projection means “some records may support this action”; it does not mean the current or later selected row is known to be authorized.
This explains a normal table pattern:
text
operations-column preflight → no row data → retain a possible action container
row render → actual row → hide actions outside the projected scopeBulk/action implementations that want selected-record semantics must pass those records to the matcher. Current generic UI surfaces may use a coarser no-row-data visibility decision; backend checkEntries(...) remains the final bulk authority.
Representative shared sources
zova/src/suite/a-home/modules/home-passport/src/model/passport.tszova/src/suite/a-home/modules/home-passport/src/lib/permissionActionMatcher.tszova/src/suite/a-home/modules/home-passport/test/lib/permissionActionMatcher.test.ts
8. SSR, hydration, and active-tab freshness
With cookie-backed server identity, Resource bootstrap can load permission state during SSR and transfer it through the query-cache hydration path. The client can start from that request-confirmed projection without an immediate duplicate server render decision.
When SSR cannot use cookies for identity, ModelResource.permissions intentionally remains unavailable on the server. Private action visibility must retain a neutral or fail-closed shell through hydration, then obtain the client-side authoritative projection at an explicit client boundary.
Current freshness limitation
ModelSdk.getPermissions(resource) uses staleTime: Infinity. The current architecture does not subscribe an already-open browser tab to server-side a-rbac:policyInvalidated, and it has no general targeted permission-query invalidation/refetch for an already-mounted page after an administrator changes policy.
Consequences:
- a removed permission can remain visible in an open tab until an explicit refresh boundary;
- a newly granted permission can remain hidden in an open tab until that boundary; and
- neither state bypasses the next backend guard or persisted-data scope check.
Use an application-appropriate refresh, reload, relogin, or explicit query invalidation/refetch boundary after live policy changes. Do not claim that clearing backend permission caches instantly updates a hydrated frontend query.
9. Authoring and debugging checklist
When extending shared RBAC:
- Decorate only actions intended for dynamic RBAC.
- Keep catalog/policy inheritance valid and test every alias.
- Provide an
a-rbac:resolvePolicylistener and scope adapter for the policy facts your application owns. - Consume the typed scope in every protected controller operation; a passing guard alone is not enough for row security.
- Derive create-time ownership with
ownerValues()instead of trusting client fields. - Emit policy invalidation through durable mutations and clear dependent server caches after successful commit.
- Treat matcher fields/values as a minimized browser contract, and keep browser matcher semantics aligned with backend scope semantics.
- Test direct requests separately from menus, buttons, or permission projections.
- Test stale-tab behavior explicitly whenever a policy change must update a live interface.
Verification checklist
- Run the shared
a-rbaccatalog/guard and scope-current tests. - Run permission tests that cover route substitution, request-decision restoration, cache isolation, and denied guards.
- Run frontend matcher tests for malformed projections,
all, restricted one-row checks, and multi-row checks. - Verify anonymous, inactive, granted, ungranted, and scoped direct API requests.
- Verify both cookie-backed and cookie-disabled SSR paths preserve a hydration-safe private-action shell.
- After a policy mutation, verify future server evaluation is fresh and explicitly verify the intended browser refresh boundary.
Read next
- RBAC Authorization — Start roles, grants, five data scopes, and operational policy management.
- Controller AOP Guide — route guard authoring.
- Model Resource Owner Pattern — Resource-owned query and permission state.
- Permission, formScene, and Action Visibility Guide — entry-page scene filtering and action rendering.
- Table Action Visibility and Permission Flow Guide — list-page action consumers.
- Menu Authorization — navigation disclosure, a separate policy domain.