Role-Based Access Control (RBAC)
Resolve allow or deny access for hierarchical roles and hierarchical resources from a sparse, explicitly assigned access matrix.
ExampleGorbacaccess-controlauthorizationrolesresourceshierarchydagtreeaccess-matrixeffective-accessdatabaseapirestnative-expressionui
π Horizon
RBAC at a Glance
Overview
Many applications need to control access to menus, screens, actions,
and data by role, without hand-coding every combination. This example
models access control as a matrix: one axis of hierarchical Roles,
one axis of hierarchical Resources, and cells that hold an explicit
allow or deny.
Only explicit assignments are stored. Every other cell resolves its
access through inheritance, and a cell that resolves to nothing
defaults to deny β a secure, fail-closed model.
Roles and Resources
Roles form a directed acyclic graph: a role may compose access from one or more parent roles, and inheritance is transitive. Resources form a tree of menus, screens, actions, buttons, and other protected capabilities, optionally instantiated in bulk from a reusable Resource Type template. Both hierarchies drive inheritance independently of one another.
Architecture
The service loads Roles, Resources, and Access Matrices once, compiles them into fast lookup structures, and answers access queries from that compiled representation instead of the database.
What It Demonstrates
-
@datatypeβ enums for access effect, access state, and resolution policy, plus hierarchical Role, Resource, and Resource Type shapes. -
@databaseβ self-referential relations that persist a Role DAG and a Resource tree as sparse, explicit data. -
@apiβ REST endpoints for managing roles, resource types, resources, and access matrices, and for resolving effective access. -
@serviceand@contextβ a runtime access context assembled once at startup and reused for every query. - A custom expression implementation for graph-shaped domain logic that a declarative expression does not comfortably cover.
Expected Result
You will define a role hierarchy and a resource hierarchy, assign explicit access to specific Role Γ Resource cells, and query effective access for any cell β observing explicit, inherited, and default resolution, and how conflicting inherited signals resolve differently under each resolution policy.
π§ Voyage
1. Problem and Constraints
- Roles form a DAG: no self-reference, no cycles, multiple parents allowed.
- Resources form a tree: at most one parent, no cycles.
- Only explicit
allow/denyis persisted; the matrix itself stays sparse. - At most one assignment exists per (matrix, role, resource).
- An explicit cell always wins; conflicting inherited effects resolve per the matrix's
accessResolutionPolicy. - A cell with no resolvable access defaults to
deny. - A Resource Type's structure becomes immutable once used to instantiate a Resource tree.
- Removing or deleting a Role or Resource cascades and removes its assignments; a Role referenced as another Role's parent cannot be deleted.
2. Prerequisites
- The Ocean toolchain, at the version this example targets.
- Docker, to run the generated Postgres database service.
curlandjq, to run the sample-data seed script (optional).- hurl, to run the included component test suite (optional).
3. Example Structure
Executable model files are grouped by DSL section:
0010-rbac/
βββ rbac-context.ocn
βββ config.ocn
βββ database.ocn
βββ datatype/
β βββ datatype.ocn
β βββ impl-datatype.ocn
βββ api/
β βββ rbac-api.ocn
βββ expression/
β βββ access-control.go
β βββ rbac-deploy.ocn
βββ service/
β βββ rbac-service.ocn
βββ ui/
β βββ rbac-ui.html
β βββ nginx.conf
β βββ Dockerfile.txt
βββ info/
β βββ info.ocn
βββ example-info/
βββ example-info.html
api/ also holds a hurl component test suite and
service/ a sample-data seed script for exploring the
console UI; both are documented in their own directories.
4. Model the Domain
Access effect and resolution state are closed enums, so every request
deterministically resolves to allow or deny.
@datatype
enum AccessEffect
allow
deny
enum AccessState
explicit
inherited
default
enum AccessResolutionPolicy
denyOverrides
allowOverrides
Role and Resource carry their own hierarchies directly as typed
relations, and AccessMatrix brings a set of participating
Roles and Resources together with the explicit assignments between
them.
Role
id pattern ROLE-UUID
displayName: String
description: String
parents: List<Role>
Resource
id pattern RSC-UUID
displayName: String
description: String
parent: *Resource
resourceType: ResourceType
AccessAssignment
id pattern AA-UUID
accessMatrixId: String
role: Role
resource: Resource
accessEffect: AccessEffect
EffectiveAccess is a computed shape, never stored: it
pairs the resolved accessEffect with the
accessState that explains how it was reached, and
hasMixedDescendantAccess for display.
5. Persist Roles, Resources, and Assignments
The Role DAG and Resource tree are ordinary self-referential relations. Only explicit assignments are stored, and a unique index enforces at most one assignment per matrix, Role, and Resource.
Entity Role
key(id)
parents -m2m-> Role
Entity Resource
key(id)
indexes: index(parentId), index(resourceTypeId)
resourceType -m2o-> ResourceType
parent -m2o-> Resource
Entity AccessAssignment
key(id)
indexes: unique(accessMatrixId, roleId, resourceId), index(accessMatrixId), index(roleId), index(resourceId)
role -m2o-> Role [not-null]
resource -m2o-> Resource [not-null]
AccessMatrix owns its assignments and declares which
Roles and Resources participate in it; the same Role or Resource may
participate in more than one matrix.
6. Define the API
CRUD endpoints manage Roles, Resource Types, Resources, and Access Matrices. Effective access and explicit assignments are reached only through their owning matrix β they have no independent endpoints.
get /role/{roleId} getRole(roleId:String) : Role
post /role createRole(role:Role) : Role
put /role updateRole(role:Role) : Role
delete /role/{roleId} deleteRole(roleId:String) : _
get /effective-access/{matrixId}/{roleId}/{resourceId} getEffectiveAccess(matrixId:String, roleId:String, resourceId:String) : EffectiveAccess
post /assignments/{matrixId}/{roleId}/{resourceId} setExplicitAssignment(matrixId:String, roleId:String, resourceId:String, input:SetAccessAssignmentInput) : EffectiveAccess
delete /assignments/{matrixId}/{roleId}/{resourceId} removeExplicitAssignment(matrixId:String, roleId:String, resourceId:String) : Void
setExplicitAssignment returns the resulting
EffectiveAccess directly, so a client sees the outcome of
its own write without a second request.
7. Compile Effective Access at Runtime
Resolving access by walking the Role DAG and Resource tree on every
request would mean recursive queries on the request path. Instead,
the service holds a compiled AccessContext in memory β
derived state, rebuilt from the database, never persisted itself.
@context
AccessContext
compiledMatrices: Map<String, CompiledAccessMatrix>
Compiling a matrix means resolving both hierarchies, flattening every
applicable assignment, and applying the matrix's
AccessResolutionPolicy to any conflicting inherited
effects β graph-shaped work that is easier to express directly in
code than as a declarative expression. This example implements that
compilation as a custom expression in an external language, called
like any other Ocean expression.
At service startup, an init expression loads every Role,
Resource, and Access Matrix, compiles them, and stores the result in
the context. From then on, getEffectiveAccess reads
directly from the compiled context rather than the database.
8. Assemble the Service
RbacService
use config RbacConfig as myCfg
impl api RbacApi as api on myCfg.apiConfig.port
use database RbacDb as rbacDb
use context AccessContext as svcCtx
connect myCfg.dbConfig -> rbacDb
use expression ReloadAccessContext
use expression GetEffectiveAccess
use expression SetExplicitAssignment
init ReloadAccessContext
connect api.getEffectiveAccess -> GetEffectiveAccess
connect api.setExplicitAssignment -> SetExplicitAssignment
Plain CRUD endpoints, such as createRole or
listResources, connect straight to database queries and
commands; only access resolution and mutation route through the
compiled context.
9. Select Technologies and Configure Generation
The API is generated with a REST engine on a configurable port, the database uses a packaged Postgres image, and the service depends on it at deploy time.
ApiConfig
port: Int (default=8080)
RbacDeploy
service RbacService
replica 1
export 9099:RbacService.api
dependsOn RbacDatabase
RbacDatabase
service PostgresqlDB
A separate, hand-written single-page console (ui/rbac-ui.html)
is included as a supporting artifact, not generated by Ocean. It is
packaged with its own nginx.conf and
Dockerfile.txt to serve it on port 5500,
the origin the API's CORS policy already allows.
10. Validate, Generate, and Run
- From
0010-rbac/, validate and generate the example with the Ocean toolchain. - Start the generated database and
RbacService; wait for a healthy/health/readyresponse on port9099. - Optionally build and run the console image from
ui/, or openui/rbac-ui.htmldirectly in a browser. - Optionally run
./service/seed-sample-data.sh seedto populate a demo role DAG and menu tree for browsing the console.
11. Verify the Result
Build a three-level role chain and give its ends conflicting explicit assignments on the same resource, to see inheritance and policy resolution at work.
POST /v1/role {"displayName":"base","parents":[]} -> base
POST /v1/role {"displayName":"mid","parents":[{"id":"<base>"}]} -> mid
POST /v1/role {"displayName":"leaf","parents":[{"id":"<mid>"}]} -> leaf
POST /v1/resource {"displayName":"target"} -> target
POST /v1/access-matrix
{"displayName":"deny-wins","accessResolutionPolicy":"denyOverrides",
"roles":[{"id":"<base>"},{"id":"<mid>"},{"id":"<leaf>"}],
"resources":[{"id":"<target>"}]} -> matrix
POST /v1/assignments/<matrix>/<base>/<target> {"accessEffect":"allow"}
POST /v1/assignments/<matrix>/<mid>/<target> {"accessEffect":"deny"}
GET /v1/effective-access/<matrix>/<leaf>/<target>
leaf has no assignment of its own, so it inherits a
conflicting allow from base and
deny from mid. Under
denyOverrides the response is
{"accessEffect":"deny","accessState":"inherited"}; create
an equivalent matrix with allowOverrides and the same
assignments, and the same query for leaf returns
allow instead. A resource with no assignment anywhere
returns {"accessEffect":"deny","accessState":"default"}.
api/30_matrix_and_effective_access.hurl automates exactly
this scenario, plus cleanup; run it with
hurl --test --variable base_url=http://localhost:9099 api/30_matrix_and_effective_access.hurl.
12. Troubleshooting
- Console reports a network error reaching the API: its "API Base URL" field defaults to a placeholder port β update it to the port the generated service actually exports (
http://localhost:9099by default). - Creating, updating, or deleting a Role or Resource returns
409 Conflict: the change would break the DAG or tree invariant β a cycle, self-reference, or a Role still referenced as another Role's parent. getEffectiveAccessreturns404 Not Found: the Role or Resource does not participate in the referenced matrix β add its participation before assigning or resolving access for it.
13. Experiments and Extensions
- Toggle a matrix's
accessResolutionPolicybetweendenyOverridesandallowOverridesagainst the same conflicting data and compare results. - Create a Resource Type such as
StandardOperationsand instantiate it as a Resource'sresourceTypeIdto see a full child subtree generated atomically. - Run
service/seed-sample-data.sh seedfor a larger sample role DAG and menu tree, then explore it through the console. - The compiled context is currently rebuilt once at service startup;
info/80-runtime-compilation.mdoutlines an event-driven refresh for keeping multiple running instances converged after a matrix changes.
Executable model
<\> Implementation
Explore the runnable model by responsibility, then select a file to inspect its complete source.
# Component test: system endpoints.
# The service should report "healthy" on all three health-check variants,
# and /info should return identifying metadata.
GET {{base_url}}/health
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.checks" exists
jsonpath "$.startedAt" exists
GET {{base_url}}/health/live
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
GET {{base_url}}/health/ready
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
GET {{base_url}}/info
HTTP 200
[Asserts]
jsonpath "$.name" exists
jsonpath "$.version" exists
jsonpath "$.startedAt" exists
# Component test: Role CRUD + hierarchy (parents), with capture/chaining between requests.
# Uses a "hurltest-" name prefix so any leftovers from a failed run are easy to spot and
# clean up by hand later.
# --- Create a root role (no parents) ---
POST {{base_url}}/v1/role
{
"displayName": "hurltest-parent-role",
"description": "Component test parent role.",
"parents": []
}
HTTP 200
[Captures]
parent_role_id: jsonpath "$.id"
[Asserts]
jsonpath "$.displayName" == "hurltest-parent-role"
jsonpath "$.parents" count == 0
# --- Create a child role, referencing the captured parent id ---
POST {{base_url}}/v1/role
{
"displayName": "hurltest-child-role",
"description": "Component test child role.",
"parents": [ { "id": "{{parent_role_id}}" } ]
}
HTTP 200
[Captures]
child_role_id: jsonpath "$.id"
[Asserts]
jsonpath "$.parents" count == 1
jsonpath "$.parents[0].id" == "{{parent_role_id}}"
# --- Fetch the child back by id, confirm the hierarchy was actually persisted ---
GET {{base_url}}/v1/role/{{child_role_id}}
HTTP 200
[Asserts]
jsonpath "$.displayName" == "hurltest-child-role"
jsonpath "$.parents[0].id" == "{{parent_role_id}}"
# --- Update the child's description, keeping the same parent link ---
PUT {{base_url}}/v1/role
{
"id": "{{child_role_id}}",
"displayName": "hurltest-child-role",
"description": "Updated by component test.",
"parents": [ { "id": "{{parent_role_id}}" } ]
}
HTTP 200
[Asserts]
jsonpath "$.description" == "Updated by component test."
# --- Confirm the update actually persisted (re-fetch, don't just trust the PUT echo) ---
GET {{base_url}}/v1/role/{{child_role_id}}
HTTP 200
[Asserts]
jsonpath "$.description" == "Updated by component test."
# --- Clean up: delete the child before the parent, since the child references it ---
DELETE {{base_url}}/v1/role/{{child_role_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{parent_role_id}}
HTTP 204
# --- Confirm both are actually gone, not just that DELETE returned 204 ---
GET {{base_url}}/v1/roles/0/500
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{child_role_id}}"
jsonpath "$..id" not contains "{{parent_role_id}}"
# Component test: ResourceType (create/get/delete, includes hierarchy, immutability)
# and Resource (full CRUD, parent/child tree, typed + untyped mix).
# --- Create a parent resource type (no includes) ---
POST {{base_url}}/v1/resource-type
{
"displayName": "hurltest-type-parent",
"description": "Component test parent type.",
"includes": []
}
HTTP 200
[Captures]
parent_type_id: jsonpath "$.id"
[Asserts]
jsonpath "$.displayName" == "hurltest-type-parent"
# --- Create a child resource type that includes the parent type ---
POST {{base_url}}/v1/resource-type
{
"displayName": "hurltest-type-child",
"description": "Component test child type.",
"includes": [ { "id": "{{parent_type_id}}" } ]
}
HTTP 200
[Captures]
child_type_id: jsonpath "$.id"
[Asserts]
jsonpath "$.includes" count == 1
jsonpath "$.includes[0].id" == "{{parent_type_id}}"
# --- Fetch it back, confirm the includes relationship was persisted ---
GET {{base_url}}/v1/resource-type/{{child_type_id}}
HTTP 200
[Asserts]
jsonpath "$.includes[0].id" == "{{parent_type_id}}"
# --- Confirm ResourceType really is immutable: no PUT route exists for it.
# We don't assert the exact status (405 vs 404 depends on the router), just that
# it's rejected as a client error rather than silently succeeding. ---
PUT {{base_url}}/v1/resource-type
{
"id": "{{child_type_id}}",
"displayName": "should-not-be-allowed"
}
HTTP *
[Asserts]
status >= 400
status < 500
# --- Build a small resource tree: untyped root -> typed child -> typed grandchild ---
POST {{base_url}}/v1/resource
{
"displayName": "hurltest-root-resource",
"description": "Untyped root resource."
}
HTTP 200
[Captures]
root_resource_id: jsonpath "$.id"
[Asserts]
jsonpath "$.resourceTypeId" == null
jsonpath "$.parentId" == null
POST {{base_url}}/v1/resource
{
"displayName": "hurltest-child-resource",
"description": "Typed child resource.",
"resourceTypeId": "{{child_type_id}}",
"parentId": "{{root_resource_id}}"
}
HTTP 200
[Captures]
child_resource_id: jsonpath "$.id"
[Asserts]
jsonpath "$.resourceTypeId" == "{{child_type_id}}"
jsonpath "$.parentId" == "{{root_resource_id}}"
POST {{base_url}}/v1/resource
{
"displayName": "hurltest-grandchild-resource",
"description": "Typed grandchild resource.",
"resourceTypeId": "{{parent_type_id}}",
"parentId": "{{child_resource_id}}"
}
HTTP 200
[Captures]
grandchild_resource_id: jsonpath "$.id"
[Asserts]
jsonpath "$.parentId" == "{{child_resource_id}}"
# --- Confirm the tree round-trips correctly via GET ---
GET {{base_url}}/v1/resource/{{grandchild_resource_id}}
HTTP 200
[Asserts]
jsonpath "$.displayName" == "hurltest-grandchild-resource"
jsonpath "$.parentId" == "{{child_resource_id}}"
jsonpath "$.resourceTypeId" == "{{parent_type_id}}"
# --- Unlike ResourceType, Resource does support update ---
PUT {{base_url}}/v1/resource
{
"id": "{{child_resource_id}}",
"displayName": "hurltest-child-resource-renamed",
"description": "Renamed by component test.",
"resourceTypeId": "{{child_type_id}}",
"parentId": "{{root_resource_id}}"
}
HTTP 200
[Asserts]
jsonpath "$.displayName" == "hurltest-child-resource-renamed"
GET {{base_url}}/v1/resource/{{child_resource_id}}
HTTP 200
[Asserts]
jsonpath "$.displayName" == "hurltest-child-resource-renamed"
# --- Clearing resourceTypeId: PUT with the field omitted should set it back to
# NULL, not silently keep the previous value. (ResourceTypeId is now *string,
# matching ParentId's pattern.) ---
PUT {{base_url}}/v1/resource
{
"id": "{{child_resource_id}}",
"displayName": "hurltest-child-resource-renamed",
"description": "Renamed by component test.",
"parentId": "{{root_resource_id}}"
}
HTTP 200
[Asserts]
jsonpath "$.resourceTypeId" == null
GET {{base_url}}/v1/resource/{{child_resource_id}}
HTTP 200
[Asserts]
jsonpath "$.resourceTypeId" == null
# --- Clean up resources leaf-first (grandchild -> child -> root) ---
DELETE {{base_url}}/v1/resource/{{grandchild_resource_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{child_resource_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{root_resource_id}}
HTTP 204
# --- Clean up resource types, child before parent (child references parent via includes) ---
DELETE {{base_url}}/v1/resource-type/{{child_type_id}}
HTTP 204
DELETE {{base_url}}/v1/resource-type/{{parent_type_id}}
HTTP 204
# --- Confirm everything is actually gone ---
GET {{base_url}}/v1/resources/0/500
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{root_resource_id}}"
jsonpath "$..id" not contains "{{child_resource_id}}"
jsonpath "$..id" not contains "{{grandchild_resource_id}}"
GET {{base_url}}/v1/resource-types/0/500
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{parent_type_id}}"
jsonpath "$..id" not contains "{{child_type_id}}"
# Component test: AccessMatrix + assignments + effective-access resolution.
#
# This is the test that actually exercises business logic, not just CRUD plumbing.
# Scenario: a 3-level role chain base -> mid -> leaf, with CONFLICTING explicit
# assignments on base (allow) and mid (deny). "leaf" has no assignment of its own,
# so its effective access depends entirely on how the matrix's policy resolves the
# conflict inherited from its two ancestors. denyOverrides and allowOverrides should
# give OPPOSITE answers for leaf, given the exact same underlying data.
# --- Build a 3-level role chain ---
POST {{base_url}}/v1/role
{ "displayName": "hurltest-role-base", "description": "", "parents": [] }
HTTP 200
[Captures]
role_base_id: jsonpath "$.id"
POST {{base_url}}/v1/role
{ "displayName": "hurltest-role-mid", "description": "", "parents": [ { "id": "{{role_base_id}}" } ] }
HTTP 200
[Captures]
role_mid_id: jsonpath "$.id"
POST {{base_url}}/v1/role
{ "displayName": "hurltest-role-leaf", "description": "", "parents": [ { "id": "{{role_mid_id}}" } ] }
HTTP 200
[Captures]
role_leaf_id: jsonpath "$.id"
# --- Two resources: one we'll assign access to, one we'll leave untouched
# (to confirm the "nothing assigned anywhere" default case separately) ---
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-resource-target", "description": "" }
HTTP 200
[Captures]
resource_target_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-resource-untouched", "description": "" }
HTTP 200
[Captures]
resource_untouched_id: jsonpath "$.id"
# --- Two matrices, same roles/resources, opposite resolution policy ---
POST {{base_url}}/v1/access-matrix
{
"displayName": "hurltest-matrix-deny-overrides",
"description": "",
"accessResolutionPolicy": "denyOverrides",
"roles": [ { "id": "{{role_base_id}}" }, { "id": "{{role_mid_id}}" }, { "id": "{{role_leaf_id}}" } ],
"resources": [ { "id": "{{resource_target_id}}" }, { "id": "{{resource_untouched_id}}" } ]
}
HTTP 200
[Captures]
matrix_deny_id: jsonpath "$.id"
[Asserts]
jsonpath "$.version" == 1
POST {{base_url}}/v1/access-matrix
{
"displayName": "hurltest-matrix-allow-overrides",
"description": "",
"accessResolutionPolicy": "allowOverrides",
"roles": [ { "id": "{{role_base_id}}" }, { "id": "{{role_mid_id}}" }, { "id": "{{role_leaf_id}}" } ],
"resources": [ { "id": "{{resource_target_id}}" }, { "id": "{{resource_untouched_id}}" } ]
}
HTTP 200
[Captures]
matrix_allow_id: jsonpath "$.id"
# ============================================================
# denyOverrides matrix
# ============================================================
# --- Set conflicting explicit assignments: base=allow, mid=deny ---
POST {{base_url}}/v1/assignments/{{matrix_deny_id}}/{{role_base_id}}/{{resource_target_id}}
{ "accessEffect": "allow" }
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "explicit"
POST {{base_url}}/v1/assignments/{{matrix_deny_id}}/{{role_mid_id}}/{{resource_target_id}}
{ "accessEffect": "deny" }
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
jsonpath "$.accessState" == "explicit"
# --- base and mid resolve to exactly what was set explicitly on them ---
GET {{base_url}}/v1/effective-access/{{matrix_deny_id}}/{{role_base_id}}/{{resource_target_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "explicit"
jsonpath "$.hasMixedDescendantAccess" == false
GET {{base_url}}/v1/effective-access/{{matrix_deny_id}}/{{role_mid_id}}/{{resource_target_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
jsonpath "$.accessState" == "explicit"
# --- THE KEY ASSERTION: leaf has no explicit assignment of its own. It inherits
# conflicting signals from both ancestors (allow from base, deny from mid).
# Under denyOverrides, deny must win regardless of which ancestor is "closer". ---
GET {{base_url}}/v1/effective-access/{{matrix_deny_id}}/{{role_leaf_id}}/{{resource_target_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
jsonpath "$.accessState" == "inherited"
jsonpath "$.roleId" == "{{role_leaf_id}}"
jsonpath "$.resourceId" == "{{resource_target_id}}"
jsonpath "$.accessMatrixId" == "{{matrix_deny_id}}"
# --- Resource with zero assignments anywhere in the matrix: pure default case.
# The service is fail-closed by design (see resolveEffects' documented intent
# in access-control-core.go), so "nothing configured" resolves to deny, not
# "unknown" β accessState is what tells you nothing was actually configured. ---
GET {{base_url}}/v1/effective-access/{{matrix_deny_id}}/{{role_leaf_id}}/{{resource_untouched_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
jsonpath "$.accessState" == "default"
# ============================================================
# allowOverrides matrix: same conflicting data, opposite expected outcome
# ============================================================
POST {{base_url}}/v1/assignments/{{matrix_allow_id}}/{{role_base_id}}/{{resource_target_id}}
{ "accessEffect": "allow" }
HTTP 200
POST {{base_url}}/v1/assignments/{{matrix_allow_id}}/{{role_mid_id}}/{{resource_target_id}}
{ "accessEffect": "deny" }
HTTP 200
# --- THE PROOF: identical role/resource/assignment shape as the denyOverrides
# matrix above, but this time allow must win for leaf. If this and the
# denyOverrides assertion above both pass, the policy flag demonstrably
# changes resolution behavior rather than being ignored. ---
GET {{base_url}}/v1/effective-access/{{matrix_allow_id}}/{{role_leaf_id}}/{{resource_target_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "inherited"
# --- Remove mid's explicit deny; mid should fall back to inheriting base's allow ---
DELETE {{base_url}}/v1/assignments/{{matrix_allow_id}}/{{role_mid_id}}/{{resource_target_id}}
HTTP 204
GET {{base_url}}/v1/effective-access/{{matrix_allow_id}}/{{role_mid_id}}/{{resource_target_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "inherited"
# ============================================================
# Cleanup β matrices first (they reference the roles/resources), then
# resources, then roles leaf-first (mirrors the parent/child delete order
# used in the earlier role and resource test files).
# ============================================================
DELETE {{base_url}}/v1/assignments/{{matrix_deny_id}}/{{role_base_id}}/{{resource_target_id}}
HTTP 204
DELETE {{base_url}}/v1/assignments/{{matrix_deny_id}}/{{role_mid_id}}/{{resource_target_id}}
HTTP 204
DELETE {{base_url}}/v1/assignments/{{matrix_allow_id}}/{{role_base_id}}/{{resource_target_id}}
HTTP 204
DELETE {{base_url}}/v1/access-matrix/{{matrix_deny_id}}
HTTP 204
DELETE {{base_url}}/v1/access-matrix/{{matrix_allow_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{resource_target_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{resource_untouched_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{role_leaf_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{role_mid_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{role_base_id}}
HTTP 204
# --- Confirm everything is actually gone ---
GET {{base_url}}/v1/access-matrices/0/500
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{matrix_deny_id}}"
jsonpath "$..id" not contains "{{matrix_allow_id}}"
GET {{base_url}}/v1/roles/0/500
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{role_base_id}}"
jsonpath "$..id" not contains "{{role_mid_id}}"
jsonpath "$..id" not contains "{{role_leaf_id}}"
# Component test: resource-hierarchy inheritance, combined role+resource
# inheritance, and hasMixedDescendantAccess (all untested by earlier files).
# --- Roles: a 2-level chain, to test combined role+resource inheritance ---
POST {{base_url}}/v1/role
{ "displayName": "hurltest-role-parent", "description": "", "parents": [] }
HTTP 200
[Captures]
role_parent_id: jsonpath "$.id"
POST {{base_url}}/v1/role
{ "displayName": "hurltest-role-child", "description": "", "parents": [ { "id": "{{role_parent_id}}" } ] }
HTTP 200
[Captures]
role_child_id: jsonpath "$.id"
# --- Resources: a simple parent/child pair for resource-hierarchy inheritance ---
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-res-parent", "description": "" }
HTTP 200
[Captures]
res_parent_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-res-child", "description": "", "parentId": "{{res_parent_id}}" }
HTTP 200
[Captures]
res_child_id: jsonpath "$.id"
# --- Resources: a parent with two children, for hasMixedDescendantAccess ---
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-res-mixed-parent", "description": "" }
HTTP 200
[Captures]
res_mixed_parent_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-res-mixed-child-a", "description": "", "parentId": "{{res_mixed_parent_id}}" }
HTTP 200
[Captures]
res_mixed_child_a_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-res-mixed-child-b", "description": "", "parentId": "{{res_mixed_parent_id}}" }
HTTP 200
[Captures]
res_mixed_child_b_id: jsonpath "$.id"
# --- One matrix, all entities ---
POST {{base_url}}/v1/access-matrix
{
"displayName": "hurltest-matrix-hierarchy",
"description": "",
"accessResolutionPolicy": "denyOverrides",
"roles": [ { "id": "{{role_parent_id}}" }, { "id": "{{role_child_id}}" } ],
"resources": [
{ "id": "{{res_parent_id}}" }, { "id": "{{res_child_id}}" },
{ "id": "{{res_mixed_parent_id}}" }, { "id": "{{res_mixed_child_a_id}}" }, { "id": "{{res_mixed_child_b_id}}" }
]
}
HTTP 200
[Captures]
matrix_id: jsonpath "$.id"
# --- A single explicit assignment: role_parent on res_parent only ---
POST {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_parent_id}}
{ "accessEffect": "allow" }
HTTP 200
# --- Mixed descendant setup: role_parent, allow on child A, deny on child B ---
POST {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_a_id}}
{ "accessEffect": "allow" }
HTTP 200
POST {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_b_id}}
{ "accessEffect": "deny" }
HTTP 200
# ============================================================
# 1) Resource-hierarchy inheritance alone (same role, no role ancestry involved)
# ============================================================
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_parent_id}}/{{res_child_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "inherited"
# ============================================================
# 2) Combined role + resource inheritance: the only assignment is on
# (role_parent, res_parent) β neither matches (role_child, res_child)
# individually. Both ancestor axes must combine to find it.
# ============================================================
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_child_id}}/{{res_child_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
jsonpath "$.accessState" == "inherited"
# ============================================================
# 3) hasMixedDescendantAccess
# ============================================================
# Sanity: each child resolves to its own explicit setting
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_a_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "allow"
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_b_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
# The parent has no assignment of its own (fail-closed default), but its two
# children disagree (allow vs deny) -> hasMixedDescendantAccess must be true.
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_parent_id}}
HTTP 200
[Asserts]
jsonpath "$.accessEffect" == "deny"
jsonpath "$.accessState" == "default"
jsonpath "$.hasMixedDescendantAccess" == true
# Contrast: res_parent's only descendant (res_child) is NOT mixed (single effect) -> false
GET {{base_url}}/v1/effective-access/{{matrix_id}}/{{role_parent_id}}/{{res_parent_id}}
HTTP 200
[Asserts]
jsonpath "$.hasMixedDescendantAccess" == false
# ============================================================
# Cleanup
# ============================================================
DELETE {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_parent_id}}
HTTP 204
DELETE {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_a_id}}
HTTP 204
DELETE {{base_url}}/v1/assignments/{{matrix_id}}/{{role_parent_id}}/{{res_mixed_child_b_id}}
HTTP 204
DELETE {{base_url}}/v1/access-matrix/{{matrix_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{res_child_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{res_parent_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{res_mixed_child_a_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{res_mixed_child_b_id}}
HTTP 204
DELETE {{base_url}}/v1/resource/{{res_mixed_parent_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{role_child_id}}
HTTP 204
DELETE {{base_url}}/v1/role/{{role_parent_id}}
HTTP 204
# Component test: deleting a role/resource that's still referenced by a matrix.
#
# The Expression* layer (rbac-service-expression.go) looks up matrices referencing
# the role/resource, deletes it anyway, then bumps those matrices' versions β
# suggesting deletion-while-referenced is *intended* to succeed. But the DB-level
# Delete* functions (rbac-service-database.go) are plain deletes with no join-table
# cleanup, same pattern as the ResourceTypeId bug we already found. So the real
# behavior depends on FK constraints we can't see from the Go code alone.
#
# We don't assert a specific status β just that it's not a server crash (5xx).
# Run this and report the actual status; we'll tighten the assertion once we know
# the real (and intended) behavior.
#
# No manual cleanup here β outcomes are uncertain by design, so this relies on
# rbac-test-cleanup.sh (prefix-based, already wired into bootstrap.sh's test
# command) to sweep up whatever's left regardless of what happened.
POST {{base_url}}/v1/role
{ "displayName": "hurltest-del-role-unassigned", "description": "", "parents": [] }
HTTP 200
[Captures]
role_unassigned_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-del-resource-unassigned", "description": "" }
HTTP 200
[Captures]
resource_unassigned_id: jsonpath "$.id"
POST {{base_url}}/v1/role
{ "displayName": "hurltest-del-role-assigned", "description": "", "parents": [] }
HTTP 200
[Captures]
role_assigned_id: jsonpath "$.id"
POST {{base_url}}/v1/resource
{ "displayName": "hurltest-del-resource-assigned", "description": "" }
HTTP 200
[Captures]
resource_assigned_id: jsonpath "$.id"
POST {{base_url}}/v1/access-matrix
{
"displayName": "hurltest-del-matrix",
"description": "",
"accessResolutionPolicy": "denyOverrides",
"roles": [ { "id": "{{role_unassigned_id}}" }, { "id": "{{role_assigned_id}}" } ],
"resources": [ { "id": "{{resource_unassigned_id}}" }, { "id": "{{resource_assigned_id}}" } ]
}
HTTP 200
[Captures]
matrix_id: jsonpath "$.id"
# One pair gets an explicit assignment; the other is only in the matrix's
# roles/resources lists. These may hit different FK constraints.
POST {{base_url}}/v1/assignments/{{matrix_id}}/{{role_assigned_id}}/{{resource_assigned_id}}
{ "accessEffect": "allow" }
HTTP 200
# --- Case 1: role/resource present in matrix.roles/resources, no assignment ---
DELETE {{base_url}}/v1/role/{{role_unassigned_id}}
HTTP *
[Asserts]
status < 500
DELETE {{base_url}}/v1/resource/{{resource_unassigned_id}}
HTTP *
[Asserts]
status < 500
# --- Case 2: role/resource also referenced by an explicit assignment ---
DELETE {{base_url}}/v1/role/{{role_assigned_id}}
HTTP *
[Asserts]
status < 500
DELETE {{base_url}}/v1/resource/{{resource_assigned_id}}
HTTP *
[Asserts]
status < 500
# @ocean-meta-start
# tags:
# - rbac-api
# - rest-api
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@api
@include O.rbac.api@1.0.0
#!/bin/sh
# Deletes all leftover hurltest-* data from a previous failed/partial run.
# Safe to run anytime β deletes nothing that isn't prefixed "hurltest-".
#
# Usage: ./cleanup.sh [base_url]
set -e
BASE="${1:-http://localhost:9099}"
echo "Cleaning up hurltest-* data at $BASE ..."
# --- Matrices: delete their assignments first, then the matrix ---
curl -s "$BASE/v1/access-matrices/0/500" \
| jq -r '.[] | select(.displayName | startswith("hurltest-")) | .id' \
| while read -r matrix_id; do
echo "Matrix: $matrix_id"
curl -s "$BASE/v1/access-matrix/$matrix_id" \
| jq -r '.accessAssignments[]? | "\(.roleId) \(.resourceId)"' \
| while read -r role_id resource_id; do
echo " deleting assignment $role_id / $resource_id"
curl -s -o /dev/null -X DELETE "$BASE/v1/assignments/$matrix_id/$role_id/$resource_id"
done
curl -s -o /dev/null -X DELETE "$BASE/v1/access-matrix/$matrix_id"
done
# --- Resources / roles / resource types can have unknown dependency order
# (parent/child trees, role DAGs). Retry deletion in a loop until a full
# pass makes no progress, instead of hardcoding an order. ---
delete_by_prefix() {
list_path="$1"
delete_path_prefix="$2"
for _ in 1 2 3 4 5; do
ids=$(curl -s "$BASE$list_path" | jq -r '.[] | select(.displayName | startswith("hurltest-")) | .id')
[ -z "$ids" ] && return 0
echo "$ids" | while read -r id; do
curl -s -o /dev/null -X DELETE "$BASE$delete_path_prefix/$id"
done
done
}
delete_by_prefix "/v1/resources/0/500" "/v1/resource"
delete_by_prefix "/v1/roles/0/500" "/v1/role"
delete_by_prefix "/v1/resource-types/0/500" "/v1/resource-type"
echo "Done."
# rbac-service component tests
Black-box HTTP tests for `rbac-service`, written in [Hurl](https://hurl.dev).
Each `.hurl` file is a sequence of real HTTP requests against a running
instance, with assertions on the responses.
## Install
```bash
brew install hurl # macOS
cargo install hurl # via Rust toolchain
# or download a binary: https://github.com/Orangeopensource/hurl/releases
```
Check it's available: `hurl --version`
## What's covered
| File | Covers |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `00_health.hurl` | `/health`, `/health/live`, `/health/ready`, `/info` |
| `10_roles_crud.hurl` | Role create/get/update/delete, parent hierarchy |
| `20_resource_types_and_resources.hurl` | ResourceType create/get/delete (immutable, no update), Resource full CRUD, resource tree |
| `30_matrix_and_effective_access.hurl` | AccessMatrix + assignments + effective-access resolution: explicit, inherited (role hierarchy), default (fail-closed), `denyOverrides` vs `allowOverrides` |
| `40_hierarchy_and_mixed_access.hurl` | Effective-access inheritance via **resource** hierarchy, combined role+resource inheritance, `hasMixedDescendantAccess` |
| `50_delete_while_referenced.hurl` | Deleting a role/resource still referenced by a matrix (with and without an explicit assignment) β behavior not yet confirmed, only asserts no server crash |
## Run
Single file:
```bash
hurl --test --variable base_url=http://localhost:9099 test/component/rbac-service/00_health.hurl
```
Whole suite (files run in the numbered order shown above):
```bash
hurl --test --variable base_url=http://localhost:9099 test/component/rbac-service/*.hurl
```
`--test` gives a pass/fail summary per file and a non-zero exit code on
failure β use that in CI.
## If a run fails partway
Cleanup steps inside a `.hurl` file only run if every earlier assertion in
that file passed. A failed run leaves `hurltest-*` data behind. Run:
```bash
./rbac-test-cleanup.sh http://localhost:9099
```
Safe to run anytime β it only ever deletes entities whose `displayName`
starts with `hurltest-`. Also fine to run before the suite, as a clean-slate step.
## Notes
- **No test isolation.** These run against whatever `rbac-service` +
database `base_url` points at. Every test creates its own data (prefixed
`hurltest-`) and deletes it at the end. If a run fails partway, cleanup is
skipped β check for `hurltest-*` roles/resources/matrices left behind and
remove them by hand before rerunning.
- **Each file is self-contained** and cleans up after itself; they don't
depend on each other's data.
- Tests assert on the service's *documented* behavior where possible (status
codes, schemas from `swagger.yaml`) and on its *actual* logic where the
spec doesn't say β e.g. fail-closed defaulting to `deny`, confirmed against
`access-control-core.go`'s own comments before being asserted on.
# @ocean-meta-start
# tags:
# - configuration
# - inclusion
# perspective:
# feature: rbac
# @ocean-meta-end
@config
@include O.rbac.config@1.0.0
# @ocean-meta-start
# tags:
# - database
# - inclusion
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@database
@include O.rbac.database@1.0.0
# @ocean-meta-start
# tags:
# - inclusion
# - datatype
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@datatype
@include O.rbac.datatype.standard@1.0.0
@include O.rbac.datatype.impl@1.0.0
# @ocean-meta-start
# tags:
# - deployment
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@deploy
Name: RBAC-deploy
@import service P.db.postgres.docker@1.0.0 as PostgresqlDB
RbacDeploy
service RbacService
replica 1
export 9099:RbacService.api
dependsOn RbacDatabase
RbacDatabase
service PostgresqlDB
flowchart TB
subgraph Database["Persistent Storage"]
DB[(Database)]
Roles[Roles]
Resources[Resources]
Matrices[Access Matrices]
Assignments[Access Assignments]
DB --> Roles
DB --> Resources
DB --> Matrices
DB --> Assignments
end
subgraph Service["Service Instance"]
Loader["LoadAccessConfigurationExpression"]
Compiler["CompileAccessMatricesExpression
(External Go Code)"]
Context[(AccessContext)]
Runtime["Runtime Expressions
GetEffectiveAccess
ValidateAccess
..."]
Loader --> Compiler
Compiler --> Context
Context --> Runtime
end
Database --> Loader
Broker[[Message Broker]]
Broker -->|"AccessMatrixUpdated"| Loader
Runtime -->|"No DB access"| Context
flowchart TD
Input["AccessCompilationInput"]
Roles["Roles"]
Resources["Resources"]
Matrices["AccessMatrices"]
Input --> Roles
Input --> Resources
Input --> Matrices
Roles --> Compile
Resources --> Compile
Matrices --> Compile
Compile["CompileAccessMatricesExpression
(External Go Implementation)"]
Compile --> BuildHierarchy["Build Role Hierarchy"]
Compile --> BuildTree["Build Resource Tree"]
BuildHierarchy --> Resolve["Resolve Effective Access"]
BuildTree --> Resolve
Resolve --> Flatten["Create Runtime Lookup Tables"]
Flatten --> Matrix["CompiledAccessMatrix"]
Matrix --> Context["AccessContext"]
# @ocean-meta-start
# tags:
# - expression
# - inclusion
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@expression
@include O.rbac.expression.standard@1.0.0
# Access Control β Core Design
## Concept
Access Control is modeled as a matrix with two hierarchical axes:
- **Role**
- **Resource**
Each cell represents the access of one Role to one Resource.
```text
Resources β
hierarchical
Roles Access Matrix
β Role Γ Resource
hierarchical
```
Both Roles and Resources may have hierarchical relationships.
---
## Access Assignment
Users may explicitly configure access for any **Role Γ Resource** cell.
A cell can explicitly define:
```text
allow
deny
```
If no explicit assignment exists, the cell inherits its access through the applicable Role and Resource hierarchies.
The matrix should be stored sparsely:
- Only explicit `allow` or `deny` assignments are persisted.
- No explicit assignment means access must be resolved through inheritance.
- If nothing can be resolved, the final access is `deny`.
```text
Role Γ Resource
β
βββ Explicit assignment exists
β β
β use it
β
βββ No explicit assignment
β
resolve inheritance
β
nothing resolved
β
deny
```
This provides a secure **default-deny** model.
---
## Effective Access
Effective access is calculated **on demand** for a given:
```text
Role Γ Resource
```
Resolution considers:
1. Explicit access on the requested cell
2. Resource hierarchy
3. Role hierarchy/composition
4. Applicable overrides
5. Default `deny` if nothing resolves
Conflicting inherited values resolve defensively: any `deny` makes the result
`deny`.
---
## AccessEffect
`AccessEffect` represents an explicitly assigned or resolved authorization
effect.
```text
enum AccessEffect
allow
deny
```
An authorization decision therefore always resolves to:
```text
allow | deny
```
There is no `inherit` access type. Inheritance means that no explicit assignment exists and the value must be resolved.
---
## AccessState
`AccessState` describes how the access is configured or represented.
```text
enum AccessState
explicit
inherited
default
```
| State | Meaning |
|-------------|-------------------------------------------------------------------------------------|
| `explicit` | Access is directly configured for this exact Role Γ Resource cell. |
| `inherited` | No explicit assignment exists for the cell; access is resolved through inheritance. |
| `default` | No applicable assignment exists, so access resolves to `deny`. |
Descendant variation is represented independently:
```text
hasMixedDescendantAccess: Boolean
```
It is `true` when descendant Resources contain both effective `allow` and
effective `deny`. It is calculated and is never assigned or persisted.
---
## Explicit vs Effective Access
**Explicit Access** is directly configured by the user.
**Effective Access** is the final `AccessEffect` calculated after resolving
inheritance and overrides.
Example:
```text
ShipmentOrder explicit allow
βββ View inherited β allow
βββ Edit inherited β allow
βββ Delete explicit deny
```
The effective access of `ShipmentOrder` itself remains:
```text
allow
```
Because its descendants do not have uniform effective access:
```text
accessEffect = allow
accessState = explicit
hasMixedDescendantAccess = true
```
---
## Core Rules
1. Roles and Resources are hierarchical.
2. Users may explicitly assign `allow` or `deny` to any Role Γ Resource cell.
3. Cells without an explicit assignment resolve their access through inheritance.
4. Effective access is calculated on demand rather than persisted.
5. If no access can be resolved, the result is `deny`.
6. `AccessEffect` always resolves to either `allow` or `deny`.
7. `AccessState` indicates `explicit`, `inherited`, or `default`.
8. `hasMixedDescendantAccess` is calculated and is never assigned or persisted.
9. An explicit value on a parent may be overridden by a more specific assignment.
10. Conflicting inherited effects resolve to `deny`.
# Access Control β Ubiquitous Language
## Access Control
The capability responsible for defining and resolving access to application resources.
---
## Role
A collection of access settings that can be assigned to users.
A role may:
- define its own access settings;
- compose one or more existing roles;
- override inherited access.
Examples:
- Administrator
- Planner
- Warehouse Operator
---
## Resource
Anything whose access can be controlled.
Resources form a hierarchy and may represent:
- Menus
- Screens
- Actions
- Buttons
- Panels
- Information sections
- Functional options
Example:
```text
ShipmentOrder
βββ View
βββ Create
βββ Edit
βββ Delete
βββ FinancialInformation
```
---
## Resource Type
A reusable template for creating resource hierarchies.
Example:
```text
StandardOperations
βββ View
βββ Create
βββ Edit
βββ Delete
βββ Restore
```
Used by:
```text
ShipmentOrder : StandardOperations
CustomerOrder : StandardOperations
```
For Phase 1, once a Resource Type is used, its structure is **immutable**. Only display-related properties may be changed.
---
## Resource ID
A unique, opaque identifier generated by the system.
Example:
```text
R-234234-sdfsdfsd-wrwer-$#5-sfsdf
```
Clients store and return Resource IDs unchanged. They do not derive them from
display names.
---
## Display Name
The human-readable name shown in the UI.
It may be changed and is not required to be unique.
---
## Access Assignment
The explicit access configured for one **Role Γ Resource** combination.
Example:
```text
Planner Γ ShipmentOrder/Edit = Allow
```
---
## Explicit Access
The `AccessEffect` directly configured for a Role Γ Resource cell.
---
## Access Effect
The assigned or resolved authorization effect:
```text
allow | deny
```
---
## Effective Access
The calculated result after resolving:
- role composition/inheritance;
- resource inheritance;
- explicit overrides.
It contains the final `AccessEffect`, its `AccessState`, and
`hasMixedDescendantAccess`.
---
## Access State
Describes how effective access was resolved:
```text
explicit | inherited | default
```
---
## Access Override
An explicit access assignment that differs from the inherited access.
---
## Mixed Access
A calculated display condition represented by
`hasMixedDescendantAccess: Boolean`. It is `true` when descendant Resources
contain both effective `allow` and effective `deny`.
Example:
```text
ShipmentOrder
βββ View Allow
βββ Edit Allow
βββ Delete Deny
ShipmentOrder.hasMixedDescendantAccess = true
```
---
## Role Composition
The ability to build a role from one or more existing roles.
Example:
```text
PlannerExtended
βββ PlannerBase
βββ ExecutionBase
```
---
## Access Matrix
The conceptual model of the platform.
- **Rows:** Roles (hierarchical)
- **Columns:** Resources (hierarchical)
- **Cells:** Access Assignments
```text
Resources
ShipmentOrder
βββ View
βββ Edit
βββ Delete
Roles
Administrator Allow
Planner Allow
Viewer Deny
```
# Access Control β Domain Datatypes
## Overview
Access Control is modeled around an `AccessMatrix` with two hierarchical axes:
- **Roles** form a Directed Acyclic Graph (DAG).
- **Resources** form a tree.
Roles and Resources are shared domain objects that may participate in multiple
matrices. A matrix stores only explicit assignments; effective access is
calculated on demand through the Role and Resource hierarchies.
```text
AccessMatrix
βββ participating Roles [DAG]
βββ participating Resources [Tree]
βββ AccessAssignments [Role Γ Resource]
```
Operations such as `View`, `Create`, and `Delete` are modeled as concrete
Resources (e.g. check [ResourceType](#resourcetype)). They are not access types.
---
# Enums
## AccessEffect
The authorization effect assigned to or resolved for a Role Γ Resource cell.
```text
enum AccessEffect
allow
deny
```
Every effective-access result is either `allow` or `deny`. If no explicit or
inherited effect can be resolved, the result is `deny`.
## AccessState
Describes how the effect of the requested Role Γ Resource cell was resolved.
```text
enum AccessState
explicit
inherited
default
```
| Value | Meaning |
|-------------|--------------------------------------------------------------------|
| `explicit` | An assignment exists for the exact Role Γ Resource cell. |
| `inherited` | The effect was resolved through the Role DAG and/or Resource tree. |
| `default` | No applicable assignment was found, so access defaulted to `deny`. |
`AccessState` is derived and is never persisted as part of an assignment.
Whether descendants have different effective effects is a separate fact,
represented by `hasMixedDescendantAccess` on `EffectiveAccess`. This keeps the
origin of the current cell visible even when its descendants differ.
## AccessResolutionPolicy
Defines how conflicting inherited access effects are resolved when multiple
applicable assignments produce different results.
```text
enum AccessResolutionPolicy
denyOverrides
allowOverrides
```
| Value | Meaning |
|------------------|---------------------------------------------------------------|
| `denyOverrides` | Any applicable `deny` causes the final result to be `deny`. |
| `allowOverrides` | Any applicable `allow` causes the final result to be `allow`. |
The selected policy is applied only when inherited access effects conflict.
Explicit assignments always take precedence over inherited ones.
---
# AccessMatrix
Defines an independent access-control context.
```text
AccessMatrix
id: String
displayName: String
description: String
accessResolutionPolicy: AccessResolutionPolicy
roleIds: List<String>
resourceIds: List<String>
accessAssignments: List<AccessAssignment>
```
### Rules
- A Role or Resource may participate in multiple matrices.
- An AccessAssignment is scoped to exactly one matrix.
- An assignment may reference only a participating Role and concrete Resource.
- Every AccessMatrix defines exactly one AccessResolutionPolicy.
- Adding a Role or Resource to a matrix does not grant access.
- Removing a Role or Resource from a matrix automatically removes its
assignments from that matrix.
- Multiple independent matrices may exist.
---
# Role
A subject that can receive assignments and inherit access from other Roles.
```text
Role
id: String
displayName: String
description: String
parentRoleIds: List<String>
```
Roles form a DAG. A Role may have no parent, one parent, or multiple parents.
Inheritance is transitive across direct and indirect parents.
```text
PlanSimple ββββββββ
βββ PlannerExtended
ExecuteSimple βββββ
```
### Rules
- A Role cannot inherit from itself.
- Circular inheritance is forbidden.
- An exact assignment for a Role Γ Resource cell takes precedence over
inherited assignments.
- Conflicting inherited effects are resolved according to the matrix's AccessResolutionPolicy.
- A Role cannot be deleted while another Role references it as a parent.
- Deleting a Role removes its matrix participation and assignments from every
matrix.
---
# ResourceType
A reusable template for creating concrete Resource trees.
```text
ResourceType
id: String
displayName: String
description: String
resources: List<String>
includes: List<String>
```
Example:
```text
ResourceType: StandardOperations
βββ View
βββ Modify
β βββ Create
β βββ Edit
β βββ Delete
βββ Restore
```
Using a `ResourceType` creates a new tree of concrete Resources. The same
template may be reused to create multiple independent trees.
### Phase 1 Rules
- A ResourceType is optional when creating a Resource tree.
- 'resources' contains stable Resource field names.
- 'includes' contains ResourceType IDs.
- Both lists may be empty.
- Inclusion is transitive.
- Direct and indirect self-inclusion are forbidden.
- Duplicate field names after resolving 'includes' are forbidden.
- Once used, 'resources' and 'includes' are immutable.
- Once used, its structure is immutable.
- Structural nodes cannot be added, removed, moved, or structurally renamed
after first use.
- Display-related properties may still change.
- Migrating existing Resources to a changed template is outside Phase 1.
- Access assignments cannot target a ResourceType.
---
# Resource
A concrete item whose access can be controlled.
```text
Resource
id: String
displayName: String
description: String
parentId: String?
resourceTypeId: String?
```
Resources form a tree and may represent menus, screens, actions, buttons,
panels, information sections, or other protected capabilities.
When a tree is instantiated from a ResourceType, its root retains the optional
`resourceTypeId`. Its descendants are concrete Resources linked through
`parentId`.
### Rules
- A Resource has at most one parent.
- A root Resource has no parent.
- Resource hierarchies are acyclic.
- Display names may change independently.
- A Resource may exist without a ResourceType.
- Deleting a Resource must preserve a valid tree and removes its matrix
participation and assignments from every matrix.
---
# AccessAssignment
An explicitly configured effect for one Role Γ concrete Resource cell in one
AccessMatrix.
```text
AccessAssignment
id: String
accessMatrixId: String
roleId: String
resourceId: String
accessEffect: AccessEffect
```
The following combination is unique:
```text
(accessMatrixId, roleId, resourceId)
```
### Rules
- The referenced Role and Resource must participate in the referenced matrix.
- The Resource must be concrete; assignments cannot target a ResourceType.
- At most one assignment exists for a matrix, Role, and Resource combination,
regardless of how many parents the Role has.
- Only explicit `allow` or `deny` effects are stored.
- There is no stored `inherit` effect.
- Removing an assignment returns the cell to inherited resolution; it does not
create a denial.
---
# EffectiveAccess
The calculated access result for one Role Γ Resource cell in one AccessMatrix.
It is derived on demand and is not persisted.
```text
EffectiveAccess
accessMatrixId: String
roleId: String
resourceId: String
accessEffect: AccessEffect
accessState: AccessState
hasMixedDescendantAccess: Boolean
```
Example:
```text
WarehouseOperator Γ Inventory
Inventory explicit allow
βββ Stock inherited allow
βββ CostInformation explicit deny
```
For `Inventory`:
```text
accessEffect = allow
accessState = explicit
hasMixedDescendantAccess = true
```
---
# Access Resolution
Effective access is resolved only from a valid model:
1. If the exact Role Γ Resource cell has an assignment, use its effect and
return `accessState = explicit`.
2. Otherwise, collect applicable assignments through the Role DAG and Resource
tree.
3. If inherited assignments apply and produce conflicting effects, resolve them according to the matrix's
AccessResolutionPolicy.
4. If no assignment applies, return `deny` with `accessState = default`.
5. Independently determine whether descendants have mixed effective effects.
Thus every valid request deterministically resolves to:
```text
AccessEffect = allow | deny
```
---
# Domain Relationships
```text
Role [DAG] ββββββββββββββββ
β participates in
βΌ
AccessMatrix
β²
β participates in
Resource [Tree] ββββββββββ
β²
β optionally instantiated from
β
ResourceType
AccessMatrix
βββ AccessAssignment
βββ Role
βββ concrete Resource
βββ AccessEffect
AccessMatrix Γ Role Γ Resource
βββ EffectiveAccess
βββ AccessEffect
βββ AccessState
βββ hasMixedDescendantAccess
```
## Domain Datatypes
```text
AccessEffect enum: allow | deny
AccessState enum: explicit | inherited | default
AccessMatrix access-control context
Role DAG node
ResourceType reusable Resource-tree template
Resource concrete tree node
AccessAssignment explicit matrix cell
EffectiveAccess calculated matrix cell
```
# Access Control β AccessMatrix Domain Behavior
## Purpose
An `AccessMatrix` defines an access-control context by bringing together shared
Roles, shared Resources, and explicit access assignments.
Roles and Resources do not belong exclusively to a matrix. They may participate
in multiple matrices. An `AccessAssignment`, however, is scoped to exactly one
matrix.
## Operations
### Add Role participation
Adds an existing Role to the matrix.
- The Role must be valid.
- The Role must not already participate in the matrix.
- No access is granted by adding it.
### Remove Role participation
Removes a Role from the matrix and automatically removes all assignments for
that Role in the matrix.
This does not delete the shared Role. Deleting the Role itself is a separate
domain operation and is allowed only when no other Role names it as a parent.
Deleting a Role also removes its participation and assignments from every
matrix.
### Add Resource participation
Adds an existing concrete Resource to the matrix.
- The Resource must be valid.
- The Resource must not already participate in the matrix.
- No access is granted by adding it.
### Remove Resource participation
Removes a Resource from the matrix and automatically removes all assignments
for that Resource in the matrix.
This does not delete the shared Resource. Deleting a Resource itself is a
separate domain operation. Deletion must preserve a valid Resource tree and
remove the Resource's participation and assignments from every matrix.
### Set explicit access
Creates or replaces the explicit assignment for a Role Γ Resource cell.
- The Role and concrete Resource must participate in the matrix.
- The value must be `allow` or `deny`.
- At most one assignment may exist for a matrix, Role, and Resource.
- Assignments cannot target a `ResourceType`.
### Remove explicit access
Removes the assignment for a Role Γ Resource cell, causing access to be
resolved through inheritance again.
Removing an assignment is not the same as explicitly denying access.
### Resolve effective access
Returns one `EffectiveAccess` for a Role Γ Resource cell:
```text
EffectiveAccess
accessEffect: allow | deny
accessState: explicit | inherited | default
hasMixedDescendantAccess: Boolean
```
Resolution rules:
1. An assignment on the exact Role Γ Resource cell takes precedence.
2. Otherwise, applicable assignments are collected through the Role DAG and
Resource tree.
3. Multiple inherited results are combined defensively: the result is `allow`
only when every applicable result is `allow`; any `deny` makes the result
`deny`.
4. If no assignment can be resolved, the result is `deny`.
5. `explicit` means the exact cell determined the result.
6. `inherited` means Role and/or Resource inheritance determined the result.
7. `hasMixedDescendantAccess` is `true` when descendant Resources contain both
effective `allow` and effective `deny`; otherwise it is `false`.
Effective access and its explanation are one operation. `accessState` describes
how the result was obtained; a separate explain operation is not required.
### Validate
Confirms that the matrix and all referenced structures satisfy the domain
invariants. Effective access may be resolved only for a valid matrix.
## Invariants
1. Roles form a DAG: self-reference and cycles are forbidden.
2. Resources form a tree: each Resource has at most one parent and cycles are
forbidden.
3. Roles and Resources may participate in multiple matrices.
4. An assignment belongs to exactly one matrix.
5. An assignment references only a Role and concrete Resource participating in
that matrix.
6. `(accessMatrixId, roleId, resourceId)` is unique, regardless of the Role's
number of parents.
7. Only explicit `allow` or `deny` assignments are stored.
8. `AccessState` and `hasMixedDescendantAccess` are derived and are never stored
as assignments.
9. Explicit access on the exact cell takes precedence over inherited access.
10. Conflicting inherited access resolves to `deny`.
11. Missing access resolves to `deny`.
12. Removing or deleting a participating Role or Resource automatically removes
affected assignments, leaving no dangling references.
13. A Role cannot be deleted while another Role references it as a parent.
14. Effective access is deterministic and may be calculated only from a valid
model.
## Related domain behavior
The following behavior belongs to the respective concepts rather than to
`AccessMatrix`:
- `Role`: manage parent Roles while preserving a DAG.
- `Resource`: manage parent/child relationships while preserving a tree.
- `ResourceType`: create reusable Resource-tree templates and enforce their
immutability after first use.
- `AccessAssignment`: represent one explicit matrix cell whose effect is
`allow` or `deny`.
- `EffectiveAccess`: represent the calculated result and its resolution state.
# Access Control β Access Resolution
## Purpose
Access resolution calculates `EffectiveAccess` for one Role Γ Resource cell
within an `AccessMatrix`.
```text
EffectiveAccess
accessMatrixId: String
roleId: String
resourceId: String
accessEffect: AccessEffect
accessState: AccessState
hasMixedDescendantAccess: Boolean
```
Resolution is performed only against a valid domain model.
## Resolution Policy
Conflict resolution uses the `AccessResolutionPolicy` configured on the
`AccessMatrix` (see Domain Datatypes).
The policy is consulted only when multiple inherited access effects conflict.
Explicit assignments always take precedence over inherited assignments.
## Resolution Rules
1. If an assignment exists for the exact Role Γ Resource cell, use it:
- `accessEffect` is the assigned `allow` or `deny`.
- `accessState` is `explicit`.
2. Otherwise, collect applicable assignments through:
- direct and indirect parent Roles; and
- direct and indirect parent Resources.
3. If inherited assignments apply:
- if all inherited effects are identical, use that effect;
- otherwise, resolve the conflicting inherited effects according to the
`AccessMatrix`'s `AccessResolutionPolicy`;
- `accessState` is `inherited`.
4. If no explicit or inherited assignment applies:
- `accessEffect` is `deny`;
- `accessState` is `default`.
5. Calculate `hasMixedDescendantAccess` independently from the current cell:
- `true` when descendant Resources contain both effective `allow` and
effective `deny`;
- `false` otherwise.
`hasMixedDescendantAccess` is derived for display and is never assigned or
persisted. A leaf Resource always has `hasMixedDescendantAccess = false`.
## Decision Table
| Exact assignment | Inherited assignments | Result |
|------------------|-----------------------|--------------------------------------------------------------|
| `allow` | Any | `allow` (`explicit`) |
| `deny` | Any | `deny` (`explicit`) |
| None | All `allow` | `allow` (`inherited`) |
| None | All `deny` | `deny` (`inherited`) |
| None | Mixed | Resolved according to `AccessResolutionPolicy` (`inherited`) |
| None | None | `deny` (`default`) |
## Invariants
- Resolution always returns `allow` or `deny`.
- Exact assignments take precedence over inherited assignments.
- Conflicting inherited effects are resolved according to the `AccessMatrix`'s `AccessResolutionPolicy`.
- Missing access always resolves to `deny`.
- The same valid model and input always produce the same result.# Access Control β API
## Purpose
This API manages the Access Control domain and resolves effective access. It
exposes domain behavior without exposing persistence details.
All identifiers are opaque strings. Examples use JSON over HTTP.
---
# Roles
```text
POST /roles
GET /roles
GET /roles/{roleId}
PUT /roles/{roleId}
DELETE /roles/{roleId}
```
- `POST` creates a Role.
- `GET /roles` returns all Roles.
- `GET /roles/{roleId}` returns one Role.
- `PUT` replaces the Role's editable state, including its complete
`parentRoleIds` list.
- `DELETE` deletes the Role.
Role changes must preserve a DAG. A Role cannot be deleted while another Role
references it as a parent. Deleting a Role removes its matrix participation and
assignments from every matrix.
```json
{
"displayName": "Extended Planner",
"description": "Extended planning permissions",
"parentRoleIds": [
"plan-simple",
"execute-simple"
]
}
```
---
# ResourceTypes
```text
POST /resource-types
GET /resource-types
GET /resource-types/{resourceTypeId}
DELETE /resource-types/{resourceTypeId}
```
- `POST` creates a ResourceType.
- `GET /resource-types` returns all ResourceTypes.
- `GET /resource-types/{resourceTypeId}` returns one ResourceType.
- `DELETE` deletes it when domain rules permit.
```json
{
"displayName": "Standard Operations",
"description": "Common resource fields",
"resources": [
"View",
"Restore"
],
"includes": [
"modify-operations"
]
}
```
Both `resources` and `includes` may be empty. Includes must reference existing
ResourceTypes and must not create a cycle. Once a ResourceType is used, its
`resources` and `includes` cannot change.
---
# Resources
```text
POST /resources
GET /resources
GET /resources/{resourceId}
PUT /resources/{resourceId}
DELETE /resources/{resourceId}
```
- `POST` creates a simple or compound concrete Resource.
- `GET /resources` returns all Resources.
- `GET /resources/{resourceId}` returns its ancestor path and complete concrete
subtree.
- `PUT` replaces its editable state.
- `DELETE` deletes the Resource.
Resource changes must preserve a tree. Deleting a Resource removes its matrix
participation and assignments from every matrix.
## Create a Resource
```json
{
"displayName": "Inventory",
"description": "Inventory functions",
"parentId": null,
"resourceTypeId": "standard-operations"
}
```
When `resourceTypeId` is absent, the system creates one concrete Resource.
When `resourceTypeId` is present, the system creates a compound Resource:
1. It creates the requested root Resource.
2. It expands the referenced ResourceType, including any included
ResourceTypes.
3. It creates one concrete child Resource for every expanded Resource field.
4. It assigns a unique, opaque, system-generated ID to every Resource.
5. It sets the parent relationships to produce the Resource tree.
The complete tree is created atomically: either every Resource is created or
none is created.
Example result:
```text
Inventory id: <generated>
βββ View id: <generated>
βββ Modify id: <generated>
βββ Delete id: <generated>
```
Every generated node is an ordinary concrete Resource. Its ID can be used
directly with the existing explicit-assignment and effective-access endpoints.
Clients discover generated IDs from the create response or by retrieving the
Resource tree; they must not derive IDs from display names.
## Get a Resource tree
`GET /resources/{resourceId}` returns:
- `ancestorPath`: the single path from the root to the immediate parent, in
that order;
- `resource`: the requested Resource with all descendants nested under
`children`.
```json
{
"ancestorPath": [
{
"id": "planning-id",
"displayName": "Planning"
},
{
"id": "functions-id",
"displayName": "Functions"
}
],
"resource": {
"id": "inventory-id",
"displayName": "Inventory",
"description": "Inventory functions",
"parentId": "functions-id",
"resourceTypeId": "standard-operations",
"children": [
{
"id": "view-id",
"displayName": "View",
"children": []
},
{
"id": "modify-id",
"displayName": "Modify",
"children": []
}
]
}
}
```
In the example above:
- `Planning` is the parent of `Functions`.
- `Functions` is the parent of the requested `Inventory`.
- `Inventory` is not included in `ancestorPath`; it appears in `resource`.
Each Resource has at most one direct parent, so `ancestorPath` is one linear
chain, never multiple parent branches (e.g. `Planning β Functions β Inventory`). Ancestor sibling branches are not
returned. A root Resource has an empty `ancestorPath`, and a leaf Resource has
an empty `children` list.
---
# AccessMatrix
```text
POST /access-matrices
GET /access-matrices/{matrixId}
GET /access-matrices/{matrixId}/effective-access/{roleId}/{resourceId}
POST /access-matrices/{matrixId}/assignments/{roleId}/{resourceId}
DELETE /access-matrices/{matrixId}
```
## Create
Creates an AccessMatrix and defines its participating Roles and Resources.
```json
{
"displayName": "Planning",
"description": "Planning access policy",
"roleIds": [
"planner",
"viewer"
],
"resourceIds": [
"inventory",
"orders"
]
}
```
## Get full matrix
Returns the complete matrix definition, including participating Role IDs,
Resource IDs, and explicit assignments.
## Get effective access
Returns the calculated `EffectiveAccess` for exactly one Role Γ Resource cell.
```json
{
"roleId": "planner",
"resourceId": "inventory",
"accessEffect": "allow",
"accessState": "inherited",
"hasMixedDescendantAccess": true
}
```
## Delete
Deletes the matrix and all of its explicit assignments. Shared Roles and
Resources are not deleted.
## Set explicit assignment
Creates or updates the unique explicit assignment for a Role Γ Resource cell.
The Role and Resource are identified by the endpoint path.
```json
{
"accessEffect": "allow"
}
```
The Role and concrete Resource must participate in the matrix.
To remove the explicit assignment and return the cell to inherited or default
resolution, send `null` as the effect:
```json
{
"accessEffect": null
}
```
`null` is an API command and is never stored as an AccessEffect.
Explicit assignments and effective access are always accessed through their
AccessMatrix; they do not have independent top-level endpoints.
---
# Responses and Errors
| Result | Status |
|-----------------|-------------------|
| Created | `201 Created` |
| Read or updated | `200 OK` |
| Deleted | `204 No Content` |
| Invalid request | `400 Bad Request` |
| Not found | `404 Not Found` |
| Domain conflict | `409 Conflict` |
Errors contain a stable code and a readable message:
```json
{
"code": "role_hierarchy_cycle",
"message": "The requested change would create a Role cycle."
}
```
# Access Control β Database Model
## Purpose
This document defines the logical database entities and their supported
commands and queries. It is independent of a specific database technology.
The database stores only explicit assignments. `EffectiveAccess`,
`AccessState`, and `hasMixedDescendantAccess` are calculated and are not persisted.
---
# ResourceType
Defines a reusable set of Resource fields and included ResourceTypes.
```text
ResourceType
id: String
displayName: String
description: String
resources: List<String>
includes: List<String>
```
`resources` contains Resource field names. `includes` contains ResourceType IDs.
Both lists may be empty.
## Relations
- `ResourceType --many-to-many--> ResourceType`
## Commands
### create
Creates the ResourceType with its complete `resources` and `includes` lists.
Rules:
- Every included ResourceType must exist.
- A ResourceType cannot include itself directly or indirectly.
- Resolving included ResourceTypes must not produce duplicate Resource fields.
- Creation is atomic.
### delete
Deletes the ResourceType.
Deletion is rejected when the ResourceType:
- is used by a Resource; or
- is included by another ResourceType.
## Queries
### findAll
Returns all ResourceTypes.
### findById
Returns one complete ResourceType, including `resources` and `includes`.
---
# Resource
Represents one concrete node in a Resource tree.
```text
Resource
id: String
displayName: String
description: String
parentId: String?
resourceTypeId: String?
```
`parentId` references another Resource. A `null` value identifies a root.
`resourceTypeId` references the optional ResourceType used to create a compound
Resource.
## Relations
- `Resource --many-to-one--> ResourceType`
- `Resource --many-to-one--> Resource`
## Commands
### create
Creates a simple or compound Resource.
When `resourceTypeId` is absent, one concrete Resource is created.
When `resourceTypeId` is present, creation:
1. resolves the ResourceType and all included ResourceTypes;
2. creates the requested root Resource;
3. creates one concrete Resource for every resolved Resource field;
4. assigns a generated ID to every Resource; and
5. sets the parent relationships.
Compound creation is atomic.
### update
Updates the editable state of a Resource. The resulting structure must remain a
valid tree.
### delete
Deletes the Resource and its complete subtree. Matrix participation and
assignments for every deleted Resource are removed automatically.
## Queries
### findById
Returns:
- the single ancestor path from the root to the immediate parent; and
- the requested Resource with its complete descendant subtree.
### findAll
Returns all Resources.
---
# Role
Represents a Role in the Role DAG.
```text
Role
id: String
displayName: String
description: String
parentRoleIds: List<String>
```
## Relations
- `Role --many-to-many--> Role`
## Commands
### create
Creates a Role with its complete parent list.
Every parent Role must exist. Self-reference and direct or indirect cycles are
forbidden.
### update
Updates the editable Role state and replaces its complete `parentRoleIds` list.
The resulting Role graph must remain a DAG.
### delete
Deletes the Role.
Deletion is rejected when another Role references it as a parent. Otherwise,
its matrix participation and assignments are removed automatically.
## Queries
### findById
Returns one Role, including its complete `parentRoleIds` list.
### findAll
Returns all Roles.
---
# AccessMatrix
Defines one independent access-control context.
```text
AccessMatrix
id: String
displayName: String
description: String
roleIds: List<String>
resourceIds: List<String>
accessAssignments: List<String>
```
Roles and Resources are shared entities and may participate in multiple
matrices.
## Relations
- `AccessMatrix --many-to-many--> Role`
- `AccessMatrix --many-to-many--> Resource`
- `AccessMatrix --one-to-many--> AccessAssignment`
## Commands
### create
Creates an AccessMatrix with its complete participating Role and Resource lists.
All referenced Roles and Resources must exist. Creation is atomic.
### update
Updates matrix metadata and replaces its complete `roleIds` and `resourceIds`
lists.
Assignments affected by removed Role or Resource participation are deleted
automatically. All remaining assignments must reference participating entities.
### delete
Deletes the AccessMatrix and all its AccessAssignments. Shared Roles and
Resources are not deleted.
## Queries
### findById
Returns the full matrix, including participating Role IDs, Resource IDs, and
explicit AccessAssignments.
### findAll
Returns all AccessMatrices.
---
# AccessAssignment
Stores one explicit access effect for an AccessMatrix Γ Role Γ Resource cell.
```text
AccessAssignment
id: String
accessMatrixId: String
roleId: String
resourceId: String
accessEffect: AccessEffect
```
The following combination is unique:
```text
(accessMatrixId, roleId, resourceId)
```
The referenced Role and concrete Resource must participate in the referenced
AccessMatrix. Only `allow` and `deny` are stored.
## Relations
- `AccessAssignment --many-to-one--> AccessMatrix`
- `AccessAssignment --many-to-one--> Role`
- `AccessAssignment --many-to-one--> Resource`
## Commands
### create
Creates an explicit AccessAssignment for a cell that does not already have one.
### update
Updates the `accessEffect` of an existing AccessAssignment. Its matrix, Role,
and Resource identity do not change.
### delete
Deletes the explicit AccessAssignment and returns the cell to inherited or
default resolution.
## Queries
### findById
Returns one AccessAssignment by its ID.
### findByMatrixId
Returns all explicit assignments in an AccessMatrix.
### findByMatrixIdAndRoleId
Returns all explicit assignments for one Role in an AccessMatrix.
### findByMatrixIdAndResourceId
Returns all explicit assignments for one Resource in an AccessMatrix.
### findByMatrixIdAndRoleIdAndResourceId
Returns the explicit assignment for one exact AccessMatrix Γ Role Γ Resource
cell, or no result when the cell has no explicit assignment.
---
# Referential Rules
- ResourceType inclusion must remain acyclic.
- Resources must remain a tree.
- Roles must remain a DAG.
- Deleting an AccessMatrix cascades to its AccessAssignments.
- Deleting a Role or Resource removes its matrix participation and assignments.
- A Role cannot be deleted while another Role references it as a parent.
- A ResourceType cannot be deleted while it is used or included.
- Effective access is never persisted.
# Access Control Runtime Compilation
## Overview
The Access Control model is intentionally designed around a simple and expressive domain model consisting of:
- Access Matrices
- Roles (DAG)
- Resources (tree)
- Explicit Access Assignments
This model is easy to understand and maintain, but it introduces a challenge when resolving effective access permissions at runtime.
This document proposes a runtime compilation approach that preserves the clean domain model while providing efficient and scalable access evaluation.
---
# The Challenge
The authoritative model stores only explicit access assignments.
To determine the effective permission for a given Role and Resource, the runtime must consider:
- inherited Roles
- inherited Resources
- explicit assignments
- inheritance conflicts
- matrix resolution policy
With the current database model, determining all applicable assignments requires traversing both hierarchies.
Typical implementation approaches therefore rely on:
- recursive SQL queries
- recursive application logic
- multiple database lookups
Although technically correct, this becomes inefficient when access checks are frequent.
---
# Design Principles
The proposed solution is based on the following principles:
- Keep the database as the single source of truth.
- Keep the domain model simple and expressive.
- Avoid recursive database queries during normal runtime.
- Optimize for read performance.
- Support horizontally scalable stateless services.
---
# Runtime Access Context
Rather than resolving permissions directly from the database for every request, each service instance maintains an in-memory **Access Context**.
The Access Context is a compiled runtime representation of one or more Access Matrices.
It is considered **derived state**, not authoritative state.
The database remains the only persistent source of truth.
---
# Compilation
During compilation, the runtime analyses the authoritative model and builds efficient lookup structures.
Examples include:
- resolved Role inheritance
- resolved Resource inheritance
- indexed Access Assignments (flatten the AccessMatrix)
- effective permission lookup tables
- additional runtime indexes
The exact internal representation is an implementation detail and may evolve without changing the domain model.
---
# Service Startup
When a service instance starts:
1. Load all Access Matrices.
2. Compile each matrix.
3. Store the compiled matrices in the local Access Context.
4. Record the compiled version of each matrix.
Once startup is complete, all access queries are resolved from the Access Context.
---
# Runtime Queries
Normal access evaluation never traverses the database hierarchy.
Instead, requests are resolved directly from the compiled Access Context.
This provides predictable and efficient lookup performance.
---
# Keeping Instances Consistent
Because services are horizontally scalable, every instance maintains its own local Access Context.
Whenever an Access Matrix changes:
- Roles
- Resources
- Access Assignments
- Resolution Policy
- or any other matrix configuration
the following process occurs:
1. Persist the change.
2. Increment the Access Matrix version.
3. Publish an `AccessMatrixUpdated` event.
4. Every service instance receives the event.
5. If the received version is newer than the local version, the instance recompiles the affected matrix.
6. The new compiled matrix atomically replaces the previous version.
All instances therefore converge to the same runtime state.
---
# Versioning
Each Access Matrix contains a version.
Example:
```text
AccessMatrix
id
version
...
```
The version is incremented whenever a modification affects the compiled runtime representation.
Versioning allows service instances to:
- detect newer matrices
- ignore duplicate events
- ignore stale events
- verify compilation status
---
# Stateless Service Considerations
Although each service instance maintains an in-memory Access Context, the service remains effectively stateless.
The Access Context is:
- derived from the database
- rebuildable at any time
- local to the instance
- never authoritative
- recreated after restart
No runtime state is shared between service instances.
---
# Advantages
## Clean Domain Model
The domain remains focused on business concepts rather than runtime optimisations.
## High Read Performance
Normal permission evaluation requires no recursive database traversal.
## Horizontal Scalability
Each service instance independently maintains its own runtime context.
## Database Independence
The implementation does not depend on recursive SQL, closure tables, or vendor-specific database features.
## Extensibility
The internal compilation strategy can evolve without changing the public domain model.
---
# Trade-offs
## Additional Memory
Each service instance stores compiled runtime structures.
Given the expected size of access configuration, this overhead is considered acceptable.
## Compilation Cost
Compilation requires CPU time.
However, access configuration changes are expected to be relatively infrequent compared to access queries.
## Event Consistency
The solution depends on reliable propagation of Access Matrix update events.
Missed events should be handled through standard recovery mechanisms (for example startup recompilation or periodic version verification).
---
# Conclusion
The proposed runtime compilation approach preserves the simplicity of the Access Control domain model while eliminating recursive database queries during normal operation.
The database remains the authoritative source of truth, while each service instance maintains a local compiled Access Context for efficient runtime evaluation.
This approach provides:
- simple authoring
- efficient runtime behaviour
- horizontal scalability
- database independence
- clear separation between the authoritative model and the runtime representation
It therefore offers a clean and maintainable foundation for implementing access evaluation in a distributed microservice architecture.# @ocean-meta-start
# tags:
# - documentation
# perspective:
# feature: rbac
# @ocean-meta-end
@info
name: Role-Based Access Control
version: 1.0.0
description: The Role-Based Access Control application provides centralized management of Roles, hierarchical Resources, and AccessMatrices.<br><br>It supports Role composition through inheritance, reusable ResourceTypes, explicit allow or deny assignments, and deterministic effective-access resolution across Role and Resource hierarchies. Unresolved or conflicting inherited access is denied by default.
# @ocean-meta-start
# tags:
# - rbac-api
# - service
# perspective:
# feature: rbac
# service: rbac-service
# @ocean-meta-end
@service
@include O.rbac.service@1.0.0
# Sample data seeding
`seed-sample-data.sh` populates a running `rbac-service` with demo data for
exploring the UI: a role DAG, resource types, and a resource tree (a mock
supply-chain app menu).
## Usage
```bash
./seed-sample-data.sh seed [base_url] # default: http://localhost:9099
./seed-sample-data.sh cleanup [base_url]
```
## What gets created
- **Roles**: `Viewer` (root) β `Editor` / `Support Agent` β `Admin` /
`Billing Manager`, plus a separate `CarrierViewer` / `CarrierOrganizer` β
`CarrierAdmin` branch, all merging into `Owner`.
- **Resource types**: `View` / `Edit` / `Delete` β `StandardOperations`;
`File` β `Folder`; `MenuGroup`, `MenuItem`.
- **Resources**: a `Supply Chain Execution` menu tree (Inventory, Order
Management, Transportation, Reports & Analytics) plus a separate
`Settings` tree β mixing typed and untyped resources.
## Notes
- Every `displayName` is prefixed `sample-`, so `cleanup` only ever deletes
data this script created β safe to run anytime.
- Not test data β this is for manually browsing the UI. For automated tests,
see `test/component/rbac-service/`.
#!/bin/sh
# Seeds sample roles, resource types, and a resource tree (the same demo data
# built by hand earlier in this project) into a running rbac-service, for
# exploring the UI. All display names are prefixed "sample-" so cleanup only
# ever touches data this script created.
#
# Usage:
# ./seed-sample-data.sh seed [base_url] (default: http://localhost:9099)
# ./seed-sample-data.sh cleanup [base_url]
set -e
CMD="$1"
BASE="${2:-http://localhost:9099}"
PREFIX="sample-"
post() {
response=$(curl -s -w "\n%{http_code}" -X POST "$BASE$1" -H "Content-Type: application/json" -d "$2")
status=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$status" != "200" ]; then
echo "β POST $1 failed (status $status):" >&2
echo " request: $2" >&2
echo " response: $body" >&2
exit 1
fi
echo "$body"
}
seed() {
echo "Seeding sample data at $BASE ..."
# --- Roles: same DAG built earlier (Viewer at the root, Owner accumulates) ---
VIEWER=$(post /v1/role "{\"displayName\":\"${PREFIX}Viewer\",\"description\":\"Read-only access.\",\"parents\":[]}" | jq -r '.id')
EDITOR=$(post /v1/role "{\"displayName\":\"${PREFIX}Editor\",\"description\":\"Create and edit content.\",\"parents\":[{\"id\":\"$VIEWER\"}]}" | jq -r '.id')
SUPPORT=$(post /v1/role "{\"displayName\":\"${PREFIX}Support Agent\",\"description\":\"Read content, respond to tickets.\",\"parents\":[{\"id\":\"$VIEWER\"}]}" | jq -r '.id')
ADMIN=$(post /v1/role "{\"displayName\":\"${PREFIX}Admin\",\"description\":\"Manage users and settings.\",\"parents\":[{\"id\":\"$EDITOR\"},{\"id\":\"$SUPPORT\"}]}" | jq -r '.id')
BILLING=$(post /v1/role "{\"displayName\":\"${PREFIX}Billing Manager\",\"description\":\"Manage invoices and payment methods.\",\"parents\":[{\"id\":\"$SUPPORT\"}]}" | jq -r '.id')
CARRIER_VIEWER=$(post /v1/role "{\"displayName\":\"${PREFIX}CarrierViewer\",\"description\":\"Carrier viewer user.\",\"parents\":[]}" | jq -r '.id')
CARRIER_ORGANIZER=$(post /v1/role "{\"displayName\":\"${PREFIX}CarrierOrganizer\",\"description\":\"Carrier organiser user.\",\"parents\":[]}" | jq -r '.id')
CARRIER_ADMIN=$(post /v1/role "{\"displayName\":\"${PREFIX}CarrierAdmin\",\"description\":\"Carrier admin user.\",\"parents\":[{\"id\":\"$CARRIER_VIEWER\"},{\"id\":\"$CARRIER_ORGANIZER\"}]}" | jq -r '.id')
post /v1/role "{\"displayName\":\"${PREFIX}Owner\",\"description\":\"Full control over the account.\",\"parents\":[{\"id\":\"$ADMIN\"},{\"id\":\"$BILLING\"},{\"id\":\"$CARRIER_ADMIN\"}]}" > /dev/null
echo "Roles created."
# --- Resource types: operations group + a plain type tree + menu types ---
VIEW=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}View\",\"description\":\"Read-only access operation.\",\"includes\":[]}" | jq -r '.id')
EDIT=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}Edit\",\"description\":\"Modify an existing resource.\",\"includes\":[]}" | jq -r '.id')
DELETE_OP=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}Delete\",\"description\":\"Remove a resource.\",\"includes\":[]}" | jq -r '.id')
post /v1/resource-type "{\"displayName\":\"${PREFIX}StandardOperations\",\"description\":\"Baseline operations.\",\"includes\":[{\"id\":\"$VIEW\"},{\"id\":\"$EDIT\"},{\"id\":\"$DELETE_OP\"}]}" > /dev/null
FILE_TYPE=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}File\",\"description\":\"A single file.\",\"includes\":[]}" | jq -r '.id')
post /v1/resource-type "{\"displayName\":\"${PREFIX}Folder\",\"description\":\"A container.\",\"includes\":[{\"id\":\"$FILE_TYPE\"}]}" > /dev/null
MENU_GROUP=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}MenuGroup\",\"description\":\"An expandable menu section.\",\"includes\":[]}" | jq -r '.id')
MENU_ITEM=$(post /v1/resource-type "{\"displayName\":\"${PREFIX}MenuItem\",\"description\":\"A single clickable page.\",\"includes\":[]}" | jq -r '.id')
echo "Resource types created."
# --- Resources: the Supply Chain Execution menu tree + a separate Settings tree ---
ROOT=$(post /v1/resource "{\"displayName\":\"${PREFIX}Supply Chain Execution\",\"description\":\"Top-level menu.\",\"resourceTypeId\":\"$MENU_GROUP\"}" | jq -r '.id')
INVENTORY=$(post /v1/resource "{\"displayName\":\"${PREFIX}Inventory\",\"resourceTypeId\":\"$MENU_GROUP\",\"parentId\":\"$ROOT\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}Stock Overview\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$INVENTORY\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Warehouse Transfers\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$INVENTORY\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Cycle Counts\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$INVENTORY\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Bin Management\",\"parentId\":\"$INVENTORY\"}" > /dev/null
ORDERS=$(post /v1/resource "{\"displayName\":\"${PREFIX}Order Management\",\"resourceTypeId\":\"$MENU_GROUP\",\"parentId\":\"$ROOT\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}Sales Orders\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$ORDERS\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Purchase Orders\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$ORDERS\"}" > /dev/null
FULFILLMENT=$(post /v1/resource "{\"displayName\":\"${PREFIX}Fulfillment\",\"resourceTypeId\":\"$MENU_GROUP\",\"parentId\":\"$ORDERS\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}Pick Lists\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$FULFILLMENT\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Pack & Ship\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$FULFILLMENT\"}" > /dev/null
TRANSPORT=$(post /v1/resource "{\"displayName\":\"${PREFIX}Transportation\",\"resourceTypeId\":\"$MENU_GROUP\",\"parentId\":\"$ROOT\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}Shipment Tracking\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$TRANSPORT\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Carrier Management\",\"parentId\":\"$TRANSPORT\"}" > /dev/null
REPORTS=$(post /v1/resource "{\"displayName\":\"${PREFIX}Reports & Analytics\",\"parentId\":\"$ROOT\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}KPI Dashboard\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$REPORTS\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}Custom Reports\",\"parentId\":\"$REPORTS\"}" > /dev/null
SETTINGS=$(post /v1/resource "{\"displayName\":\"${PREFIX}Settings\",\"description\":\"Outside the main nav tree.\"}" | jq -r '.id')
post /v1/resource "{\"displayName\":\"${PREFIX}User Preferences\",\"resourceTypeId\":\"$MENU_ITEM\",\"parentId\":\"$SETTINGS\"}" > /dev/null
post /v1/resource "{\"displayName\":\"${PREFIX}System Configuration\",\"parentId\":\"$SETTINGS\"}" > /dev/null
echo "Resources created."
# --- Matrix: all sample- roles x all sample- resources ---
ROLES_JSON=$(curl -s "$BASE/v1/roles/0/500" | jq --arg p "$PREFIX" '[.[] | select(.displayName | startswith($p)) | {id}]')
RESOURCES_JSON=$(curl -s "$BASE/v1/resources/0/500" | jq --arg p "$PREFIX" '[.[] | select(.displayName | startswith($p)) | {id}]')
MATRIX_PAYLOAD=$(jq -n --arg p "$PREFIX" --argjson roles "$ROLES_JSON" --argjson resources "$RESOURCES_JSON" \
'{displayName: ($p+"Matrix"), description: "Covers all sample roles and resources.", accessResolutionPolicy: "denyOverrides", roles: $roles, resources: $resources}')
post /v1/access-matrix "$MATRIX_PAYLOAD" > /dev/null
echo "Matrix created."
echo "Done. Everything is prefixed \"$PREFIX\" β run cleanup to remove it."
}
cleanup() {
echo "Cleaning up ${PREFIX}* data at $BASE ..."
# Matrices: delete assignments first, then the matrix (no cascade on delete)
curl -s "$BASE/v1/access-matrices/0/500" \
| jq -r --arg p "$PREFIX" '.[] | select(.displayName | startswith($p)) | .id' \
| while read -r matrix_id; do
curl -s "$BASE/v1/access-matrix/$matrix_id" \
| jq -r '.accessAssignments[]? | "\(.roleId) \(.resourceId)"' \
| while read -r role_id resource_id; do
curl -s -o /dev/null -X DELETE "$BASE/v1/assignments/$matrix_id/$role_id/$resource_id"
done
curl -s -o /dev/null -X DELETE "$BASE/v1/access-matrix/$matrix_id"
done
# Resources/roles/resource-types can have unknown dependency order
# (trees, DAGs) β retry deletion until a pass finds nothing left.
delete_by_prefix() {
for _ in 1 2 3 4 5; do
ids=$(curl -s "$BASE$1" | jq -r --arg p "$PREFIX" '.[] | select(.displayName | startswith($p)) | .id')
[ -z "$ids" ] && return 0
echo "$ids" | while read -r id; do
curl -s -o /dev/null -X DELETE "$BASE$2/$id"
done
done
}
delete_by_prefix "/v1/resources/0/500" "/v1/resource"
delete_by_prefix "/v1/roles/0/500" "/v1/role"
delete_by_prefix "/v1/resource-types/0/500" "/v1/resource-type"
echo "Done."
}
case "$CMD" in
seed) seed ;;
cleanup) cleanup ;;
*)
echo "Usage: $0 {seed|cleanup} [base_url]"
exit 1
;;
esac
sequenceDiagram
participant Service
participant Database
participant Compiler
participant Context
participant Broker
Service->>Database: Load Roles
Service->>Database: Load Resources
Service->>Database: Load Access Matrices
Database-->>Service: AccessCompilationInput
Service->>Compiler: CompileAccessMatrices(...)
Compiler-->>Service: Map<String,CompiledAccessMatrix>
Service->>Context: SetCompiledMatrices(...)
Note over Service: Runtime begins
Broker-->>Service: AccessMatrixUpdated
Service->>Database: Reload configuration
Database-->>Service: Updated AccessCompilationInput
Service->>Compiler: Recompile
Compiler-->>Service: Updated CompiledAccessMatrix
Service->>Context: Replace compiled matrix
FROM nginx:alpine
COPY ./app/rbac-ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY ./app/rbac-ui/rbac-ui.html /usr/share/nginx/html/index.html
EXPOSE 5500
server {
listen 5500;
server_name _;
root /usr/share/nginx/html;
index rbac-ui.html;
# Serve rbac-ui.html both at "/" and at its own filename
location / {
try_files $uri $uri/ /rbac-ui.html;
}
# Small perf/hygiene defaults, harmless for a single-file app
gzip on;
gzip_types text/html text/css application/javascript;
add_header Cache-Control "no-cache";
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RBAC Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<style>
:root {
--bg: #14171c;
--surface: #1c212a;
--surface-2: #262d38;
--surface-3: #323b4a;
--surface-4: #3d4759;
--border: #3d4658;
--border-soft: #2b3240;
--text: #f1f3f6;
--text-dim: #b7bdc9;
--text-faint: #8991a1;
--accent: #6cadea;
--accent-dim: #3f6688;
--accent-soft: rgba(108, 173, 234, 0.16);
--allow: #5fca8d;
--allow-bg: rgba(95, 202, 141, 0.16);
--allow-border: rgba(95, 202, 141, 0.4);
--deny: #ec7d72;
--deny-bg: rgba(236, 125, 114, 0.16);
--deny-border: rgba(236, 125, 114, 0.4);
--unknown: #a3aabb;
--unknown-bg: rgba(163, 170, 187, 0.14);
--radius: 7px;
--radius-lg: 11px;
--mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-dim);
}
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
#app {
max-width: 1180px;
margin: 0 auto;
padding: 20px 24px 64px;
}
header.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 18px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand .mark {
width: 26px;
height: 26px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), #3d6e99);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 600;
font-size: 12px;
color: #0e141c;
}
.brand h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
}
.brand .sub {
font-size: 12px;
color: var(--text-faint);
font-family: var(--mono);
}
.conn {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.conn input {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
width: 230px;
font-family: var(--mono);
font-size: 12.5px;
color: var(--text);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-faint);
flex: none;
}
.status-dot.ok {
background: var(--allow);
box-shadow: 0 0 0 3px var(--allow-bg);
}
.status-dot.bad {
background: var(--deny);
box-shadow: 0 0 0 3px var(--deny-bg);
}
.status-label {
font-size: 11.5px;
color: var(--text-faint);
font-family: var(--mono);
min-width: 80px;
}
nav.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-soft);
overflow-x: auto;
}
nav.tabs button {
background: none;
border: none;
padding: 10px 16px;
cursor: pointer;
color: var(--text-dim);
font-weight: 500;
font-size: 13.5px;
white-space: nowrap;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .12s;
}
nav.tabs button:hover {
color: var(--text);
}
nav.tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
nav.tabs button .count {
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
background: var(--surface-2);
border-radius: 8px;
padding: 1px 6px;
margin-left: 6px;
}
#status-banner {
display: none;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 12.5px;
border: 1px solid var(--border);
}
#status-banner.show {
display: flex;
}
#status-banner.error {
background: var(--deny-bg);
border-color: var(--deny-border);
color: #f3a49d;
}
#status-banner.success {
background: var(--allow-bg);
border-color: var(--allow-border);
color: #a6e0bc;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
flex-wrap: wrap;
}
.panel-head h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
}
.panel-head .desc {
font-size: 12.5px;
color: var(--text-faint);
margin-top: 2px;
}
.btn {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 13px;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
font-size: 13px;
transition: background .12s, border-color .12s;
}
.btn:hover {
background: var(--surface-3);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #0e141c;
font-weight: 600;
}
.btn-primary:hover {
background: #6dabe0;
}
.btn-danger {
background: transparent;
border-color: var(--deny-border);
color: var(--deny);
}
.btn-danger:hover {
background: var(--deny-bg);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-dim);
padding: 5px 9px;
}
.btn-ghost:hover {
background: var(--surface-2);
color: var(--text);
}
.btn-sm {
padding: 4px 9px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 16px;
width: 26px;
height: 26px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--surface-2);
color: var(--text);
}
.card {
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 16px;
}
.empty {
text-align: center;
padding: 36px 20px;
color: var(--text-faint);
font-size: 13px;
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
}
.empty .big {
font-size: 22px;
margin-bottom: 6px;
opacity: 0.6;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
}
table.data {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.data th {
text-align: left;
padding: 9px 12px;
background: var(--surface-2);
color: var(--text-faint);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--border-soft);
white-space: nowrap;
}
table.data td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
}
table.data tr:last-child td {
border-bottom: none;
}
table.data tr:hover td {
background: rgba(255, 255, 255, 0.015);
}
.id-cell {
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-faint);
}
.name-cell {
font-weight: 500;
}
.desc-cell {
color: var(--text-dim);
font-size: 12.5px;
max-width: 240px;
}
.actions-cell {
white-space: nowrap;
text-align: right;
}
.no-edit {
font-size: 11px;
color: var(--text-faint);
font-style: italic;
}
.chip {
display: inline-block;
background: var(--surface-3);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
margin: 2px 3px 2px 0;
}
.chips-wrap {
display: flex;
flex-wrap: wrap;
max-width: 260px;
}
.muted-x {
color: var(--text-faint);
font-style: italic;
font-size: 12px;
}
.tree-node {
border-left: 1px dashed var(--border);
}
.tree-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-bottom: 1px solid var(--border-soft);
}
.tree-row:hover {
background: rgba(255, 255, 255, 0.015);
}
.tree-left {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.tree-name {
font-weight: 500;
}
.tree-type {
font-size: 11px;
color: var(--text-faint);
font-family: var(--mono);
}
.tree-children {
margin-left: 18px;
}
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: .03em;
}
.field input[type=text],
.field textarea,
.field select {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
color: var(--text);
}
.field input[type=text]:focus,
.field textarea:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
}
.field textarea {
resize: vertical;
min-height: 56px;
}
.field .hint {
font-size: 11.5px;
color: var(--text-faint);
margin-top: 4px;
}
.checklist {
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 6px;
}
.checklist label {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
border-radius: 5px;
font-size: 12.5px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text);
}
.checklist label:hover {
background: var(--surface-3);
}
.checklist input {
margin: 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border-soft);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(8, 10, 13, 0.65);
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 6vh 16px;
z-index: 50;
overflow-y: auto;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 460px;
padding: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal.wide {
max-width: 640px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-head h3 {
font-size: 15px;
margin: 0;
font-weight: 600;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: .02em;
}
.badge.allow {
background: var(--allow-bg);
color: var(--allow);
border: 1px solid var(--allow-border);
}
.badge.deny {
background: var(--deny-bg);
color: var(--deny);
border: 1px solid var(--deny-border);
}
.badge.unknown {
background: var(--unknown-bg);
color: var(--unknown);
border: 1px solid var(--border);
}
.badge.policy {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent-dim);
}
.state-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
display: inline-block;
}
.state-dot.explicit {
background: currentColor;
}
.state-dot.inherited {
background: transparent;
border: 1.5px solid currentColor;
}
.state-dot.default {
background: currentColor;
opacity: 0.35;
}
.matrix-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 860px) {
.matrix-layout {
grid-template-columns: 1fr;
}
}
.matrix-list-item {
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border-soft);
margin-bottom: 8px;
cursor: pointer;
transition: border-color .12s, background .12s;
}
.matrix-list-item:hover {
border-color: var(--border);
}
.matrix-list-item.selected {
border-color: var(--accent);
background: var(--accent-soft);
}
.matrix-list-item .mtitle {
font-weight: 600;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.matrix-list-item .mdesc {
font-size: 11.5px;
color: var(--text-faint);
margin-top: 3px;
}
.matrix-list-item .mmeta {
font-size: 10.5px;
color: var(--text-faint);
font-family: var(--mono);
margin-top: 6px;
}
.grid-scroll {
overflow: auto;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
max-height: 64vh;
}
table.matrix-grid {
border-collapse: collapse;
font-size: 12px;
}
table.matrix-grid th,
table.matrix-grid td {
border: 1px solid var(--border-soft);
padding: 0;
}
table.matrix-grid th {
background: var(--surface-2);
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
padding: 8px 10px;
position: sticky;
top: 0;
z-index: 2;
white-space: nowrap;
}
table.matrix-grid th.corner {
position: sticky;
left: 0;
top: 0;
z-index: 3;
background: var(--surface-2);
text-align: left;
}
table.matrix-grid td.rowhead {
position: sticky;
left: 0;
background: var(--surface);
font-weight: 500;
padding: 8px 12px;
white-space: nowrap;
z-index: 1;
border-right: 1px solid var(--border);
}
td.cell {
cursor: pointer;
text-align: center;
min-width: 76px;
transition: filter .1s;
}
td.cell:hover {
filter: brightness(1.25);
}
.cell-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 9px 8px;
height: 100%;
}
.cell-inner.allow {
background: var(--allow-bg);
color: var(--allow);
}
.cell-inner.deny {
background: var(--deny-bg);
color: var(--deny);
}
.cell-inner.unknown {
background: var(--unknown-bg);
color: var(--unknown);
}
.cell-inner .lbl {
font-size: 11px;
font-weight: 600;
}
.legend {
display: flex;
gap: 16px;
align-items: center;
margin-top: 10px;
flex-wrap: wrap;
font-size: 11.5px;
color: var(--text-faint);
}
.legend .item {
display: flex;
align-items: center;
gap: 6px;
}
.legend .sw {
width: 11px;
height: 11px;
border-radius: 3px;
}
.eff-result .row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid var(--border-soft);
}
.eff-result .row:last-child {
border-bottom: none;
}
.eff-result .label {
color: var(--text-faint);
font-size: 12px;
width: 150px;
flex: none;
}
.eff-actions {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
footer.note {
margin-top: 34px;
padding-top: 16px;
border-top: 1px solid var(--border-soft);
font-size: 11.5px;
color: var(--text-faint);
}
.view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-toggle-btn {
background: var(--surface-2);
border: none;
color: var(--text-dim);
padding: 6px 12px;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
border-right: 1px solid var(--border);
}
.view-toggle-btn:last-child {
border-right: none;
}
.view-toggle-btn:hover {
color: var(--text);
}
.view-toggle-btn.active {
background: var(--accent);
color: #0e141c;
font-weight: 600;
}
.dag-wrap {
overflow: auto;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 8px;
background: var(--surface);
}
.dag-node-box {
fill: var(--surface-4);
stroke: var(--border);
stroke-width: 1.2;
}
.dag-node-box.root {
stroke: var(--accent);
stroke-width: 1.6;
}
.dag-node-text {
fill: var(--text);
font-family: 'Inter', sans-serif;
font-weight: 600;
}
.dag-node-sub {
fill: var(--text-dim);
font-family: 'IBM Plex Mono', monospace;
}
.dag-edge {
fill: none;
stroke: var(--accent-dim);
stroke-width: 1.8;
}
.dag-arrowhead {
fill: var(--accent-dim);
}
.dag-legend {
display: flex;
gap: 16px;
padding: 10px 4px 2px;
font-size: 11.5px;
color: var(--text-faint);
flex-wrap: wrap;
}
</style>
</head>
<body>
<div id="app">
<header class="top">
<div class="brand">
<span class="mark">R</span>
<h1>RBAC Console</h1>
<span class="sub">/ access matrix admin</span>
</div>
<div class="conn">
<span class="status-dot" id="conn-dot"></span>
<span class="status-label" id="conn-label">unchecked</span>
<input type="text" id="api-base-input" spellcheck="false" value="http://localhost:9092">
<button class="btn btn-sm" onclick="applyApiBase()">Connect</button>
</div>
</header>
<div id="status-banner"></div>
<nav class="tabs">
<button class="tab-btn active" data-tab="roles" onclick="setTab('roles')">Roles <span class="count"
id="count-roles">β</span></button>
<button class="tab-btn" data-tab="resources" onclick="setTab('resources')">Resources & Types <span
class="count" id="count-resources">β</span></button>
<button class="tab-btn" data-tab="matrices" onclick="setTab('matrices')">Matrices & Access <span class="count"
id="count-matrices">β</span></button>
</nav>
<!-- ============ ROLES TAB ============ -->
<section class="tab-panel active" id="tab-roles">
<div class="panel-head">
<div>
<h2>Roles</h2>
<div class="desc">Roles form a DAG, not a strict tree β a role can have more than one parent.</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<div class="view-toggle">
<button class="view-toggle-btn active" data-view="table" onclick="setRolesView('table')">Table</button>
<button class="view-toggle-btn" data-view="dag" onclick="setRolesView('dag')">DAG</button>
</div>
<button class="btn btn-primary" onclick="openRoleForm()">+ New role</button>
</div>
</div>
<div class="table-wrap" id="roles-view-table">
<table class="data">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Parents</th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody id="roles-tbody"></tbody>
</table>
</div>
<div id="roles-view-dag" style="display:none;"></div>
</section>
<!-- ============ RESOURCES & TYPES TAB ============ -->
<section class="tab-panel" id="tab-resources">
<div class="panel-head">
<div>
<h2>Resource Types</h2>
<div class="desc">Types can include other types. Immutable once created β no edit, create & delete only.
</div>
</div>
<div>
<button class="btn btn-primary" onclick="openResourceTypeForm()">+ New type</button>
</div>
</div>
<div class="table-wrap" style="margin-bottom:28px;">
<table class="data">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Includes</th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody id="types-tbody"></tbody>
</table>
</div>
<div class="panel-head">
<div>
<h2>Resources</h2>
<div class="desc">Resources form a strict tree β each resource has at most one parent.</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<div class="view-toggle">
<button class="view-toggle-btn active" data-view="list" onclick="setResourcesView('list')">List</button>
<button class="view-toggle-btn" data-view="diagram" onclick="setResourcesView('diagram')">Diagram</button>
</div>
<button class="btn btn-primary" onclick="openResourceForm()">+ New resource</button>
</div>
</div>
<div class="card" id="resources-view-list"></div>
<div id="resources-view-diagram" style="display:none;"></div>
</section>
<!-- ============ MATRICES & ACCESS TAB (merged) ============ -->
<section class="tab-panel" id="tab-matrices">
<div class="panel-head">
<div>
<h2>Access Matrices</h2>
<div class="desc">Select a matrix to view and edit effective access per role Γ resource.</div>
</div>
<div>
<button class="btn btn-primary" onclick="openMatrixForm()">+ New matrix</button>
</div>
</div>
<div class="matrix-layout">
<div id="matrices-list"></div>
<div id="matrix-detail"></div>
</div>
</section>
<footer class="note">
Base URL and connection state are kept in memory only for this browser tab β they reset on reload.
If requests fail with a network/CORS error, make sure the RBAC service allows requests from this page's origin.
</footer>
</div>
<div id="modal-root"></div>
<script>
/* ===================== state ===================== */
const state = {
apiBase: 'http://localhost:9092',
roles: [],
resourceTypes: [],
resources: [],
matrices: [],
selectedMatrixId: null,
matrixDetail: null,
matrixCells: {},
loaded: { roles: false, resourceTypes: false, resources: false, matrices: false }
};
let bannerTimer = null;
/* ===================== helpers ===================== */
function escapeHtml(str) {
if (str === undefined || str === null) return '';
return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function shortId(id) { if (!id) return ''; return id.length > 10 ? id.slice(0, 8) + 'β¦' : id; }
function roleName(id) { const r = state.roles.find(r => r.id === id); return r ? (r.displayName || r.id) : id; }
function typeName(id) { const t = state.resourceTypes.find(t => t.id === id); return t ? (t.displayName || t.id) : id; }
function resourceName(id) { const r = state.resources.find(r => r.id === id); return r ? (r.displayName || r.id) : id; }
function showBanner(msg, type) {
const b = document.getElementById('status-banner');
b.textContent = msg;
b.className = 'show ' + type;
clearTimeout(bannerTimer);
bannerTimer = setTimeout(() => { b.classList.remove('show'); }, 5000);
}
function showError(msg) { showBanner(msg, 'error'); }
function showSuccess(msg) { showBanner(msg, 'success'); }
/* ===================== API layer ===================== */
async function api(method, path, body) {
const url = state.apiBase.replace(/\/+$/, '') + path;
let res;
try {
res = await fetch(url, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined
});
} catch (err) {
showError(`Network error calling ${method} ${path} β is the API reachable at ${state.apiBase}?`);
throw err;
}
if (!res.ok) {
let detail = '';
try { detail = await res.text(); } catch (e) { }
const msg = `${method} ${path} β ${res.status}${detail ? ': ' + detail.slice(0, 220) : ''}`;
showError(msg);
throw new Error(msg);
}
if (res.status === 204) return null;
const text = await res.text();
return text ? JSON.parse(text) : null;
}
const Api = {
listRoles: () => api('GET', '/v1/roles/0/500'),
createRole: (r) => api('POST', '/v1/role', r),
updateRole: (r) => api('PUT', '/v1/role', r),
deleteRole: (id) => api('DELETE', `/v1/role/${encodeURIComponent(id)}`),
listResourceTypes: () => api('GET', '/v1/resource-types/0/500'),
createResourceType: (rt) => api('POST', '/v1/resource-type', rt),
deleteResourceType: (id) => api('DELETE', `/v1/resource-type/${encodeURIComponent(id)}`),
listResources: () => api('GET', '/v1/resources/0/500'),
createResource: (r) => api('POST', '/v1/resource', r),
updateResource: (r) => api('PUT', '/v1/resource', r),
deleteResource: (id) => api('DELETE', `/v1/resource/${encodeURIComponent(id)}`),
listMatrices: () => api('GET', '/v1/access-matrices/0/500'),
createMatrix: (m) => api('POST', '/v1/access-matrix', m),
getMatrix: (id) => api('GET', `/v1/access-matrix/${encodeURIComponent(id)}`),
deleteMatrix: (id) => api('DELETE', `/v1/access-matrix/${encodeURIComponent(id)}`),
setAssignment: (matrixId, roleId, resourceId, effect) =>
api('POST', `/v1/assignments/${encodeURIComponent(matrixId)}/${encodeURIComponent(roleId)}/${encodeURIComponent(resourceId)}`, { accessEffect: effect }),
removeAssignment: (matrixId, roleId, resourceId) =>
api('DELETE', `/v1/assignments/${encodeURIComponent(matrixId)}/${encodeURIComponent(roleId)}/${encodeURIComponent(resourceId)}`),
getEffectiveAccess: (matrixId, roleId, resourceId) =>
api('GET', `/v1/effective-access/${encodeURIComponent(matrixId)}/${encodeURIComponent(roleId)}/${encodeURIComponent(resourceId)}`),
health: () => api('GET', '/health')
};
/* ===================== connection ===================== */
function applyApiBase() {
const val = document.getElementById('api-base-input').value.trim();
if (!val) return;
state.apiBase = val;
checkConnection();
Object.keys(state.loaded).forEach(k => state.loaded[k] = false);
loadForTab(currentTab);
}
async function checkConnection() {
const dot = document.getElementById('conn-dot');
const label = document.getElementById('conn-label');
dot.className = 'status-dot'; label.textContent = 'checkingβ¦';
try {
await Api.health();
dot.className = 'status-dot ok'; label.textContent = 'connected';
} catch (e) {
dot.className = 'status-dot bad'; label.textContent = 'unreachable';
}
}
/* ===================== tabs ===================== */
let currentTab = 'roles';
function setTab(name) {
currentTab = name;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
loadForTab(name);
}
function loadForTab(name) {
if (name === 'roles' && !state.loaded.roles) loadRoles();
if (name === 'resources') {
if (!state.loaded.resourceTypes) loadResourceTypes();
if (!state.loaded.resources) loadResources();
}
if (name === 'matrices') {
if (!state.loaded.roles) loadRoles();
if (!state.loaded.resources) loadResources();
if (!state.loaded.matrices) loadMatrices();
}
}
/* ===================== modal ===================== */
function openModal(title, bodyHtml, wide) {
document.getElementById('modal-root').innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this) closeModal()">
<div class="modal ${wide ? 'wide' : ''}">
<div class="modal-head"><h3>${escapeHtml(title)}</h3><button class="icon-btn" onclick="closeModal()">β</button></div>
${bodyHtml}
</div>
</div>`;
}
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
function checklistHtml(name, items, selectedIds, excludeId) {
const list = items.filter(i => i.id !== excludeId);
if (list.length === 0) return `<div class="checklist"><div class="muted-x" style="padding:6px;">Nothing available yet.</div></div>`;
return `<div class="checklist">${list.map(i => `
<label><input type="checkbox" name="${name}" value="${escapeHtml(i.id)}" ${selectedIds.includes(i.id) ? 'checked' : ''}>
${escapeHtml(i.displayName || i.id)}</label>`).join('')}</div>`;
}
function collectChecked(name) {
return Array.from(document.querySelectorAll(`input[name="${name}"]:checked`)).map(el => el.value);
}
/* ===================== ROLES ===================== */
function setRolesView(view) {
document.querySelectorAll('#tab-roles .view-toggle-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
document.getElementById('roles-view-table').style.display = view === 'table' ? '' : 'none';
document.getElementById('roles-view-dag').style.display = view === 'dag' ? '' : 'none';
}
async function loadRoles() {
try {
const data = await Api.listRoles();
state.roles = Array.isArray(data) ? data : [];
state.loaded.roles = true;
} catch (e) { state.roles = []; }
document.getElementById('count-roles').textContent = state.roles.length;
renderRoles();
}
function renderRoles() {
document.getElementById('roles-tbody').innerHTML = state.roles.length ? state.roles.map(r => `
<tr>
<td class="name-cell">${escapeHtml(r.displayName || 'β')}</td>
<td class="desc-cell">${escapeHtml(r.description || '')}</td>
<td class="chips-wrap">${(r.parents && r.parents.length) ? r.parents.map(p => `<span class="chip">${escapeHtml(roleName(p.id))}</span>`).join('') : ''}</td>
<td class="id-cell" title="${escapeHtml(r.id)}">${shortId(r.id)}</td>
<td class="actions-cell">
<button class="btn btn-ghost btn-sm" onclick='openRoleForm(${JSON.stringify(r).replace(/'/g, "'")})'>Edit</button>
<button class="btn btn-ghost btn-sm" style="color:var(--deny)" onclick="deleteRole('${r.id}')">Delete</button>
</td>
</tr>`).join('') : `<tr><td colspan="5"><div class="empty">No roles yet. Create the first one.</div></td></tr>`;
renderRoleDag();
}
function renderRoleDag() {
const container = document.getElementById('roles-view-dag');
if (!container) return;
if (state.roles.length === 0) { container.innerHTML = `<div class="empty">No roles yet.</div>`; return; }
const levelOf = {};
function levelFor(id, guard) {
if (levelOf[id] !== undefined) return levelOf[id];
guard = guard || new Set();
if (guard.has(id)) return levelOf[id] = 0;
guard.add(id);
const role = state.roles.find(r => r.id === id);
if (!role || !role.parents || role.parents.length === 0) { return levelOf[id] = 0; }
const lvl = 1 + Math.max(...role.parents.map(p => levelFor(p.id, guard)));
return levelOf[id] = lvl;
}
state.roles.forEach(r => levelFor(r.id));
const maxLevel = Math.max(...Object.values(levelOf));
const byLevel = [];
for (let l = 0; l <= maxLevel; l++) byLevel.push(state.roles.filter(r => levelOf[r.id] === l));
const nodeW = 156, nodeH = 46, colGap = 34, rowGap = 78, padX = 24, padY = 30;
const maxCols = Math.max(...byLevel.map(l => l.length));
const svgW = Math.max(360, padX * 2 + maxCols * nodeW + (maxCols - 1) * colGap);
const svgH = padY * 2 + (maxLevel + 1) * nodeH + maxLevel * rowGap;
const pos = {};
byLevel.forEach((levelRoles, l) => {
const rowW = levelRoles.length * nodeW + (levelRoles.length - 1) * colGap;
const startX = (svgW - rowW) / 2;
levelRoles.forEach((r, i) => {
pos[r.id] = { x: startX + i * (nodeW + colGap), y: padY + l * (nodeH + rowGap), w: nodeW, h: nodeH };
});
});
function edgePath(p, c) {
const x1 = p.x + p.w / 2, y1 = p.y + p.h;
const x2 = c.x + c.w / 2, y2 = c.y;
const midY = (y1 + y2) / 2;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
let edges = '';
let nodes = '';
state.roles.forEach(r => {
(r.parents || []).forEach(p => {
const pp = pos[p.id], cc = pos[r.id];
if (pp && cc) edges += `<path class="dag-edge" marker-end="url(#dag-arrow)" d="${edgePath(pp, cc)}"/>`;
});
});
state.roles.forEach(r => {
const p = pos[r.id];
const parentCount = (r.parents || []).length;
nodes += `
<g transform="translate(${p.x},${p.y})">
<rect class="dag-node-box" width="${p.w}" height="${p.h}" rx="8"></rect>
<text class="dag-node-text" x="${p.w / 2}" y="19" text-anchor="middle" font-size="12.5">${escapeHtml(r.displayName)}</text>
${parentCount ? `<text class="dag-node-sub" x="${p.w / 2}" y="34" text-anchor="middle" font-size="10">${parentCount} parent${parentCount > 1 ? 's' : ''}</text>` : ''}
</g>`;
});
container.innerHTML = `
<div class="dag-wrap">
<svg width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
<defs>
<marker id="dag-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="dag-arrowhead"></path>
</marker>
</defs>
${edges}
${nodes}
</svg>
</div>
<div class="dag-legend">
<span>Arrows point from parent β child (child inherits downward).</span>
</div>`;
}
function openRoleForm(role) {
const editing = !!role;
const body = `
<form onsubmit="return submitRoleForm(event, ${editing ? `'${role.id}'` : 'null'})">
<div class="field">
<label>Display name</label>
<input type="text" name="displayName" required value="${escapeHtml(role?.displayName || '')}">
</div>
<div class="field">
<label>Description</label>
<textarea name="description">${escapeHtml(role?.description || '')}</textarea>
</div>
<div class="field">
<label>Parent roles</label>
${checklistHtml('parents', state.roles, (role?.parents || []).map(p => p.id), role?.id)}
<div class="hint">This role will inherit access from the roles checked above.</div>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${editing ? 'Save changes' : 'Create role'}</button>
</div>
</form>`;
openModal(editing ? 'Edit role' : 'New role', body);
}
async function submitRoleForm(ev, existingId) {
ev.preventDefault();
const f = ev.target;
const payload = {
displayName: f.displayName.value.trim(),
description: f.description.value.trim(),
parents: collectChecked('parents').map(id => ({ id }))
};
if (existingId) payload.id = existingId;
try {
if (existingId) await Api.updateRole(payload);
else await Api.createRole(payload);
closeModal();
showSuccess(existingId ? 'Role updated.' : 'Role created.');
await loadRoles();
} catch (e) { }
return false;
}
async function deleteRole(id) {
if (!confirm('Delete this role? This cannot be undone.')) return;
try {
await Api.deleteRole(id);
showSuccess('Role deleted.');
await loadRoles();
} catch (e) { }
}
/* ===================== RESOURCE TYPES ===================== */
async function loadResourceTypes() {
try {
const data = await Api.listResourceTypes();
state.resourceTypes = Array.isArray(data) ? data : [];
state.loaded.resourceTypes = true;
} catch (e) { state.resourceTypes = []; }
document.getElementById('count-resources').textContent = state.resourceTypes.length + state.resources.length;
renderTypes();
}
function renderTypes() {
document.getElementById('types-tbody').innerHTML = state.resourceTypes.length ? state.resourceTypes.map(t => `
<tr>
<td class="name-cell">${escapeHtml(t.displayName || 'β')}</td>
<td class="desc-cell">${escapeHtml(t.description || '')}</td>
<td class="chips-wrap">${(t.includes && t.includes.length) ? t.includes.map(i => `<span class="chip">${escapeHtml(typeName(i.id))}</span>`).join('')
: '<span class="muted-x">none</span>'
}</td>
<td class="id-cell" title="${escapeHtml(t.id)}">${shortId(t.id)}</td>
<td class="actions-cell">
<span class="no-edit">immutable</span>
<button class="btn btn-ghost btn-sm" style="color:var(--deny)" onclick="deleteResourceType('${t.id}')">Delete</button>
</td>
</tr>`).join('') : `<tr><td colspan="5"><div class="empty">No resource types yet.</div></td></tr>`;
}
function openResourceTypeForm() {
const body = `
<form onsubmit="return submitResourceTypeForm(event)">
<div class="field">
<label>Display name</label>
<input type="text" name="displayName" required>
</div>
<div class="field">
<label>Description</label>
<textarea name="description"></textarea>
</div>
<div class="field">
<label>Includes (other types)</label>
${checklistHtml('includes', state.resourceTypes, [])}
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create type</button>
</div>
</form>`;
openModal('New resource type', body);
}
async function submitResourceTypeForm(ev) {
ev.preventDefault();
const f = ev.target;
const payload = {
displayName: f.displayName.value.trim(),
description: f.description.value.trim(),
includes: collectChecked('includes').map(id => ({ id }))
};
try {
await Api.createResourceType(payload);
closeModal();
showSuccess('Resource type created.');
await loadResourceTypes();
} catch (e) { }
return false;
}
async function deleteResourceType(id) {
if (!confirm('Delete this resource type?')) return;
try {
await Api.deleteResourceType(id);
showSuccess('Resource type deleted.');
await loadResourceTypes();
} catch (e) { }
}
/* ===================== RESOURCES ===================== */
function setResourcesView(view) {
document.querySelectorAll('#tab-resources .view-toggle-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
document.getElementById('resources-view-list').style.display = view === 'list' ? '' : 'none';
document.getElementById('resources-view-diagram').style.display = view === 'diagram' ? '' : 'none';
}
async function loadResources() {
try {
const data = await Api.listResources();
state.resources = Array.isArray(data) ? data : [];
state.loaded.resources = true;
} catch (e) { state.resources = []; }
document.getElementById('count-resources').textContent = state.resourceTypes.length + state.resources.length;
renderResourceTree();
}
function renderResourceTree() {
document.getElementById('resources-view-list').innerHTML = buildTree(null) || `<div class="empty">No resources yet.</div>`;
renderResourceDiagram();
}
function buildTree(parentId) {
const children = state.resources.filter(r => (r.parentId || null) === parentId);
if (children.length === 0) return '';
return `<div class="tree-children">${children.map(r => `
<div class="tree-node">
<div class="tree-row">
<div class="tree-left">
<span class="tree-name">${escapeHtml(r.displayName)}</span>
<span class="tree-type">${escapeHtml(typeName(r.resourceTypeId))}</span>
<span class="id-cell" title="${escapeHtml(r.id)}">${shortId(r.id)}</span>
</div>
<div>
<button class="btn btn-ghost btn-sm" onclick='openResourceForm(${JSON.stringify(r).replace(/'/g, "'")})'>Edit</button>
<button class="btn btn-ghost btn-sm" style="color:var(--deny)" onclick="deleteResource('${r.id}')">Delete</button>
</div>
</div>
${buildTree(r.id)}
</div>`).join('')}</div>`;
}
function renderResourceDiagram() {
const container = document.getElementById('resources-view-diagram');
if (!container) return;
if (state.resources.length === 0) { container.innerHTML = `<div class="empty">No resources yet.</div>`; return; }
const levelOf = {};
function levelFor(id, guard) {
if (levelOf[id] !== undefined) return levelOf[id];
guard = guard || new Set();
if (guard.has(id)) return levelOf[id] = 0;
guard.add(id);
const res = state.resources.find(r => r.id === id);
if (!res || !res.parentId) { return levelOf[id] = 0; }
return levelOf[id] = 1 + levelFor(res.parentId, guard);
}
state.resources.forEach(r => levelFor(r.id));
const maxLevel = Math.max(...Object.values(levelOf));
const byLevel = [];
for (let l = 0; l <= maxLevel; l++) byLevel.push(state.resources.filter(r => levelOf[r.id] === l));
const nodeW = 156, nodeH = 46, colGap = 30, rowGap = 78, padX = 24, padY = 30;
const maxCols = Math.max(...byLevel.map(l => l.length));
const svgW = Math.max(360, padX * 2 + maxCols * nodeW + (maxCols - 1) * colGap);
const svgH = padY * 2 + (maxLevel + 1) * nodeH + maxLevel * rowGap;
const pos = {};
byLevel.forEach((levelResources, l) => {
const rowW = levelResources.length * nodeW + (levelResources.length - 1) * colGap;
const startX = (svgW - rowW) / 2;
levelResources.forEach((r, i) => {
pos[r.id] = { x: startX + i * (nodeW + colGap), y: padY + l * (nodeH + rowGap), w: nodeW, h: nodeH };
});
});
function edgePath(p, c) {
const x1 = p.x + p.w / 2, y1 = p.y + p.h;
const x2 = c.x + c.w / 2, y2 = c.y;
const midY = (y1 + y2) / 2;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
let edges = '';
let nodes = '';
state.resources.forEach(r => {
if (r.parentId && pos[r.parentId] && pos[r.id]) {
edges += `<path class="dag-edge" marker-end="url(#tree-arrow)" d="${edgePath(pos[r.parentId], pos[r.id])}"/>`;
}
});
state.resources.forEach(r => {
const p = pos[r.id];
const isRoot = !r.parentId;
nodes += `
<g transform="translate(${p.x},${p.y})">
<rect class="dag-node-box ${isRoot ? 'root' : ''}" width="${p.w}" height="${p.h}" rx="8"></rect>
<text class="dag-node-text" x="${p.w / 2}" y="19" text-anchor="middle" font-size="12.5">${escapeHtml(r.displayName)}</text>
<text class="dag-node-sub" x="${p.w / 2}" y="34" text-anchor="middle" font-size="10">${escapeHtml(typeName(r.resourceTypeId) || 'untyped')}</text>
</g>`;
});
container.innerHTML = `
<div class="dag-wrap">
<svg width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
<defs>
<marker id="tree-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="dag-arrowhead"></path>
</marker>
</defs>
${edges}
${nodes}
</svg>
</div>
<div class="dag-legend">
<span>Arrows point from parent β child.</span>
<span>Border highlighted = root resource (no parent).</span>
<span>Subtitle shows the resource's type.</span>
</div>`;
}
function openResourceForm(resource) {
const editing = !!resource;
const typeOptions = state.resourceTypes.map(t => `<option value="${t.id}" ${resource?.resourceTypeId === t.id ? 'selected' : ''}>${escapeHtml(t.displayName || t.id)}</option>`).join('');
const parentOptions = state.resources.filter(r => r.id !== resource?.id).map(r => `<option value="${r.id}" ${resource?.parentId === r.id ? 'selected' : ''}>${escapeHtml(r.displayName || r.id)}</option>`).join('');
const body = `
<form onsubmit="return submitResourceForm(event, ${editing ? `'${resource.id}'` : 'null'})">
<div class="field">
<label>Display name</label>
<input type="text" name="displayName" required value="${escapeHtml(resource?.displayName || '')}">
</div>
<div class="field">
<label>Description</label>
<textarea name="description">${escapeHtml(resource?.description || '')}</textarea>
</div>
<div class="field">
<label>Resource type</label>
<select name="resourceTypeId"><option value="">β none β</option>${typeOptions}</select>
</div>
<div class="field">
<label>Parent resource</label>
<select name="parentId"><option value="">β none (root) β</option>${parentOptions}</select>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${editing ? 'Save changes' : 'Create resource'}</button>
</div>
</form>`;
openModal(editing ? 'Edit resource' : 'New resource', body);
}
async function submitResourceForm(ev, existingId) {
ev.preventDefault();
const f = ev.target;
const payload = {
displayName: f.displayName.value.trim(),
description: f.description.value.trim(),
resourceTypeId: f.resourceTypeId.value || undefined,
parentId: f.parentId.value || undefined
};
if (existingId) payload.id = existingId;
try {
if (existingId) await Api.updateResource(payload);
else await Api.createResource(payload);
closeModal();
showSuccess(existingId ? 'Resource updated.' : 'Resource created.');
await loadResources();
} catch (e) { }
return false;
}
async function deleteResource(id) {
if (!confirm('Delete this resource? Child resources may become orphaned.')) return;
try {
await Api.deleteResource(id);
showSuccess('Resource deleted.');
await loadResources();
} catch (e) { }
}
/* ===================== MATRICES & ACCESS (merged) ===================== */
async function loadMatrices() {
try {
const data = await Api.listMatrices();
state.matrices = Array.isArray(data) ? data : [];
state.loaded.matrices = true;
} catch (e) { state.matrices = []; }
document.getElementById('count-matrices').textContent = state.matrices.length;
renderMatricesList();
if (!state.selectedMatrixId && state.matrices.length) {
selectMatrix(state.matrices[0].id);
} else if (state.selectedMatrixId) {
renderMatrixDetailWrap();
}
}
function renderMatricesList() {
const wrap = document.getElementById('matrices-list');
if (state.matrices.length === 0) {
wrap.innerHTML = `<div class="empty">No matrices yet.</div>`;
return;
}
wrap.innerHTML = state.matrices.map(m => `
<div class="matrix-list-item ${state.selectedMatrixId === m.id ? 'selected' : ''}" onclick="selectMatrix('${m.id}')">
<div class="mtitle"><span>${escapeHtml(m.displayName)}</span>
<button class="icon-btn" title="Delete" style="color:var(--deny)" onclick="event.stopPropagation(); deleteMatrix('${m.id}')">β</button>
</div>
<div class="mmeta">${escapeHtml(m.accessResolutionPolicy || 'unknown')} Β· v${m.version ?? 0} Β· id ${shortId(m.id)}</div>
</div>`).join('');
}
function renderMatrixDetailCard(m) {
const roleChips = (m.roles || []).length
? m.roles.map(r => `<span class="chip">${escapeHtml(roleName(r.id))}</span>`).join('')
: '<span class="muted-x">none</span>';
const resourceChips = (m.resources || []).length
? m.resources.map(r => `<span class="chip">${escapeHtml(resourceName(r.id))}</span>`).join('')
: '<span class="muted-x">none</span>';
const assignmentRows = (m.accessAssignments || []).length
? m.accessAssignments.map(a => `
<div class="row">
<span class="label">${escapeHtml(roleName(a.roleId))} β ${escapeHtml(resourceName(a.resourceId))}</span>
<span class="badge ${a.accessEffect === 'allow' ? 'allow' : a.accessEffect === 'deny' ? 'deny' : 'unknown'}">${escapeHtml(a.accessEffect)}</span>
</div>`).join('')
: '<div class="muted-x" style="padding:4px 0;">No explicit assignments.</div>';
return `
<div class="card" style="margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-size:15px; font-weight:600;">${escapeHtml(m.displayName)}</div>
<div style="font-size:12.5px; color:var(--text-dim); margin-top:3px;">${escapeHtml(m.description || '')}</div>
</div>
<div style="text-align:right;">
<span class="badge policy">${escapeHtml(m.accessResolutionPolicy || 'unknown')}</span>
<div class="id-cell" style="margin-top:6px;" title="${escapeHtml(m.id)}">${escapeHtml(m.id)}</div>
<div class="id-cell">version ${m.version ?? 0}</div>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px;">
<div>
<div class="field" style="margin-bottom:8px;"><label style="margin-bottom:6px;">Roles</label><div class="chips-wrap" style="max-width:none;">${roleChips}</div></div>
<div class="field" style="margin-bottom:0;"><label style="margin-bottom:6px;">Resources</label><div class="chips-wrap" style="max-width:none;">${resourceChips}</div></div>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:var(--text-dim); margin-bottom:6px; text-transform:uppercase; letter-spacing:.03em;">Explicit assignments</label>
<div class="eff-result">${assignmentRows}</div>
</div>
</div>
</div>`;
}
function openMatrixForm() {
const body = `
<form onsubmit="return submitMatrixForm(event)">
<div class="field">
<label>Display name</label>
<input type="text" name="displayName" required>
</div>
<div class="field">
<label>Description</label>
<textarea name="description"></textarea>
</div>
<div class="field">
<label>Access resolution policy</label>
<select name="accessResolutionPolicy">
<option value="denyOverrides">denyOverrides</option>
<option value="allowOverrides">allowOverrides</option>
</select>
</div>
<div class="field">
<label>Roles in this matrix</label>
${checklistHtml('mroles', state.roles, [])}
</div>
<div class="field">
<label>Resources in this matrix</label>
${checklistHtml('mresources', state.resources, [])}
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create matrix</button>
</div>
</form>`;
openModal('New access matrix', body, true);
}
async function submitMatrixForm(ev) {
ev.preventDefault();
const f = ev.target;
const payload = {
displayName: f.displayName.value.trim(),
description: f.description.value.trim(),
accessResolutionPolicy: f.accessResolutionPolicy.value,
roles: collectChecked('mroles').map(id => ({ id })),
resources: collectChecked('mresources').map(id => ({ id }))
};
try {
await Api.createMatrix(payload);
closeModal();
showSuccess('Matrix created.');
await loadMatrices();
} catch (e) { }
return false;
}
async function deleteMatrix(id) {
if (!confirm('Delete this matrix and all its assignments?')) return;
try {
await Api.deleteMatrix(id);
if (state.selectedMatrixId === id) {
state.selectedMatrixId = null;
state.matrixDetail = null;
document.getElementById('matrix-detail').innerHTML =
`<div class="empty"><div class="big">β¦</div>Select a matrix on the left to view its access grid.</div>`;
}
showSuccess('Matrix deleted.');
await loadMatrices();
} catch (e) { }
}
async function selectMatrix(id) {
state.selectedMatrixId = id;
renderMatricesList();
await renderMatrixDetailWrap();
}
async function renderMatrixDetailWrap() {
const wrap = document.getElementById('matrix-detail');
wrap.innerHTML = `<div class="grid-loading">Loading matrixβ¦</div>`;
try {
state.matrixDetail = await Api.getMatrix(state.selectedMatrixId);
} catch (e) { wrap.innerHTML = `<div class="empty">Couldn't load this matrix.</div>`; return; }
const roleList = state.matrixDetail.roles || [];
const resourceList = state.matrixDetail.resources || [];
if (roleList.length === 0 || resourceList.length === 0) {
wrap.innerHTML = renderMatrixDetailCard(state.matrixDetail) +
`<div class="empty">This matrix has no roles and/or resources assigned yet β nothing to grid.</div>`;
return;
}
const cellCount = roleList.length * resourceList.length;
if (cellCount > 300) {
wrap.innerHTML = renderMatrixDetailCard(state.matrixDetail) +
`<div class="grid-warn">This matrix has ${roleList.length} roles Γ ${resourceList.length} resources
(${cellCount} cells). Loading the full grid will make ${cellCount} requests.
<div style="margin-top:10px;"><button class="btn btn-primary btn-sm" onclick="renderMatrixGrid()">Load grid anyway</button></div></div>`;
return;
}
await renderMatrixGrid();
}
async function renderMatrixGrid() {
const wrap = document.getElementById('matrix-detail');
const m = state.matrixDetail;
const mRoles = (m.roles || []).map(r => state.roles.find(x => x.id === r.id) || r);
const mResources = (m.resources || []).map(r => state.resources.find(x => x.id === r.id) || r);
wrap.innerHTML = renderMatrixDetailCard(m) + `<div class="grid-loading">Resolving effective access for ${mRoles.length * mResources.length} cellsβ¦</div>`;
const pairs = [];
mRoles.forEach(r => mResources.forEach(res => pairs.push([r.id, res.id])));
const results = await Promise.all(pairs.map(([roleId, resourceId]) =>
Api.getEffectiveAccess(m.id, roleId, resourceId).catch(() => null)
));
state.matrixCells = {};
pairs.forEach(([roleId, resourceId], i) => { state.matrixCells[roleId + '|' + resourceId] = results[i]; });
wrap.innerHTML = `
${renderMatrixDetailCard(m)}
<div class="grid-scroll"><table class="matrix-grid">
<thead><tr><th class="corner">Role \\ Resource</th>
${mResources.map(res => `<th>${escapeHtml(res.displayName || res.id)}</th>`).join('')}
</tr></thead>
<tbody>
${mRoles.map(r => `
<tr>
<td class="rowhead">${escapeHtml(r.displayName || r.id)}</td>
${mResources.map(res => renderCellTd(m.id, r.id, res.id)).join('')}
</tr>`).join('')}
</tbody>
</table></div>
<div class="legend">
<div class="item"><span class="sw" style="background:var(--allow-bg); border:1px solid var(--allow-border);"></span> Allow</div>
<div class="item"><span class="sw" style="background:var(--deny-bg); border:1px solid var(--deny-border);"></span> Deny</div>
<div class="item"><span class="sw" style="background:var(--unknown-bg); border:1px solid var(--border);"></span> Unknown / default</div>
<div class="item"><span class="state-dot explicit" style="color:var(--text-dim)"></span> Explicit</div>
<div class="item"><span class="state-dot inherited" style="color:var(--text-dim)"></span> Inherited</div>
<div class="item">Click a cell to view or change it</div>
</div>`;
}
function renderCellTd(matrixId, roleId, resourceId) {
const data = state.matrixCells[roleId + '|' + resourceId];
const effect = data?.accessEffect || 'unknown';
const stateCls = data?.accessState || 'default';
const cls = effect === 'allow' ? 'allow' : effect === 'deny' ? 'deny' : 'unknown';
const label = effect === 'allow' ? 'Allow' : effect === 'deny' ? 'Deny' : 'β';
return `<td class="cell" onclick="openCellEditor('${matrixId}','${roleId}','${resourceId}')">
<div class="cell-inner ${cls}"><span class="state-dot ${stateCls}"></span><span class="lbl">${label}</span></div>
</td>`;
}
function openCellEditor(matrixId, roleId, resourceId) {
const data = state.matrixCells[roleId + '|' + resourceId];
const body = `
<div class="field"><label>Role</label><div>${escapeHtml(roleName(roleId))}</div></div>
<div class="field"><label>Resource</label><div>${escapeHtml(resourceName(resourceId))}</div></div>
<div class="field">
<label>Current effective access</label>
<div class="eff-result">
<div class="row"><span class="label">Effect</span>
<span class="badge ${data?.accessEffect === 'allow' ? 'allow' : data?.accessEffect === 'deny' ? 'deny' : 'unknown'}">${escapeHtml(data?.accessEffect || 'unknown')}</span></div>
<div class="row"><span class="label">State</span>
<span class="badge unknown"><span class="state-dot ${data?.accessState || 'default'}"></span> ${escapeHtml(data?.accessState || 'default')}</span></div>
<div class="row"><span class="label">Mixed descendant access</span><span>${data?.hasMixedDescendantAccess ? 'Yes' : 'No'}</span></div>
</div>
</div>
<div class="eff-actions">
<button class="btn" style="border-color:var(--allow-border); color:var(--allow);" onclick="setCellAssignment('${matrixId}','${roleId}','${resourceId}','allow')">Set Allow</button>
<button class="btn" style="border-color:var(--deny-border); color:var(--deny);" onclick="setCellAssignment('${matrixId}','${roleId}','${resourceId}','deny')">Set Deny</button>
<button class="btn btn-danger" onclick="removeCellAssignment('${matrixId}','${roleId}','${resourceId}')">Remove explicit assignment</button>
<button class="btn btn-ghost" onclick="closeModal()">Close</button>
</div>`;
openModal('Assignment', body);
}
async function setCellAssignment(matrixId, roleId, resourceId, effect) {
try {
await Api.setAssignment(matrixId, roleId, resourceId, effect);
showSuccess(`Set to ${effect}.`);
closeModal();
await renderMatrixGrid();
} catch (e) { }
}
async function removeCellAssignment(matrixId, roleId, resourceId) {
try {
await Api.removeAssignment(matrixId, roleId, resourceId);
showSuccess('Explicit assignment removed.');
closeModal();
await renderMatrixGrid();
} catch (e) { }
}
/* ===================== init ===================== */
checkConnection();
loadRoles();
</script>
</body>
</html>