Skip to main content

Authentication and authorization

Read this when you add or change an operation's access rules, work with administrator or ban roles, or debug a 400, 401, 403 or 503 from Tadoku API.

The public contract is in the Authorization API reference; its source is services/tadoku-api/spec/openapi.yaml.

Authentication and authorization​

  • Identity keeps tenant-owned accounts and sessions in Tadoku API. The identity entry point provides the authenticated session callback. Oathkeeper's cookie-session authenticator forwards the complete Cookie and trusted X-Dev-Branch headers through the branch-aware API hop. Oathkeeper's id_token mutator produces an RS256 JWT whose sub is the account UUID, or guest for anonymous requests. Session traits travel in the session claim; type is user. API tokens carry a signed tenant claim for both anonymous and signed-in requests: tadoku/prod, or tadoku/<route> when a trusted branch selection reaches Oathkeeper. The edge removes client branch headers, so only branch hosts and in-cluster callers forwarding the selection choose a test tenant; production Oathkeeper signs an empty, rejected tenant for a malformed route. Service-token exchanges use their own claims.
  • Permissions record who is an administrator and who is banned in each tenant. They are rows in the Tadoku API database, read on every request that needs them, never cached and never put in the token.

Email access tokens​

Only GET /api/internal/emails and its message-detail route accept tenant access tokens. Their Oathkeeper rule tries cookie_session and then noop, signs the normal cookie-session JWT, and restores the original Authorization header only when it contains a tdk_at_ bearer. Other routes keep their existing authentication. The backend verifies tokens itself; proxy admission never grants email access. Anonymous requests and forged JWTs are rejected.

The authz token feature parses the tenant from the token, checks API_BRANCH, and looks up its SHA-256 hash under that tenant's row-level security scope. The hash must be unrevoked, unexpired and scoped exactly to email:read, the one scope set the tokens service accepts. The lookup binds that set as an exact array match, including dimensions and bounds, and the service checks the returned scopes. Successful verification updates last_used_at and creates a private application email-reader context, without creating a user identity or bypassing other routes. Token authentication rejects tadoku/prod when API_PRODUCTION is true (the default). Development explicitly sets it false; production administrators still use cookies, administrator permits and transactional, fail-closed read auditing. Canonical development token reads record the token UUID as actor and include access_token_id in the same fail-closed audit transaction.

See Tenant lifecycle command for attached issuance, limits, listing and revocation.

Identity reads​

Profile and moderation use identity.Reader for public profile details and avatars, account age, admin listing and ban-target existence. Targets must be active accounts in the request tenant. Users delete their own account through identity settings, and administrators file deletions for others through the profile API; see Account deletion. Read access does not authorize shared provider mutations.

Permission model​

Features define permissions as Permission model describes.

authz.Permissions (services/tadoku-api/features/authz/authz_permissions.go) defines the app namespace, with one object per tenant, app:root: the relations admins and banned, and the permits admin, Related("admins"), and is_banned, Related("banned").

Nothing is inherited between tenants: a test or branch tenant starts with no administrators and no bans.

A user is never both an administrator and banned: a ban refuses an administrator, and a grant refuses a banned user.

Administrators can change only the banned relation through the API (PUT /authz/users/{id}/role), and never for another administrator. No API grants admins; see Granting an administrator. A role update locks the target's users row, then writes the relationship and its moderation_audit_log entry, ban_user or unban_user, in one transaction. A target whose account deletion is filed returns 409, also when the update waited for the deletion worker; a target that is already deleted returns 404.

Granting an administrator​

Grant or revoke an administrator in a deployed environment with operator SQL as the runtime role, setting the tenant inside the transaction as Tenancy requires. A banned user cannot be made an administrator: the grant inserts nothing while a banned relationship exists. To grant a production administrator:

begin;
select set_config('tadoku.tenant', 'tadoku/prod', true);
select deletion_locked_at, deleted_at from users where id = '<lowercase user UUID>' for update;
-- If either value is set, the account is being deleted: run rollback; instead.
insert into permission_relationships (tenant, object_type, object_id, relation, subject_type, subject_id)
select current_setting('tadoku.tenant'), 'app', 'root', 'admins', 'user', '<lowercase user UUID>'
where not exists (
select from permission_relationships
where tenant = current_setting('tadoku.tenant')
and object_type = 'app'
and object_id = 'root'
and relation = 'banned'
and subject_type = 'user'
and subject_id = '<lowercase user UUID>'
);
-- INSERT 0 1: granted. INSERT 0 0: nothing was granted because the user is banned; roll back.
commit;

Locking the users row first, as role updates do, makes an account deletion or a ban filed at the same moment either see the new administrator and refuse, or commit first. A filed deletion still runs, so roll the grant back when the lock shows deletion_locked_at or deleted_at set. When the insert reports INSERT 0 0, the user is banned: roll back, and grant only after deliberately unbanning them. Ask another administrator to unban them through PUT /authz/users/{id}/role, or, when there is none, remove the ban with the unban block below. A duplicate key error means the user is already an administrator; roll back.

To revoke one:

begin;
select set_config('tadoku.tenant', 'tadoku/prod', true);
delete from permission_relationships
where tenant = current_setting('tadoku.tenant')
and object_type = 'app'
and object_id = 'root'
and relation = 'admins'
and subject_type = 'user'
and subject_id = '<lowercase user UUID>';
commit;

To unban a user without another administrator:

begin;
select set_config('tadoku.tenant', 'tadoku/prod', true);
select id from users where id = '<lowercase user UUID>' for update;
delete from permission_relationships
where tenant = current_setting('tadoku.tenant')
and object_type = 'app'
and object_id = 'root'
and relation = 'banned'
and subject_type = 'user'
and subject_id = '<lowercase user UUID>';
commit;

The lock orders the unban with role updates and grants. Unlike the API, it writes no moderation_audit_log entry, because no administrator acts.

These blocks name the tenant only in set_config. For a production branch tenant, use its key there instead of tadoku/prod. In development, tadoku-tenant development-accounts grants Dev Admin admins on each branch tenant through authz.GrantAdmin under Dev Admin's users row lock, so a concurrent ban either commits first and the grant returns authz.ErrBannedUser, a conflict, or waits and refuses the new administrator; see Development data.

Request pipeline​

Public /api/internal/identity/* entry rules use only Oathkeeper's anonymous authenticator and id_token mutator. The signed guest JWT supplies the tenant; the original Cookie reaches the identity API, which owns session authentication and CSRF. These rules skip the global cookie-session callback so a banned user can read their session or log out. This does not exempt unrelated business routes from the ban pipeline below. The identity API's admission rules are owned by the identity feature; changing gateway rules does not implement those handlers.

Every business route registered through services/tadoku-api/transport/http/router.go passes two shared middlewares before its handler. Health probes (/livez, /readyz) are outside this pipeline.

  1. JWT authentication (services/tadoku-api/transport/http/authentication.go) verifies the RS256 signature against API_JWKS, Oathkeeper's public key set. Startup fetches it and fails if it cannot. Tokens must carry exp and iat, be younger than API_MAX_TOKEN_AGE (default 24h) and, when API_JWT_ISSUER is set, match that issuer. Tokens with type: service are rejected. The verified subject, email and display name are placed on the request context as identity.User (services/tadoku-api/internal/identity/). The signed tenant must match ^[a-z0-9][a-z0-9-]{0,55}/[a-z0-9][a-z0-9-]{0,55}$. Authentication parses it into internal/tenant.Key and puts it on the context beside the identity. Missing or malformed tenant claims are invalid credentials. A scoped API_BRANCH deployment rejects a different valid tenant with 421; the unscoped base accepts every parsed tenant. This step checks no roles.
  2. Tenant admission and ban gate (services/tadoku-api/transport/http/banned_users.go) calls authz.Roles.Admit, which checks the is_banned permit on the tenant's app:root. A missing tenant fails with unavailable.

Every tenant admits requests by the same rules, so test tenants are open to anonymous visitors and to signed-in users without any grant:

RequestResult
Guest or empty subjectContinues without a permission query.
Signed-in user, not bannedOne query; continues.
Permission query errorLogged; continues with the ban state recorded as unknown. Read-only operations still run; strict checks return 503.
Signed-in user with confirmed banEmpty 403, including for administrators. Only GET /authz/current-user/role continues and reports banned.

The current-user-role exception lets a banned user discover its ban.

Public identity entry rules use anonymous authentication with the signed tenant and the id_token mutator. Their handlers resolve the identity cookie themselves, so a banned account can inspect its session and log out without presenting a banned user JWT. Protected routes retain cookie-session authentication and the normal ban gate. Do not exempt identity paths from the backend ban middleware; the gateway must use the anonymous-only identity rule.

Checks in application operations​

Application operations in services/tadoku-api/app/api/ own actor authorization; no HTTP middleware enforces administrator access. They call *permissions.Checker (services/tadoku-api/internal/permissions/) and, for administrator checks, *authz.Roles (services/tadoku-api/features/authz/):

MethodPasses whenOtherwise
RequireAuthenticatedAn actor is present (a signed-in, non-guest user) and the ban lookup succeeded.401 for no user or guest; 503 if the ban state is unknown.
RequireAuthenticatedAllowingUnknownBanAn actor is present (a signed-in, non-guest user).401. Read-only operations only; mutations must never use it.
Roles.RequireAdminRequireAuthenticated passes and the actor holds the admin permit on the request tenant's app:root.As above, 403 for non-administrators, 503 when the check fails.
Roles.RequireProductionAdminRequireAdmin passes and the request tenant is tadoku/prod.As RequireAdmin, and 403 for every other tenant.
Roles.IsAdmin, Roles.IsAdminOrFalseReport administrator status to expand behavior inside an already-authorized operation.IsAdmin returns unavailable on errors; IsAdminOrFalse returns false.
Require, AllowedEvaluate any permissions.Check a namespace built, for the actor; Allowed reports the result.Require returns the errors below.
AllowedEachReports many checks for the actor, each as Allowed would.false for every check for a guest or a banned user; 503 when the ban state is unknown or any check fails.

Checks return 401 when nobody is signed in, 403 when denied and 503 when the check fails or the request's ban state is unknown. A signed-in subject that is not a UUID has no roles.

Feature services may inspect permissions only to expand behavior inside an operation the application has already authorized, and never repeat the ban gate. Roles of other users come from authz.Roles.UserRoles and never authorize the actor. Relationship writes and reads use the building blocks.

Private contests​

A private contest is unlisted: anyone with its link can read it, because reads of one contest by its ID do not check privacy. Privacy only changes which contests listings return, and official contests cannot be private.

  • The contest list includes a private contest only for its owner and for administrators, with or without the owner filter (user_id). Filtering by another user's ID does not reveal that user's private contests. The list total and next-page token count only the contests the caller can see. The contests service chooses the visibility from the actor and the administrator status the application reports; see newListVisibility in services/tadoku-api/features/contests/domain.go.
  • The contest list returns deleted contests only to administrators, matching reads of one contest by its ID. The contests service ignores include_deleted for everyone else; see ListContests in services/tadoku-api/features/contests/contests_service.go.
  • A user's yearly contest registrations include private contests only when the actor is that user or an administrator, and their total excludes the private contests they hide. The contests service decides this from the actor and the administrator status the application reports; see ListYearlyRegistrations in services/tadoku-api/features/contests/contests_service.go.

Shared data​

languages has no tenant column: every tenant, including production, reads the same rows. The tenants and tenant_overrides registry is also shared, but only the owner tadoku-tenant command writes it. Any tenant may read shared data. Application operations that write it call RequireProductionAdmin, so an administrator acting through a test tenant receives 403. Row-level security policies on the shared tables also reject writes from any other tenant.

Oathkeeper administrator callback​

Operator routes, such as the Flipt UI on flags.tadoku.dev.lab, require a native session. Oathkeeper's remote_json authorizer then posts the session subject to POST /authz/internal/v1/proxy/admin-check. This callback route bypasses the JWT and ban pipeline and never creates a user identity. It accepts only the shared bearer API_OATHKEEPER_AUTHZ_TOKEN (development Secret dev-oathkeeper-authz, also mounted into Oathkeeper). It returns 200 when IsProductionAdmin finds the subject's admin permit in tadoku/prod, whatever tenant the context holds, and 403 otherwise.

Error to HTTP status mapping​

Application errors are services/tadoku-api/internal/errx/ kinds, mapped in services/tadoku-api/transport/http/errors.go.

CauseStatus
Missing or malformed Authorization: Bearer header400, JSON missing or malformed jwt
Invalid, expired, too old, service JWT or invalid tenant claim401, JSON invalid or expired jwt
Valid tenant not served by this deployment421, JSON tenant not served by this deployment
Confirmed ban403, empty body
Invalid or missing callback credential401, empty body
errx.Unauthorized (no user or guest)401
errx.Forbidden (not an administrator)403
errx.Unavailable (failed permission check, missing tenant, unknown ban state, PostgreSQL outage)503
errx.InvalidInput400
Request deadline exceeded504

Seeding an administrator in development​

Run make dev-seed (scripts/dev/seed-db.sh) once the base API is ready. It only runs against the homelab-talos-dev Kubernetes context and is safe to re-run.

  • Creates a native administrator account (TADOKU_DEV_ADMIN_EMAIL, default dev@tadoku.app) and reader account (TADOKU_DEV_READER_EMAIL, default reader@tadoku.app) in the canonical tenant. Passwords come from TADOKU_DEV_ADMIN_PASSWORD and TADOKU_DEV_READER_PASSWORD. An account with the same email that the seed does not own is never modified.
  • Grants the administrator admins on app:root in tadoku/prod with SQL under its users row lock, keeping an existing grant. A banned administrator fails the seed without a grant. Ask another administrator to unban them through PUT /authz/users/{id}/role, or remove the ban with the unban block, then rerun. The reader gets no role.
  • Loads the application seed data from scripts/dev/seed/.

See Development data for fixture UUIDs, reruns and branch seeding, and Development environment for cleanup.