PRD: Resource, Action & Permission Declaration Hierarchy
| Module | Permissions | PRD ID | PRD-HIER-001 |
| Status | Ready to dev | FEAT | HIER |
| Epic | — | Plane | BANA-1572 |
| Date | 2026-06-04 | Version | v1.0 |
| Packages | @nx/identity | URD | HIER |
| Surface | Backend | ||
| Owner | Phát Nguyễn | ||
What the resource, action & declaration hierarchy is
The merchant-scoped RBAC engine supports three inheritance axes BANA has not fully exploited yet: an action axis (manage covers read), a resource axis (an order's refund operation sits under the order; an order's line items sit under the order), and a scope axis (an organizer covers its merchants).
Before this increment, permissions were flat: close to 950 per-operation permission codes, each granted one row at a time. An owner role carried close to 950 grant rows; "headquarters sees every merchant" was faked by writing one membership row per merchant. There was no broad manage/write action, and no convenient way to declare permissions - every module hand-rolled its own declaration. The result was roles that were huge and brittle, slow to grow when a new operation was added, and hard to read back to reason about who could do what.
Three axes nested together
This increment models permissions as a resource tree times an action lattice times a scope tree, so a role collapses to a handful of coarse grants while a role that needs fine-grained access can still grant each operation precisely, and turns declaring a new controller's permissions into a single call.
When an admin grants access to the owner role on the create/edit role screen, they now pick one management grant for the whole Sale module instead of ticking every operation one by one - every subject and operation under Sale is covered automatically. Granting access at the module or subject level unlocks every screen and sub-operation underneath right away, instead of granting each operation one at a time. A headquarters/management role reaches every merchant under its organizer through a single organizer-scoped grant, with no per-merchant membership row required.
An engineer declares a new controller's permissions - its resource, its operations, and their base action - with one call in the code, instead of hand-writing every permission row. When the migration runs at backend startup, permissions and hierarchy edges sync automatically and stay unchanged on repeated runs; a permission code removed from the code declaration gets cleaned up automatically along with its grant rows, so nothing accumulates.
The easiest place to get it wrong
"Manage Sale" is not a wildcard
A grant's coarseness comes from the resource tree plus the manage action, not from a wildcard pattern like "everything under sale". There is no arbitrary wildcard permission in the system - every breadth flows through an explicitly declared hierarchy.
Likewise, a headquarters role's reach through the scope axis (organizer covers merchant) is a separate edge that is still being built - today a headquarters role still needs a membership row per merchant, and cannot reach them through the organizer-scoped grant alone yet.
1. Goals & Non-Goals
Goals
- Collapse a role's grant rows - the owner role becomes one row (Sale, manage) instead of every operation row of a sale order.
- A three-tier action lattice: manage covers {write, read, execute}; write covers {create, update, delete}.
- A resource hierarchy with no code renames - subjects keep their existing identifier codes; grouping happens by module plus cross-entity nesting, and operation nesting happens automatically through dotted codes.
- A scope hierarchy merchant-under-organizer - so a headquarters/management role reaches every merchant of an organizer through one organizer-scoped grant, replacing the need to add a membership row for each merchant.
- A minimal-boilerplate declaration toolkit (one call to declare a resource/permission, a role-to-grant matrix, and structural-edge seeding steps that stay unchanged on repeated runs), built in BANA first. The action lattice, grant variants, and the enforcement engine already exist in the underlying framework; BANA builds the seeding steps, the declaration call, and the matrix on top of that foundation.
Non-Goals
- Porting the declaration toolkit into the platform framework - deferred to a later increment (BANA first, port later).
- Arbitrary wildcard-pattern permissions - coarseness comes from the hierarchy, not from wildcard patterns.
- Changing the role set or the meaning of role priority - a separate concern.
- Time-of-day/shift-based permissions and a permission audit log.
2. Success Metrics
| Metric | Target / signal |
|---|---|
| Grant-row compaction | Grant rows per role drop from around 950 to a few dozen |
| Declaration experience | Adding a new controller takes one declaration call, not a per-operation listing |
| Stability on re-seed | Re-running the seed step changes nothing; a permission code removed from the declaration is cleaned up automatically |
| Authorization-outcome parity | Launchpad and every permission-gated route keep the same allow/deny outcome as the previous flat model (regression-tested) |
| Headquarters reach | A headquarters/management role reaches every merchant of its organizer with no per-merchant membership row |
3. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Platform owner / admin | Grant a coarse, legible row (manage Sale) instead of editing hundreds of grant rows |
| Headquarters owner | Reach every merchant of their organizer through one organizer-scoped grant |
| Engineer | Declare a controller's permission, resource node, and base action in one call |
| System operator | Trust that re-seeding is safe - unchanged on repeated runs, with removed codes cleaned up automatically |
Core scenario: an engineer declares a controller's resource and operations with one call; the startup migration seeds the action lattice, the module roll-up edges, and the organizer-merchant scope edges unchanged on repeated runs; an admin assigns roles as a compact coarse grant matrix; a request is then resolved through the action lattice, the resource tree, and the scope tree without any flat, per-operation listing.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | admin | to grant (Sale, manage) to the owner role | the role covers every subject and operation of Sale without listing each one |
| 02 | headquarters owner | one organizer-scoped grant to reach every merchant of my organizer | I no longer depend on a per-merchant membership row |
| 03 | engineer | to declare a controller's resource node, operations, and base action in one call | adding a new route does not mean hand-writing every permission |
| 04 | system operator | seeding to clean up permission codes removed from the declaration automatically | stale permissions and grant rows never pile up |
| 05 | role designer | a broader action (manage/write) to satisfy any narrower request | I grant by intent rather than by listing every operation verb |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | A grant MAY target a resource at any level - module, subject, or operation. Automatically covers all descendants of that level. | ✅ | URD-HIER-001 |
FR-002 | Operation resources nest under their subject through a dotted code (an order's refund operation sits under the order) with no extra configuration. | ✅ | URD-HIER-002 |
FR-003 | A subject MAY roll up to a module. A module-level grant covers every subject in that module. | ✅ | URD-HIER-003 |
FR-004 | A child entity MAY nest under a parent entity regardless of naming (an order's line items sit under the order). The parent's grant covers the child too. | 🚧 | URD-HIER-004 |
FR-005 | Actions form a lattice - manage covers {write, read, execute}; write covers {create, update, delete}. A broader granted action satisfies any narrower request. | ✅ | URD-HIER-005 |
FR-006 | A route requires one base action (read/create/update/delete/execute). A grant MAY use any tier including manage/write to satisfy it. | ✅ | URD-HIER-006 |
FR-007 | A grant scoped to an organizer applies to every merchant under that organizer. A grant scoped to a merchant applies only there. | 🚧 | URD-HIER-007 |
FR-008 | A headquarters/management role reaches every merchant of its organizer through one organizer-scoped grant. No per-merchant membership row is needed. | 🚧 | URD-HIER-008 |
FR-009 | A grant MAY pin to system-wide scope (everywhere) or to every-merchant-already-joined. | ✅ | URD-HIER-009 |
FR-010 | Permissions, the action lattice, and the resource/scope hierarchy edges are declared in code. They are seeded unchanged on repeated runs. | 🔶 | URD-DECL-001 |
FR-011 | Declaring a controller's operations, resource node, and base action takes one call. | ✅ | URD-DECL-002 |
FR-012 | Role-to-grant assignment is declared as a compact matrix (owner to manage Sale), not a per-operation listing. | ✅ | URD-DECL-003 |
FR-013 | Seeding cleans up permission codes removed from the declaration automatically - stale permissions and grant rows never accumulate. | ✅ | URD-DECL-004 |
FR-014 | The declaration toolkit is built in BANA first. The action lattice, grant variants, and the enforcement engine already exist in the underlying framework. | ✅ | URD-DECL-005 |
5.1 Acceptance Criteria
- Grant (Sale, manage) to a role, then that role can perform every operation on every subject of the Sale module, with no other grant row needed.
- Grant a role read access scoped to one specific subject only (for example, orders); a create request on that same subject is denied, because read does not cover create.
- Add a new operation under an existing subject (for example, an order's refund operation) with no extra declaration; the grant already on the parent subject covers the new operation right away.
- Add a new child entity nested under a differently-named parent (for example, an order's line items); the parent's grant does not automatically cover the child yet - the child must be granted separately.
- Grant a role the write action on a subject; every create/update/delete request on that subject is accepted, and read requests are accepted too through the lattice.
- A route requires the read action on a subject and the role is granted only read there; the request is accepted. A different subject where the role is granted only execute; the request there is denied.
- A headquarters role is granted access scoped to an organizer, then acts on any merchant under that organizer; today this is still denied unless a separate membership row exists for that merchant, and a grant scoped to a different single merchant does not apply.
- A headquarters/management role wants to reach every merchant of its organizer; today it still needs a membership row per merchant, and cannot reach them through the organizer-scoped grant alone yet.
- Grant a row pinned to system-wide scope; it applies at every merchant including one created later. Grant a row pinned to every-merchant-already-joined scope; it applies only at merchants the user is already a member of.
- Declare a module's permissions and resource hierarchy edges in code, then run the migration at startup; permissions, the action lattice, and resource edges sync, while organizer-merchant scope edges are not seeded yet even though the engine already supports them.
- Run the migration repeatedly back to back; nothing new is created and the authorization outcome does not change.
- Add a new controller and declare its resource and operations with one call; the permissions and the route's authorization spec are generated without hand-listing every CRUD operation.
- Assign a role-to-grant with one line in the declared matrix (for example, owner to manage Sale); every owner role gets manage access to Sale right away, with no per-operation loop.
- Remove a permission code from the code declaration and re-run the migration; that code and its grant rows are cleaned out of the system and no longer show up when listing permissions.
6. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Authorization-outcome parity | Resolving coverage across the resource, action, and scope axes keeps the same allow/deny outcome as the previous flat model |
| Stability on re-seed | Structural edges (action / resource / scope) and permission codes seed unchanged on repeated runs; a code removed from the declaration is cleaned up automatically |
| Merchant scope per request | A grant is resolved within exactly one active merchant per request; organizer scope expands down the scope tree, not through per-merchant rows |
| Performance / scale | Grant rows per role collapse from around 950 to a few dozen, shrinking policy-load and per-request enforcement cost |
| Maintainability | One declaration call per controller; no more hand-rolled per-module permission declarations |
| i18n | User-facing permission names and descriptions stay bilingual (English and Vietnamese) |
7. UX & Flows
This increment is authorization plumbing with no dedicated end-user screen. It surfaces through the existing role permission-tree views (on the create/edit role screen), where coarse grants now render as a handful of rows instead of nearly 950. There is no dedicated permission-hierarchy screen; the Permission Hierarchy and Permission Matrix pages are conceptual wiki maps, not app UI.
8. Data & Domain
| Entity | Role |
|---|---|
| Permission (resource code) | Catalog of modules, subjects, and custom operations - no longer close to 950 per-operation rows |
| Action lattice | Inheritance edges across manage/write/read/execute/create/update/delete |
| Resource edges | Module roll-up edges and cross-entity/operation nesting |
| Scope edges | Organizer covers merchant, so an organizer-scoped grant reaches every merchant under it |
| Grant | Role or user, resource, action, scope, allow/deny effect |
| Role-to-grant matrix | Declarative coarse assignment (owner to manage Sale) |
Conceptual only - the full schema and the merchant-scoped RBAC model live in the developer docs RBAC and Casbin Authorization.
9. Dependencies & Assumptions
Depends on
| # | Feature | What it depends on |
|---|---|---|
| 01 | Merchant-scoped RBAC engine | provides the action, resource, and scope axes plus dotted-code operation nesting. |
| 02 | Fixed & custom roles (URD-ROLE · URD-CROLE) | roles are the subject of a grant. |
| 03 | Permission catalog & grant/revoke (URD-PERM · URD-GRANT) | the catalog this hierarchy reshapes. |
| 04 | Effective permissions & scope (URD-EFF) | resolution still stays within exactly one active merchant. |
| 05 | Platform core & identity | the merchant-scoped RBAC model, policy loader, resolution engine (platform core); roles, permissions, grants (identity). |
Assumptions
| # | Assumption | What breaks if wrong |
|---|---|---|
| 01 | Subject codes are stable - the hierarchy is additive on top of existing codes. | If subject codes were unstable, every hierarchy link (module roll-up, dotted-code operation nesting) would attach to the wrong subject, and existing grant rows could cover the wrong resources or miss ones they should cover. |
| 02 | The organizer-merchant relationship is available to seed scope edges from. | Without that relationship, the organizer→merchant scope edge can't be built, and a headquarters role can't reach a child merchant through a single grant. |
| 03 | A regression test suite covers launchpad and every permission-gated route to compare allow/deny outcomes. | Without that suite, there's no way to confirm resolving coverage across the three new axes produces the same allow/deny outcome as the old flat model - a silent regression could slip through. |
10. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P3 - see the URD feature catalog (HIER + DECL) |
| Rollout | Phased: (1) declaration toolkit, action-lattice seeding, collapsing owner/cashier/employee grant rows into coarse per-module rows; (2) the organizer-merchant scope tree and organizer-scoped management grants, replacing per-merchant membership rows; (3) porting the declaration toolkit into the platform framework (a separate PRD) |
| Migration | Data migration: existing per-merchant owner membership rows move to organizer-scoped grants; the structural-edge seed step runs at backend startup |
| Launch criteria | Grant rows per role collapse to a few dozen; re-seeding does not change the outcome; launchpad and every permission-gated route keep identical allow/deny outcomes |
| Monitoring | Grant rows per role, re-seed stability checks, the authorization allow/deny regression suite |
References
- URD: Permissions - Resource, Action & Scope Hierarchy · Permission Declaration
- Builds on: Fixed Roles · Custom Roles · Permission Catalog · Grant/Revoke · Effective Permissions & Scope
- Map: Permission Hierarchy · Permission Matrix
- Module: Permissions - URD
- Developer: @nx/identity · RBAC · Casbin Authorization
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | Coverage resolution drifts from the previous flat model | Regression-test launchpad and every permission-gated route for identical allow/deny outcomes |
| 02 | The exact module roll-up list (which subject belongs to which module) | Declared explicitly per module right where the subject is defined; reviewed whenever a new module is added |
| 03 | Whether the execute action for custom operations is granted per-operation or only folded into manage | Open - decide before extending the action lattice for new custom operations |
| 04 | Migrating existing per-merchant owner membership rows to organizer-scoped grants | Open - the data-migration shape needs to be defined before organizer-merchant scope edges are seeded |
| 05 | Re-seeding could drop a code still in use | Cleanup of stale codes is driven entirely by the code declaration itself; repeated runs do not change the outcome |
Frequently Asked Questions
| # | Question | Answer |
|---|---|---|
| 01 | Does this require renaming subject codes already in use? | No. The hierarchy is additive - subjects keep their existing codes; grouping and nesting happen through resource edges and dotted operation codes. |
| 02 | How is "manage Sale" different from a wildcard pattern like "everything under sale"? | Coarseness comes from the resource tree plus the manage action, not from a wildcard pattern. There is no arbitrary wildcard permission in the system. |
| 03 | How will a headquarters owner reach every merchant? | Through one organizer-scoped grant, resolved down the organizer-merchant scope tree. This is the scope axis of the hierarchy model; today a headquarters role's reach still needs a membership row per merchant. |
| 04 | Does a broader action cover narrower ones? | Yes. Manage covers {write, read, execute} and write covers {create, update, delete}, so a manage grant satisfies any narrower request. |
| 05 | Is the declaration toolkit going into the platform framework? | Not in this increment - it is built in BANA first, with a clear direction to move it into the platform framework in a later PRD. |
| 06 | What happens to a permission code removed from the code declaration? | Seeding cleans it up automatically, along with its grant rows - no stale rows are left behind. |