Skip to main content

Tenant IAM API

Billerang's tenant-side identity and access surface: users, roles, user groups, the permission (capability) catalog, and OAuth2 API credentials for machine-to-machine access. All of it lives under /api/v1 and keeps the Billerang database and the Keycloak realm billerang in sync on every mutation.

This is the tenant control plane. Org-level, cross-environment IAM (inviting people into an organization, SSO/IDP, 2FA, password policy) lives in a separate service; see Two control planes below.

Every endpoint on this page is backed by KeycloakAdminClientService (module billerang-admin/ejbs, package org.meveo.security.client) and the Role entity (org.meveo.model.security.Role), per docs/adr/ADR-v1-iam.md. Curl examples were run against the live local stack on 2026-07-09; responses are pasted as returned.

Quick start

TOKEN=$(curl -s -X POST "$KEYCLOAK_TOKEN_URL" \
-d "grant_type=client_credentials&client_id=$KEYCLOAK_CLIENT_ID&client_secret=$KEYCLOAK_CLIENT_SECRET" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/users/me

Users

Platform users kept in sync between the Billerang database and Keycloak. username is the immutable functional key.

MethodPathDescription
GET/api/v1/usersList users. Filters: roles (comma-separated), userLevel. Paginated (limit, offset, sortBy, sortOrder).
POST/api/v1/usersCreate a user. 409 if the username exists.
GET/api/v1/users/{username}Get a user by username.
PUT/api/v1/users/{username}Update a user. Partial update: a null field keeps the stored value.
POST/api/v1/users/{username}Create-or-update (upsert), keyed on username in the path. 201 on create, 200 on update.
DELETE/api/v1/users/{username}Delete the user from both the database and Keycloak.
GET/api/v1/users/meReturn the record for the authenticated caller, derived from the token username.
GET/PUT/api/v1/users/me/profilePictureGet or set the caller's own profile picture (raw PNG/JPEG bytes).
GET/api/v1/users/{username}/profilePictureGet another user's profile picture.
POST/api/v1/users/{username}/roles/{roleCode}Append one role to the user. Idempotent if already present.
DELETE/api/v1/users/{username}/roles/{roleCode}Remove one role from the user.

UserV1 fields

FieldTypeRequiredDescription
usernamestringYesLogin username. Functional key; immutable.
emailstringNoEmail address.
firstName / lastNamestringNoGiven / family name.
rolesstring[]NoRole codes granted to the user. On update, replaceRoles controls replace vs. append.
userLevelstringNoUser level in the account hierarchy that scopes what the user can see.
disabledbooleanNo, default falseWhether the user is disabled.
passwordstringNoWrite-only: accepted on create/update, never serialized back on read.
securedEntitiesarrayNoSecured-entity restrictions scoping the user's data access.
clientRolesobject (map of string to string[])NoClient roles keyed by client id, passed through to Keycloak.
attributesobject (map of string to string)NoArbitrary user attributes passed through to Keycloak.
customFieldsobjectNoStandard Billerang custom fields map.
replaceRolesbooleanNo, default falseOn update, true replaces the stored roles list; false/absent appends.

Live example: get the caller's own user

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/users/me
{"username":"service-account-billerang-backend","roles":["default-roles-billerang","ReadAllCE","uma_protection","CE_DIGITAL_TRANSACTION-modify","ModifyAllCE","CE_DIGITAL_TRANSACTION-read"]}

The caller in this example is the OAuth2 confidential client's own Keycloak service account (client credentials grant has no human user), so its roles are the client-role grants attached to that service account.

Roles

Roles are Keycloak roles (realm or client-scoped). code is the role name, the functional key.

MethodPathDescription
GET/api/v1/rolesList roles. expand (CSV) opts into extra legacy retrieval flags. Paginated.
POST/api/v1/rolesCreate a role. 409 if the name exists.
GET/api/v1/roles/{code}Get a role by code, including its child roles.
PUT/api/v1/roles/{code}Update a role. Partial update.
POST/api/v1/roles/{code}Upsert keyed on code. 201 on create, 200 on update.
DELETE/api/v1/roles/{code}Delete a role.
GET/api/v1/roles/{code}/permissionsRead-only. Mirrors the legacy fields=permissions retrieval flag; see Permissions for why this is not a capability catalog.
POST/api/v1/roles/{code}/childRoles/{childCode}Attach childCode as a composite child of code. Backed by Keycloak addComposites; creates the parent role if it does not exist yet.
DELETE/api/v1/roles/{code}/childRoles/{childCode}Detach childCode from code. Backed by Keycloak deleteComposites. 422 if childCode is not currently a composite child.

RoleV1 fields

FieldTypeRequiredDescription
codestringYesRole name. Functional key for get, upsert, update, delete.
descriptionstringNoHuman-readable description.
childRolesstring[]NoNames of roles this role inherits (composite roles).
securedEntitiesarrayNoSecured-entity restrictions attached to the role.
customFieldsobjectNoStandard Billerang custom fields map.

There is no permissions field on the DTO: no permission store exists on the Role entity, so permissions are exposed only as the read-only /roles/{code}/permissions sub-resource above.

Composite (child) roles

A role's childRoles list is the only real role-to-permission association mechanism in Billerang today: attaching a role as a composite child is how one role inherits another's grants. There is no separate permission-assignment endpoint; see Permissions.

curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/roles/BILLING_MANAGER/childRoles/INVOICE_VIEWER

Returns 204 on success, 404 if either role code is unknown.

Live example: list roles

curl -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/roles?limit=5"
{"results":[{"code":"userManagement","description":"User management","childRoles":[]},{"code":"superAdministrator","childRoles":[]},{"code":"uma_authorization","description":"${role_uma_authorization}","childRoles":[]},{"code":"default-roles-billerang","description":"${role_default-roles}","childRoles":[]},{"code":"apiUserSelfManagement","description":"User self management via API","childRoles":[]}],"pagination":{"total":13,"limit":5,"offset":0}}

(total on this local stack is 13 realm roles; a fresh Keycloak realm ships with the Billerang default set plus whatever custom roles have been added.)

User groups

Groups are Keycloak realm groups. code is the group name, the functional key. Membership is a separate sub-resource: /{group}/members/{username}.

MethodPathDescription
GET/api/v1/userGroupsList groups. Filter name (substring match). Paginated.
POST/api/v1/userGroupsCreate a group. 409 if the name exists. 422 if a required field is missing.
GET/api/v1/userGroups/{group}Get a group by name, including child groups and member count.
PUT/api/v1/userGroups/{group}Update a group's description. Partial update.
POST/api/v1/userGroups/{group}Upsert keyed on group in the path. 201 on create, 200 on update.
DELETE/api/v1/userGroups/{group}Delete a group.
GET/api/v1/userGroups/{group}/membersList usernames who are direct members of the group. Paginated.
POST/api/v1/userGroups/{group}/members/{username}Add a user to the group (Keycloak join). Idempotent if already a member. 422 for an unknown username.
DELETE/api/v1/userGroups/{group}/members/{username}Remove a user from the group (Keycloak leave).

Every mutating group endpoint can also return 502 with a keycloak-unavailable problem-detail body if Keycloak itself is unreachable; see Errors.

UserGroupV1 fields

FieldTypeRequiredDescription
codestringYesGroup name. Functional key for get, upsert, update, delete.
descriptionstringNoHuman-readable description. Stored as a Keycloak group attribute, since groups have no native description field.
childGroupsstring[]NoNames of child (sub) groups.
memberCountintegerNo, read-onlyNumber of direct members. Present on GET /{group} only.

Live example: list user groups

curl -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/userGroups?limit=5"
{"results":[],"pagination":{"total":0,"limit":5,"offset":0}}

This local stack has no groups seeded yet; the empty results array with total: 0 is the real, correctly-shaped response for a tenant that has not created any groups.

Permissions

There is no Permission entity or role-permission table in Billerang. GET /api/v1/permissions returns a read-only capability catalog: every realm role and every app client role known to Keycloak, flattened into one list. It answers "what capability codes exist," not "which role has which permission" (that association is composite roles; see Composite (child) roles).

MethodPathDescription
GET/api/v1/permissionsList the capability catalog. No pagination parameters; returns the full set.

There is no POST /api/v1/permissions. The catalog is defined entirely by the roles that exist in the realm and in the app's own Keycloak client.

PermissionV1 fields (catalog entry)

FieldTypeDescription
codestringThe role name, used as the capability code.
descriptionstringHuman-readable description, when Keycloak has one set. Absent for roles with no description.
scopestringrealm for a realm-level role, client for a role scoped to the app's own Keycloak client.

Live example: permissions catalog (excerpt)

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/permissions
{"results":[
{"code":"CE_DIGITAL_TRANSACTION-modify","description":"CE_DIGITAL_TRANSACTION-modify","scope":"client"},
{"code":"CE_DIGITAL_TRANSACTION-read","description":"CE_DIGITAL_TRANSACTION-read","scope":"client"},
{"code":"Custom_API-AccessAll","description":"Allows to access any Custom API endpoint","scope":"client"},
{"code":"ModifyAllCE","description":"Allows to modify any Custom entity","scope":"client"},
{"code":"ReadAllCE","description":"Allows to view all Custom entities","scope":"client"},
{"code":"administrator","scope":"realm"},
{"code":"apiAccountManagement","scope":"client"},
{"code":"apiBillingManagement","scope":"client"},
{"code":"apiCatalogManagement","scope":"client"}
]}

This local stack's full catalog has 140 entries (mostly client-scoped per-module manage/view roles, plus a handful of realm-scoped roles such as administrator). The response was truncated above for readability; the live call returns the complete list with no pagination envelope.

API credentials

OAuth2 confidential clients for machine-to-machine API access. clientId is the functional key. clientSecret is populated only in the response body of create and secret-rotation, never on GET or list.

MethodPathDescription
GET/api/v1/apiCredentialsList credentials, paginated. Secrets are never included.
POST/api/v1/apiCredentialsCreate a credential. Returns clientSecret once. 409 if clientId exists. 422 for a missing required field.
GET/api/v1/apiCredentials/{clientId}Get a credential by clientId. Secret never included.
PUT/api/v1/apiCredentials/{clientId}Update name and/or enabled. Partial update. Never returns or accepts the secret.
DELETE/api/v1/apiCredentials/{clientId}Delete the credential.
POST/api/v1/apiCredentials/{clientId}/secret-rotationGenerate a new secret, invalidating the previous one. Returns the new clientSecret once.

ApiCredentialV1 fields

FieldTypeRequiredDescription
clientIdstringYesClient id. Functional key for get, update, delete, and secret rotation.
namestringNoHuman-readable display name.
enabledbooleanNoWhether the client is enabled.
clientSecretstringNo, read-onlyThe client secret. Populated only in the create and secret-rotation responses; never on GET or list.

Secret-only-on-create-or-rotate rule

This is the one rule to internalize before integrating: the secret is shown exactly twice in a credential's lifetime — the create response and each secret-rotation response. Store it immediately; there is no way to retrieve it again short of rotating (which invalidates the old one).

Live example: create, rotate, and delete an API credential

Create (QS1_DOCS_SMOKE, a throwaway smoke-test credential):

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"clientId":"QS1_DOCS_SMOKE","name":"Docs smoke test credential","enabled":true}' \
http://localhost:8080/api/v1/apiCredentials
{"clientId":"QS1_DOCS_SMOKE","name":"Docs smoke test credential","enabled":true,"clientSecret":"CEl3dPQBSAUgBPgYougS6GLGJUyiigGm"}

HTTP status: 201.

Rotate the secret:

curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/apiCredentials/QS1_DOCS_SMOKE/secret-rotation
{"clientId":"QS1_DOCS_SMOKE","name":"Docs smoke test credential","enabled":true,"clientSecret":"B2xHCXo6yxHY6bIIZCS9VRIbMIoqj086"}

HTTP status: 200. Note the new clientSecret differs from the one returned on create; the old one is now invalid.

Confirm a plain GET never leaks the secret:

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/apiCredentials/QS1_DOCS_SMOKE
{"clientId":"QS1_DOCS_SMOKE","name":"Docs smoke test credential","enabled":true}

Delete it (this credential was created only for this documentation run and was removed immediately after capture; it does not exist on the live stack):

curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/apiCredentials/QS1_DOCS_SMOKE

HTTP status: 204. A subsequent GET on the same clientId returns 404:

{"error_code":"NOT_FOUND","message":"No API credential with clientId QS1_DOCS_SMOKE was found","status":"FAIL"}

Two control planes

Billerang has two separate IAM surfaces, both writing into the same Keycloak realm (billerang), at different scopes:

  • This page, tenant /api/v1: in-app IAM for a single tenant environment. Users, roles (with composite children), groups, the permission catalog, and OAuth2 API credentials, all scoped to that one Keycloak realm. Authenticates with an OAuth 2.0 client-credentials bearer token.
  • Connect portal /api/portal/*: the org-level control plane. Manages people and access across every tenant environment an organization owns (invite by org, SSO/IDP, 2FA, password policy, org credentials). Authenticates with a browser session cookie (OIDC login). See Connect Portal API for the full comparison and reference.

Groups and OAuth2 clients now exist on both planes; that is intentional (a non-Connect, single-tenant deployment needs full in-app IAM), and both converge on the same Keycloak realm object rather than diverging. Avoid running both writers against the same realm object concurrently in a Connect-managed deployment.

Errors

All IAM errors use the standard Billerang RFC 7807 problem-detail shape (see API Principles).

CaseStatusNotes
Unknown group, role, or clientId404Not found.
Create with an existing name or clientId409Duplicate.
Missing required field422Validation failed.
Add a group member with an unknown username422Unknown user.
Remove a child role that is not currently a composite child422Not a child role.
Caller lacks the required admin role (e.g. manage-clients)403Forbidden.
Keycloak unreachable, or a Keycloak admin call fails at the transport level502keycloak-unavailable. Distinct from a 500: the caller's request was well-formed, the identity provider itself is down. No partial write is left committed.

Not found (404)

{"error_code":"NOT_FOUND","message":"No API credential with clientId QS1_DOCS_SMOKE was found","status":"FAIL"}

(Live-captured above, from the confirm-deleted step.)

Implementation reference

Backing service: KeycloakAdminClientService (billerang-backend/billerang-admin/ejbs/src/main/java/org/meveo/security/client/). Roles (list, find, create, update, delete, composite add) and group reads (listGroups, findGroup) existed before this surface; group CRUD, group membership add/remove, composite-role removal, and the entire apiCredentials resource (Keycloak confidential-client admin calls) were added as new code to support this page. Full capability-by-capability verification is in docs/adr/ADR-v1-iam.md.