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
}
passwordis optional — omit it for federated-only accounts.- Returns
201 Createdwith 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