feat(pocket-id): move the identity provider's state into Postgres (#35) #72

Merged
tepichord merged 1 commit from feat/35-pocket-id-postgres into main 2026-09-19 15:05:35 -07:00
Owner

Pocket ID kept its whole world in /app/data/pocket-id.db: one user, two
passkeys and three OIDC client registrations, on a 5 GiB persistent claim
beside a 4 MiB WAL. All of it becomes the pocket_id database in the
postgres cluster — with the same shape Woodpecker's cut-over established,
and two differences that are this app's rather than a variant of the same
edit.

Nothing is ported, and that is a decision rather than a shortcut. Pocket ID
has no export/import and its migrations are per-provider, so the alternative
is hand-written SQL across 32 tables it owns, to preserve about twenty
minutes of §5c-§5e browser work. It is also the one cut-over that cannot lock
anyone out: §5c's /setup reopens whenever the database holds no users, and
no path to the cluster runs through the provider (kubectl uses the Talos
kubeconfig; Vault still answers to its root token).

  • data/postgres/{roles,databases}.yaml: a DatabaseRole and a Database
    named pocket-id — Kubernetes naming, because the app is spelled with a
    hyphen — whose spec.name is pocket_id, which is what PostgreSQL parses.
    Owner, login, and both reclaim policies retain, exactly as Woodpecker's
    pair.
  • secrets/pocket-id-database.yaml: one Vault path
    (secret/pocket-id/database), two Secrets, because CNPG's
    passwordSecret is a LocalObjectReference and Pocket ID reads a DSN in its
    own namespace. The postgres copy is basic-auth and carries
    k8s.enterprisedb.io/reload (the rotation mechanism); the pocket-id copy
    is Opaque and DB_CONNECTION_STRING is templated from the same two fields,
    so they cannot drift. Hex password, sslmode=require — pgx's require,
    whose own parser was read rather than borrowing lib/pq's meaning from
    Woodpecker's DSN.
  • apps/pocket-id/helmrelease.yaml: the DSN arrives as one env entry added
    by a postRenderer. The chart leaves no other route — config.* renders
    into a ConfigMap (a password in a ConfigMap is what the Vault-first rule
    exists to prevent) and its single existingSecret slot is already spent on
    ENCRYPTION_KEY, with no extraEnv. Worth the awkwardness because of how
    Pocket ID reads it: the provider is inferred from the DSN's scheme, so no
    DSN at all means SQLite at data/pocket-id.db — a silent fallback, not a
    startup error, onto the very file this change retires.
  • The encryption key does not move: same Vault path, same Secret, same job.
    Uploads stay on the claim (avatars are images, not rows).

The cut-over is a hand-cut recorded in docs/pocket-id.md §5g, including what
lives outside the database: the three clients' secrets (Vault's .env copy,
the console's oz-secrets, Headlamp's Terraform variable) and the two group
names that must come back spelled the same. Its hard step is the last one —
renaming the retired file rather than deleting it, because a copy left where
the app looks is how a future DSN regression comes back up on months-old
identities instead of an empty database that asks for a passkey.

Drills follow the data again: drills/check.sh now counts rows per database
and keys them by it (rows_<db>_<table>), so a second app's rows cannot
silently shadow the first's, and a database that does not exist yet reads ?
— a difference rather than silence (verified live: 45 predicates, Woodpecker's
counts and rows_pocket_id_* as ?).

Two stale C1 leftovers are corrected here rather than left to rot:
docs/logging.md still said Woodpecker's build logs stream into SQLite on a
persistent PVC, and ARCHITECTURE.md's durable budget said 51 GiB of 2 TiB —
it is 270 GiB, the Nix store's 200 GiB having never been counted. Also fixed
in this change, both found by running what the docs claim: §5f's v auth list
is denied to the scoped token the section tells you to load (auth methods are
admin-level; it now checks the key path that token can read), and §5's
internal-URL story was wrong — INTERNAL_APP_URL is derived from APP_URL,
which is why every published endpoint is the public one.

Guard: tests/test_chart_sources.py renders the chart and applies this
postRenderer with kustomize, asserting the variable lands on the container as
a secretKeyRef — a patch that quietly stopped applying would leave the app
running on the retired file. 220 tests pass, also under a CI environment.

Pocket ID kept its whole world in `/app/data/pocket-id.db`: one user, two passkeys and three OIDC client registrations, on a 5 GiB `persistent` claim beside a 4 MiB WAL. All of it becomes the `pocket_id` database in the `postgres` cluster — with the same shape Woodpecker's cut-over established, and two differences that are this app's rather than a variant of the same edit. Nothing is ported, and that is a decision rather than a shortcut. Pocket ID has no export/import and its migrations are per-provider, so the alternative is hand-written SQL across 32 tables it owns, to preserve about twenty minutes of §5c-§5e browser work. It is also the one cut-over that cannot lock anyone out: §5c's `/setup` reopens whenever the database holds no users, and no path to the cluster runs through the provider (kubectl uses the Talos kubeconfig; Vault still answers to its root token). - `data/postgres/{roles,databases}.yaml`: a `DatabaseRole` and a `Database` named `pocket-id` — Kubernetes naming, because the app is spelled with a hyphen — whose `spec.name` is `pocket_id`, which is what PostgreSQL parses. Owner, `login`, and both reclaim policies `retain`, exactly as Woodpecker's pair. - `secrets/pocket-id-database.yaml`: one Vault path (`secret/pocket-id/database`), two Secrets, because CNPG's `passwordSecret` is a LocalObjectReference and Pocket ID reads a DSN in its own namespace. The `postgres` copy is basic-auth and carries `k8s.enterprisedb.io/reload` (the rotation mechanism); the `pocket-id` copy is Opaque and `DB_CONNECTION_STRING` is templated from the same two fields, so they cannot drift. Hex password, `sslmode=require` — pgx's `require`, whose own parser was read rather than borrowing lib/pq's meaning from Woodpecker's DSN. - `apps/pocket-id/helmrelease.yaml`: the DSN arrives as one `env` entry added by a postRenderer. The chart leaves no other route — `config.*` renders into a ConfigMap (a password in a ConfigMap is what the Vault-first rule exists to prevent) and its single `existingSecret` slot is already spent on `ENCRYPTION_KEY`, with no `extraEnv`. Worth the awkwardness because of how Pocket ID reads it: the provider is inferred from the DSN's *scheme*, so no DSN at all means SQLite at `data/pocket-id.db` — a silent fallback, not a startup error, onto the very file this change retires. - The encryption key does not move: same Vault path, same Secret, same job. Uploads stay on the claim (avatars are images, not rows). The cut-over is a hand-cut recorded in docs/pocket-id.md §5g, including what lives *outside* the database: the three clients' secrets (Vault's `.env` copy, the console's `oz-secrets`, Headlamp's Terraform variable) and the two group names that must come back spelled the same. Its hard step is the last one — renaming the retired file rather than deleting it, because a copy left where the app looks is how a future DSN regression comes back up on months-old identities instead of an empty database that asks for a passkey. Drills follow the data again: `drills/check.sh` now counts rows per database and keys them by it (`rows_<db>_<table>`), so a second app's rows cannot silently shadow the first's, and a database that does not exist yet reads `?` — a difference rather than silence (verified live: 45 predicates, Woodpecker's counts and `rows_pocket_id_*` as `?`). Two stale C1 leftovers are corrected here rather than left to rot: docs/logging.md still said Woodpecker's build logs stream into SQLite on a persistent PVC, and ARCHITECTURE.md's durable budget said 51 GiB of 2 TiB — it is 270 GiB, the Nix store's 200 GiB having never been counted. Also fixed in this change, both found by running what the docs claim: §5f's `v auth list` is denied to the scoped token the section tells you to load (auth methods are admin-level; it now checks the key path that token *can* read), and §5's internal-URL story was wrong — `INTERNAL_APP_URL` is derived from `APP_URL`, which is why every published endpoint is the public one. Guard: tests/test_chart_sources.py renders the chart and applies this postRenderer with kustomize, asserting the variable lands on the container as a `secretKeyRef` — a patch that quietly stopped applying would leave the app running on the retired file. 220 tests pass, also under a CI environment.
feat(pocket-id): move the identity provider's state into Postgres (#35)
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
ci/woodpecker/pull_request_closed/cleanup Pipeline was successful
001061f7b3
Pocket ID kept its whole world in `/app/data/pocket-id.db`: one user, two
passkeys and three OIDC client registrations, on a 5 GiB `persistent` claim
beside a 4 MiB WAL. All of it becomes the `pocket_id` database in the
`postgres` cluster — with the same shape Woodpecker's cut-over established,
and two differences that are this app's rather than a variant of the same
edit.

Nothing is ported, and that is a decision rather than a shortcut. Pocket ID
has no export/import and its migrations are per-provider, so the alternative
is hand-written SQL across 32 tables it owns, to preserve about twenty
minutes of §5c-§5e browser work. It is also the one cut-over that cannot lock
anyone out: §5c's `/setup` reopens whenever the database holds no users, and
no path to the cluster runs through the provider (kubectl uses the Talos
kubeconfig; Vault still answers to its root token).

- `data/postgres/{roles,databases}.yaml`: a `DatabaseRole` and a `Database`
  named `pocket-id` — Kubernetes naming, because the app is spelled with a
  hyphen — whose `spec.name` is `pocket_id`, which is what PostgreSQL parses.
  Owner, `login`, and both reclaim policies `retain`, exactly as Woodpecker's
  pair.
- `secrets/pocket-id-database.yaml`: one Vault path
  (`secret/pocket-id/database`), two Secrets, because CNPG's
  `passwordSecret` is a LocalObjectReference and Pocket ID reads a DSN in its
  own namespace. The `postgres` copy is basic-auth and carries
  `k8s.enterprisedb.io/reload` (the rotation mechanism); the `pocket-id` copy
  is Opaque and `DB_CONNECTION_STRING` is templated from the same two fields,
  so they cannot drift. Hex password, `sslmode=require` — pgx's `require`,
  whose own parser was read rather than borrowing lib/pq's meaning from
  Woodpecker's DSN.
- `apps/pocket-id/helmrelease.yaml`: the DSN arrives as one `env` entry added
  by a postRenderer. The chart leaves no other route — `config.*` renders
  into a ConfigMap (a password in a ConfigMap is what the Vault-first rule
  exists to prevent) and its single `existingSecret` slot is already spent on
  `ENCRYPTION_KEY`, with no `extraEnv`. Worth the awkwardness because of how
  Pocket ID reads it: the provider is inferred from the DSN's *scheme*, so no
  DSN at all means SQLite at `data/pocket-id.db` — a silent fallback, not a
  startup error, onto the very file this change retires.
- The encryption key does not move: same Vault path, same Secret, same job.
  Uploads stay on the claim (avatars are images, not rows).

The cut-over is a hand-cut recorded in docs/pocket-id.md §5g, including what
lives *outside* the database: the three clients' secrets (Vault's `.env` copy,
the console's `oz-secrets`, Headlamp's Terraform variable) and the two group
names that must come back spelled the same. Its hard step is the last one —
renaming the retired file rather than deleting it, because a copy left where
the app looks is how a future DSN regression comes back up on months-old
identities instead of an empty database that asks for a passkey.

Drills follow the data again: `drills/check.sh` now counts rows per database
and keys them by it (`rows_<db>_<table>`), so a second app's rows cannot
silently shadow the first's, and a database that does not exist yet reads `?`
— a difference rather than silence (verified live: 45 predicates, Woodpecker's
counts and `rows_pocket_id_*` as `?`).

Two stale C1 leftovers are corrected here rather than left to rot:
docs/logging.md still said Woodpecker's build logs stream into SQLite on a
persistent PVC, and ARCHITECTURE.md's durable budget said 51 GiB of 2 TiB —
it is 270 GiB, the Nix store's 200 GiB having never been counted. Also fixed
in this change, both found by running what the docs claim: §5f's `v auth list`
is denied to the scoped token the section tells you to load (auth methods are
admin-level; it now checks the key path that token *can* read), and §5's
internal-URL story was wrong — `INTERNAL_APP_URL` is derived from `APP_URL`,
which is why every published endpoint is the public one.

Guard: tests/test_chart_sources.py renders the chart and applies this
postRenderer with kustomize, asserting the variable lands on the container as
a `secretKeyRef` — a patch that quietly stopped applying would leave the app
running on the retired file. 220 tests pass, also under a CI environment.
tepichord deleted branch feat/35-pocket-id-postgres 2026-09-19 15:05:35 -07:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tepichord/milner!72
No description provided.