RBAC Endpoint-Role Management
Purpose and use
RBAC Endpoint-Role Management lets authorized administrators adjust which roles can call which secured endpoints without manually editing seed files for every change.
Who uses this. Security administrators, platform operations, release engineers, and auditors use this manual when changing endpoint access or exporting reviewed permissions back into seed files.
How it works. Configurator reads registered endpoints, shows unassigned permissions, applies role assignments, invalidates the RBAC cache, and can export current permissions to YAML.
What users do. Users list unassigned endpoints, assign or remove roles, protect admin-only endpoints, export reviewed permissions, and verify the resulting access through RBAC.
Outcomes and side effects. Endpoint-role changes can immediately grant or block API access. Record-level permissions remain controlled by module workflows and are not authored in RBAC seed files.
Related manuals: RBAC, RBAC seed authoring, Roles, Users.
Overview
The RBAC Endpoint-Role Management provides API endpoints for dynamically managing role-based access control permissions. These endpoints enable the Corebanq Configurator UI to:
- Discover endpoints that need role assignments
- Assign roles to endpoints without editing YAML files
- Remove role assignments
- Export current permissions back to seed files
YAML seed format and $DATA_DIR/rbac/ layout: seed_authoring.md. Module index: README.md.
Key Features
- Real-time Permission Management: Change endpoint access without restarting the server
- Security Controls:
- Cannot assign non-Administrator roles to RBAC management endpoints
- Cannot remove Administrator role from any endpoint
- Automatic cache invalidation on permission changes
- YAML Export: Generate seed files from current database state
Security Notes
Protected Endpoints
The following endpoint patterns are protected and can ONLY be assigned the Administrator role:
/v1/rbac/endpoint-role/*- Endpoint-role management operations/v1/user-roles*- User-role assignment operations/v1/roles*- Role management operations/v1/admin/app-config/*- Platform configuration administration
isProtectedEndpoint matches four prefixes (constants.ProtectedEndpointPrefix*), not
three — the app-config one is easy to miss and behaves exactly like the others.
Why? These endpoints control the permission system itself. Allowing non-Administrator roles to access them would enable privilege escalation attacks.
Error Response when attempting to assign non-admin roles to protected endpoints. Every
error in this module is apireply.StdResponse — there is no error field, and code is a
machine key, never the HTTP status as a string:
{
"status": 403,
"message": "Cannot assign non-Administrator roles to protected endpoint /v1/rbac/endpoint-role/assign. This endpoint controls the permission system and must remain Administrator-only.",
"code": "rbac_m.cannot_assign_to_protected_endpoint",
"class": "business"
}Cache Invalidation
When permissions are modified:
- All affected users' permission caches are automatically cleared
- Users get fresh permissions on their next API request
- No server restart required
How endpoints are identified
Every route below matches an endpoint by exact string equality against the value stored in
the registry — and that value is not the route pattern. RegisterAPIEndpoints stores
makeSQLFriendly(route.Pattern), which replaces each {segment} with a literal %; the
original pattern survives only in metadata.pattern. The committed seeds show the stored form:
- endpoint: /v1/accounts/%
- endpoint: /v1/misc/feeds/%/%So a parameterised route must be written with %, not with braces. POST /assign with
"endpoint": "/v1/accounts/{id}" answers 404 rbac_m.endpoint_not_found, and the same
value in a bulk export is dropped silently — you get 200 with a
# Note: 1 endpoints could not be exported line that does not say which one. Responses carry
the % form too. Parameterless routes such as /v1/customers are unaffected, which is why most
examples below look ordinary; the bulk-export example deliberately shows both forms.
API Endpoints
Get Unassigned Endpoints
GET /v1/rbac/endpoint-role/unassigned
Returns all API endpoints that currently only have Administrator role access. These endpoints are candidates for role assignment.
Response Example:
[
{
"endpoint": "/v1/new-feature",
"method": "GET",
"roles": ["Administrator"],
"is_unassigned": true
},
{
"endpoint": "/v1/beta-endpoint",
"method": "POST",
"roles": ["Administrator"],
"is_unassigned": true
}
]When there is nothing to report the body is the JSON literal null, not []:
GetUnassignedEndpoints accumulates into a nil slice and returns it unchanged. The sibling
/endpoint-role/endpoints builds its slice with make(..., 0, len) and really is [] when
empty, so the two routes cannot share one client-side guard.
Use Case: After deploying new features, scan for endpoints that need role configuration.
Get All Endpoint Permissions
GET /v1/rbac/endpoint-role/endpoints
Returns all API endpoints with their currently assigned roles.
Response Example:
[
{
"endpoint": "/v1/customers",
"method": "GET",
"roles": ["Administrator", "User", "StandardUser"],
"is_unassigned": false
},
{
"endpoint": "/v1/accounts",
"method": "POST",
"roles": ["Administrator", "User"],
"is_unassigned": false
}
]Use Case: View complete RBAC configuration across all modules.
Assign Roles to Endpoint
POST /v1/rbac/endpoint-role/assign
Assigns one or more roles to a specific endpoint. This is additive - existing role assignments are preserved.
⚠️ Important: This operation is atomic - if ANY role fails to assign (e.g., role not found), the ENTIRE operation is rejected and NO roles are assigned. This prevents partial/inconsistent permission states.
Request Body:
{
"endpoint": "/v1/customers",
"method": "GET",
"roles": ["User", "StandardUser"]
}Success Response (200):
{
"message": "Roles assigned successfully",
"endpoint": "/v1/customers",
"method": "GET",
"roles": ["User", "StandardUser"]
}Error Response - Invalid Roles (400):
{
"status": 400,
"message": "Failed to assign roles to endpoint: InvalidRole, NonExistentRole (assigned 0/3)",
"code": "rbac_m.endpoint_role_assignment_failed",
"class": "validation"
}The failed roles and the assigned/total counts are message parameters interpolated into
message, not body fields. There is no params object to read them from.
A missing or empty endpoint, method or roles produces a different 400. All three carry
validate:"required" (and roles also min=1), so apireply.DecodeJSONInput — the handler's
first statement — refuses the body with common.invalid_input and a details entry naming the
field:
{
"status": 400,
"message": "Invalid input",
"code": "common.invalid_input",
"class": "validation",
"details": [
{ "field": "roles", "rule": "required", "message": "roles is required" }
]
}A body that is not valid JSON at all takes the same code and status but carries no details:
decodeFailureAppError has no field to name, and the parse error goes to the log, not to the
client. Branch on the array being present rather than on the status.
Use Case: Grant access to new user roles for an endpoint. All specified roles must be valid or the operation fails.
Remove Role from Endpoint
POST /v1/rbac/endpoint-role/remove
Removes a specific role's access from an endpoint. Administrator role cannot be removed (safety protection).
Request Body:
{
"endpoint": "/v1/customers",
"method": "GET",
"role": "StandardUser"
}Response:
{
"message": "Role removed successfully",
"endpoint": "/v1/customers",
"method": "GET",
"role": "StandardUser"
}Error Response (trying to remove admin):
{
"status": 403,
"message": "Cannot remove Administrator role from endpoints",
"code": "rbac_m.cannot_remove_admin_role",
"class": "business"
}Use Case: Revoke access when a role should no longer have permission to an endpoint.
Export Endpoint to Seed YAML
GET /v1/rbac/endpoint-role/export/{method}/{endpoint}
Generates a YAML seed snippet for the current permissions of a specific endpoint.
This route cannot succeed for any endpoint whose path contains a slash — that is, every endpoint the router registers.
{endpoint}is a single chi path segment, but every value it must carry is itself a path. Percent-encoding the slashes does not help: chi v5 routes onr.URL.RawPathwhenever it is set, and neither the handler nor any middleware callsurl.PathUnescape— soGET …/export/GET/%2Fv1%2Fcustomersreaches the service with the literal string%2Fv1%2Fcustomers, the lookup misses, and the caller gets 404rbac_m.endpoint_not_found. Sending the slashes undecoded adds path segments and does not match the route at all.Use
POST /v1/rbac/endpoint-role/exportbelow, which takes the endpoint in a JSON body.
Parameters:
method(path): HTTP method (GET, POST, PUT, PATCH, DELETE)endpoint(path): Endpoint path. See the note above — no registered value can be expressed here.
Use Case: Export permissions after UI configuration to commit to version control — via the bulk route.
Export Endpoints to Seed YAML (bulk)
POST /v1/rbac/endpoint-role/export
Generates one YAML seed file covering the endpoints named in the body. This is the working export route.
Request Body:
{
"endpoints": [
{ "endpoint": "/v1/customers", "method": "GET" },
{ "endpoint": "/v1/customers/%", "method": "PUT" },
{ "endpoint": "/v1/customers/{id}", "method": "DELETE" }
]
}The third entry is deliberate: the registry holds that route as /v1/customers/%, so the brace
form resolves to nothing and is the endpoint the # Note: line below counts.
Response (text/yaml) to exactly that request: the module name is repeated in the
X-Module-Name header and in the Content-Disposition filename, which the Configurator uses
when saving the download. The body always opens with a comment header — a parser that starts at
module: will trip:
# RBAC seed file for customers module
# Generated: 2026-08-28 10:15:00
# Total endpoints: 2
# Note: 1 endpoints could not be exported
module: customers
endpoints:
- endpoint: /v1/customers
method: GET
roles:
- User
- StandardUser
description: "GET /v1/customers"
- endpoint: /v1/customers/%
method: PUT
roles:
- User
description: "PUT /v1/customers/%"X-Module-Name: customers and
Content-Disposition: attachment; filename=customers.rbac.yaml accompany it — every resolved
endpoint belongs to the customers module.
Two things to know about that header:
X-Module-Namereadsmixedwhen the resolved endpoints span more than one module. The handler also has a"mixed"fallback for a YAML with nomodule:line, but the service always writes one, so that branch never fires.# Total endpoints: Ncounts the endpoints that resolved, and is computed before admin-only ones are dropped from the body. Administrator is filtered out of every role list and an endpoint left with no roles is skipped entirely — so a request naming one Administrator-only endpoint returns 200 with# Total endpoints: 1above an emptyendpoints:list.
If none of the requested endpoints resolves, the answer is 404 rbac_m.no_endpoints_found.
Only the endpoints array itself is validated (required,min=1); the entries inside it are not.
{ "endpoint": "", "method": "" } passes the validator, reaches the lookup, matches nothing and
is counted as an endpoint that could not be exported — so it produces a 200 or a 404, never a 400.
Use Case: Export a reviewed set of permissions in one file, ready to commit.
Export All Modules to Files
POST /v1/rbac/endpoint-role/export/modules
Writes one .rbac.yaml file per module on the server, into $DATA_DIR/rbac/. Unlike the
two routes above this one has a side effect on the filesystem of the running instance: it does
not stream the YAML back for the caller to save, it overwrites the seed files in place.
Response:
{
"message": "Exported 12 modules",
"exported_files": { "customers": "/data/rbac/customers.rbac.yaml" },
"errors": {},
"total_modules": 12,
"success_count": 12,
"error_count": 0
}A module that fails to export or to write is recorded in errors and does not fail the
request — a 200 does not mean every module was written. Compare success_count with
total_modules.
Use Case: Refresh the whole seed set from the live database before a deployment.
Sync Endpoints
POST /v1/rbac/endpoint-role/sync
Returns count of currently registered endpoints. Full endpoint sync requires server restart (routes are registered on startup).
Response:
{
"message": "Endpoints synced successfully",
"count": 245
}Use Case: Verify endpoint count after deployments.
Available Roles
| Role | Description | Auto-Granted |
|---|---|---|
Administrator | Full system access | ✅ All endpoints |
Internal | Bank employee / system service | ⚠️ Explicitly grant |
User | Standard registered user | ⚠️ Explicitly grant |
StandardUser | Basic user with limited access | ⚠️ Explicitly grant |
Note: Administrator role is automatically granted to all endpoints and cannot be removed.
Workflow Example
Scenario: New Feature Deployment
-
Deploy new API module with endpoints
- Server automatically registers endpoints with admin-only access
-
Scan for unassigned endpoints
GET /v1/rbac/endpoint-role/unassignedReturns:
/v1/new-feature(GET, POST) -
Assign roles via UI
POST /v1/rbac/endpoint-role/assign { "endpoint": "/v1/new-feature", "method": "GET", "roles": ["User", "StandardUser"] } -
Export to seed file (optional)
POST /v1/rbac/endpoint-role/export { "endpoints": [ { "endpoint": "/v1/new-feature", "method": "GET" } ] }
Copy YAML output to .runtime/data/rbac/new_module.rbac.yaml. Use this route rather than
GET .../export/{method}/{endpoint}, which cannot resolve a multi-segment endpoint.
- Verify access by testing with different role accounts
Security
Authorization
These handlers carry no role check of their own. Access is decided by auth.Middleware
before the handler runs: rbac.CanCallAPIv0 lets an Administrator through unconditionally (the
licence check still applies) and looks every other caller up against the endpoint grant in
api_permissions. A caller without a grant gets 403 common.rbac_no_rec_access.
In the shipped seeds no non-Administrator role holds a grant on any /v1/rbac/endpoint-role/*
route (.samples/data/rbac/rbac.rbac.yaml), which is why these routes are Administrator-only in
a stock deployment. That is the grant table, not a role requirement — and isProtectedEndpoint
does not close the gap: it constrains only what this API will assign, so a permission row
written through the core POST /v1/rbac/api-permissions CRUD or through a seed file gives a
non-Administrator role working access to the configurator. Revoking someone's Administrator role
is therefore not by itself enough to lock them out; remove the grant.
Audit logging
A permission change through this API leaves no audit record. The blame package writes
history only for models handed to InitializeBlameSystem, and the only one registered in the
process is models.AppConfig (); models.APIPermission is not,
and neither these handlers nor common/rbac call blame at all.
What remains is the application log. logger.InfofCtx attaches request_id and nothing else —
there is no actor_id field, and the message text is the whole of the detail:
INFO Granted role StandardUser access to GET /v1/customers request_id=abc-123
INFO Assigned 2 roles to GET /v1/customers request_id=abc-123
INFO Removed role StandardUser from GET /v1/customers request_id=abc-123Who made the change is recorded nowhere: the handlers do not log the caller, and logAPICall
in is an empty pass-through with its logging line commented out. Treat these routes as
unaudited when planning a review of permission changes.
Safety Mechanisms
- ✅ Administrator role cannot be removed from any endpoint
- ✅ Protected endpoints (RBAC management, user-roles, roles, app-config admin) can ONLY be assigned Administrator role
- ✅ Cache automatically invalidated when permissions change
- ✅ Atomic role assignment - if ANY role fails, the entire operation is rejected (no partial success)
- ✅ A failed assignment names the offending roles and the assigned/total counts — interpolated into
message, not returned as separate fields - ✅ Missing endpoints return 404 errors
- ✅ All database operations use parameterized queries (SQL injection safe)
Integration with Seed Files
The configurator works alongside $DATA_DIR/rbac/{module}.rbac.yaml seeds (see seed_authoring.md):
| Mode | When | Notes |
|---|---|---|
| Bootstrap seeds | API startup | Content-hash dedup; stale history repair; RAM cache refresh even when skipped |
| Configurator API | Runtime | /v1/rbac/endpoint-role/* — no restart |
| Export → git | After runtime changes | Copy YAML into DATA_DIR/rbac/ for the next deployment |
RBAC seeds do not use *.diff.yaml — edit the full module file or export from Configurator.
Error Codes
| Error Code | Message | Resolution |
|---|---|---|
rbac_m.role_not_found | Role '{role}' not found | Check role spelling or create role first |
rbac_m.endpoint_not_found | Endpoint {method} {endpoint} not found | Verify endpoint is registered in database |
rbac_m.cannot_remove_admin_role | Cannot remove Administrator role | Admin role is protected |
rbac_m.permission_not_found | Permission not found | Role wasn't assigned to endpoint |
rbac_m.database_query_error | Database query failed | Check logs for details |
rbac_m.cannot_assign_to_protected_endpoint | Cannot assign non-Administrator roles to {endpoint} | Protected prefix — assign Administrator only |
rbac_m.endpoint_role_assignment_failed | Failed to assign roles … (assigned n/m) | One or more role names are wrong; nothing was assigned |
rbac_m.no_endpoints_found | No endpoints found to export | None of the requested endpoints resolved to an active row |
common.invalid_input | Invalid input | A required body field is missing or malformed |
rbac_m.module_not_found is declared in the catalog but never reaches a client as a code:
only ExportModuleToSeed raises it, and POST /export/modules captures it into the 200 body's
errors map as text. Even there it cannot fire — the module names it iterates come from
GetEndpointPermissionsByModule, so the empty-module branch is unreachable.
common.forbidden is not among them — none of the eight routes in this manual emits it.
The module's core CRUD routes do (, ,
all raise errs.MsgForbidden on a missing record permission), but those are
documented in rbac.mdx. The codes that arrive before the handler runs are:
| Status | Source | code |
|---|---|---|
| 401 | auth.Middleware | common.unauthorized |
| 403 | endpoint grant denied | common.rbac_no_rec_access |
| 403 | licence check | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed |
| 429 | auth.RateLimitMiddleware | rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit, or the literal strings Global rate limit exceeded / rate limit exceeded |
| 503 | graceful shutdown | none — the body is {overall_status, message, timestamp}, not the envelope |
| 503 | auth cache unreachable | auth_m.internal_server_error — note the auth_m prefix, not common.server_error. Only observable with the rate limiter off: auth.RateLimitMiddleware is registered before auth.Middleware and touches the same cache, so with rate_limits.rate_limits_switcher on, a dead cache is answered by the limiter first — 500 common.server_error for an authenticated caller, 429 for an anonymous one — and this 503 is never reached |
license_m.license_key_missing and license_m.license_service_unavailable are matched by the
middleware but cannot reach a client. The first needs licenseService == nil, which a running
process cannot be in — rbac.Init calls InitLicenseService unconditionally and routes a
failure through logger.Fatalf, so a bad licence key stops the process at startup instead of
serving 403s. The second is never written into the licence-error context by any path.
The 429 middleware sits on the root router ahead of authentication, so an unauthenticated
caller can be rate-limited into a 429 without ever seeing the 401. It is mounted only when
app-config rate_limits.rate_limits_switcher is true.
Performance
- Permission Changes: Instant (no server restart required)
- Cache Behavior: Permissions cached per-user, refreshed on next request
- Database Impact: Minimal - single UPDATE/INSERT per role assignment
- Scalability: Configurator endpoints are admin-only (low traffic)
Best Practices
DO:
✅ Use configurator for quick permission adjustments
✅ Export changes to seed files for version control
✅ Test with different role accounts after changes
✅ Review unassigned endpoints after deployments
✅ Use seed files for bulk/initial permissions
DON'T:
❌ Remove Administrator role (it's auto-granted anyway)
❌ Make production changes without testing in staging
❌ Forget to export important changes to seed files
❌ Assign roles to endpoints that don't exist yet
Related Documentation
- Seed authoring: seed_authoring.md
- Core RBAC API: rbac.mdx
- OpenAPI:
rbac_configurator.openapi.json,rbac.openapi.json