# Security > 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. ## At a glance | 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 | ## Authentication The system has **two distinct authentication boundaries**. **Browser users** authenticate to the `ui` container, which owns: - The OIDC redirect/callback flow (Microsoft Entra ID via `jumbojett/openid-connect-php`, code flow with PKCE). - The local-admin login form, validated against an Argon2id password hash stored in the UI's environment variable `LOCAL_ADMIN_PASSWORD_HASH` — never in the database. - File-backed PHP sessions, `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__<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. ### Brute-force lockout 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. ## Authorization 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. ## Transport 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: nosniff` - `X-Frame-Options: DENY` (UI) / `SAMEORIGIN` (api) - `Referrer-Policy: strict-origin-when-cross-origin` - `Permissions-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. ## Data at rest The api's DB carries: - Reports, scores, manual blocks, allowlist entries, policies, categories, reporters, consumers — operational data, not sensitive. - Hashed API tokens (`api_tokens.token_hash`, SHA-256). - Audit log (`audit_log`), append-only at the application layer. - User identity records (`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. ## Secret storage 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). ## Logging Both containers log JSON to stdout via Monolog. A `SecretScrubbingProcessor` runs on every record before it hits the handler, scrubbing: - Any context key whose name contains `password`, `authorization`, `auth_token`, `bearer`, `secret`, `license_key`, `service_token`, `job_token`, `cookie` — value replaced with `***`. - Any string value matching the Bearer + token-shaped pattern — the kind prefix is preserved (`Bearer irdb_adm_***`) so triage logs still tell you "an admin token failed" without leaking the secret half. - Argon2 / bcrypt hashes embedded in messages. Tests: `api/tests/Unit/Logging/SecretScrubbingProcessorTest.php`, `ui/tests/Unit/Logging/SecretScrubbingProcessorTest.php`. ## Rate limiting **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. ## Supply chain - `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. - The Dockerfile base image (`dunglas/frankenphp:1-php8.3-alpine`) is pinned to a specific tag. Updating it is a deliberate operator action. **Policy**: when an audit fails: 1. The CI job blocks the merge. 2. An admin reviews the advisory and either: - patches the dependency, or - documents an accepted exception (advisory ID, reason, expected remediation date) in this section. No exceptions are currently accepted. ## Audit Every state-changing admin action emits one row to `audit_log` via the `AuditEmitter` (M12). The actor recording rule: - service-token + `X-Acting-User-Id` → `actor_kind=user`, `actor_id=` (the impersonated user is the responsible party — not the service token, even though the call carries it). - admin token → `actor_kind=admin-token`, `actor_id=`. - reporter / consumer tokens → `actor_kind=reporter` / `consumer`. - system-internal (e.g. expired-block cleanup job) → `actor_kind=system`, no `actor_id`. Failed validation paths (`400`s) 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. ## Out of scope The following are deliberately not built into IRDB; the stack expects them at higher layers: - **WAF / IPS / fail2ban-on-the-UI**. Run one in front of the reverse proxy if you need it. - **2FA on the local admin path**. Use OIDC for that — Entra has first-class MFA support. The local admin is a recovery channel, not a daily driver. - **mTLS between api and ui**. Docker network isolation is the trust boundary in the default compose deployment; document and enforce that boundary at your network layer. - **Encryption at rest of the SQLite file**. Host disk encryption is the right layer. - **Tamper-evident audit log**. Future work; the append-only guarantee today is application-level only. - **Penetration test report**. Out of scope for the build. ## Threat model snapshot 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.