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
| Layer | Mechanism | Defined in |
|---|---|---|
| API route | CanCallAPIv0 — may this role call METHOD + path? | $DATA_DIR/rbac/{module}.rbac.yaml seeds and Configurator endpoint-role API |
| Record | RecPermission — 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.
| Group | Method and path | Purpose |
|---|---|---|
| Endpoint registry | GET /v1/rbac/endpoints | List registered routes |
POST /v1/rbac/endpoints | Register 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 permissions | GET /v1/rbac/permissions | List record permissions |
POST /v1/rbac | Grant 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-types | List grantable record types | |
| API permissions | GET /v1/rbac/api-permissions | List endpoint grants |
POST /v1/rbac/api-permissions | Grant 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.
| Status | Source | code in the body |
|---|---|---|
| 401 | auth.Middleware — missing, malformed, expired or blacklisted bearer token | common.unauthorized |
| 403 | rbac.CanCallAPIv0 denied the endpoint grant | common.rbac_no_rec_access |
| 403 | licence check | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed |
| 429 | auth.RateLimitMiddleware | see below |
| 503 | health.LifecycleMiddleware — graceful shutdown | no code — a different body shape, see below |
| 503 | auth.ensureCacheAvailable — auth cache unreachable | auth_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:
| Caller | code |
|---|---|
| No usable bearer token (per-IP global counter) | Global rate limit exceeded — the literal English string |
| Authenticated, per-permission limit tripped | rate_limits_m.exceeded, or the literal string rate limit exceeded |
| Authenticated, global per-IP ceiling hit | rate_limits_m.global_exceeded |
| Authenticated, the limiter's own counter failed | rate_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, not0and not absent, and reads back as-1. Both0and-1mean "no limit" to the enforcer. rate_limit_per_yearis stored and returned but never enforced — the enforcement loop covers minute, hour, day, week and month only.- An omitted
activestoresfalse, 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 noreturn— so the service runs withuuid.Niland its own reply is appended to the already-committed body. The status stays 400 (the firstWriteHeaderwins) but the payload is the error envelope immediately followed by a second JSON document. Decode only the first one.
Reads profiles.v_userlinks filtered to the signed-in user. Returns a BARE ARRAY — there is no envelope, no total and no pagination, and no query parameter is read. Two fields are rewritten on the way out: a blank name becomes a label built from profile_m.new_company_text, and the type kyb_flow becomes profile_m.kyb_flow_text. Both are resolved for the request's Accept-Language, and in every shipped bundle both resolve to a frontend translation token rather than to end-user text — see the field descriptions. Always an array; [] when the user is linked to nothing.
RBAC Endpoint-Role Management
Next Page