CorebanqCorebanq Developer Docs
RBAC

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:

  1. Discover endpoints that need role assignments
  2. Assign roles to endpoints without editing YAML files
  3. Remove role assignments
  4. 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:

  1. All affected users' permission caches are automatically cleared
  2. Users get fresh permissions on their next API request
  3. 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 on r.URL.RawPath whenever it is set, and neither the handler nor any middleware calls url.PathUnescape — so GET …/export/GET/%2Fv1%2Fcustomers reaches the service with the literal string %2Fv1%2Fcustomers, the lookup misses, and the caller gets 404 rbac_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/export below, 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-Name reads mixed when the resolved endpoints span more than one module. The handler also has a "mixed" fallback for a YAML with no module: line, but the service always writes one, so that branch never fires.
  • # Total endpoints: N counts 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: 1 above an empty endpoints: 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

RoleDescriptionAuto-Granted
AdministratorFull system access✅ All endpoints
InternalBank employee / system service⚠️ Explicitly grant
UserStandard registered user⚠️ Explicitly grant
StandardUserBasic 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

  1. Deploy new API module with endpoints

    • Server automatically registers endpoints with admin-only access
  2. Scan for unassigned endpoints

    GET /v1/rbac/endpoint-role/unassigned

    Returns: /v1/new-feature (GET, POST)

  3. Assign roles via UI

    POST /v1/rbac/endpoint-role/assign
    {
      "endpoint": "/v1/new-feature",
      "method": "GET",
      "roles": ["User", "StandardUser"]
    }
  4. 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.

  1. 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-123

Who 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):

ModeWhenNotes
Bootstrap seedsAPI startupContent-hash dedup; stale history repair; RAM cache refresh even when skipped
Configurator APIRuntime/v1/rbac/endpoint-role/* — no restart
Export → gitAfter runtime changesCopy 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 CodeMessageResolution
rbac_m.role_not_foundRole '{role}' not foundCheck role spelling or create role first
rbac_m.endpoint_not_foundEndpoint {method} {endpoint} not foundVerify endpoint is registered in database
rbac_m.cannot_remove_admin_roleCannot remove Administrator roleAdmin role is protected
rbac_m.permission_not_foundPermission not foundRole wasn't assigned to endpoint
rbac_m.database_query_errorDatabase query failedCheck logs for details
rbac_m.cannot_assign_to_protected_endpointCannot assign non-Administrator roles to {endpoint}Protected prefix — assign Administrator only
rbac_m.endpoint_role_assignment_failedFailed to assign roles … (assigned n/m)One or more role names are wrong; nothing was assigned
rbac_m.no_endpoints_foundNo endpoints found to exportNone of the requested endpoints resolved to an active row
common.invalid_inputInvalid inputA 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:

StatusSourcecode
401auth.Middlewarecommon.unauthorized
403endpoint grant deniedcommon.rbac_no_rec_access
403licence checklicense_m.license_invalid, license_m.license_expired, license_m.module_not_licensed
429auth.RateLimitMiddlewarerate_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
503graceful shutdownnone — the body is {overall_status, message, timestamp}, not the envelope
503auth cache unreachableauth_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


On this page