Skip to content

Authentication & Authorization Model

Credential management, credential-bearing imports, and restore confirmation use fresh authentication approvals.

This page describes who can authenticate to Hubuum, how identity is structured, and how a request is authorized. It also covers service accounts (non-human API principals) and token scopes, which narrow what an automated credential may do.

For the collection/group permission model itself (what ReadCollection, CreateClass, … mean), see permissions.md. This page is about identity, tokens, and the authority gates that sit in front of those permissions. For provider setup, see external_auth.md.


Identity: principals

Every authenticated identity in Hubuum is a principal. There are two kinds:

Kind Table Can password-login? Can hold tokens? Typical use
human users Yes Yes People
service_account service_accounts No Yes Automation / integrations

Principals use class-table inheritance: a single principals row owns the identity, and a users or service_accounts row shares the same id (users.id / service_accounts.id are INT PRIMARY KEY REFERENCES principals(id)). A principal id is the user/service-account id.

                 ┌──────────────────────────┐
                 │        principals         │
                 │ id (PK), kind, scope, name│   name is unique inside
                 │ provider metadata         │   one identity scope
                 │ UNIQUE(scope, name)       │
                 └────────────┬─────────────┘
            kind='human'      │      kind='service_account'
        ┌────────────────────┐│┌──────────────────────────────┐
        │       users        │││      service_accounts        │
        │ id (PK,FK), password│││ id (PK,FK), owner_group_id,  │
        │ email, …           │││ description, disabled_at, …  │
        └────────────────────┘ └──────────────────────────────┘

Key consequences:

  • The login/display name lives on principals.name — there is no separate users.username. Names are unique per identity scope, so local/alice and example-directory/alice are distinct principals.
  • Provider ownership lives on the principal. identity_scope_id, provider_managed, external_subject, and sync timestamps describe which provider owns the identity. The users and service_accounts tables remain kind-specific profile tables.
  • Provider-managed data is source-authoritative. Hubuum materializes provider users, groups, and memberships so local permission assignments have stable ids, but provider-managed users and groups are read-only through local mutation APIs.
  • Subtypes are mutually exclusive by construction. The composite (id, kind) foreign key makes it impossible for one id to be both a user and a service account, or to disagree with principals.kind. No triggers or caller discipline are needed.
  • Deleting a principal cascades. Deleting a user/service account deletes the principals row, which cascades to the subtype row, its group_memberships, and its tokens. Subtype rows are never deleted alone (that would orphan the principal).

Identity scopes are provider-backed principal namespaces. local is the built-in scope. LDAP and future external providers can add more scopes; token scopes are a separate concept and only narrow what a token may do.

Service accounts are currently local principals with Hubuum-issued token credentials. Long term, service account credentials should be treated as a credential provider plugged into the same principal model, not as a separate identity system.


Authorization is group-based

Hubuum authorization is entirely group → collection (see permissions.md). Principals do not hold permissions directly; they hold group memberships, and groups hold permissions on collections. Those grants apply to the collection itself and to descendant collections.

  • Membership lives in group_memberships(principal_id, group_id) — it is principal-centric, so both users and service accounts can be members of a group and gain that group's permissions.
  • The effective permission check funnels through a single group-id subquery over group_memberships, so the same authorization path serves humans and service accounts identically.
  • A principal is an admin iff it is a member of the configured admin group selected by HUBUUM_ADMIN_GROUPNAME and HUBUUM_ADMIN_IDENTITY_SCOPE. local/admin and an external-scope admin group are different groups. Admin membership grants collection authority — but, by itself, it does not make a service account a human IAM administrator (see Privilege separation).

A freshly created service account has its owner_group_id set for management purposes only; that does not grant it any runtime collection permission. A service account gets permissions only via an explicit group_memberships insert (i.e. by being added to a group).


Tokens

Authenticated requests carry a bearer token:

Authorization: Bearer <token>

Tokens belong to a principal and have a full lifecycle. The stored value is an HMAC hash — the raw token is shown exactly once, at creation or renewal.

Field Meaning
name, description Optional, human-facing labels
issued Creation time
expires_at Authoritative expiry; newly issued tokens always store a value
last_used_at Advanced on every successful validation
revoked_at Soft-revoke marker
active Server-derived authentication status using the same expiry and revocation rule as validation
expired Server-derived effective-expiry status, independent of revocation
scope Optional object containing the independent permissions and resources boundaries; null means unscoped

Validation

Validation is a single atomic statement. A token is accepted only if it is simultaneously:

  1. present and not revoked (revoked_at IS NULL),
  2. unexpired — expires_at > now(), or, for a legacy row where expires_at IS NULL, issued within the global window HUBUUM_TOKEN_LIFETIME_HOURS (default 24h), and
  3. not owned by a disabled service account.

On success, last_used_at is advanced. Any failure yields 401 Unauthorized with a generic message (no distinction between unknown / revoked / expired / disabled).

Revocation

Revocation is a soft delete: revoked_at is set and the row remains available for management and debugging during the retention window. Revoked tokens never validate again.

Retention

Token rows are retained for HUBUUM_TOKEN_RETENTION_DAYS (default 30) after their terminal time, then deleted automatically. Terminal time is the earlier of revocation and effective expiry. Newly issued tokens always use their stored expires_at; a legacy token without one uses issued plus HUBUUM_TOKEN_LIFETIME_HOURS.

The purge worker is enabled by default and deletes at most HUBUUM_TOKEN_RETENTION_PURGE_BATCH_SIZE rows per batch (default 1000). It requires a batch size of at least 10 and checks hourly by default, controlled by HUBUUM_TOKEN_RETENTION_PURGE_INTERVAL_SECONDS. Deleting a token also deletes its scope rows through their foreign keys; task provenance retains the task and clears its nullable submitted-token reference.

Token creation, renewal, revocation, and purge audit events do not contain the raw token or its stored HMAC hash. Immediately before each physical deletion, the purge transaction writes a system-authored token.purged event whose immutable before snapshot includes the exact permission and resource scope and whose metadata identifies the retention basis. This also preserves a final complete snapshot for legacy tokens whose earlier events did not record scope. If the event cannot be persisted, the transaction rolls back without deleting the token. Audit-event retention is configured independently from token retention.


Scopes

A token may be narrowed independently by permission and by resource identity. Scopes are a least-privilege mechanism for automation: they can only narrow authority, never widen it.

Effective authority = principal/group grants ∩ permission scope ∩ resource scope

The singular scope request field contains optional permissions and resources dimensions. Omitting one nested dimension leaves it unrestricted. Omitting scope or sending it as null creates an unscoped token; a present scope object must contain at least one dimension.

Scope semantics are fail-closed, with the persisted dimension flags as the source of truth rather than child-row presence:

Token state Meaning
Both dimensions omitted Unscoped — full principal authority
Permission dimension present Grants are intersected with its permission rows
Resource dimension present Grants are intersected with its resource rows
A flagged dimension has zero rows That dimension denies everything
Request body scope: { permissions: [] } or scope: { resources: [] } Rejected with 400 — an empty list is a client bug, not "grant nothing"
More than 1,000 scope.resources entries Rejected with 400 — resource boundaries are bounded for persistence and per-request authorization work

Enforcement details:

  • The scope check is a Rust pre-filter applied before the permission query, and before any admin bypass. A scoped token held by an admin cannot exceed its scopes — the admin "all access" fast paths apply only when the token is unscoped.
  • Scopes apply to every authority-bearing path, not just can! checks: search/list/export visibility is intersected with scopes too. An admin's scoped token, for example, lists only scope ∩ grant collections.
  • Scopes may name permissions or resources the principal does not currently have access to. Those entries are inert: a scope is never a grant. A point request to such a resource returns 403 Forbidden, while list and search endpoints omit it and compute pagination totals after both grant and scope filtering.

Resource scopes

Each resource entry uses a tagged object:

{
  "scope": {
    "resources": [
      { "kind": "collection", "id": 17 },
      { "kind": "class", "id": 42 },
      { "kind": "object", "id": 99 }
    ]
  }
}

IDs must be positive and must name resources that exist when the token is minted. Duplicate entries are rejected with 400.

Entry Included authority
collection That collection, its classes, and its objects
class That class and its objects, but not its parent collection or sibling classes
object That object only

Entries are additive inside the resource boundary. A relation is visible or actionable only when both endpoints are inside that boundary. Collection-level features such as templates, remote targets, audit events, and event subscriptions require an explicit collection scope; a class or object entry does not expose its parent collection. Runtime-wide system operations remain unavailable to any resource-scoped token. Task visibility remains principal-owned rather than part of the collection/class/object hierarchy.

Valid scope strings

Scope strings are the permission names from the permission model (permissions.md):

ReadCollection UpdateCollection DeleteCollection DelegateCollection
CreateClass ReadClass UpdateClass DeleteClass
CreateObject ReadObject UpdateObject DeleteObject
CreateClassRelation ReadClassRelation UpdateClassRelation DeleteClassRelation
CreateObjectRelation ReadObjectRelation UpdateObjectRelation DeleteObjectRelation
CreateTemplate ReadTemplate UpdateTemplate DeleteTemplate
CreateRemoteTarget ReadRemoteTarget UpdateRemoteTarget DeleteRemoteTarget
ExecuteRemoteTarget

Unknown strings are rejected (fail-closed) wherever scopes are parsed.

Scopes and async tasks

Asynchronous work must not later run with more authority than the request that enqueued it. Import, export, and remote-call tasks record a scope snapshot at enqueue time: the submitting token id, its effective scoped flag, and both optional scope dimensions. The worker reconstructs that snapshot fail-closed and intersects it with the principal's live grants during execution. Legacy permission-only snapshots remain readable.

Collection-scoped import and export work is available to ordinary principals. Identity, template, and integration imports still require an unscoped administrator. Disabling a submitting service account while a task is queued makes the task fail closed.


Service accounts

A service account is a non-human principal for automation. It cannot password-login; it acts exclusively through tokens.

Lifecycle

Action Endpoint Notes
Create POST /api/v1/iam/service-accounts Body: name, owner_group_id, optional description
List GET /api/v1/iam/service-accounts Admin sees all; others see SAs of groups they belong to
Get / Update GET / PATCH /api/v1/iam/service-accounts/{id}
Disable POST /api/v1/iam/service-accounts/{id}/disable Soft-revokes all its tokens and cancels its pending tasks
Delete DELETE /api/v1/iam/service-accounts/{id} Cascades via the principal row

owner_group_id uses ON DELETE RESTRICT: a group that owns service accounts cannot be deleted until those SAs are reassigned or deleted. Group deletion returns 409 Conflict listing the owned SAs rather than failing with an opaque FK error.

A disabled service account: its existing tokens stop validating immediately, and it cannot mint new tokens (the mint endpoint returns 409 Conflict). Its queued (not-yet-claimed) tasks are cancelled. A task already claimed by a worker is caught by the worker's pre-dispatch disabled-SA gate and does not execute; a task already mid-execution runs to completion (Hubuum does not interrupt in-flight work, and never mislabels running work as cancelled).

Who may create a service account

  • An admin may create an SA for any group.
  • A non-admin human may create an SA only for a group they already belong to (you cannot mint an SA owned by a group you are not in).
  • Service accounts cannot create service accounts.

Who may manage a service account

Management means get/update/disable/delete and managing the SA's tokens. The rule is admin OR a human member of the SA's owner_group_id.

A service account never manages itself. Even if a service account is added to its own owner group, it cannot manage itself or mint/revoke its own tokens — only human owner-group members (and admins) can. This avoids a token bootstrapping more tokens.


Tokens and groups, by principal

Token and group-membership management is principal-shaped, so one route family serves both kinds:

Endpoint Purpose Authorization
POST /api/v1/iam/principals/{principal_id}/tokens Mint a token (returns raw value and expiry once) human: self or admin; SA: admin or human owner-group member
GET /api/v1/iam/principals/{principal_id}/tokens List active token metadata by default; select retained rows with state same as above
GET /api/v1/iam/principals/{principal_id}/tokens/{token_id} Read one retained token until it is purged same as above
POST /api/v1/iam/principals/{principal_id}/tokens/{token_id}/renew Mint a fresh copy of an active or expired token same as above
POST /api/v1/iam/principals/{principal_id}/tokens/{token_id}/revoke Soft-revoke a token same as above
GET /api/v1/iam/principals/{principal_id}/groups List the principal's groups same as above
GET /api/v1/iam/principals/{principal_id}/permissions Direct permission rows across all collections, grouped by granting group same as above
GET /api/v1/collections/{collection_id}/permissions/principal/{principal_id} Direct permission rows on a single collection for the principal's groups collection read authority
GET /api/v1/collections/{collection_id}/permissions/effective/principal/{principal_id} Direct and inherited permission rows on a single collection for the principal's groups collection read authority
POST / DELETE /api/v1/iam/groups/{group_id}/members/{principal_id} Add/remove a member (human or SA) admin only

Mint and renewal require fresh authentication approval from the acting human in addition to the authority above. Resolve defaults during approval and copy the returned expiry into the final mutation.

Mint accepts name, description, expires_at, and an optional scope object containing permissions and resources. If expires_at is omitted, Hubuum materializes issued + HUBUUM_TOKEN_LIFETIME_HOURS; the response returns the raw token and authoritative expires_at. Clients can discover the configured default without authentication from GET /api/v1/config at authentication.default_token_lifetime_hours. A requested expiry must be later than the database issuance timestamp and cannot exceed issued + HUBUUM_MAX_TOKEN_LIFETIME_HOURS (default 8760 hours). The public config exposes that bound as authentication.max_token_lifetime_hours.

Token lists accept state=active|expired|revoked|all; omitting it preserves the active-only default. The expired and revoked subsets can overlap because a revoked token may later pass its effective expiry. Retained point lookups return 200 for active, expired, and revoked rows, and return 404 once the row has been purged. These management endpoints require a separate valid management credential: an expired bearer token cannot authenticate itself or be used as a lookup capability.

Renewal creates a new token row and raw secret while copying the source token's name, description, and exact permission/resource scope. It never modifies or reactivates the source, and it applies the default lifetime unless the request provides a new expires_at. Explicitly revoked tokens cannot be renewed; a caller that deliberately wants different authority must submit a normal token mint request. The creation audit event for the fresh token records the source as renewed_from_token_id.

GET /api/v1/iam/me returns the scope object for the current token. Both token-list endpoints return the same object for every visible token; scope: null identifies an unscoped token without a redundant boolean.

Two safety properties worth calling out:

  • Token operations are scoped by both path ids. Point lookup, renewal, and revocation constrain the token id by the path principal, so a manager of principal A cannot operate on principal B's token by guessing its id (mismatch → 404).
  • Group-membership mutation is admin-only. Being a human owner-group member lets you manage an SA and its credentials, but it does not let you grant that SA runtime collection access by adding it to arbitrary groups.

Principal settings

Every principal has a local settings document for preferences. It is not included in existing user, service-account, principal, or /iam/me responses, and it is not searchable.

Endpoint Purpose Authorization
GET /api/v1/iam/me/settings Read the current principal settings Any valid token for the current principal
PUT /api/v1/iam/me/settings Replace the current principal settings Any valid token for the current principal
PATCH /api/v1/iam/me/settings Apply JSON Merge Patch or JSON Patch to the current principal settings Any valid token for the current principal
DELETE /api/v1/iam/me/settings Reset the current principal settings to {} Any valid token for the current principal
/api/v1/iam/principals/{principal_id}/settings The same operations for a named principal Self, or an unscoped human admin; denied cross-principal requests return 404

The settings document root must be a JSON object. Nested values may be any JSON type. PUT replaces the entire document, so it can retain a nested null value.

PATCH selects its semantics from Content-Type. application/json retains the existing object-only JSON Merge Patch behavior. The standards-oriented application/merge-patch+json media type is accepted as an alias:

  • Object values merge recursively.
  • A null patch value removes that key; it does not store a JSON null.
  • Arrays, strings, numbers, and booleans replace the existing value.
  • Patching an object into a missing key or a non-object value starts with {}.

For example:

// Current settings
{
  "theme": "light",
  "layout": { "density": "normal", "sidebar": true },
  "tags": ["a"]
}

// PATCH body
{
  "theme": "dark",
  "layout": { "sidebar": null, "columns": 2 },
  "tags": ["b"]
}

// Result
{
  "theme": "dark",
  "layout": { "density": "normal", "columns": 2 },
  "tags": ["b"]
}

Use Content-Type: application/json-patch+json to apply an RFC 6902 operation array instead. Hubuum supports add, remove, replace, move, copy, and test. Every path and from is an RFC 6901 JSON Pointer relative to the settings document root. test compares arrays and objects recursively and compares JSON numbers by numeric value, so representations such as 1, 1.0, and 1e0 are equal. For example, this patch compares the current theme, updates it, inserts one array element, and stores a literal JSON null:

[
  { "op": "test", "path": "/theme", "value": "light" },
  { "op": "replace", "path": "/theme", "value": "dark" },
  { "op": "add", "path": "/shortcuts/1", "value": "search" },
  { "op": "add", "path": "/dismissed_tip", "value": null }
]

The complete JSON Patch is applied to the latest settings value after the principal row is locked. A failed operation, including a failed test, returns 409 Conflict and rolls back all operations and audit side effects. The final document root must remain an object; replacing it with another JSON type returns 400 Bad Request. A successful no-op returns the current document without advancing its revision or emitting an event.

JSON Patch requests and results are limited to 2 MiB, 1,000 operations, 128 pointer segments per path or from, 64 nested containers, and 32 MiB of cumulative application work. Intermediate results are checked after every operation for these bounds and for PostgreSQL JSONB representability. Array indices can become stale when another client changes the array first; pair index-sensitive operations with test when element identity matters.

Settings mutations are serialized with a principal row lock, so concurrent patches preserve unrelated changes. Each mutation that changes the settings records a complete before and after snapshot in an updated audit event for the target user or service account. Unchanged mutations are no-ops and do not emit an event.


Request authority: extractors and gates

Each handler declares the authority it requires. There are two families.

Scope-aware (the only family that accepts scoped tokens and service accounts):

  • Authenticated — resolves the principal and, if the token is scoped, its scope set. Every downstream authority decision threads the scopes into the fail-closed pre-filter. Resource endpoints and task submission use this extractor. Imports, exports, and remote-target invocation retain ordinary scope-aware authorization.

Human/IAM (privilege-separated):

  • UserAccess, AdminAccess, AdminOrSelfAccess, ManagementAccess — used for human-only and credential/IAM operations (user CRUD, service-account CRUD, principal token management, admin/all-token logout, group-member mutation).

The human/IAM extractors apply two gates, in order, before any admin/self check:

  1. Kind gate — the principal must be kind = 'human'. A service account is rejected with 403 Forbidden (cleanly, never a 500), even if it is in the admin group and presents an unscoped token.
  2. Scope gate — the token must be unscoped. A scoped token is rejected with 403 Forbidden.

So a scoped automation token can be used only on Authenticated endpoints (where the scope pre-filter applies), and only humans with unscoped tokens can reach IAM/credential-management surfaces.

Login, validate, logout

  • POST /api/v0/auth/login — humans only, by name. Service accounts have no password and receive a generic 401. Failed attempts are rate-limited by name + client IP (see login_rate_limiting.md). A successful response returns the raw token and its authoritative expires_at. Login identity_scope and name values are limited to 255 Unicode characters, and passwords to 4096. Larger fields are rejected before rate-limit bookkeeping, directory/database lookup, or password verification.
  • GET /api/v0/auth/validate and current-token logout use Authenticated, so a valid scoped service-account token validates as valid.
  • All-token logout / revoke-all are unscoped human/IAM management operations.

Privilege separation

The model guarantees, structurally, that a service account is never a human IAM administrator, regardless of its group membership or token:

  • An SA may be granted runtime collection authority by being placed in a group (even the admin group) — that is a deliberate, grantable capability.
  • But the kind = 'human' gate on the IAM/management extractors means an SA can never create/modify users, manage service accounts, manage credentials, or mutate group membership — it is denied with 403 before any admin check runs.
  • Scoped tokens are likewise confined to scope-aware resource endpoints and can never reach IAM/management surfaces.

This separation is enforced by construction (extractor gates + database constraints), not by convention.


See also