Identity & Access Management (IAM)

tuvl has a built-in IAM system based on users, roles, and scopes. Access tokens are Ed25519-signed JWTs that carry identity, group membership, and fine-grained permission scopes — see Access Tokens.


Concepts

Users

A User is a principal that can authenticate with tuvl. Users can use:

  • Password auth — email (or phone number) + bcrypt-hashed password via POST /auth/token
  • Federated auth — OAuth2 login via Google, GitHub, or Microsoft (see Federation)
  • Both — a user may link both methods to the same account

Roles

A Role is a named bundle of scopes. Examples:

Role Scopes
superadmin iam:admin (all admin operations)
hr_manager requisition:write, candidate:read
recruiter candidate:read, interview:read

Scopes

Scopes are arbitrary resource:action strings that your workflows and endpoints check. The only built-in scope is iam:admin, which gates all /auth/admin/* endpoints.


Authorization Surfaces

Scopes and groups gate two built-in surfaces beyond any custom endpoints you protect yourself (see Protecting Your Own Endpoints): the auto-generated model CRUD API and workflow triggers.

Model CRUD

Every /models/{model}/… route requires a valid access token by default. Scope names default to {modelname.lower()}:read / :write / :delete, overridable per model with spec.access.{read,write,delete}_scope in the ModelDefinition.

spec.access.{read,write,delete}_groups (a list, or a bare group name) layers an IAM group requirement on top of the scope check — both the scope and the group must pass. A tier left undeclared cascades from the next-more-privileged tier (write_groups falls back to read_groups, delete_groups falls back to write_groups), so pinning only read_groups never leaves mutations open to a wider audience than reads. Declaring no groups at all is scope-only. iam:admin bypasses every scope and group check.

kind: ModelDefinition
metadata:
  name: Candidate
spec:
  access:
    read_scope: candidate:read
    write_scope: candidate:write
    read_groups: recruiter          # bare string or a list
    write_groups: [hr_manager]      # delete_groups falls back to this

Use GET /admin/scopes to see every enforceable scope grouped by source (crud, workflows, system) — its crud_api_enabled field also reports whether the CRUD kill switch is currently on.

Disabling CRUD entirely

The whole /models/* surface can be turned off project-wide with spec.api.expose_model_crud: false — see Configuration Overview. When disabled, the routes are absent, not merely scope-denied.

Workflow Triggers

Workflow triggers are gated by metadata.required_scope and/or metadata.required_group in the workflow YAML. Every way of starting a run — the workflow's HTTP route, POST /api/workflows/{name}/runs, and MCP export — requires a valid bearer token by default, even for a workflow that declares neither.

Anonymous access is an explicit opt-in via spec.trigger.http.public: true. Combining public: true with a declared required_scope or required_group is a tuvl validate error — the engine fails closed and always enforces the declared scope/group over public at runtime.

kind: Workflow
version: tuvl/v2
metadata:
  name: register_user
spec:
  trigger:
    http: { path: /api/register, method: POST, public: true }   # anonymous — no required_scope/required_group alongside

In dev mode (tuvl dev), workflows with no scope/group requirement stay tokenless so quickstarts run without a login step.

An IAM role's name is the token's group: required_group: finance_lead admits users who hold the finance_lead role. The role's scopes travel in the token too, and iam:admin passes every check.

People who follow runs and decide human steps

Starting a run is one grant; following it and deciding its human steps are separate scopes:

To… The caller needs
read the runs they started and their events (e.g. poll a 202 run) nothing beyond signing in — iam:admin reads everyone's
pause, resume, abort or steer a run runs:control
see and decide approvals approvals:decide and the step's human.required_group, if it has one

A reviewer role therefore carries both: name it after the group the human step requires and give it the scope. Insight's approver picker flags a role without approvals:decide and grants it in one click.

curl -X POST http://localhost:8885/auth/admin/roles -H "Authorization: Bearer $ADMIN" \
  -H "Content-Type: application/json" \
  -d '{"name": "finance_lead", "scopes": ["approvals:decide"]}'

On a group-gated step, the person who started the run can't approve it. Every decision is journaled with who made it.

When access changes

Taking access away is immediate. Deactivating a user, changing their password, deleting them, removing one of their roles, or deleting or narrowing a role they hold ends their sessions: tokens issued before that are refused (401 Session ended — sign in again.), and the next sign-in gets the current roles. Granting a role or adding scopes ends nothing — the user picks it up at their next sign-in. Users and roles that are in use can be deleted; their role links go with them.


Bootstrap

On a fresh installation with no users, the bootstrap endpoint creates the first superadmin:

curl -X POST http://localhost:8885/auth/bootstrap \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@example.com",
    "password": "change-me-now"
  }'
{
  "access_token": "<jwt>",
  "token_type": "bearer"
}

One-time only

Bootstrap fails with 409 Conflict if any user already exists. It is a one-time setup operation intended only for empty databases.


Authentication

Password Login

POST /auth/token
Content-Type: application/x-www-form-urlencoded

username=admin@example.com&password=change-me-now

OAuth2 form format

/auth/token follows the OAuth2 password-grant standard — it expects application/x-www-form-urlencoded with fields username and password (not JSON). The username field accepts either an email address or a phone number. This makes it compatible with the Swagger UI Authorize button.

Response:

{
  "access_token": "<jwt>",
  "token_type": "bearer"
}

Use the token in subsequent requests:

curl http://localhost:8885/auth/admin/users \
  -H "Authorization: Bearer <jwt>"

Token Refresh

Exchange the current token for a new one with a fresh TTL (the old token is immediately revoked):

curl -X POST http://localhost:8885/auth/refresh \
  -H "Authorization: Bearer <old_token>"

Returns a new TokenResponse. The old token is added to the blacklist and can no longer be used.

Logout

Revoke the current token immediately:

curl -X POST http://localhost:8885/auth/logout \
  -H "Authorization: Bearer <token>"

Returns 204 No Content. The token is added to the blacklist.


Using the TypeScript SDK

The @tuvl/client package ships a TuvlAuth helper that wraps all /auth/* endpoints — no manual fetch required.

Install

npm install @tuvl/client

Password login

import { TuvlAuth, TuvlClient } from "@tuvl/client";

const auth = new TuvlAuth({ baseUrl: "http://localhost:8885" });

const { access_token } = await auth.loginWithPassword("admin@example.com", "secret");

// Attach the token to the workflow client
const client = new TuvlClient({ baseUrl: "http://localhost:8885", token: access_token });

OAuth2 login (browser)

// 1. Redirect the browser to the provider
const auth = new TuvlAuth({ baseUrl: "http://localhost:8885" });
window.location.href = auth.getOAuthLoginUrl("google");

// 2. After login the server redirects to TUVL_OAUTH_UI_REDIRECT_URL?token=<jwt>
//    On that landing page, extract the token:
const token = new URLSearchParams(window.location.search).get("token")!;
const client = new TuvlClient({ baseUrl: "http://localhost:8885", token });

Configure the redirect

Set TUVL_OAUTH_UI_REDIRECT_URL in your server .env to the URL of the page that should receive the token after OAuth completes. Without it the callback returns JSON (suitable for server-side and CLI flows).

TUVL_OAUTH_UI_REDIRECT_URL=https://app.example.com/auth/callback

Token refresh

const { access_token: newToken } = await auth.refresh(currentToken);
client.setToken(newToken);   // update the workflow client in-place

Logout

await auth.logout(token);
// discard the token from storage after this call

Full bootstrap → login → call example

import { TuvlAuth, TuvlClient } from "@tuvl/client";

const BASE_URL = "http://localhost:8885";
const auth = new TuvlAuth({ baseUrl: BASE_URL });

// Step 1 — on a fresh install: bootstrap the first admin
// (curl -X POST .../auth/bootstrap  or via the tuvl UI)

// Step 2 — login
const { access_token } = await auth.loginWithPassword("admin@example.com", "secret");

// Step 3 — use the token for workflow calls
const client = new TuvlClient({ baseUrl: BASE_URL, token: access_token });
const result = await client.execute("hello");

// Step 4 — refresh before expiry (default TTL: 24 h)
const { access_token: fresh } = await auth.refresh(access_token);
client.setToken(fresh);

Admin: Users

All user management endpoints require the iam:admin scope.

Create User

POST /auth/admin/users
Authorization: Bearer <admin_token>
Content-Type: application/json
{
  "email": "jane@example.com",
  "password": "secure-password",
  "is_active": true
}
  • password is optional — omit it for federated-only accounts.
  • Returns 201 Created with the user object.

List Users

GET /auth/admin/users
Authorization: Bearer <admin_token>

Returns an array of user objects including their assigned roles.

Get Single User

GET /auth/admin/users/{user_id}
Authorization: Bearer <admin_token>

Update User

PATCH /auth/admin/users/{user_id}
Authorization: Bearer <admin_token>
Content-Type: application/json
{
  "password": "new-password",
  "is_active": false
}

Both fields are optional. Omit a field to leave it unchanged.

Delete User

DELETE /auth/admin/users/{user_id}
Authorization: Bearer <admin_token>

Returns 204 No Content. Also removes all role assignments for the user.


Admin: Roles

Create Role

POST /auth/admin/roles
Authorization: Bearer <admin_token>
Content-Type: application/json
{
  "name": "hr_manager",
  "description": "Can manage job requisitions and review candidates",
  "scopes": ["requisition:write", "candidate:read"]
}

List Roles

GET /auth/admin/roles
Authorization: Bearer <admin_token>

Update Role Scopes

Replace the complete scope list for a role:

PATCH /auth/admin/roles/{role_id}/scopes
Authorization: Bearer <admin_token>
Content-Type: application/json
{
  "scopes": ["requisition:write", "candidate:read", "interview:read"]
}

This operation is atomic — all existing scopes are deleted and the new list is inserted in one transaction.

Delete Role

DELETE /auth/admin/roles/{role_id}
Authorization: Bearer <admin_token>

Returns 204 No Content.


Admin: Role Assignment

Assign Role to User

POST /auth/admin/users/{user_id}/roles/{role_id}
Authorization: Bearer <admin_token>

Returns 200 OK. The user's next token (on refresh or re-login) will include the new role's scopes.

Revoke Role from User

DELETE /auth/admin/users/{user_id}/roles/{role_id}
Authorization: Bearer <admin_token>

Returns 204 No Content.


Protecting Your Own Endpoints

Require Authentication

Use verify_token + get_current_user as FastAPI dependencies:

from fastapi import APIRouter, Depends
from tuvl.core.auth.access import get_current_user
from tuvl.core.auth.schemas import TokenUser

router = APIRouter()

@router.get("/my-endpoint")
async def my_endpoint(user: TokenUser = Depends(get_current_user)):
    return {"user_id": user.user_id, "scopes": user.scopes}

Require a Specific Scope

from tuvl.core.auth.access import require_scope

@router.post("/requisitions")
async def create_requisition(
    user: TokenUser = Depends(require_scope("requisition:write")),
):
    ...

require_scope raises 403 Forbidden if the token does not carry the required scope.

Require Group Membership

from tuvl.core.auth.access import require_groups

@router.get("/admin-panel")
async def admin_panel(
    user: TokenUser = Depends(require_groups(["hr_manager", "superadmin"])),
):
    ...

require_groups raises 403 Forbidden unless the token carries at least one of the listed groups.


Database Tables

The IAM system creates four tables on startup:

Table Purpose
tuvl_system_iam_users User credentials (email, phone number, names, bcrypt hash, federation fields)
tuvl_system_iam_roles Named roles with optional description
tuvl_system_iam_user_roles Many-to-many: user ↔ role assignments
tuvl_system_iam_role_scopes One row per scope per role

Tables are created automatically via SQLModel's create_all during startup — no migration tool required for initial setup.


Dev Mode

In tuvl dev, the dev API key (auto-generated and shown on startup) is accepted on all /auth/* endpoints as a synthetic superuser with the iam:admin scope. This removes the need to create a user or manage tokens during local development.

# Both of these work in dev mode:
curl -H "Authorization: Bearer <dev_api_key>" http://localhost:8885/dev/files
curl -H "Authorization: Bearer <dev_api_key>" http://localhost:8885/auth/admin/users