Audience: anyone wiring a client to IRDB. Covers all four token kinds, the BFF impersonation pattern, the OIDC and local-admin login flows, and the recommended extension point for SPA / native / mobile clients that don't use a BFF.
| Caller | Token kind | Extra header | Audited as |
|---|---|---|---|
| Web/IDS/fail2ban agent | reporter |
— | actor_kind=reporter |
| Firewall / proxy | consumer |
— | actor_kind=consumer |
| Automation script | admin |
— | actor_kind=admin-token |
| UI BFF (current) | service |
X-Acting-User-Id: <id> |
actor_kind=user, actor_id=<id> |
| Future SPA/native/mobile | (not built) | (would issue user-bound tokens) | actor_kind=user |
The api refuses requests with no token. It always returns the same 401
unauthorized envelope for any auth failure (bad token, expired,
revoked, wrong-kind for the route) — callers can never tell which.
RBAC failures (authenticated but role-denied) are 403; an audit row
is not emitted on either.
Every IRDB token is irdb_<kind3>_<32 base32 chars> where kind3 is
one of rep, con, adm, svc. 160 bits of entropy. The first 8
characters (irdb_<kind3>) form the token_prefix recorded for log
triage; the full string is SHA-256 hashed at rest in
api_tokens.token_hash.
The raw value is shown once at creation. After that the api can verify a token by hashing the presented value and looking it up — but nobody, including admins, can recover the raw form. Treat tokens like passwords.
sequenceDiagram
participant Agent as Reporter or Consumer
participant API
participant DB
Agent->>API: GET /api/v1/blocklist<br/>Authorization: Bearer irdb_con_…
API->>DB: lookup token by hash<br/>verify kind=consumer, not revoked, not expired
DB-->>API: TokenRecord
API->>API: build blocklist for consumer's policy<br/>(30s cache)
API-->>Agent: 200 text/plain<br/>ETag: …
For reporters the path is identical except the agent sends POST
/api/v1/report and the api validates kind=reporter. The token
binds to a row in reporters / consumers, which carries the
trust weight (reporters) or policy id (consumers). Audit records
the FK on each successful state change.
sequenceDiagram
participant Script as Automation
participant API
participant DB
Script->>API: GET /api/v1/admin/ips<br/>Authorization: Bearer irdb_adm_…
API->>DB: lookup token by hash<br/>verify kind=admin, role=viewer/operator/admin
DB-->>API: TokenRecord(role=admin)
API->>API: RBAC: route requires Viewer → ok
API-->>Script: 200 {items, page, …}
Admin tokens carry their own role (set at creation). The route's
required role is checked against the token's role; same matrix as the
UI uses. No impersonation — the audit row records actor_kind=admin-token,
actor_id=<token id>, not a user.
Admin tokens are the recommended path for automation that doesn't go through a browser — CI scripts, Terraform providers, monitoring that creates tokens for new reporters automatically. Don't use a service token for those use cases; service tokens are for the BFF.
The ui container holds the service token. On every admin-API call
it adds two headers:
Authorization: Bearer <UI_SERVICE_TOKEN>
X-Acting-User-Id: <user-id-from-session>
sequenceDiagram
participant Browser
participant UI as ui (BFF)
participant API as api
participant DB
Browser->>UI: GET /app/dashboard<br/>(session cookie)
UI->>UI: SessionMiddleware → user_id=7
UI->>API: GET /api/v1/admin/stats/dashboard<br/>Authorization: Bearer <svc><br/>X-Acting-User-Id: 7
API->>DB: validate service token<br/>load user 7 + role
DB-->>API: User(role=admin)
API->>API: RBAC: route requires Viewer → ok<br/>audit context: user=7
API-->>UI: 200 {stats}
UI->>Browser: 200 HTML (Twig)
The api never trusts X-Acting-User-Id without a service token —
on a reporter / consumer / admin token the header is silently ignored.
On a service token without the header the api returns 400.
This pattern works because:
Building a different BFF (Node, Rails, Django) is mostly a matter of
implementing this two-header pattern, plus session management. See
frontend-development.md.
The local admin only exists if LOCAL_ADMIN_ENABLED=true. The UI
validates the password against LOCAL_ADMIN_PASSWORD_HASH (Argon2id),
then asks the api to upsert a user record:
sequenceDiagram
participant Browser
participant UI
participant API
Browser->>UI: POST /login/local<br/>(username, password, csrf_token)
UI->>UI: csrf check; argon2id verify against env hash
UI->>API: POST /api/v1/auth/users/upsert-local<br/>{"username":"admin"}<br/>Authorization: Bearer <svc>
API-->>UI: {user_id, role: "admin", is_local: true}
UI->>UI: regenerate session id; store user_id
UI-->>Browser: 303 → /app/dashboard
The local admin is always role admin. Generate the password hash:
php -r "echo password_hash('your-password', PASSWORD_ARGON2ID);"
Then double every $ in the resulting hash before pasting into .env
— Docker Compose's variable substitution otherwise eats them. For
production deployments disable the local admin (LOCAL_ADMIN_ENABLED=false)
once an OIDC user with admin role exists; it's a defence-in-depth
recommendation, not a hard requirement.
The local-admin sign-in carries a per-process throttle: failures are
bucketed by (username, source_ip) so a single attacker can't lock
out the legitimate admin from another address. Progression:
| Failures | Lockout |
|---|---|
| 1–4 | none |
| 5 | 60 s |
| 10 | 300 s |
| 15+ | 1800 s |
A successful login resets the bucket for that pair. The store is in-memory in the UI container, so:
ui container
(docker compose restart ui). This is the documented unlock
path — there is intentionally no API for clearing a lockout
(adding one would be a new attack surface).OIDC sign-in has no equivalent throttle in the UI — Entra rate-limits failed auth at the IdP, which is the right layer for SSO.
sequenceDiagram
participant Browser
participant UI
participant Entra
participant API
Browser->>UI: GET /login/oidc
UI->>Browser: 302 → Entra authorize URL<br/>(PKCE code_challenge, state, nonce)
Browser->>Entra: GET /authorize
Entra-->>Browser: 302 → /oidc/callback?code=…&state=…
Browser->>UI: GET /oidc/callback
UI->>Entra: POST /token (code + verifier + secret)
Entra-->>UI: id_token + access_token
UI->>UI: validate id_token (sig/iss/aud/nonce/exp)<br/>extract sub, email, groups
UI->>API: POST /api/v1/auth/users/upsert-oidc<br/>{subject, email, display_name, groups}
API->>API: resolve role via oidc_role_mappings<br/>(else OIDC_DEFAULT_ROLE)
API-->>UI: {user_id, role}
UI-->>Browser: 303 → /app/dashboard
When role=none (no mapping matched and OIDC_DEFAULT_ROLE=none), the
UI sends the user to /no-access instead of completing the session.
Tested against an Entra workforce tenant. Replace example values with your own.
entra.microsoft.com → App registrations → New registration):
IRDB — UI (any human-readable label).Web, URI matching OIDC_REDIRECT_URI, e.g.
http://localhost:8080/oidc/callback for dev,
https://reputation.example.com/oidc/callback for prod.Save the Application (client) ID → OIDC_CLIENT_ID and the
Directory (tenant) ID, which goes into
OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0.
Create a client secret (Certificates & secrets → Client secrets
→ New). Copy the secret Value (not the Secret ID) →
OIDC_CLIENT_SECRET. The portal hides it after you leave the page.
Configure the groups claim (Token configuration → Add groups
claim → Security groups). For each token type (ID + Access), pick
"Group ID" — the api expects Entra group object IDs.
sAMAccountName or display names will not match.
If the user has more than ~150 groups assigned, Entra emits an
_claim_names overage indicator instead of the raw groups array.
The current implementation doesn't follow the indicator (would
require a Graph call); keep test users under the threshold or
reduce their group count.
API permissions: the default User.Read is enough for openid
profile email plus the groups claim. No additional scopes unless
you call Graph elsewhere. Click Grant admin consent if your
tenant requires it.
Map groups to roles in IRDB. The dedicated admin UI for this
is forthcoming; until then, write to the oidc_role_mappings table
directly:
INSERT INTO oidc_role_mappings (group_id, role) VALUES
('11111111-1111-1111-1111-111111111111', 'admin'),
('22222222-2222-2222-2222-222222222222', 'operator'),
('33333333-3333-3333-3333-333333333333', 'viewer');
Group IDs are GUID/UUID strings as Entra emits them. The api resolves the user's role on each login as the highest matching role across their groups.
Set the UI env (in the repo's .env):
OIDC_ENABLED=true
OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
OIDC_CLIENT_ID=<application-client-id>
OIDC_CLIENT_SECRET=<value-from-step-2>
OIDC_REDIRECT_URI=http://localhost:8080/oidc/callback
Restart ui. The login page now offers "Sign in with Microsoft".
AADSTS50011: redirect URI mismatch — the URI in the request
doesn't match what's registered. /oidc/callback is case-sensitive;
the host must match exactly.groups claim missing in the ID token — re-check step 3 above,
including selecting the claim for the ID token type (not just
access token). Sign in again from a private window so a fresh token
is issued.viewer — the matched group ID isn't in
oidc_role_mappings. The api returns OIDC_DEFAULT_ROLE when no
row matches.OIDC handshake failed: state mismatch — the session cookie was
lost between /login/oidc and /oidc/callback (third-party-cookie
blocking on cross-site flows; session fixation reset). Confirm
SameSite=Lax is set and that the redirect URI is on the same host
the user reaches the UI through.NOT IMPLEMENTED. This section sketches the recommended extension point for replacement frontends that don't want a BFF.
The current model assumes a server-side process that holds the service token. A native or single-page app can't do that — the service token would be exposed to the browser/device and would impersonate every user. Instead, the api would need to issue user-bound tokens directly, and a new endpoint group would handle the flow:
POST /api/v1/auth/oauth/start → returns an OIDC redirect URL
GET /api/v1/auth/oauth/callback → exchanges code; issues user token
POST /api/v1/auth/oauth/refresh → exchanges refresh token
POST /api/v1/auth/oauth/revoke → revokes a user token
The user token would be a fifth kind=user row in api_tokens,
bound to a user_id rather than a reporter/consumer. The same RBAC
middleware would apply.
This is future work. If you're building an SPA today, the
recommended path is option (b) in
frontend-development.md: a thin BFF
that mints short-lived signed cookies the SPA presents.
ui container's
writable layer. 8 h idle, 24 h absolute (configurable via
SESSION_IDLE_SECONDS / SESSION_ABSOLUTE_SECONDS). Tied to a
specific UI replica — sticky sessions required if scaling UI
horizontally. Session ids regenerate after every auth-state change
(login success, logout) to defeat fixation.UI_ORIGIN
with Access-Control-Allow-Credentials: true and
X-Acting-User-Id on the allow-list. The current PHP UI calls
server-to-server and doesn't trigger CORS; the headers exist for
future browser-direct frontends.api-overview.md — endpoint groups, conventions, worked examples.frontend-development.md — three patterns for replacement UIs./api/v1/openapi.yaml — canonical endpoint shapes.