Identity & Access Management (IAM)¶
tuvl has a built-in IAM system based on users, roles, and scopes. Access tokens are Biscuit tokens — cryptographically signed Datalog structures that embed identity, group membership, and fine-grained permission scopes.
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 Biscuit 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. In production, every trigger route — the REST mount, the versioned
/{schema_version}/run/{name} route, and gRPC RunWorkflow — requires a valid bearer token by
default, even for a workflow that declares neither.
Anonymous access is an explicit opt-in via spec.trigger.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
metadata:
name: register_user
spec:
trigger:
path: /api/register
method: POST
public: true # anonymous access — no required_scope/required_group allowed alongside this
In dev mode (tuvl dev), workflows with no scope/group requirement stay tokenless so quickstarts
run without a login step.
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"
}'
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:
Use the token in subsequent requests:
Token Refresh¶
Exchange the current token for a new one with a fresh TTL (the old token is immediately revoked):
Returns a new TokenResponse. The old token is added to the blacklist and can no longer be used.
Logout¶
Revoke the current token immediately:
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¶
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=<biscuit>
// 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).
Token refresh¶
const { access_token: newToken } = await auth.refresh(currentToken);
client.setToken(newToken); // update the workflow client in-place
Logout¶
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¶
passwordis optional — omit it for federated-only accounts.- Returns
201 Createdwith the user object.
List Users¶
Returns an array of user objects including their assigned roles.
Get Single User¶
Update User¶
PATCH /auth/admin/users/{user_id}
Authorization: Bearer <admin_token>
Content-Type: application/json
Both fields are optional. Omit a field to leave it unchanged.
Delete User¶
Returns 204 No Content. Also removes all role assignments for the user.
Admin: Roles¶
Create Role¶
{
"name": "hr_manager",
"description": "Can manage job requisitions and review candidates",
"scopes": ["requisition:write", "candidate:read"]
}
List Roles¶
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
This operation is atomic — all existing scopes are deleted and the new list is inserted in one transaction.
Delete Role¶
Returns 204 No Content.
Admin: Role Assignment¶
Assign Role to User¶
Returns 200 OK. The user's next token (on refresh or re-login) will include the new role's scopes.
Revoke Role from User¶
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.biscuit_auth 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.biscuit_auth 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.biscuit_auth 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.