CorebanqCorebanq Developer Docs
RBAC

Description

Purpose and use

RBAC controls who can call operational endpoints and which records a user can act on. It keeps administration, support, customer service, compliance, and customer-channel access separated by role and record scope.

Who uses this. Security operations, administrators, compliance owners, platform teams, and auditors use RBAC when granting, reviewing, or investigating access.

How it works. API route permissions decide whether a role may call a method and path. Record permissions decide whether a user may act on a specific customer, upload, account, or other scoped record.

What users do. Users review endpoint permissions, inspect record types, adjust endpoint-role assignments through Configurator, and verify that a user has the expected customer or record access.

Outcomes and side effects. Permission changes can immediately allow or block actions across the service. They do not change the underlying business record unless a newly allowed action is later performed.

Related manuals: Roles, RBAC Configurator, Users, Profile.

Overview

The RBAC module controls access to resources through permissions assigned to users or roles.
All endpoints below require a valid bearer token.

Permission layers

LayerMechanismDefined in
API routeCanCallAPIv0 — may this role call METHOD + path?$DATA_DIR/rbac/{module}.rbac.yaml seeds and Configurator endpoint-role API
RecordRecPermission — may this user act on this record_id?Service-layer code per module (not in .rbac.yaml)

Seed authoring (file layout, wildcards, bootstrap): seed_authoring.md. Env vars: docs/data-dir-seeds-env.md.

Route map

The module registers 23 routes. The 15 below are the core CRUD surface; for the eight /v1/rbac/endpoint-role/* Configurator routes see RBAC Configurator.

GroupMethod and pathPurpose
Endpoint registryGET /v1/rbac/endpointsList registered routes
POST /v1/rbac/endpointsRegister a route
GET /v1/rbac/endpoints/{id}Read one route
PUT /v1/rbac/endpoints/{id}Replace a route
DELETE /v1/rbac/endpoints/{id}Remove a route and its grants
Record permissionsGET /v1/rbac/permissionsList record permissions
POST /v1/rbacGrant a record permission
PUT /v1/rbac/{permission_id}Replace a record permission
DELETE /v1/rbac/{permission_id}Remove a record permission
GET /v1/rbac/record-typesList grantable record types
API permissionsGET /v1/rbac/api-permissionsList endpoint grants
POST /v1/rbac/api-permissionsGrant endpoint access
GET /v1/rbac/api-permissions/{id}Read one grant
POST /v1/rbac/api-permissions/{id}Update a grant
DELETE /v1/rbac/api-permissions/{id}Revoke a grant

Refusals that never reach the handler

Every route in this module is wrapped by auth.WrapWithMiddlewares, and three more middlewares sit on the root router in front of it. A client sees these on all 15 routes, whatever the operation does.

StatusSourcecode in the body
401auth.Middleware — missing, malformed, expired or blacklisted bearer tokencommon.unauthorized
403rbac.CanCallAPIv0 denied the endpoint grantcommon.rbac_no_rec_access
403licence checklicense_m.license_invalid, license_m.license_expired, license_m.module_not_licensed
429auth.RateLimitMiddlewaresee below
503health.LifecycleMiddleware — graceful shutdownno code — a different body shape, see below
503auth.ensureCacheAvailable — auth cache unreachableauth_m.internal_server_error

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.

429. auth.RateLimitMiddleware is mounted on the root router, before authentication, and only when app-config rate_limits.rate_limits_switcher is true. Because it runs first, a request with no valid token can be rate-limited into a 429 without ever reaching the 401. The code differs by path and is not always a dotted key:

Callercode
No usable bearer token (per-IP global counter)Global rate limit exceeded — the literal English string
Authenticated, per-permission limit trippedrate_limits_m.exceeded, or the literal string rate limit exceeded
Authenticated, global per-IP ceiling hitrate_limits_m.global_exceeded
Authenticated, the limiter's own counter failedrate_limits_m.failed_to_increment_ip_limit — an infrastructure fault reported to the caller as a 429

class is temporary and retryable is true on all of them. No Retry-After header is sent.

503 has two body shapes. During graceful shutdown health.LifecycleMiddleware — which is r.Use'd on the root router, ahead of authentication — writes a bare map, not the standard envelope. It is not, however, ahead of everything: auth.RateLimitMiddleware is registered two lines earlier, so a caller over its limit still gets a 429 while the server is draining.

{
  "overall_status": "unhealthy",
  "message": "Service is shutting down",
  "timestamp": "2026-08-27T15:04:05Z"
}

There is no status, code, class or retryable field on it, and the message is a fixed English string with no i18n key. The other 503 — the auth cache being unreachable — is the standard envelope, but its code is auth_m.internal_server_error, not common.server_error: ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package auth, so the constant that resolves is auth.MsgInternalServerError. Branch on the shape, not on the status — and on auth_m., not on common..

That second 503 is only observable with the rate limiter off. auth.RateLimitMiddleware is registered before auth.Middleware and touches the same cache, so with app-config rate_limits.rate_limits_switcher on, an unreachable cache is answered by the limiter first — 500 for an authenticated caller, 429 for an anonymous one — and this 503 is never reached. That 500 is generic too: handleRateLimitError's default branch calls apireply.InternalServerError500(w, r) without the AppError, so auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and rate_limits_m.failed_to_increment_ip_limit are all discarded and the body reads common.server_error. Do not use this 503 as the cache-down signal in a deployment that rate limits.

created_by and modified_by are stripped for most callers

Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares runs RemoveAuditFieldsHandler on every response. It re-marshals the JSON body and deletes created_by and modified_by recursively for any caller who does not hold the internal role. The two keys are then absent, not null. The samples below are abridged: the one that does show the pair — the POST /v1/rbac/endpoints response — is written from an internal-role session; the rest leave the audit block (created_at, modified_at, created_by, modified_by, metadata) out for brevity. Every entity in this module embeds models.BaseModel and returns it.

Endpoints

List API Endpoints

GET /v1/rbac/endpoints

Return all registered API endpoints that can be secured via RBAC. Supports the standard list query parameters (limit, offset, sort, filter, search / search_text, search., stack, distinct). An absent limit falls back to 10 and a value above 100 is silently clamped; a limit or offset that strconv.Atoi cannot parse is rejected with 400 common.invalid_input, not defaulted. A negative offset silently becomes 0.

stack changes the shape of the response, not just its order: createStackedResponse groups the page by the named field, so data comes back as an object keyed by the formatted field value instead of an array, and keys lists those keys in sort order. The same is true of the two other list routes. A stack naming a field the route does not declare is a 400 query_m.invalid_field — but only when there are rows to group: on an empty result, and on limit=-1, the service returns the empty envelope before ParseStackField ever runs, so the same bad field answers 200 with "data": {}.

Anything outside that list is silently dropped — query.ParseQueryParameters ignores the key in its default branch — so a typo such as ?lmit=50 produces no error and the caller gets a plausible but unfiltered first page. fill_gaps is the one key the parser recognises beyond the list above, and it changes nothing here either: only the chart parameters carry that field, models.Parameters does not, so it is discarded a step later. The same applies to the two other list routes below.

A typo inside a recognised parameter behaves differently again, and not the way you would expect: naming a field that does not exist, as in ?search.nosuchfield.eq=x, answers 500 carrying query_m.invalid_search_field. validateFieldName builds that error without a WithCode, and an empty code sends HandleAppErrorWithCode to 500 — so a caller's mistake is reported as a server fault. A bad value on a valid field is a normal 400 (query_m.invalid_search_value).

GetAllAPIEndpoints handles the response-conversion error without a return, unlike its two siblings — but nothing observable follows from it. ConvertGetAllResponseToGetAllAPIResponse fails only on a nil input or on an envelope carrying neither paginated nor stacked data, and every constructor in common/query fills one of the two, so the branch cannot be entered. Were it entered, the status would be the 400 the error writes and not the 200 that follows it, with a second JSON document appended to the body.

Response (200)

{
  "data": [
    {
      "endpoint": "/v1/customers",
      "method": "GET",
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "active": true
    }
  ],
  "total": 1,
  "total_unfiltered": 245,
  "has_more": false
}

Register an API Endpoint

POST /v1/rbac/endpoints

Add a route to the registry. Answers 200 with the created entity — not 201.

An omitted active stores false. The server runs no field validation here: an omitted or unrecognised method reaches the http_method database enum and comes back as 500, not 400.

Mind the endpoint form. The startup sync does not store route patterns verbatim: RegisterAPIEndpoints stores makeSQLFriendly(route.Pattern), which replaces every {segment} with a literal % — the committed seeds read /v1/accounts/% and /v1/misc/feeds/%/% — and keeps the original only in metadata.pattern. This route does no such rewriting: it stores the string you send, unchanged. Registering /v1/accounts/{id} by hand therefore creates a row that the auto-registered /v1/accounts/% does not collide with, and that permission lookups matching on the stored form will not find.

Request

{
  "endpoint": "/v1/accounts/%",
  "method": "GET",
  "active": true,
  "metadata": null
}

Response (200)

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "endpoint": "/v1/accounts/%",
  "method": "GET",
  "active": true,
  "metadata": null,
  "created_at": "2026-08-27T10:00:00Z",
  "created_by": "9f1c0f6e-2b6a-4b1f-9d5a-1e2c3d4e5f60",
  "modified_at": "2026-08-27T10:00:00Z",
  "modified_by": "9f1c0f6e-2b6a-4b1f-9d5a-1e2c3d4e5f60"
}

Get an API Endpoint

GET /v1/rbac/endpoints/{id}

Return a single registered endpoint. The body is the entity itself, not the paginated envelope.

The handler never calls uuid.Parse on {id} — unlike the PUT and DELETE on the same path it drops the raw segment into an id.eq search. The shared query parser validates it anyway: id is declared as a uuid field, so a malformed value is rejected with 400 query_m.invalid_search_value before any SQL is issued. The handler's own 400, for an empty {id}, is unreachable — chi does not route an empty path segment here.

Update an API Endpoint

PUT /v1/rbac/endpoints/{id}

Full replacement, not a patch. The handler assigns endpoint, method, active and metadata onto the stored row from the decoded body, and the Go fields are non-pointer, so an omitted field is written as its zero value. Sending only {"endpoint", "method"} answers 200 and silently sets active to false and wipes metadata. Send every field, including the ones you are not changing. The path id wins over any id in the body.

Delete an API Endpoint

DELETE /v1/rbac/endpoints/{id}

Removes the endpoint and every API permission that references it. This is a hard delete: the models carry no deleted_at, so the rows are gone, not deactivated.

The cascade is not atomic, despite appearances. RemoveAPIEndpoint opens Conn.Transaction(...) and then discards the handle it is given, issuing both deletes on the outer *gorm.DB — so they run as two independent statements. A failure on the second leaves the grants deleted and the endpoint in place, and the caller sees 500 common.database_error.

Neither this route nor the PUT above invalidates the API-permission cache. CanCallAPIv0 reads each actor's endpoint list from a cache entry written with a 24-hour TTL, and the endpoint service — unlike the api-permission service, which invalidates on every write — makes no invalidation call. An actor whose grants went with the endpoint, or whose endpoint a partial PUT silently set to active: false, keeps passing the check until that entry expires.

Response (200)

{ "status": 200, "message": "OK" }

List Permissions

GET /v1/rbac/permissions

Return record-level and role-based permissions visible to the authenticated user.

Response (200)

{
  "data": [
    {
      "actor_id": "550e8400-e29b-41d4-a716-446655440000",
      "type": "user",
      "rec_type_id": "7c2f1e40-91b8-4a2e-9d1c-2f3a5b6c7d8e",
      "rec_id": null,
      "permission": "CR"
    }
  ],
  "total": 1,
  "total_unfiltered": 87,
  "has_more": false
}

Permissions are expressed using CRUDA notation:
• C - Create
• R - Read
• U - Update
• D - Delete
• A - Applies to all records (permission to select all records)

The three write routes answer 401, not 403, when RBAC denies. CreateRbac, UpdateRbacByID and DeleteRbacByID end on errs.New(errs.MsgInvalidInput).WithCode(http.StatusUnauthorized) when RecPermission returns false, so a denial on POST /v1/rbac, PUT /v1/rbac/{permission_id} or DELETE /v1/rbac/{permission_id} arrives as 401 common.invalid_input with a perfectly valid token. Do not wire "401 → refresh the token and retry" to these three; the retry is refused the same way. The endpoint and api-permission routes use 403 common.forbidden for the same condition.

Grant a Record Permission

POST /v1/rbac

Grant an actor a permission mask on a record type, optionally scoped to a single record id. rec_id: null grants on the whole record type. The response is the standard success envelope, not the created entity — read the id back from GET /v1/rbac/permissions.

Request

{
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "type": "user",
  "rec_type": "rbac",
  "rec_id": null,
  "permission": "CRUD"
}

Response (200)

{ "status": 200, "message": "OK" }

Update a Record Permission

PUT /v1/rbac/{permission_id}

Replaces the permission mask and the active flag. The actor, record type and record id cannot be changed here.

Both fields are a full replacement. Sending only {"Permission": "CRUD"} answers 200 and silently sets active to false.

The capitalised Permission key is the Go field name — that field carries no json tag — but it is not the only spelling that works: encoding/json falls back to case-insensitive matching, so "permission" decodes just as well. The spec declares the capitalised form as canonical, so a strict validator will reject a lowercase body the server happily accepts.

Request

{ "Permission": "CRUD", "active": true }

Delete a Record Permission

DELETE /v1/rbac/{permission_id}

Removes the record permission row and answers the standard success envelope.

List Record Types

GET /v1/rbac/record-types

Return record types that can be protected by RBAC. The response is a bare array, not the paginated envelope. Schema is capitalised for the same reason as Permission above.

Response (200)

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "customers",
    "Schema": "customers",
    "description": null,
    "active": true
  }
]

List API Permissions

GET /v1/rbac/api-permissions

Return API permissions with their endpoint expanded, in the standard paginated envelope.

Grant an API Permission

POST /v1/rbac/api-permissions

Grant an actor the right to call a registered endpoint, with optional per-period rate limits. Answers 201.

Idempotent on the (api_endpoint_id, actor_id) pair, and only on that pair. If a grant for it already exists the stored row is returned unchanged with 201 and the rest of the body is ignored — including type and every rate limit. Re-posting with a new rate_limit_per_minute answers 201 while the old limit stays in force; posting type: "role" over an existing user grant returns the user row. Use POST /v1/rbac/api-permissions/{id} to change an existing grant.

Two more surprises in the body:

  • An omitted rate limit is stored as -1, not 0 and not absent, and reads back as -1. Both 0 and -1 mean "no limit" to the enforcer.
  • rate_limit_per_year is stored and returned but never enforced — the enforcement loop covers minute, hour, day, week and month only.
  • An omitted active stores false, so the grant is created switched off.

Request

{
  "api_endpoint_id": "550e8400-e29b-41d4-a716-446655440000",
  "actor_id": "9f1c0f6e-2b6a-4b1f-9d5a-1e2c3d4e5f60",
  "type": "role",
  "active": true,
  "rate_limit_per_minute": 60
}

An unknown api_endpoint_id answers 500 common.database_error, not 404: rbac.api_permissions carries a foreign key on the column — GORM's AutoMigrate declares it at boot for the APIPermission → APIEndpoint relation — so the insert is rejected and nothing is written.

The 404 on this route is a narrower case, and it does not mean nothing was written. The service inserts the grant and invalidates the actor's permission cache before it reads the endpoint to expand it into the response; a 404 means that final read found nothing, i.e. the endpoint disappeared between the two steps. The write and the cache flush have already happened by then.

Get an API Permission

GET /v1/rbac/api-permissions/{id}

Return a single API permission with its endpoint expanded.

Update an API Permission

POST /v1/rbac/api-permissions/{id}

Update, not create: the path id names the existing grant and the body replaces its actor, endpoint, type and rate limits. Answers 200.

The write rules invert here. The storage layer calls GORM Updates() with a struct, and GORM skips zero-valued struct fields — so on this route an omitted active leaves the stored value untouched, where on create it would have stored false. An explicit "active": false is no different: populateAPIPermissionData turns both into the same false, GORM skips it, and the route answers 200 with the grant still active. This route cannot deactivate a grant — revoke it with DELETE instead. An explicit "rate_limit_per_minute": 0 is silently not written for the same reason. The -1 that an omitted limit turns into is non-zero and is written, so leaving a limit out overwrites it with "unlimited" while setting it to 0 does nothing. Send -1 to clear a limit.

Delete an API Permission

DELETE /v1/rbac/api-permissions/{id}

Revokes the grant and invalidates the actor's permission cache.

A 400 on the three api-permissions/{id} routes returns two JSON documents. When {id} is not a uuid the handler writes the 400 envelope and then falls through — the uuid-parse branch has no return — so the service runs with uuid.Nil and its own reply is appended to the already-committed body. The status stays 400 (the first WriteHeader wins) but the payload is the error envelope immediately followed by a second JSON document. Decode only the first one.

On this page