Skip to content

Commit 506b379

Browse files
fix(docker): give a preview its own login apps and signing key (#1457)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ec64831 commit 506b379

4 files changed

Lines changed: 129 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: 44 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
@@ -74,6 +104,15 @@ effective value and is not.
74104

75105
`-` is Spring's disabled-cron value; an empty string fails the boot instead of disabling the schedule.
76106

107+
## This file only takes effect once it reaches `main`
108+
109+
Coolify parses the Compose file from the branch configured on the application — `main` — and stores it
110+
on the application record. A preview deployment builds the pull request's images but runs that stored
111+
definition, so a change to `compose.app.yaml` or [`sanitize.sql`](./sanitize.sql) is **not** exercised
112+
by the preview of the pull request that makes it. Reviewing a change here means reading it; the first
113+
preview that actually runs it is the next one created after the change is merged and Coolify has
114+
re-read `main`.
115+
77116
The Docker socket mount is privileged access to the host Docker daemon even though it is mounted
78117
read-only. It is intentionally confined to the trusted preview application and used only for
79118
`pg_dump`, restore, and sandbox execution. Coolify's `SERVICE_NAME_POSTGRES` is a network alias, so

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)