# 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_<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.

### 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.
- `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`, and `DB_MYSQL_PASSWORD` 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=<user_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=<token_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.
