This document walks through every action the instance admin can take
from the dashboard's /admin/* surface, plus the recovery paths when
something goes wrong. Aimed at someone running DAP for a 5-20 person
team — single instance, shared database, you wear the admin hat.
Out of scope here: provider-app setup (see
auth.md) and deployment / env vars (see
self-hosting.md). The security posture
itself — what we protect against, what we don't — lives in
security.md.
A fresh DAP instance has zero users. /health and POST /auth/register stay open (so the dashboard and the engine itself
can come up), but every admin-only surface
(/admin/* in the dashboard, GET /users, GET /audit/events,
GET /auth/api-tokens/admin) is inaccessible until you bootstrap
an admin — the engine returns 404 on those for
non-admin / anonymous callers.
dap init creates the admin row (or promotes an existing email)
idempotently. Three input modes:
# 1. Flag-driven (quick, password lands in shell history).
dap init --admin-email=you@example.com --admin-password=hunter12345
# 2. Stdin (kubectl-style — automation-friendly).
echo 'hunter12345' | dap init --admin-email=you@example.com --admin-password-stdin
# 3. Interactive (getpass + confirmation, no echo).
dap init --admin-email=you@example.com
# → "Admin password (leave empty to auto-generate a random one): "
# → "Re-enter password: "Leaving the password empty in interactive mode triggers a generated
random — printed once to stdout. You then change it via
/admin/users once you're logged in.
dap init reads DAP_DB_PATH so it bootstraps against the right
SQLite even inside the Docker container (where compose sets it to
/data/state.db). For Postgres deployments, run dap init
against the engine machine; the row writes through any
SQLAlchemy-supported DB.
Everything. There is no role boundary above admin in v0.3 — an admin can list, edit, suspend, soft-delete any user, revoke any API token, read the full audit log, and modify their own row.
The bootstrap admin's permissions match every subsequent admin's;
they're not a special "superadmin". Promoting a second user via
/admin/users gives them an identical scope.
dap init writes a small file next to the database (chmod 600) so
dap status can confirm the bootstrap happened:
.dap/
├── state.db # SQLite (default backend)
├── bootstrap.json # {"email":"...","user_id":"...","created_at":"...","promoted_existing":false}
└── config.json # engine host / port, dashboard port
No secret material in bootstrap.json — the generated password is
only ever printed to stdout. dap status reads this file and
reports:
✓ admin bootstrap: you@example.com (2026-05-12T10:00:00+00:00)
● engine: running PID 12345 port 7333 uptime 3h12m
When the marker is missing but .dap/ exists, status shows a hint:
○ admin bootstrap: none — run \dap init` to create one`.
The admin user table. Underlying engine endpoints:
GET /users(admin-only) — paginated list with filters.POST /auth/register— create a new user (any role can call; the new user is non-admin by default).PATCH /users/{id}— edit fields includingis_superuser,is_active, email, password.DELETE /users/{id}— soft delete (setsdeleted_at).
The anti-enumeration rule (return 404, not 403) covers the
admin-only list endpoints DAP adds — GET /users,
GET /audit/events, GET /auth/api-tokens/admin — so attackers
can't probe whether an admin surface exists. The per-row routes
fastapi-users provides (PATCH /users/{id}, DELETE /users/{id})
return its standard 403 for non-admin callers; we didn't override
that since they only authorize against the target row, which is
indexed by an opaque UUID.
/admin/users/new form fields:
- Email — must be unique, validated server-side by
fastapi-users'
EmailStr. - Password — 8+ chars. Engine hashes with Argon2id via pwdlib.
- Admin — checkbox; checked →
is_superuser=True.
The dashboard's form sends POST /auth/register then a
PATCH /users/{id} to flip is_superuser when needed (fastapi-users
doesn't accept is_superuser on register, by design).
/admin/users/{id} exposes:
- Email — changing it invalidates any pending OAuth identity matches; the user must re-link.
- Password — admin sets a temporary value via the user-edit
form (engine:
PATCH /users/{id}withpassword=...); user logs in with the temp value and rotates viaPATCH /users/mefrom/profile. (/auth/reset-passwordis the token-based forgot-password flow, not a "change password" endpoint — cf.auth.md.) - Role — toggles
is_superuser. - Active — toggles
is_active. False → user can't log in, but audit history and owned resources are preserved.
- Suspend (
is_active=False): user can't log in but their row is still visible in the admin list (without the soft-deleted filter on) and owns their resources normally. - Soft delete (
deleted_at NOT NULL): row is hidden from default/admin/userslistings unless you toggle "include deleted". Owned resources stay attached to the deleted user — visible to admins for audit, invisible to other users.
Hard delete (drop the row + all owned resources) is out of
scope in v0.3. If you need it: stop the engine, then
DELETE FROM users WHERE id=... against the DB. The user's
pipelines / agents / projects / runs / oauth_accounts /
api_tokens cascade-delete automatically (ondelete=CASCADE on
each user_id FK to users.id). The audit_log is not
FK-linked to users (audit integrity preserved over referential
cleanliness) — its rows survive the user deletion with their
original user_id value, which is what you want for compliance.
A managed admin UI for hard delete lands in v0.4.
Two paths:
- From the UI:
/admin/users/{id}→ toggle the Admin checkbox → Save. FiresPATCH /users/{id}withis_superuser=true. - From the CLI:
dap init --force --admin-email=existing@user --admin-password=.... The bootstrap is idempotent — it detects the existing row and promotes it. Useful when the only admin lost their password (see Recovery below).
Append-only event ledger fed by the auth subsystem and the
ownership repositories. Underlying endpoint:
GET /audit/events?event_type=...&user_id=...&offset=...&limit=....
| Event type | Fires when |
|---|---|
user.registered |
POST /auth/register succeeds |
user.logged_in |
POST /auth/jwt/login succeeds |
user.forgot_password |
POST /auth/forgot-password (token issued) |
user.password_reset |
POST /auth/reset-password succeeds |
user.deleted |
Soft-delete via DELETE /users/{id} |
api_token.created |
POST /auth/api-tokens mint |
api_token.revoked |
DELETE /auth/api-tokens/{id} (self) or DELETE /auth/api-tokens/admin/{id} (admin) |
agent.created / .updated / .archived |
Lifecycle on /agents |
pipeline.created / .updated / .archived |
Lifecycle on /pipelines |
project.created / .updated / .archived |
Lifecycle on /projects |
run.triggered |
POST /runs |
Each row carries:
id(UUID)user_id— the acting user (admin doing the deletion, owner triggering the run, etc.). Nullable for system-emitted events.event_type(string above)event_data(JSON) — varies per event type; typically includes the target row id and any relevant before/after deltas.created_at
event_type— exact match (e.g.user.logged_in).user_id— filter to events triggered by a specific user.offset+limit— pagination (limitmax 500).
Default sort is created_at DESC so freshest first.
Append-only, no automatic pruning in v0.3. The audit log will grow forever otherwise — for now the recommended cleanup is manual SQL once the file gets large:
sqlite3 .dap/state.db \
"DELETE FROM audit_log WHERE created_at < datetime('now', '-180 days');"Operators with compliance requirements should keep everything
(append-only is the audit-friendly default). A retention CLI
(dap audit prune --older-than 180d) lands in v0.4.
Admin-wide view of every API token across all users. Underlying engine endpoints:
GET /auth/api-tokens/admin— admin-wide list.DELETE /auth/api-tokens/admin/{id}— revoke any user's token.
The page surfaces:
- Token
prefix— the 8-char indexed prefix used for DB lookup, e.g.abc12345. The raw token isdap_<prefix><rest>, shown exactly once at creation and never persisted. Operators identify a token in the admin list by matching its prefix against the first 8 chars afterdap_in the value the user originally captured. - Owner email + display name.
namelabel set at creation.created_at/last_used_at/expires_at/revoked_at.
Revocation fires api_token.revoked audit events carrying both
the acting admin's user_id and the target token's owner — so
the log clearly distinguishes self-revocations from admin
forced revocations.
See auth.md for the per-user token
lifecycle (mint, list, self-revoke).
Two recovery paths depending on whether you can still reach the engine machine:
1. From a shell on the engine host — re-run dap init. The
command is idempotent: an existing email is promoted to admin,
and when you pass --admin-password=<new> explicitly, the new
value overwrites the stored hash. Without --admin-password,
the existing credentials are preserved untouched (so you can
re-bootstrap to fix is_superuser/is_active without rotating
the password).
# Local dev / single-machine — rotates password to <new>
cd /path/to/.dap/parent
dap init --force --admin-email=you@example.com --admin-password=<new>
# Docker — same shape; reads $DAP_DB_PATH internally
docker compose -f examples/standalone/docker-compose.yml exec dap \
dap init --force --admin-email=you@example.com --admin-password=<new>--force is required because .dap/ already exists; without it
dap init refuses to clobber the project skeleton (though the
DB-side operations still respect --force semantics).
2. From raw SQL — if dap isn't installed but you have DB
access. fastapi-users' PasswordHelper produces an Argon2id hash
(via pwdlib); that's the format the engine writes and verifies:
python3 -c "
from fastapi_users.password import PasswordHelper
print(PasswordHelper().hash('your-new-password'))
"
# → '\$argon2id\$v=19\$m=65536,t=3,p=4\$<salt>\$<hash>'
sqlite3 .dap/state.db \
"UPDATE users SET hashed_password='<hash>', is_active=1, is_superuser=1
WHERE email='you@example.com';"Log in with your-new-password; rotate again via /profile once
you're in.
Rotate DAP_AUTH_JWT_SECRET and restart the engine. Every
outstanding cookie session is invalidated — every user gets logged
out and needs to log in again.
# Mint a new secret.
NEW=$(openssl rand -hex 32)
# Replace it in .env and restart.
sed -i "s/^DAP_AUTH_JWT_SECRET=.*/DAP_AUTH_JWT_SECRET=$NEW/" .env
docker compose down && docker compose up -dImportant: API tokens (the opaque dap_* values) are NOT
signed with the JWT secret — they're hashed in the DB. Rotating
the JWT secret leaves API tokens working. If the compromise scope
includes API tokens, revoke them from /admin/api-tokens after
the JWT rotation.
There's no in-place rolling rotation in v0.3 (would require a secret-version column on the JWT payload + concurrent validation against old + new keys); operators willing to accept a brief mass logout do the swap-and-restart above.
Before any migration or upgrade, take a backup:
# Hot-copy with sqlite3's online backup API.
sqlite3 .dap/state.db ".backup '/path/to/backup-$(date +%Y%m%d).db'"
# Or .dump → SQL text if you want it grep-able.
sqlite3 .dap/state.db ".dump" > backup-$(date +%Y%m%d).sqlTo restore from a SQL dump:
mv .dap/state.db .dap/state.db.corrupted
sqlite3 .dap/state.db < backup-2026-05-12.sqlMigrations are forward-only — there's no automatic down-migration support. If you need to roll back to a prior schema, restore the last backup taken before the offending migration ran.
That's the pre-v0.3 upgrade path (#343) firing on a fresh
apply_migrations against a .dap/state.db that pre-dates the
multi-user schema. The migration prints a generated password
exactly once on stdout:
Migrated single-user install. Bootstrap admin: legacy-admin@local
with password=<random>. Change immediately at /admin/users.
Capture the password, log in as legacy-admin@local, and either
rotate the password or rename the account via /admin/users. The
password is not persisted anywhere — if you miss it, re-bootstrap
via dap init --force --admin-email=legacy-admin@local --admin-password=<new>.
| Symptom | Look at |
|---|---|
| Bootstrap admin can log in but doesn't see "Admin" link in dashboard | is_superuser=False. Re-run dap init --force --admin-email=... (idempotent promotion) or flip the flag in SQL. |
/admin/users returns 404 |
Currently logged-in user isn't an admin. Anti-enumeration — 404 instead of 403. |
/admin/audit-log empty |
Engine started fresh; no events yet. Trigger a login or resource create. |
Audit log entry has user_id: null |
System-emitted event (migration, internal). The acting principal is the engine itself. |
API token shows up in /admin/api-tokens but auth still fails |
Check revoked_at and expires_at. Revoked / expired tokens stay in the list (for audit), they just stop authenticating. |
dap init works but engine starts without the admin |
The dap init CWD didn't match where the engine is reading from. Verify DAP_DB_PATH — both processes need to point at the same SQLite. |
See also:
auth.md— how each credential mechanism works.self-hosting.md— deployment + env vars.security.md— secrets handling, threat model.