Sfoglia il codice sorgente

Hardened container and host configurations

Efren Yevale Varela 1 mese fa
parent
commit
a9a6ec88f4

+ 51 - 1
.github/workflows/ci.yml

@@ -5,7 +5,8 @@
 #   fuzz-build (nightly leg inside `fuzz/`, so the pinned stable
 #   fuzz-build (nightly leg inside `fuzz/`, so the pinned stable
 #   `rust-toolchain.toml` at the root is never violated).
 #   `rust-toolchain.toml` at the root is never violated).
 # Scheduled legs (not per-PR):
 # Scheduled legs (not per-PR):
-#   mutants (weekly budgeted mutation run over the pure security modules).
+#   mutants (weekly budgeted mutation run over the pure security modules),
+#   fuzz-run (bounded run of every fuzz target; the per-PR leg only builds).
 #
 #
 # Must NOT gate on miri for tokio/sqlx/redis; no lint suppressions anywhere.
 # Must NOT gate on miri for tokio/sqlx/redis; no lint suppressions anywhere.
 
 
@@ -158,6 +159,55 @@ jobs:
       - name: Build all fuzz targets
       - name: Build all fuzz targets
         run: cargo fuzz build
         run: cargo fuzz build
 
 
+  fuzz-run:
+    name: fuzz-run
+    # Scheduled bounded fuzzing. `fuzz-build` (above) only compiles the
+    # targets per PR; this leg actually executes them, so a crash is found
+    # without a human starting it. It is bounded and scheduled, not 24/7:
+    # continuous coverage is OSS-Fuzz/ClusterFuzzLite, tracked in docs/TODO.md.
+    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    timeout-minutes: 70
+    strategy:
+      fail-fast: false
+      matrix:
+        target: [token_form, dcr_json, jar_claims]
+    defaults:
+      run:
+        working-directory: fuzz
+    steps:
+      - uses: actions/checkout@v4
+      # `fuzz/rust-toolchain.toml` selects nightly; the root stable pin is
+      # untouched, exactly as in `fuzz-build`.
+      - name: Install cargo-fuzz (nightly)
+        run: cargo install cargo-fuzz --locked
+      # `uses` steps resolve paths from the workspace root, not the job's
+      # `working-directory`; keep the fuzz/ prefix explicit.
+      - name: Restore corpus for ${{ matrix.target }}
+        uses: actions/cache/restore@v4
+        with:
+          path: fuzz/corpus/${{ matrix.target }}
+          key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
+          restore-keys: |
+            fuzz-corpus-${{ matrix.target }}-
+      - name: Fuzz ${{ matrix.target }} (bounded)
+        run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=600 -rss_limit_mb=2048
+      # Save only on success; a per-run key grows the corpus while the prefix
+      # `restore-keys` always pick the newest entry.
+      - name: Save corpus for ${{ matrix.target }}
+        if: success()
+        uses: actions/cache/save@v4
+        with:
+          path: fuzz/corpus/${{ matrix.target }}
+          key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
+      - name: Upload crash artifacts
+        if: failure()
+        uses: actions/upload-artifact@v4
+        with:
+          name: fuzz-crash-${{ matrix.target }}
+          path: fuzz/artifacts/${{ matrix.target }}
+          if-no-files-found: ignore
+
   mutants:
   mutants:
     name: mutants
     name: mutants
     # Scheduled budgeted job only: never blocks a PR.
     # Scheduled budgeted job only: never blocks a PR.

+ 21 - 7
deploy/k8s/configmap.yaml

@@ -2,11 +2,13 @@
 #
 #
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
-# (database/redis URLs, token pepper, TOTP key) lives in `secret.yaml` and
-# is NEVER added here: the pepper and TOTP key are file-mounted by the
-# Deployment, and their `*_FILE` paths are declared in `deployment.yaml`,
-# not in this ConfigMap (`scripts/check_manifests.py` rejects a
-# secret-shaped key here).
+# (the database/redis passwords, token pepper, TOTP key) lives in
+# `secret.yaml` and is NEVER added here. The passwordless DSNs below are not
+# secret and live here; the workloads add the passwords from mounted files
+# (`D_OAUTH_DATABASE__PASSWORD_FILE` / `D_OAUTH_REDIS__PASSWORD_FILE`), and
+# their `*_FILE` paths are declared in `deployment.yaml`, not in this
+# ConfigMap (`scripts/check_manifests.py` rejects a secret-shaped key here and
+# a DSN with an embedded password).
 apiVersion: v1
 apiVersion: v1
 kind: ConfigMap
 kind: ConfigMap
 metadata:
 metadata:
@@ -20,6 +22,15 @@ data:
   D_OAUTH_ENVIRONMENT: "production"
   D_OAUTH_ENVIRONMENT: "production"
   D_OAUTH_BIND_ADDR: "0.0.0.0:8080"
   D_OAUTH_BIND_ADDR: "0.0.0.0:8080"
   D_OAUTH_HEALTH_ADDR: "0.0.0.0:8081"
   D_OAUTH_HEALTH_ADDR: "0.0.0.0:8081"
+  # Passwordless DSN. Production requires `sslmode=verify-full`; the password
+  # comes from the mounted `database-password` file. `verify-full` also needs a
+  # trusted CA: put the Postgres CA at `/certs/db-ca.crt` (a `db-ca.crt` key on
+  # the `d-oauth-tls` Secret; see tls-secret.example.yaml) and the root_cert_path
+  # below pins it. A server with a publicly-trusted certificate can drop the
+  # root_cert_path and rely on system roots instead. Edit the host/user/database
+  # for the target environment.
+  D_OAUTH_DATABASE__URL: "postgres://oauth@postgres.d-oauth.svc.cluster.local:5432/oauth?sslmode=verify-full"
+  D_OAUTH_DATABASE__ROOT_CERT_PATH: "/certs/db-ca.crt"
   D_OAUTH_TLS__CERT_PATH: "/certs/tls.crt"
   D_OAUTH_TLS__CERT_PATH: "/certs/tls.crt"
   D_OAUTH_TLS__KEY_PATH: "/certs/tls.key"
   D_OAUTH_TLS__KEY_PATH: "/certs/tls.key"
   # Client-CA enables in-process mTLS verification. While set, the cluster
   # Client-CA enables in-process mTLS verification. While set, the cluster
@@ -27,6 +38,9 @@ data:
   # terminates TLS strips the client certificate. See docs/DEPLOYMENT.md and
   # terminates TLS strips the client certificate. See docs/DEPLOYMENT.md and
   # scripts/check_manifests.py, which rejects the forbidden pairing.
   # scripts/check_manifests.py, which rejects the forbidden pairing.
   D_OAUTH_TLS__CLIENT_CA_PATH: "/certs/client-ca.crt"
   D_OAUTH_TLS__CLIENT_CA_PATH: "/certs/client-ca.crt"
-  # Single-Redis topology takes a plain URL; the contract the server asserts
-  # at startup is noeviction + AOF, configured on the Redis side.
+  # Single-Redis topology takes a bare URL (no credentials; the mounted
+  # `redis-password` file is injected at connect time). The durability
+  # contract the server asserts at startup is noeviction + AOF, configured on
+  # the Redis side.
+  D_OAUTH_REDIS__URL: "redis://redis.d-oauth.svc.cluster.local:6379"
   D_OAUTH_REDIS__DURABILITY: "waitaof"
   D_OAUTH_REDIS__DURABILITY: "waitaof"

+ 26 - 28
deploy/k8s/deployment.yaml

@@ -15,15 +15,16 @@
 #   `automountServiceAccountToken: false` (the server never calls the
 #   `automountServiceAccountToken: false` (the server never calls the
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   + Postgres + Redis.
 #   + Postgres + Redis.
-# - Secret material is file-mounted, never env-injected: the token pepper and
-#   TOTP key are projected from `d-oauth-secrets` into a tmpfs volume at
-#   `/run/secrets` (`defaultMode: 0400`, group 65532 via `fsGroup`) and read
-#   through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`.
-#   The inline `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms
-#   remain supported and take precedence when both are set
-#   (`src/config/build.rs`), but this deployment never sets them, so no key
-#   material enters the process environment (`/proc/self/environ`, children,
-#   crash dumps, `docker inspect`).
+# - Secret material is file-mounted, never env-injected: the token pepper,
+#   TOTP key, and the Postgres/Redis passwords are projected from
+#   `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode:
+#   0400`, group 65532 via `fsGroup`) and read through their `*_FILE`
+#   variables. The passwordless DSNs come from the ConfigMap. The inline
+#   `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
+#   supported and take precedence when both are set (`src/config/build.rs`),
+#   but this deployment never sets them, so no secret enters the process
+#   environment (`/proc/self/environ`, children, crash dumps, `docker
+#   inspect`) — a `kubectl exec` reads nothing from the environment.
 # - The image is digest-pinned. The all-zero digest below is a deliberate
 # - The image is digest-pinned. The all-zero digest below is a deliberate
 #   placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
 #   placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
 #   writes the real signed-build digest into the `images` entry of
 #   writes the real signed-build digest into the `images` entry of
@@ -90,21 +91,14 @@ spec:
             - configMapRef:
             - configMapRef:
                 name: d-oauth-config
                 name: d-oauth-config
           env:
           env:
-            - name: D_OAUTH_DATABASE__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: database-url
-            - name: D_OAUTH_REDIS__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: redis-url
-            # File-mounted key material: the values stay
-            # in the tmpfs secret volume, never in the process environment.
-            # The inline D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOTP_ENCRYPTION_KEY
-            # forms are still supported and take precedence when both are set
-            # (`src/config/build.rs`); this deployment deliberately uses files.
+            # Every secret is a file, never an env value: code execution in
+            # the container cannot read the DB/Redis credentials or key
+            # material from the environment. The DSNs (non-secret) come from
+            # the ConfigMap above.
+            - name: D_OAUTH_DATABASE__PASSWORD_FILE
+              value: /run/secrets/database-password
+            - name: D_OAUTH_REDIS__PASSWORD_FILE
+              value: /run/secrets/redis-password
             - name: D_OAUTH_TOKEN_PEPPER_FILE
             - name: D_OAUTH_TOKEN_PEPPER_FILE
               value: /run/secrets/token-pepper
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
@@ -161,15 +155,19 @@ spec:
           secret:
           secret:
             secretName: d-oauth-tls
             secretName: d-oauth-tls
             defaultMode: 0o400
             defaultMode: 0o400
-        # tmpfs-backed secret volume. `defaultMode:
-        # 0400` plus `fsGroup: 65532` leaves the projected files readable only
-        # by the runtime user/group. Only the two key files are projected;
-        # database-url and redis-url remain env secretKeyRefs by design.
+        # tmpfs-backed secret volume. `defaultMode: 0400` plus `fsGroup:
+        # 65532` leaves the projected files readable only by the runtime
+        # user/group. Every secret is projected here; nothing is injected
+        # through a `secretKeyRef` env.
         - name: secrets
         - name: secrets
           secret:
           secret:
             secretName: d-oauth-secrets
             secretName: d-oauth-secrets
             defaultMode: 0o400
             defaultMode: 0o400
             items:
             items:
+              - key: database-password
+                path: database-password
+              - key: redis-password
+                path: redis-password
               - key: token-pepper
               - key: token-pepper
                 path: token-pepper
                 path: token-pepper
               - key: totp-encryption-key
               - key: totp-encryption-key

+ 15 - 16
deploy/k8s/migrate-job.yaml

@@ -10,10 +10,11 @@
 #
 #
 # Hardened like the Deployment: non-root 65532,
 # Hardened like the Deployment: non-root 65532,
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
-# false`, and the token pepper / TOTP key mounted as 0400 tmpfs files under
-# `/run/secrets` (read through the `*_FILE` variables) instead of env vars.
-# The image carries the same placeholder digest; `pin-digest` rewrites the
-# real digest in `kustomization.yaml` for every workload.
+# false`, and every secret (token pepper, TOTP key, DB/Redis passwords) mounted
+# as 0400 tmpfs files under `/run/secrets` (read through the `*_FILE`
+# variables) instead of env vars. The image carries the same placeholder
+# digest; `pin-digest` rewrites the real digest in `kustomization.yaml` for
+# every workload.
 apiVersion: batch/v1
 apiVersion: batch/v1
 kind: Job
 kind: Job
 metadata:
 metadata:
@@ -55,18 +56,12 @@ spec:
             - configMapRef:
             - configMapRef:
                 name: d-oauth-config
                 name: d-oauth-config
           env:
           env:
-            - name: D_OAUTH_DATABASE__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: database-url
-            - name: D_OAUTH_REDIS__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: redis-url
-            # File-mounted key material, same pattern as the Deployment; the
-            # inline forms remain supported by `Config` but are not used here.
+            # Same file-only secret rule as the Deployment; the DSNs come from
+            # the ConfigMap.
+            - name: D_OAUTH_DATABASE__PASSWORD_FILE
+              value: /run/secrets/database-password
+            - name: D_OAUTH_REDIS__PASSWORD_FILE
+              value: /run/secrets/redis-password
             - name: D_OAUTH_TOKEN_PEPPER_FILE
             - name: D_OAUTH_TOKEN_PEPPER_FILE
               value: /run/secrets/token-pepper
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
@@ -101,6 +96,10 @@ spec:
             secretName: d-oauth-secrets
             secretName: d-oauth-secrets
             defaultMode: 0o400
             defaultMode: 0o400
             items:
             items:
+              - key: database-password
+                path: database-password
+              - key: redis-password
+                path: redis-password
               - key: token-pepper
               - key: token-pepper
                 path: token-pepper
                 path: token-pepper
               - key: totp-encryption-key
               - key: totp-encryption-key

+ 29 - 14
deploy/k8s/secret.yaml

@@ -1,12 +1,27 @@
 # App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
 # App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
 #
 #
-# Consumption: the Deployment and the migrate Job
-# project `token-pepper` and `totp-encryption-key` from this Secret into a
-# tmpfs volume at `/run/secrets` (`defaultMode: 0400`, group 65532 via the
-# pod `fsGroup`) and read them through `D_OAUTH_TOKEN_PEPPER_FILE` /
-# `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so the key material never enters the
-# process environment. `database-url` and `redis-url` are still injected as
-# `secretKeyRef` environment variables.
+# Consumption: the Deployment and the migrate Job project every key below into
+# a tmpfs volume at `/run/secrets` (`defaultMode: 0400`, group 65532 via the
+# pod `fsGroup`) and read them through `*_FILE` variables, so no secret enters
+# the process environment (`/proc/self/environ`, children, crash dumps,
+# `docker inspect`):
+#
+#   token-pepper        -> D_OAUTH_TOKEN_PEPPER_FILE
+#   totp-encryption-key -> D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
+#   database-password   -> D_OAUTH_DATABASE__PASSWORD_FILE
+#   redis-password      -> D_OAUTH_REDIS__PASSWORD_FILE
+#
+# The passwordless DSNs (host/user/db/sslmode and the Redis address) are
+# non-secret and live in `configmap.yaml`; only the passwords are here. A
+# `kubectl exec` into a running pod therefore reads no credential from the
+# environment — only the 0400 files, which the container user already holds.
+# `scripts/check_manifests.py` fails the build if a workload injects any secret
+# through a `secretKeyRef` env instead.
+#
+# In-cluster Redis is expected to run with AUTH (`requirepass`); the password
+# is injected into the bare `redis://host:port` URL at connect time. A Redis
+# deployment without AUTH must enable it, or the workload fails closed on the
+# missing password file.
 #
 #
 # Production rule: this file's keys are replaced by the secret manager
 # Production rule: this file's keys are replaced by the secret manager
 # (external-secrets / sealed-secrets / vault agent). The manifests only ever
 # (external-secrets / sealed-secrets / vault agent). The manifests only ever
@@ -15,8 +30,9 @@
 # manifest, or if a secret-shaped key lands in the ConfigMap.
 # manifest, or if a secret-shaped key lands in the ConfigMap.
 #
 #
 # To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
 # To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
-#   --from-literal=database-url='postgres://...' ...` (or the external-secret
-#   equivalent), then delete this placeholder file from the applied set.
+#   --from-literal=database-password='...' --from-literal=redis-password='...'
+#   ...` (or the external-secret equivalent), then delete this placeholder
+# file from the applied set.
 apiVersion: v1
 apiVersion: v1
 kind: Secret
 kind: Secret
 metadata:
 metadata:
@@ -27,11 +43,10 @@ metadata:
     app.kubernetes.io/part-of: d-oauth
     app.kubernetes.io/part-of: d-oauth
 type: Opaque
 type: Opaque
 stringData:
 stringData:
-  # Full Postgres URL. Production requires `sslmode=verify-full`.
-  database-url: "REPLACE_ME__postgres-url-with-sslmode-verify-full"
-  # Redis single-topology URL (no password embedded for cluster use; prefer
-  # the password-file mechanism when auth is enabled).
-  redis-url: "REPLACE_ME__redis-url"
+  # Postgres password, split out of the passwordless DSN in configmap.yaml.
+  database-password: "REPLACE_ME__postgres-password"
+  # Redis AUTH password, injected into the bare redis:// URL at connect time.
+  redis-password: "REPLACE_ME__redis-password"
   # 256-bit server-side pepper for opaque-token hashing. Losing it invalidates
   # 256-bit server-side pepper for opaque-token hashing. Losing it invalidates
   # every live token; rotate per docs/OPERATIONS.md.
   # every live token; rotate per docs/OPERATIONS.md.
   token-pepper: "REPLACE_ME__32-bytes-of-random"
   token-pepper: "REPLACE_ME__32-bytes-of-random"

+ 9 - 3
deploy/k8s/tls-secret.example.yaml

@@ -6,12 +6,17 @@
 #   kubectl create secret generic d-oauth-tls -n d-oauth \
 #   kubectl create secret generic d-oauth-tls -n d-oauth \
 #     --from-file=tls.crt=./tls.crt \
 #     --from-file=tls.crt=./tls.crt \
 #     --from-file=tls.key=./tls.key \
 #     --from-file=tls.key=./tls.key \
-#     --from-file=client-ca.crt=./client-ca.crt
+#     --from-file=client-ca.crt=./client-ca.crt \
+#     --from-file=db-ca.crt=./db-ca.crt
 #
 #
 # `client-ca.crt` is REQUIRED while `D_OAUTH_TLS__CLIENT_CA_PATH` is set in
 # `client-ca.crt` is REQUIRED while `D_OAUTH_TLS__CLIENT_CA_PATH` is set in
 # the ConfigMap (in-process mTLS verification). Omit the file AND unset the
 # the ConfigMap (in-process mTLS verification). Omit the file AND unset the
-# env key together if mTLS is not used. Never bake this material into the
-# image; `scripts/check_manifests.py` fails on PEM blocks inside manifests.
+# env key together if mTLS is not used.
+#
+# `db-ca.crt` is the Postgres CA for `sslmode=verify-full`, read through
+# `D_OAUTH_DATABASE__ROOT_CERT_PATH`. Omit it and that key together only if the
+# database serves a publicly-trusted certificate. Never bake this material into
+# the image; `scripts/check_manifests.py` fails on PEM blocks inside manifests.
 # ---
 # ---
 # apiVersion: v1
 # apiVersion: v1
 # kind: Secret
 # kind: Secret
@@ -23,3 +28,4 @@
 #   tls.crt: "<server certificate PEM>"
 #   tls.crt: "<server certificate PEM>"
 #   tls.key: "<server private key PEM>"
 #   tls.key: "<server private key PEM>"
 #   client-ca.crt: "<client CA bundle PEM>"
 #   client-ca.crt: "<client CA bundle PEM>"
+#   db-ca.crt: "<Postgres CA bundle PEM>"

+ 5 - 1
docs/CONFIGURATION.md

@@ -91,6 +91,9 @@ Production requires `sslmode=verify-full` in `database.url` unless
 | `database.require_verify_full` | `true` |
 | `database.require_verify_full` | `true` |
 
 
 `max_connections` must be greater than zero and at least `min_connections`.
 `max_connections` must be greater than zero and at least `min_connections`.
+`database.root_cert_path` (or `sslrootcert` in the URL) is required in practice
+for `sslmode=verify-full` unless the server certificate chains to a system root;
+the cluster ConfigMap pins `/certs/db-ca.crt`.
 
 
 ## Redis keys
 ## Redis keys
 
 
@@ -123,7 +126,8 @@ none, is rejected as ambiguous.
 
 
 Durability must match the topology. Single node requires `waitaof` or
 Durability must match the topology. Single node requires `waitaof` or
 `appendfsync_always`; replicated topologies require `wait`. A mismatch is
 `appendfsync_always`; replicated topologies require `wait`. A mismatch is
-rejected.
+rejected. `redis.password_file` holds the raw password and is applied to the
+connection directly, so it may contain any bytes (no URL encoding required).
 
 
 ## Key-ring keys
 ## Key-ring keys
 
 

+ 66 - 26
docs/DEPLOYMENT.md

@@ -230,16 +230,18 @@ A correct Deployment has these properties:
   grace budget.
   grace budget.
 - A `PodDisruptionBudget` with `minAvailable: 2`, and
 - A `PodDisruptionBudget` with `minAvailable: 2`, and
   `topologySpreadConstraints` so replicas spread across nodes.
   `topologySpreadConstraints` so replicas spread across nodes.
-- A `Service`, a `ConfigMap` for non-secret settings, and a `Secret` for
-  secret material. Secrets are mounted or injected, never baked into the
-  image. The token pepper and TOTP key are file-mounted under `/run/secrets`
-  (see below), not injected into the environment.
+- A `Service`, a `ConfigMap` for non-secret settings (including the
+  passwordless DSNs), and a `Secret` for secret material. Every secret is
+  file-mounted under `/run/secrets` (see below), never injected into the
+  environment or baked into the image.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   Redis, and admits ingress only on the two pod ports (see below).
   Redis, and admits ingress only on the two pod ports (see below).
 - A digest-pinned image reference. The release pipeline replaces the committed
 - A digest-pinned image reference. The release pipeline replaces the committed
   placeholder digest in `kustomization.yaml` with the signed build digest;
   placeholder digest in `kustomization.yaml` with the signed build digest;
   `:latest` is never a shippable reference.
   `:latest` is never a shippable reference.
-- Client-CA material mounted read-only if in-process mTLS is used.
+- Client-CA material mounted read-only if in-process mTLS is used. The Postgres
+  CA for `sslmode=verify-full` is mounted at `/certs/db-ca.crt` and pinned with
+  `database.root_cert_path` (`d-oauth-tls` also carries a `db-ca.crt` key).
 
 
 ### Migrations run once
 ### Migrations run once
 
 
@@ -254,26 +256,33 @@ rollout. See [DATABASE.md](DATABASE.md).
 
 
 ### Secret material as files
 ### Secret material as files
 
 
-The Deployment does not put the token pepper or the TOTP encryption key in the
-environment. It projects the two keys of the `d-oauth-secrets` Secret into a
-tmpfs volume at `/run/secrets`, `defaultMode: 0400`, with the pod's `fsGroup`
-set to `65532`, and points the loader at them:
+The Deployment puts no secret in the environment. It projects every key of the
+`d-oauth-secrets` Secret into a tmpfs volume at `/run/secrets`,
+`defaultMode: 0400`, with the pod's `fsGroup` set to `65532`, and points the
+loader at them:
 
 
 ```text
 ```text
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
+D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
+D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 ```
 ```
 
 
-Only the runtime user/group can read the files, so the key material never
-appears in `/proc/self/environ`, in child processes, in `docker inspect` /
-`kubectl describe` output, or in a crash dump. `database.url` and `redis.url`
-are still `secretKeyRef` environment values; prefer `database.password_file`
-where the deployment can split the credential from the URL. The inline
-`D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported
-and take precedence when both are set (`src/config/build.rs`), so an existing
-environment-based deployment still starts; the shipped manifests deliberately
-never set them. The migrate Job mounts the same files. To rotate, replace the
-Secret and roll the replicas; see [CONFIGURATION.md](CONFIGURATION.md).
+The passwordless DSNs are non-secret and live in the ConfigMap
+(`D_OAUTH_DATABASE__URL` with `sslmode=verify-full` and `D_OAUTH_REDIS__URL`);
+only the passwords come from the Secret files. Only the runtime user/group can
+read the files, so no credential or key material ever appears in
+`/proc/self/environ`, in child processes, in `docker inspect` / `kubectl
+describe` output, or in a crash dump — a `kubectl exec` into the container
+reads nothing useful from the environment. `scripts/check_manifests.py` fails
+the build if a workload injects a secret through a `secretKeyRef` env, or if a
+ConfigMap DSN embeds a password. The inline `D_OAUTH_TOKEN_PEPPER` /
+`D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported and take precedence when
+both are set (`src/config/build.rs`), so an existing environment-based
+deployment still starts; the shipped manifests deliberately never set them.
+The migrate Job mounts the same files. To rotate a password, update it in
+Postgres/Redis and the Secret together, then roll the replicas; see
+[CONFIGURATION.md](CONFIGURATION.md) and [OPERATIONS.md](OPERATIONS.md).
 
 
 ### Network policy
 ### Network policy
 
 
@@ -297,6 +306,15 @@ manifest; verify the cluster CNI applies it.
   namespace is still admitted because the ingress rule constrains ports, not
   namespace is still admitted because the ingress rule constrains ports, not
   sources.
   sources.
 
 
+The policy deliberately denies outbound HTTPS (443). The JAR `request_uri`
+fetch (`src/jose/jar/fetch.rs`) retrieves a pre-registered request object from
+a publicly routable HTTPS host, so it cannot reach its target under the shipped
+default-deny. A deployment that relies on remote request objects must add a
+scoped egress rule to that specific host on 443 — never a blanket 443
+allowance, which would restore arbitrary exfiltration. A deployment that does
+not can leave the default-deny in place and treat remote `request_uri` as
+unavailable. The tension is recorded in [TODO.md](TODO.md).
+
 ### Multi-replica validation
 ### Multi-replica validation
 
 
 A local compose file is the intended way to prove shared-state behavior: three
 A local compose file is the intended way to prove shared-state behavior: three
@@ -304,6 +322,20 @@ app replicas against one Postgres and one Redis, completing an
 authorization-code flow whose token issued by one replica is introspectable via
 authorization-code flow whose token issued by one replica is introspectable via
 another. The compose file is the local analogue of the cluster manifests.
 another. The compose file is the local analogue of the cluster manifests.
 
 
+## Optional: sandboxed runtime class
+
+`RuntimeDefault` seccomp plus non-root, dropped capabilities, and a read-only
+rootfs already block most post-RCE paths. A deployment that also wants to
+contain a kernel or runtime escape can run under a sandboxed runtime class
+(gVisor or Kata Containers). The manifests do not set `runtimeClassName`
+because availability is cluster-specific: if the target cluster provides one,
+set it on the Deployment and the migrate Job and verify that the health probes
+and the Postgres/Redis connections still work under the sandboxed syscall
+surface. A custom seccomp allowlist is deliberately not shipped — maintaining
+one by hand across toolchain and dependency upgrades costs more than the
+marginal reduction over `RuntimeDefault`. This is an operational option, not a
+code change.
+
 ## Graceful shutdown
 ## Graceful shutdown
 
 
 On `SIGTERM` the server:
 On `SIGTERM` the server:
@@ -349,6 +381,8 @@ The image pipeline is gated on:
 - Digest pinning, with a periodic rebuild so base digests advance.
 - Digest pinning, with a periodic rebuild so base digests advance.
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
   `deny.toml`.
   `deny.toml`.
+- A per-PR fuzz build plus a scheduled bounded run of every fuzz target
+  (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run.
 
 
 ## Security properties
 ## Security properties
 
 
@@ -356,8 +390,13 @@ The image pipeline is gated on:
   surface to the binary.
   surface to the binary.
 - Non-root with a read-only root filesystem.
 - Non-root with a read-only root filesystem.
 - Secrets and CA material are mounted, never baked into the image or manifests.
 - Secrets and CA material are mounted, never baked into the image or manifests.
-- The token pepper and TOTP key are `0400` tmpfs files, not environment
-  variables, and the pod carries no service-account token.
+- Every secret — token pepper, TOTP key, and the database and Redis passwords —
+  is a `0400` tmpfs file, not an environment variable, and the pod carries no
+  service-account token.
+- `kubectl exec` / `docker exec` reach a non-root process with a read-only
+  rootfs, no capabilities, and no shell, but still bypass the application's own
+  auth; restrict `pods/exec` with RBAC and audit it (see
+  [OPERATIONS.md](OPERATIONS.md)).
 - A NetworkPolicy default-denies pod egress except DNS, Postgres, and Redis,
 - A NetworkPolicy default-denies pod egress except DNS, Postgres, and Redis,
   and admits ingress only on the two pod ports.
   and admits ingress only on the two pod ports.
 - Health routes are unreachable from the public listener, so the probe port is
 - Health routes are unreachable from the public listener, so the probe port is
@@ -412,11 +451,12 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
   cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
   weekly rebuild.
   weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
-  (non-secret settings only), `secret.yaml` (placeholder keys, file-mounted by
-  the workloads and replaced by the secret manager), `service.yaml` (public
-  TLS port only, never the health port), `deployment.yaml` (3 stateless
-  replicas, probes on the plaintext health listener, PDB + topology spread,
-  `automountServiceAccountToken: false`, pepper/TOTP file mounts),
+  (non-secret settings only, including the passwordless DSNs), `secret.yaml`
+  (placeholder secrets — pepper, TOTP key, database and Redis passwords — all
+  file-mounted and replaced by the secret manager), `service.yaml` (public TLS
+  port only, never the health port), `deployment.yaml` (3 stateless replicas,
+  probes on the plaintext health listener, PDB + topology spread,
+  `automountServiceAccountToken: false`, every secret file-mounted),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `networkpolicy.yaml` (default-deny egress except DNS/Postgres/Redis, ingress
   `networkpolicy.yaml` (default-deny egress except DNS/Postgres/Redis, ingress
   to the two pod ports), `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`
   to the two pod ports), `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`

+ 9 - 0
docs/OPERATIONS.md

@@ -243,6 +243,12 @@ survives a Redis loss.
 - A restricted session (forced password change or missing TOTP) cannot reach
 - A restricted session (forced password change or missing TOTP) cannot reach
   `/authorize`, the OAuth endpoints, or admin-gated actions.
   `/authorize`, the OAuth endpoints, or admin-gated actions.
 - Secrets stay out of argv, logs, and audit events.
 - Secrets stay out of argv, logs, and audit events.
+- The container is non-root with a read-only rootfs and no shell, and every
+  secret is a mounted file, so `kubectl exec` / `docker exec` reads no
+  credential from the environment. Exec still bypasses the application's own
+  authorization — the binary *is* the admin CLI — so it must be restricted.
+- Grant `pods/exec` only to a break-glass role and enable Kubernetes audit
+  logging for the `pods/exec` subresource.
 
 
 ## Edge cases
 ## Edge cases
 
 
@@ -264,6 +270,9 @@ survives a Redis loss.
   entitlements; `user create` for users.
   entitlements; `user create` for users.
 - Key rotation: overlap the pepper for at least the refresh lifetime.
 - Key rotation: overlap the pepper for at least the refresh lifetime.
 - Backup: `pg_dump` Postgres; Redis is not backed up.
 - Backup: `pg_dump` Postgres; Redis is not backed up.
+- Access: prefer running admin commands as a one-shot Job (or the local CLI
+  against the database) over `exec` into a running replica. If `exec` is
+  needed, restrict `pods/exec` to a break-glass role and audit the subresource.
 - Health: liveness `GET /health/live`, readiness `GET /health/ready` on the
 - Health: liveness `GET /health/live`, readiness `GET /health/ready` on the
   plaintext health listener.
   plaintext health listener.
 
 

+ 7 - 5
docs/SECURITY.md

@@ -56,16 +56,18 @@ class, or runtime setting).
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
-| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts the pepper and TOTP key as `0400` tmpfs files under `/run/secrets` and reads them through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so no key material enters the environment | `database-url` and `redis-url` are still `secretKeyRef` environment values; an attacker with code execution reads those. |
+| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts every secret — pepper, TOTP key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
-| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379), with link-local `169.254.0.0/16` excluded; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. |
+| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379), with link-local `169.254.0.0/16` excluded; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. The default-deny also blocks the JAR `request_uri` HTTPS fetch — a deployment that needs it must allow the specific host on 443 (see `docs/DEPLOYMENT.md`). |
+| Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 
 
-Last reviewed: 2026-10-04. File-mounted key material, digest pinning,
-NetworkPolicy, and `automountServiceAccountToken: false` are implemented in
-the shipped manifests; the remaining mitigations are tracked in `docs/TODO.md`.
+Last reviewed: 2026-10-04. File-mounted DB/Redis passwords, digest pinning,
+NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
+implemented in the shipped manifests and workflows; the remaining mitigations
+are tracked in `docs/TODO.md`.
 
 
 ## Toplevel rules
 ## Toplevel rules
 
 

+ 34 - 39
docs/TODO.md

@@ -80,43 +80,34 @@ list is deliberately short and ordered by value.
 
 
 ## Container and host hardening
 ## Container and host hardening
 
 
-The attack surface and the existing controls are documented in
-[SECURITY.md](SECURITY.md); this section lists only the mitigations to close the
-residual risks.
-
-This section covers a second threat-model question: if an attacker is already
-inside the image's trust boundary, how could they reach the container or the
-host, and how should we reduce that risk? The image is already hardened: it runs
-as non-root UID 65532, uses a read-only root filesystem, drops all capabilities,
-sets `allowPrivilegeEscalation: false` and `seccompProfile: RuntimeDefault`,
-ships no shell, builds a static musl binary from digest-pinned bases, and
-produces an SBOM, provenance, and a cosign signature in CI. The remaining vectors
-are therefore narrow, and none of them is a current defect. This section lists
-the mitigations to implement.
-
-### Mitigations to plan and implement
-
-### 5. Strengthen runtime isolation
-
-- **Risk.** A kernel or runtime escape is possible after code execution.
-- **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
-  custom seccomp allowlist instead of `RuntimeDefault`.
-- **Files.** `deploy/k8s/deployment.yaml`, `docs/DEPLOYMENT.md`.
-
-### 6. Continuous fuzzing and dependency review
-
-- **Risk.** CI-only fuzzing and `cargo deny` do not catch a zero-day in a
-  trusted crate.
-- **Proposed change.** Run the fuzz targets continuously and add
-  `cargo vet`/`cargo crev` review.
-- **Files.** `.github/workflows/`, `fuzz/`, `deny.toml`.
-
-### 7. Restrict and audit container exec
-
-- **Risk.** `docker exec` / `kubectl exec` into the running container bypasses
-  every image control.
-- **Proposed change.** Restrict who may exec and audit it.
-- **Files.** `docs/OPERATIONS.md`, `docs/DEPLOYMENT.md`.
+The attack surface and the controls already in place are documented in
+[SECURITY.md](SECURITY.md); this section lists only the mitigations that remain.
+It answers a second threat-model question: if an attacker is already inside the
+image's trust boundary, how could they reach the container or the host, and how
+should we reduce that risk? The remaining vectors are narrow.
+
+### 5. NetworkPolicy versus remote `request_uri`
+
+- **Risk.** The default-deny egress blocks outbound HTTPS, so the JAR
+  `request_uri` fetch (`src/jose/jar/fetch.rs`) cannot reach a publicly routable
+  request-object host. A deployment either relies on a feature that silently
+  fails, or loosens egress further than intended.
+- **Proposed change.** Decide per deployment: allow the specific request-object
+  host on 443 with a scoped rule, or document that remote `request_uri` is
+  unavailable under the shipped policy. A blanket 443 allowance is not an
+  option because it restores arbitrary exfiltration.
+- **Files.** `deploy/k8s/networkpolicy.yaml`, `docs/DEPLOYMENT.md`.
+
+### 6. Narrow Postgres/Redis egress to their destination
+
+- **Risk.** `networkpolicy.yaml` allows egress to *any* address on ports 5432 and
+  6379 (link-local excluded), so those ports are an exfiltration channel to an
+  arbitrary internet host, not only the database and cache.
+- **Proposed change.** Where Postgres and Redis run in-cluster, replace the
+  `ipBlock: 0.0.0.0/0` peer with their `namespaceSelector`/`podSelector` so only
+  the real endpoints are reachable; keep a port-scoped rule only for an external
+  managed database.
+- **Files.** `deploy/k8s/networkpolicy.yaml`, `docs/DEPLOYMENT.md`.
 
 
 ## Priority
 ## Priority
 
 
@@ -125,7 +116,8 @@ Ranked by value against effort:
 - **Medium:** items 1, 2, 3. Item 1 is medium/high effort because it needs a
 - **Medium:** items 1, 2, 3. Item 1 is medium/high effort because it needs a
   migration plus a blind index with uniqueness and rotation handling.
   migration plus a blind index with uniqueness and rotation handling.
 - **Lower, mostly operational:** item 4.
 - **Lower, mostly operational:** item 4.
-- **Container/host hardening:** items 5, 6, 7 are medium/operational.
+- **Container/host hardening:** item 5 is the concrete correctness risk; item 6
+  is the egress-narrowing follow-up.
 
 
 Recommendation: implement item 1 next. It closes the largest plaintext
 Recommendation: implement item 1 next. It closes the largest plaintext
 disclosure; items 2 and 3 then reduce the cost of a future key compromise.
 disclosure; items 2 and 3 then reduce the cost of a future key compromise.
@@ -136,4 +128,7 @@ disclosure; items 2 and 3 then reduce the cost of a future key compromise.
 - Is the token pepper and TOTP key held in mounted files or a secrets manager
 - Is the token pepper and TOTP key held in mounted files or a secrets manager
   in the target deployment? This decides whether envelope encryption (item 2)
   in the target deployment? This decides whether envelope encryption (item 2)
   is worth building at all.
   is worth building at all.
-- Is a sandboxed runtime class (gVisor/Kata) available in the target cluster?
+- Which target environments rely on remote JAR `request_uri` objects? This
+  decides whether item 5 needs a scoped egress rule or only documentation.
+- Do Postgres and Redis run in-cluster? This decides whether item 6 can narrow
+  egress to their namespace/selector.

+ 71 - 9
scripts/check_manifests.py

@@ -12,11 +12,18 @@ contract WITHOUT a live cluster:
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - PDB: `minAvailable: 2`.
 - PDB: `minAvailable: 2`.
 - Service: never exposes the health port (8081).
 - Service: never exposes the health port (8081).
-- Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; the
-  token pepper and TOTP key must be file-mounted from `d-oauth-secrets` at
-  `/run/secrets` (`defaultMode: 0400`) and referenced with
-  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`; the inline
-  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL.
+- Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; every
+  secret (token pepper, TOTP key, database and Redis passwords) must be
+  file-mounted from `d-oauth-secrets` at `/run/secrets` (`defaultMode: 0400`)
+  and referenced with its `*_FILE` variable; the inline
+  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL, and any
+  workload env that injects a secret FAILS (`secretKeyRef`, `envFrom.secretRef`,
+  a literal value embedding a DSN password, or a workload redefinition of
+  `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL`).
+- ConfigMap: the ConfigMap referenced by a workload `envFrom` must provide the
+  passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
+  may embed a password (it belongs in the Secret, read through the
+  `*_PASSWORD_FILE` form).
 - NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
 - NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
   `Ingress` + `Egress`, admit ingress only on 8080/8081, and allow egress only
   `Ingress` + `Egress`, admit ingress only on 8080/8081, and allow egress only
   on 53 (DNS), 5432 (Postgres), and 6379 (Redis) with `169.254.0.0/16`
   on 53 (DNS), 5432 (Postgres), and 6379 (Redis) with `169.254.0.0/16`
@@ -54,6 +61,8 @@ SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)"
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
+# A DSN that carries `user:password@` (or `:password@`) embeds a credential.
+EMBEDDED_PASSWORD_RE = re.compile(r"://[^/@?#]*:[^/@?#]*@")
 
 
 CLIENT_CERT_ANNOTATIONS = (
 CLIENT_CERT_ANNOTATIONS = (
   "nginx.ingress.kubernetes.io/auth-tls-verify-client",
   "nginx.ingress.kubernetes.io/auth-tls-verify-client",
@@ -69,8 +78,11 @@ SECRET_VOLUME_MODE = 0o400
 FILE_SECRET_ENV_KEYS = {
 FILE_SECRET_ENV_KEYS = {
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
   "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
+  "D_OAUTH_DATABASE__PASSWORD_FILE": "database-password",
+  "D_OAUTH_REDIS__PASSWORD_FILE": "redis-password",
 }
 }
 INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
 INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
+PASSWORDLESS_DSN_KEYS = ("D_OAUTH_DATABASE__URL", "D_OAUTH_REDIS__URL")
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 METADATA_CIDR = "169.254.0.0/16"
 METADATA_CIDR = "169.254.0.0/16"
 
 
@@ -122,13 +134,39 @@ def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
       fails.add(f"{src}: placeholder belongs only in secret.yaml")
       fails.add(f"{src}: placeholder belongs only in secret.yaml")
 
 
 
 
-def check_configmap(doc: dict, fails: Failures) -> None:
+def check_configmap(doc: dict, fails: Failures, requires_dsn: bool) -> None:
   data = doc.get("data") or {}
   data = doc.get("data") or {}
   for key in data:
   for key in data:
     if SECRET_NAME_RE.search(key):
     if SECRET_NAME_RE.search(key):
       fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
       fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
-  if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
-    pass  # mTLS on: L7 check below becomes load-bearing.
+  if not requires_dsn:
+    return
+  for key in PASSWORDLESS_DSN_KEYS:
+    value = (data.get(key) or "").strip()
+    if not value:
+      fails.add(
+        f"configmap: {key} is required; the workloads take the DSN from here "
+        "and the password from the mounted *_PASSWORD_FILE"
+      )
+    elif EMBEDDED_PASSWORD_RE.search(value):
+      fails.add(
+        f"configmap: {key} must not embed a password; move it to the Secret "
+        "and use the *_PASSWORD_FILE form"
+      )
+
+
+def referenced_configmaps(docs: list[dict]) -> set[str]:
+  """Names of ConfigMaps any workload pulls into the environment via `envFrom`."""
+  names: set[str] = set()
+  for kind in ("Deployment", "Job"):
+    for doc in by_kind(docs, kind):
+      tpl = (doc.get("spec") or {}).get("template", {}).get("spec", {})
+      for container in tpl.get("containers") or []:
+        for ref in container.get("envFrom") or []:
+          name = ((ref or {}).get("configMapRef") or {}).get("name")
+          if name:
+            names.add(name)
+  return names
 
 
 
 
 def mtls_enabled(docs: list[dict]) -> bool:
 def mtls_enabled(docs: list[dict]) -> bool:
@@ -172,6 +210,28 @@ def check_file_mounted_secrets(
   """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
   """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
   cname = container.get("name", "?")
   cname = container.get("name", "?")
   envs = env_by_name(container)
   envs = env_by_name(container)
+  for ref in container.get("envFrom") or []:
+    if isinstance(ref, dict) and "secretRef" in ref:
+      fails.add(
+        f"{workload}/{cname}: envFrom.secretRef injects every secret into the "
+        "environment; mount the keys as files instead"
+      )
+  for name, entry in envs.items():
+    if "secretKeyRef" in (entry.get("valueFrom") or {}):
+      fails.add(
+        f"{workload}/{cname}: env {name} injects a secret via secretKeyRef; "
+        "mount it as a file and reference the *_FILE form"
+      )
+    if name in PASSWORDLESS_DSN_KEYS:
+      fails.add(
+        f"{workload}/{cname}: {name} must come from the ConfigMap, not the "
+        "workload env (an inline value could embed a password)"
+      )
+    value = entry.get("value")
+    if isinstance(value, str) and EMBEDDED_PASSWORD_RE.search(value):
+      fails.add(
+        f"{workload}/{cname}: env {name} embeds a password; mount it as a file"
+      )
   for inline in INLINE_SECRET_ENVS:
   for inline in INLINE_SECRET_ENVS:
     if inline in envs:
     if inline in envs:
       fails.add(
       fails.add(
@@ -404,8 +464,10 @@ def main() -> int:
       fails.add(f"missing required kind: {required}")
       fails.add(f"missing required kind: {required}")
 
 
   check_pem_and_placeholders(docs, fails)
   check_pem_and_placeholders(docs, fails)
+  required_dsn_configmaps = referenced_configmaps(docs)
   for doc in by_kind(docs, "ConfigMap"):
   for doc in by_kind(docs, "ConfigMap"):
-    check_configmap(doc, fails)
+    name = (doc.get("metadata") or {}).get("name")
+    check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
   for doc in by_kind(docs, "Deployment"):
   for doc in by_kind(docs, "Deployment"):
     check_deployment(doc, fails, args.strict_digest)
     check_deployment(doc, fails, args.strict_digest)
   for doc in by_kind(docs, "Job"):
   for doc in by_kind(docs, "Job"):

+ 2 - 2
src/cli/context.rs

@@ -8,7 +8,6 @@
 
 
 use std::{io::Read as _, path::Path, sync::Arc};
 use std::{io::Read as _, path::Path, sync::Arc};
 
 
-use secrecy::ExposeSecret as _;
 use sqlx::PgPool;
 use sqlx::PgPool;
 
 
 use crate::config::Config;
 use crate::config::Config;
@@ -108,11 +107,12 @@ impl Context {
 /// Returns [`CliError::Database`] when the pool cannot connect.
 /// Returns [`CliError::Database`] when the pool cannot connect.
 pub async fn open_pool(config: &Config) -> Result<PgPool, CliError> {
 pub async fn open_pool(config: &Config) -> Result<PgPool, CliError> {
   let db = &config.database;
   let db = &config.database;
+  let options = crate::state::connect_options(db, config.environment)?;
   let pool = sqlx::postgres::PgPoolOptions::new()
   let pool = sqlx::postgres::PgPoolOptions::new()
     .max_connections(db.max_connections)
     .max_connections(db.max_connections)
     .min_connections(0)
     .min_connections(0)
     .acquire_timeout(db.acquire_timeout)
     .acquire_timeout(db.acquire_timeout)
-    .connect(db.url.expose_secret())
+    .connect_with(options)
     .await?;
     .await?;
   Ok(pool)
   Ok(pool)
 }
 }

+ 1 - 1
src/cli/test_support.rs

@@ -168,7 +168,7 @@ async fn build() -> Result<Harness, TestError> {
   })
   })
 }
 }
 
 
-async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
+pub(crate) async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
   let container = GenericImage::new("postgres", "16-alpine")
   let container = GenericImage::new("postgres", "16-alpine")
     .with_exposed_port(5432.tcp())
     .with_exposed_port(5432.tcp())
     .with_wait_for(WaitFor::message_on_stderr(
     .with_wait_for(WaitFor::message_on_stderr(

+ 35 - 0
src/cli/tests.rs

@@ -677,3 +677,38 @@ fn admin_bootstrap_runbook_matches_cli() -> Result<(), TestError> {
   }
   }
   Ok(())
   Ok(())
 }
 }
+
+/// `open_pool` must honor the mounted database password: a passwordless DSN
+/// plus `password_file` connects (the cluster migrate-Job contract).
+#[tokio::test]
+async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
+  let (_postgres, pg_url) = test_support::postgres_container().await?;
+  let user_prefix = "postgres://postgres";
+  let user_end = user_prefix.len();
+  assert!(pg_url.starts_with(&format!("{user_prefix}:")));
+  let at = pg_url
+    .find('@')
+    .ok_or(TestError::Missing("container url carries userinfo"))?;
+  let password = pg_url[user_end + 1..at].to_owned();
+  let passwordless = format!("{}{}", &pg_url[..user_end], &pg_url[at..]);
+  let pw_path = std::env::temp_dir().join(format!(
+    "d-oauth-cli-open-pool-pw-{}.txt",
+    uuid::Uuid::new_v4()
+  ));
+  std::fs::write(&pw_path, format!("{password}\n"))?;
+  let fixture = serde_json::json!({
+    "issuer": "https://auth.example.test",
+    "environment": "development",
+    "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
+    "database": {"url": passwordless, "password_file": pw_path.to_string_lossy()},
+    "redis": {"url": "redis://127.0.0.1:6379", "durability": "waitaof"},
+    "token_pepper": "cli-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  let config = Config::from_source(source)?;
+  let pool = super::context::open_pool(&config).await?;
+  sqlx::query("SELECT 1").execute(&pool).await?;
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}

+ 3 - 1
src/config.rs

@@ -326,7 +326,9 @@ pub(crate) fn read_secret_file(path: Option<&Path>) -> Result<Option<SecretStrin
         path: path.to_path_buf(),
         path: path.to_path_buf(),
         source,
         source,
       })?;
       })?;
-      Ok(Some(SecretString::from(contents.trim().to_owned())))
+      Ok(Some(SecretString::from(
+        contents.trim_end_matches(['\r', '\n']).to_owned(),
+      )))
     }
     }
   }
   }
 }
 }

+ 15 - 0
src/config/tests.rs

@@ -287,3 +287,18 @@ fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dy
   );
   );
   Ok(())
   Ok(())
 }
 }
+
+#[test]
+fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
+  use secrecy::ExposeSecret as _;
+  let path = std::env::temp_dir().join(format!(
+    "d-oauth-read-secret-file-{}.txt",
+    std::process::id()
+  ));
+  std::fs::write(&path, "  spaced-secret  \n")?;
+  let secret = super::read_secret_file(Some(path.as_path()))?;
+  let _ = std::fs::remove_file(&path);
+  let secret = secret.ok_or("secret file returned None")?;
+  assert_eq!(secret.expose_secret(), "  spaced-secret  ");
+  Ok(())
+}

+ 1 - 0
src/state.rs

@@ -21,6 +21,7 @@ mod tests_resilience;
 mod tests_topology;
 mod tests_topology;
 
 
 pub use health::health_router;
 pub use health::health_router;
+pub(crate) use pg::connect_options;
 pub use redis::RedisConn;
 pub use redis::RedisConn;
 pub use sentinel::SentinelConn;
 pub use sentinel::SentinelConn;
 
 

+ 1 - 1
src/state/pg.rs

@@ -42,7 +42,7 @@ fn has_explicit_sslmode(url: &str) -> bool {
 }
 }
 
 
 /// Build `PgConnectOptions` from [`DbConfig`] without opening connections.
 /// Build `PgConnectOptions` from [`DbConfig`] without opening connections.
-fn connect_options(
+pub(crate) fn connect_options(
   db: &DbConfig,
   db: &DbConfig,
   environment: Environment,
   environment: Environment,
 ) -> Result<PgConnectOptions, StateError> {
 ) -> Result<PgConnectOptions, StateError> {

+ 21 - 17
src/state/redis.rs

@@ -18,7 +18,7 @@
 use std::fmt;
 use std::fmt;
 
 
 use redis::{
 use redis::{
-  AsyncConnectionConfig, Client, RedisFuture, Value,
+  AsyncConnectionConfig, Client, ConnectionInfo, IntoConnectionInfo, RedisFuture, Value,
   aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig},
   aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig},
   cluster::ClusterClientBuilder,
   cluster::ClusterClientBuilder,
   cluster_async::ClusterConnection,
   cluster_async::ClusterConnection,
@@ -90,30 +90,34 @@ impl ConnectionLike for RedisConn {
   }
   }
 }
 }
 
 
-/// Inject `cfg.password` (mounted secret file) into a bare `redis://` URL.
+/// Apply `cfg.password` (mounted secret file) to a bare `redis://` URL.
 ///
 ///
-/// Passwords containing URL-significant bytes must already be percent-encoded
-/// in the URL itself; this only handles the common alphanumeric-secret case.
-fn single_url(url: &secrecy::SecretString, password: Option<&secrecy::SecretString>) -> String {
-  let url = url.expose_secret().to_owned();
-  let Some(password) = password else {
-    return url;
-  };
-  if url.contains('@') {
-    return url;
-  }
-  match url.split_once("://") {
-    Some((scheme, rest)) => format!("{scheme}://:{}@{rest}", password.expose_secret()),
-    None => url,
+/// The URL is parsed into a [`ConnectionInfo`] first and the password is set
+/// programmatically, so the secret never passes through URL string
+/// interpolation: any password bytes are safe (no percent-encoding needed),
+/// and a `Some` password is always applied even when the URL already carries
+/// userinfo without a password.
+fn single_connection_info(
+  url: &secrecy::SecretString,
+  password: Option<&secrecy::SecretString>,
+) -> Result<ConnectionInfo, redis::RedisError> {
+  let mut info: ConnectionInfo = url.expose_secret().into_connection_info()?;
+  if let Some(password) = password {
+    let redis_settings = info
+      .redis_settings()
+      .clone()
+      .set_password(password.expose_secret());
+    info = info.set_redis_settings(redis_settings);
   }
   }
+  Ok(info)
 }
 }
 
 
 async fn connect_single(
 async fn connect_single(
   config: &RedisConfig,
   config: &RedisConfig,
   url: &secrecy::SecretString,
   url: &secrecy::SecretString,
 ) -> Result<RedisConn, StateError> {
 ) -> Result<RedisConn, StateError> {
-  let url = single_url(url, config.password.as_ref());
-  let client = Client::open(url.as_str()).map_err(StateError::Redis)?;
+  let info = single_connection_info(url, config.password.as_ref()).map_err(StateError::Redis)?;
+  let client = Client::open(info).map_err(StateError::Redis)?;
   let manager_config = ConnectionManagerConfig::new()
   let manager_config = ConnectionManagerConfig::new()
     .set_number_of_retries(config.retry_budget as usize)
     .set_number_of_retries(config.retry_budget as usize)
     .set_min_delay(config.retry_min_delay)
     .set_min_delay(config.retry_min_delay)

+ 50 - 0
src/state/tests_topology.rs

@@ -103,6 +103,56 @@ async fn health_single_uses_password_from_config() -> TestResult {
   Ok(())
   Ok(())
 }
 }
 
 
+/// Single node with a URL-significant AUTH password: the mounted-file
+/// password is applied programmatically, so `/ ? # % @ :` never misroute.
+#[tokio::test]
+async fn health_redis_single_password_special_chars_connects() -> TestResult {
+  let secret = "p/a?s#s%e@c:d";
+  let (_pg, pg_url) = postgres_container().await?;
+  let (_redis, redis_url) = redis_single_container(Some(secret)).await?;
+  let pw_path = std::env::temp_dir().join("d-oauth-health-redis-pw-special.txt");
+  std::fs::write(&pw_path, format!("{secret}\n"))
+    .map_err(|_| TestError::Body("pw write".into()))?;
+  let redis_block = serde_json::json!({
+    "url": redis_url,
+    "durability": "waitaof",
+    "aof_required": false,
+    "password_file": pw_path.to_string_lossy(),
+  });
+  let config = test_config(&pg_url, redis_block)?;
+  let state = AppState::connect(Arc::new(config)).await?;
+  mark_migrated(&state).await?;
+  let (ready_status, _) = get_health(&state, "/health/ready").await?;
+  assert_eq!(ready_status, 200);
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}
+
+/// Single node where the URL carries userinfo but no password: the
+/// mounted-file password must still be applied, never silently dropped.
+#[tokio::test]
+async fn health_redis_single_password_userinfo_url_connects() -> TestResult {
+  let secret = "health-redis-pw-userinfo-4d1";
+  let (_pg, pg_url) = postgres_container().await?;
+  let (_redis, redis_url) = redis_single_container(Some(secret)).await?;
+  let userinfo_url = redis_url.replacen("://", "://default@", 1);
+  let pw_path = std::env::temp_dir().join("d-oauth-health-redis-pw-userinfo.txt");
+  std::fs::write(&pw_path, format!("{secret}\n"))
+    .map_err(|_| TestError::Body("pw write".into()))?;
+  let redis_block = serde_json::json!({
+    "url": userinfo_url,
+    "durability": "waitaof",
+    "aof_required": false,
+    "password_file": pw_path.to_string_lossy(),
+  });
+  let config = test_config(&pg_url, redis_block)?;
+  let state = AppState::connect(Arc::new(config)).await?;
+  mark_migrated(&state).await?;
+  let (ready_status, _) = get_health(&state, "/health/ready").await?;
+  assert_eq!(ready_status, 200);
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}
 /// Start a Sentinel pair: `master` serves data, `sentinel` monitors it.
 /// Start a Sentinel pair: `master` serves data, `sentinel` monitors it.
 /// Returns the guards plus the sentinel bridge address.
 /// Returns the guards plus the sentinel bridge address.
 async fn sentinel_pair() -> Result<(ContainerGuard, ContainerGuard, String), TestError> {
 async fn sentinel_pair() -> Result<(ContainerGuard, ContainerGuard, String), TestError> {