Skip to content

Commit 98bcf27

Browse files
fix(docker): give a preview its own login apps and signing key
A preview starts from a clone of another instance's database and reads it with that instance's encryption key, because that is the only key those rows can be read with. The same key unseals the source's JWT signing key and decrypts its OAuth client secrets, so a preview inherited both. Sign-in could therefore never work: the cloned OAuth apps are registered against the source's hostname, and a provider rejects every authorization request that starts from a preview host. The preview also minted its own tokens signed with the source instance's production signing key. The clone is now re-homed before the application server starts. Emptying login_provider, jwt_signing_key and issued_jwt makes the preview rebuild each from its own configuration on boot. Accounts are untouched, because identity_link keys on identity_provider rather than on login_provider. The policy moves out of a shell heredoc into sanitize.sql, organised by the two questions it answers — a clone must not act, and a clone must not keep the source instance's identity — so it can be read and diffed as SQL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZADQeSx6zQNNqsdu7AAqZ
1 parent a1da80f commit 98bcf27

4 files changed

Lines changed: 120 additions & 44 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Pull request previews now sign in through their own login apps instead of the ones they cloned. A preview starts from a copy of another instance's database, and it used to keep that instance's OAuth registrations — whose callback URLs belong to the original hostname, so every sign-in attempt was rejected by the provider before it began. A preview now rebuilds its login providers from its own configuration on first boot, and mints its own token-signing key rather than reusing the cloned one. Existing accounts are unaffected: signing in through the preview's own app lands on the same account as before.

docker/preview/README.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ Before the first restore, the seeder recreates only the uniquely resolved previe
2222
both succeed. A failed or partial first attempt can therefore retry cleanly, while redeploying an
2323
already seeded PR preserves changes made while testing. A new preview volume gets a fresh staging clone.
2424

25-
## Silence policy
25+
## Preview policy
2626

27-
Before the application server starts, `seed-loader`:
27+
`seed-loader` applies [`sanitize.sql`](./sanitize.sql) to the restored clone before the application
28+
server starts. A clone is another instance's live database, so the policy answers two questions, and
29+
the file is organised by them.
30+
31+
### Silence — a clone must not act
2832

2933
1. disables automatic and manual practice-review triggers for every workspace;
3034
2. disables each `PRACTICE_REVIEW` model binding while preserving its selected model;
@@ -37,6 +41,29 @@ visible in the UI. To test reviews for one workspace, enable its practice-review
3741
enable the desired manual or automatic trigger in that workspace's practice-review settings. Those
3842
changes persist for the lifetime of the PR preview.
3943

44+
### Re-home — a clone must not keep the source instance's identity
45+
46+
A preview runs with the **source instance's** `HEPHAESTUS_SECURITY_ENCRYPTION_KEY`, because that is the
47+
only key the cloned rows can be read with. Without it the preview boots and then fails every request
48+
that touches a credential, with `AEADBadTagException: Tag mismatch` in the log. That key also unseals
49+
the source's JWT signing key and decrypts its OAuth client secrets, so three tables are emptied and
50+
rebuilt from this deployment's own configuration:
51+
52+
| Table | Why it cannot be inherited |
53+
|---|---|
54+
| `login_provider` | The source's OAuth apps are registered against the source's hostname, so the provider rejects every sign-in that starts from a preview host |
55+
| `jwt_signing_key` | The preview would otherwise mint its own tokens signed with the source instance's production signing key |
56+
| `issued_jwt` | Cloned sessions belong to the source instance's users |
57+
58+
`LoginProviderService` seeds `login_provider` from the environment whenever a registration id is
59+
absent, so emptying the table hands the preview its own login apps on the next boot — which is why the
60+
preview stack needs `GITHUB_OAUTH_*` (and any other provider it should offer) pointed at an OAuth app
61+
whose callback covers the preview hostnames. A provider with no credentials in the preview environment
62+
is simply not offered; Slack, being link-only, is normally absent for that reason.
63+
64+
Accounts survive all of this: `identity_link` keys on `identity_provider`, not on `login_provider`, so
65+
a cloned user signs in through the preview's own OAuth app and lands on the same account.
66+
4067
## One-time server and Coolify setup
4168

4269
1. Keep staging's `nats-server` attached to the external Docker network `shared-network`.
@@ -45,11 +72,14 @@ changes persist for the lifetime of the PR preview.
4572
joins `shared-network` as a second network.
4673
4. Set `PREVIEW_SEED_SOURCE_CONTAINER=app-postgres-1` if the staging Compose project/container name
4774
ever changes. The source user defaults to `root` and database to `hephaestus`.
48-
5. Assign the web and API services sibling wildcard domains. With the current template these are
75+
5. Set `HEPHAESTUS_SECURITY_ENCRYPTION_KEY` to the **seed source instance's** key, and point
76+
`GITHUB_OAUTH_CLIENT_ID`/`_SECRET` at an OAuth app whose callback covers the preview hostnames —
77+
see the re-home policy above for both.
78+
6. Assign the web and API services sibling wildcard domains. With the current template these are
4979
`pr<id>.hephaestus.felixdietrich.com` and `pr<id>.api.hephaestus.felixdietrich.com`.
50-
6. Leave the preview `IMAGE_TAG` unset. Coolify injects `SOURCE_COMMIT`, and CI publishes the matching
80+
7. Leave the preview `IMAGE_TAG` unset. Coolify injects `SOURCE_COMMIT`, and CI publishes the matching
5181
application-server image before the preview update is requested.
52-
7. Put every preview-safe value in the application's **base** environment variables, not in Coolify's
82+
8. Put every preview-safe value in the application's **base** environment variables, not in Coolify's
5383
separate preview-variable scope. See below.
5484

5585
## Why the safe values live in Coolify's base scope

docker/preview/compose.app.yaml

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -115,49 +115,18 @@ services:
115115
-U hephaestus -d hephaestus \
116116
--exit-on-error --no-owner --no-privileges < /tmp/seed.dump
117117
118-
echo "Applying preview silence policy"
119-
docker exec -i "$$PREVIEW_PG" psql -v ON_ERROR_STOP=1 -U hephaestus -d hephaestus <<'SQL'
120-
UPDATE workspace
121-
SET practice_review_auto_trigger_enabled = FALSE,
122-
practice_review_manual_trigger_enabled = FALSE;
123-
124-
UPDATE workspace_agent_binding
125-
SET enabled = FALSE,
126-
updated_at = NOW()
127-
WHERE purpose = 'PRACTICE_REVIEW';
128-
129-
UPDATE review_sweep_schedule
130-
SET enabled = FALSE,
131-
updated_at = NOW();
132-
133-
UPDATE agent_job
134-
SET status = 'CANCELLED',
135-
completed_at = COALESCE(completed_at, NOW()),
136-
worker_id = NULL,
137-
error_message = 'Cancelled when staging data was cloned into a preview.',
138-
cancellation_reason = NULL
139-
WHERE status IN ('QUEUED', 'RUNNING');
140-
141-
UPDATE agent_job
142-
SET delivery_status = 'FAILED',
143-
error_message = CONCAT_WS(E'\n', NULLIF(error_message, ''),
144-
'Delivery paused when staging data was cloned into a preview.')
145-
WHERE status = 'COMPLETED'
146-
AND delivery_status = 'PENDING';
147-
148-
UPDATE sync_job
149-
SET status = 'CANCELLED',
150-
cancel_requested = TRUE,
151-
finished_at = COALESCE(finished_at, NOW()),
152-
heartbeat_at = NULL,
153-
error_summary = 'Cancelled when staging data was cloned into a preview.'
154-
WHERE status IN ('PENDING', 'RUNNING');
155-
SQL
118+
echo "Applying the preview policy"
119+
test -s /preview/sanitize.sql
120+
docker exec -i "$$PREVIEW_PG" psql \
121+
-v ON_ERROR_STOP=1 -U hephaestus -d hephaestus < /preview/sanitize.sql
156122
157123
docker exec "$$PREVIEW_PG" touch /var/lib/postgresql/data/.hephaestus-preview-seeded
158-
echo "Seed loaded; every cloned workspace starts with practice reviews paused"
124+
echo "Seed loaded; reviews are paused and the preview owns its login apps and signing key"
159125
volumes:
160126
- /var/run/docker.sock:/var/run/docker.sock:ro
127+
# The policy this preview is held to, kept next to the stack it applies to rather than inline
128+
# in a shell heredoc where it cannot be read, reviewed or diffed as SQL.
129+
- ./sanitize.sql:/preview/sanitize.sql:ro
161130
restart: "no"
162131
deploy:
163132
resources:

docker/preview/sanitize.sql

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
-- Applied once to a freshly restored clone, before this preview's application server starts.
2+
--
3+
-- The clone is another instance's live database, so two things have to be made true before it boots.
4+
--
5+
-- 1. SILENCE — a clone must not act. It holds real workspaces, real connections and real
6+
-- credentials, so everything that would post, deliver or schedule work still points at the
7+
-- originals. Triggers, schedules and in-flight jobs are stopped. Nothing is deleted: the data
8+
-- and the settings stay visible, and a tester can re-enable one workspace deliberately.
9+
--
10+
-- 2. RE-HOME — a clone must not keep the source instance's identity. A preview reads the clone
11+
-- with the source instance's encryption key, because that is the only key those rows can be
12+
-- read with. The same key also unseals the source's JWT signing key and decrypts its OAuth
13+
-- client secrets. Both belong to the source deployment, not this one, and are replaced here.
14+
--
15+
-- seed-loader runs this with ON_ERROR_STOP=1 and does not write the seed marker unless it succeeds,
16+
-- so a preview is never left half-sanitized: the next deployment retries the whole clone.
17+
18+
-- ─── 1. Silence ──────────────────────────────────────────────────────────────────────────────────
19+
20+
UPDATE workspace
21+
SET practice_review_auto_trigger_enabled = FALSE,
22+
practice_review_manual_trigger_enabled = FALSE;
23+
24+
UPDATE workspace_agent_binding
25+
SET enabled = FALSE,
26+
updated_at = NOW()
27+
WHERE purpose = 'PRACTICE_REVIEW';
28+
29+
UPDATE review_sweep_schedule
30+
SET enabled = FALSE,
31+
updated_at = NOW();
32+
33+
UPDATE agent_job
34+
SET status = 'CANCELLED',
35+
completed_at = COALESCE(completed_at, NOW()),
36+
worker_id = NULL,
37+
error_message = 'Cancelled when staging data was cloned into a preview.',
38+
cancellation_reason = NULL
39+
WHERE status IN ('QUEUED', 'RUNNING');
40+
41+
UPDATE agent_job
42+
SET delivery_status = 'FAILED',
43+
error_message = CONCAT_WS(E'\n', NULLIF(error_message, ''),
44+
'Delivery paused when staging data was cloned into a preview.')
45+
WHERE status = 'COMPLETED'
46+
AND delivery_status = 'PENDING';
47+
48+
UPDATE sync_job
49+
SET status = 'CANCELLED',
50+
cancel_requested = TRUE,
51+
finished_at = COALESCE(finished_at, NOW()),
52+
heartbeat_at = NULL,
53+
error_summary = 'Cancelled when staging data was cloned into a preview.'
54+
WHERE status IN ('PENDING', 'RUNNING');
55+
56+
-- ─── 2. Re-home ──────────────────────────────────────────────────────────────────────────────────
57+
58+
-- Cloned sessions were issued by the source instance to its users. A preview has no use for them,
59+
-- and they are the only thing that could carry a signed-in session across the clone.
60+
DELETE FROM issued_jwt;
61+
62+
-- Left in place, this preview would mint its own tokens signed with the source instance's
63+
-- production signing key. Emptied, JwtSigningKeyService bootstraps a sealed signing identity that
64+
-- belongs to this preview alone.
65+
DELETE FROM jwt_signing_key;
66+
67+
-- The cloned rows carry the source instance's OAuth apps, whose callback URLs are registered
68+
-- against the source's hostname — a provider rejects every sign-in that starts from a preview host.
69+
-- LoginProviderService seeds this table from this deployment's own environment when a registration
70+
-- id is absent, so emptying it hands the preview its own login apps. Accounts are unaffected:
71+
-- identity_link keys on identity_provider, not on this table.
72+
DELETE FROM login_provider;

0 commit comments

Comments
 (0)