Audience: operators evaluating IRDB for production, security reviewers. This document describes the as-built posture — claims here can be verified against the code in this repo. If you find a claim that doesn't hold, that's a bug.
| Concern | Posture |
|---|---|
| Authentication | OIDC (Entra ID) + local admin (UI); Bearer tokens (api) |
| Authorization | RBAC: viewer / operator / admin; enforced server-side on every endpoint |
| Transport | Plain HTTP between containers; reverse-proxy TLS termination expected |
| Data at rest | DB: unencrypted (host volume encryption recommended); tokens hashed (SHA-256) |
| Secret storage | .env for runtime secrets; never persisted to the DB |
| Logging | JSON to stdout; secret-scrubbing Monolog processor |
| Rate limiting | Public API: 60 req/s/token (token-bucket); login: brute-force lockout |
| Supply chain | composer audit + npm audit in CI; locked Dockerfile base image |
| Audit | Append-only audit_log; one row per state-changing admin action |
The system has two distinct authentication boundaries.
Browser users authenticate to the ui container, which owns:
jumbojett/openid-connect-php, code flow with PKCE).LOCAL_ADMIN_PASSWORD_HASH — never in the database.HttpOnly, SameSite=Lax, Secure when
APP_ENV=production.Machine clients and the UI itself authenticate to the api with a
Bearer token. There are four token kinds:
| Kind | Calls | Bound to |
|---|---|---|
reporter |
POST /api/v1/report |
a reporter row |
consumer |
GET /api/v1/blocklist |
a consumer row |
admin |
/api/v1/admin/* |
a configured RBAC role |
service |
/api/v1/admin/*, /api/v1/auth/* |
the UI; carries impersonation header |
Token format: irdb_<kind3>_<32 base32 chars>. 160 bits of entropy from
random_bytes(20) (PHP CSPRNG). Verified by
api/tests/Unit/Auth/TokenEntropyTest.php. Tokens are stored as their
SHA-256 digest plus an 8-character prefix used for log triage —
verified by api/tests/Integration/Auth/SchemaSecretsAtRestTest.php.
The raw token is shown once at creation time and never retrievable afterwards.
The local-admin sign-in is gated by App\Auth\LoginThrottle (UI
container). Failures are bucketed by (username, source_ip) so a
single attacker's flood doesn't lock the legitimate admin coming in
from a different address. Progression:
| Failures | Lockout |
|---|---|
| 1–4 | none |
| 5 | 60 s |
| 10 | 300 s |
| 15+ | 1800 s |
Successful login clears the bucket. The store is per-process in memory; restarting the UI container clears all locks (the documented "unlock the admin" path).
OIDC sign-in has no equivalent throttle — Entra rate-limits failed authentication attempts at the IdP, which is the right layer for enterprise SSO.
Three roles, strictly hierarchical for read access:
| Role | Can do |
|---|---|
| viewer | Read everything; cannot mutate |
| operator | viewer + manage manual blocks + allowlist |
| admin | operator + tokens, policies, categories, users, jobs, settings |
Enforcement is in the api: every admin route declares the required
role, and RbacMiddleware rejects with 403 on mismatch. The UI
hides UI elements the user can't use, but does not enforce
security with hidden buttons — direct API access with a lower-role
token is properly rejected. Every test in
tests/Integration/Admin/*ControllerTest.php exercises this.
The service token is special: it's only accepted with an
X-Acting-User-Id header, which the api uses to look up the
impersonated user and apply that user's role. The header is only
trusted in combination with the service token — admin-token requests
ignore it. Service tokens never appear in /api/v1/admin/tokens list
output and cannot be created via the admin API.
The Docker Compose stack runs both containers over plain HTTP. In
production, front both with a reverse proxy (Caddy / Traefik /
nginx) that terminates TLS, typically with one hostname per
container. An example Caddy config is in examples/reverse-proxy/.
When APP_ENV=production, both containers send
Strict-Transport-Security: max-age=31536000; includeSubDomains. This
is gated on APP_ENV because HSTS is sticky in browsers — turning it
on for localhost development locks subsequent localhost work into
HTTPS for a year.
Other security headers are applied unconditionally (api +
api/docker/Caddyfile, ui + ui/docker/Caddyfile):
X-Content-Type-Options: nosniffX-Frame-Options: DENY (UI) / SAMEORIGIN (api)Referrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), microphone=(), camera=()Content-Security-Policy — strict on the api
(default-src 'none'; frame-ancestors 'none'); UI policy allows
'self' 'unsafe-eval' 'unsafe-inline' for scripts to accommodate
Alpine.js v3's Function() evaluator and inline style attributes;
documented trade-off in the Caddyfile.The api's DB carries:
api_tokens.token_hash, SHA-256).audit_log), append-only at the application layer.users) — email, display name, OIDC subject;
no credentials.The DB itself is not encrypted at rest by the application. The recommended layer is host disk encryption (LUKS for SQLite-on-volume, managed encryption for MySQL).
A regression test (SchemaSecretsAtRestTest) scans every column on
every table for names that suggest plaintext credentials
(password, _secret, client_secret, license_key, ...) and
fails CI if any appear. The intent: a future migration that adds a
column called client_secret will fail the build before it ships.
Runtime secrets live in .env — read by Compose at boot, exported as
container environment variables, never written to the DB. The list:
UI_SERVICE_TOKEN — shared between api and ui; the api reads it to
authenticate UI-originated calls.INTERNAL_JOB_TOKEN — bearer for the scheduler's
/internal/jobs/* calls. Network-restricted in addition to the
token.APP_SECRET, UI_SECRET — application-internal signing seeds.LOCAL_ADMIN_PASSWORD_HASH — Argon2id hash; the raw password is
never on disk.OIDC_CLIENT_SECRET, MAXMIND_LICENSE_KEY, IPINFO_TOKEN,
DB_MYSQL_PASSWORD — third-party credentials; passed straight
through.GET /api/v1/admin/config (admin-only) surfaces effective
configuration with the sensitive values masked: INTERNAL_JOB_TOKEN,
MAXMIND_LICENSE_KEY, IPINFO_TOKEN, DB_MYSQL_PASSWORD,
APP_SECRET show as ***; UI_SERVICE_TOKEN shows the first 8
characters. Empty values are visible (so misconfiguration is
debuggable).
Both containers log JSON to stdout via Monolog. A
SecretScrubbingProcessor runs on every record before it hits the
handler, scrubbing:
password, authorization,
auth_token, bearer, secret, license_key, service_token,
job_token, cookie — value replaced with ***.Bearer irdb_adm_***) so triage logs
still tell you "an admin token failed" without leaking the secret
half.Tests:
api/tests/Unit/Logging/SecretScrubbingProcessorTest.php,
ui/tests/Unit/Logging/SecretScrubbingProcessorTest.php.
Public API: token-bucket per token, default 60 req/s with a burst
of 120, configurable via API_RATE_LIMIT_PER_SECOND. Returns 429
with Retry-After: 1 when exhausted. In-process per replica —
multi-replica deployments will see N × the rate before exhaustion;
this is acceptable for the documented topology (single api replica
for SQLite, scaled api with shared MySQL).
Login: brute-force lockout per (username, ip) (above).
Admin endpoints are unrated. Real abuse on admin endpoints is rare (Bearer-authed humans/UI). Add a limit if you measure a problem.
composer audit --no-dev runs in CI for both subprojects, failing
on any reported advisory against production dependencies.npm audit --omit=dev --audit-level=high runs in CI for the UI's
asset bundle. Moderate advisories are not gating but should be
reviewed during dependency bump passes.dunglas/frankenphp:1-php8.3-alpine) is
pinned to a specific tag. Updating it is a deliberate operator
action.Policy: when an audit fails:
No exceptions are currently accepted.
Every state-changing admin action emits one row to audit_log via
the AuditEmitter (M12). The actor recording rule:
X-Acting-User-Id → actor_kind=user,
actor_id=<user_id> (the impersonated user is the responsible
party — not the service token, even though the call carries it).actor_kind=admin-token, actor_id=<token_id>.actor_kind=reporter / consumer.actor_kind=system,
no actor_id.Failed validation paths (400s) don't emit. Only successful state
changes do. The audit_log table is append-only at the application
layer — there is no UI for editing or deleting rows — but a
sufficiently privileged DB user can rewrite history. Tamper-evident
audit (chained signing) is future work.
The following are deliberately not built into IRDB; the stack expects them at higher layers:
A non-exhaustive list of threats and the controls that mitigate them.
| Threat | Control |
|---|---|
| Stolen Bearer token in transit | Reverse-proxy TLS termination; HSTS in production |
| Stolen Bearer token at rest in DB | Tokens stored as SHA-256; raw value never persisted |
| Token leaked via logs | SecretScrubbingProcessor scrubs Bearer values before output |
| Brute-force on local admin | Per-(user,ip) lockout: 5/60, 10/300, 15/1800 seconds |
| Public API abuse | 60 req/s/token token-bucket; 429 Retry-After: 1 |
| CSRF on UI form | Per-session CSRF token validated by CsrfMiddleware |
| Click-jacking / framing | X-Frame-Options: DENY + CSP frame-ancestors 'none' |
| MIME confusion | X-Content-Type-Options: nosniff |
| Internal jobs reachable from outside | Caddy 404s /internal/* outside RFC1918 + bearer required |
| Privileged action without audit | All state-changing admin endpoints emit one audit_log row each |
| Schema drift introducing plaintext secret column | SchemaSecretsAtRestTest regression scan in CI |
| Vulnerable dependency in production deps | composer audit + npm audit in CI, fail on advisory |
If you find a threat the table doesn't address, please open an issue.