Skip to content

Commit 1dcd612

Browse files
committed
Authorize org membership from the local mirror
1 parent ae80ee8 commit 1dcd612

45 files changed

Lines changed: 3003 additions & 487 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@executor-js/cloud": patch
3+
"@executor-js/api": patch
4+
"@executor-js/host-selfhost": patch
5+
---
6+
7+
Cloud now authorizes every protected request against the local membership mirror through the shared `MemberDirectory` seam: the per-request org membership check, the admin gates on the account and admin planes, the org switcher's organization list, and the free-organization limit all read the mirror instead of calling WorkOS. WorkOS is now a write target and an event source only. The seam gains `membershipsOf(accountId)` and `membershipById(organizationId, membershipId)` on both hosts.
8+
9+
The mirror is trusted only while it is **ready**: the backfill has written every organization and the Events reconciler has drained the stream within the last ten minutes (both recorded on the `workos_sync` row). Until then the membership check falls back to WorkOS, exactly as before, so a member the backfill has not written yet is not locked out and a member revoked while the reconciler was down is not let in. The deploy runs `scripts/ensure-workos-mirror-ready.ts` after the migrations: it runs the backfill if needed, drains the events stream itself if the reconciler has not recently (so the gate never waits on a cron this same deploy ships), and fails the deploy if the mirror is still not ready. An organization the mirror does not hold at all (one that predates the mirror and nobody has signed in to since) is resolved from WorkOS on demand for a caller WorkOS confirms as its member, so CLI and MCP tokens naming such an organization are not refused. Deleting an organization now cancels billing before deleting the WorkOS organization, and a retry after a partial deletion is admitted from the mirror even while the mirror is not ready.
10+
11+
**Ops step (cloud):** add the `WORKOS_API_KEY` secret to the `production` GitHub environment so the deploy gate can run the backfill.

‎.claude/skills/prod-telemetry/SKILL.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ join the same traces via traceparent).
5757
`execute`/`execute-action` calls `mcp.execute.code` (the script itself,
5858
capped at 10k chars — cloud-only content capture; local/self-host
5959
telemetry never records content).
60+
- `auth.authorize_organization` — every membership authorization.
61+
`mirror.ready` (bool: the local membership mirror answered; `false` =
62+
the request fell back to a live WorkOS read) and `mirror.readiness`
63+
(why: `ready`, `backfill pending: …`, `reconciler stale: …`). The
64+
mirror's write spans are `workos_mirror.<op>`; the reconciler run is
65+
`workos_events.sync`. `workos_sync.drained_at` in the prod DB is the
66+
reconciler heartbeat.
67+
68+
**Recipe — membership-mirror fallback rate (should be ~0 after cutover):**
69+
70+
```apl
71+
['executor-cloud']
72+
| where _time > ago(1h) and name == "auth.authorize_organization"
73+
| extend ready = tobool(['attributes.custom']['mirror.ready'])
74+
| extend why = tostring(['attributes.custom']['mirror.readiness'])
75+
| summarize n = count() by ready, why
76+
```
6077

6178
**Recipe — error signatures by class (the daily-digest query):**
6279

‎.github/workflows/deploy.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ jobs:
5454
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
5555
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5656

57+
# The build below authorizes every request from the local membership
58+
# mirror. This runs the mirror backfill if it has not completed, drains
59+
# the WorkOS events stream itself if the reconciler has not recently
60+
# (it does not wait on the cron, which this same deploy may be the one
61+
# to ship), and FAILS the deploy if the mirror is still not ready — see
62+
# scripts/ensure-workos-mirror-ready.ts.
63+
- name: Backfill and verify the membership mirror
64+
run: bun run scripts/ensure-workos-mirror-ready.ts
65+
working-directory: apps/cloud
66+
env:
67+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
68+
WORKOS_API_KEY: ${{ secrets.WORKOS_API_KEY }}
69+
5770
deploy-cloud:
5871
name: Deploy cloud
5972
runs-on: blacksmith-4vcpu-ubuntu-2404

‎apps/cloud/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"db:backfill-workos-mirror:dev": "op run --env-file=.env.op -- bun run scripts/backfill-workos-mirror.ts",
3737
"db:drain-workos-events:prod": "op run --env-file=.env.production -- bun run scripts/drain-workos-events.ts",
3838
"db:drain-workos-events:dev": "op run --env-file=.env.op -- bun run scripts/drain-workos-events.ts",
39+
"db:ensure-workos-mirror-ready:prod": "op run --env-file=.env.production -- bun run scripts/ensure-workos-mirror-ready.ts",
3940
"routes:gen": "bun scripts/gen-routes.ts",
4041
"vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts"
4142
},
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band deploy gate over a raw postgres connection */
2+
// ---------------------------------------------------------------------------
3+
// Deploy gate: make the membership mirror READY before the build that
4+
// authorizes from it goes live, and fail the deploy if it cannot be.
5+
//
6+
// bun run db:ensure-workos-mirror-ready:prod # op run --env-file=.env.production
7+
// (deploy.yml runs it after the migrations, before the cloud deploy)
8+
//
9+
// Readiness is the SAME rule the request path applies
10+
// (`src/auth/mirror-readiness-store.ts`): the one-off backfill has written
11+
// every organization (`workos_sync.backfill_completed_at`) AND the events
12+
// reconciler has drained the stream within its lag budget
13+
// (`workos_sync.drained_at`). Until both hold the deployed build reads
14+
// membership from WorkOS instead of the mirror, so an unready mirror never
15+
// locks anyone out or lets a revoked member in — but a deploy that leaves it
16+
// unready would run every request through that fallback, which is the state
17+
// this whole cutover exists to leave behind. So this gate:
18+
// 1. reads the readiness row;
19+
// 2. if the backfill has not completed, RUNS it (scripts/backfill-workos-mirror.ts,
20+
// idempotent) and reads again;
21+
// 3. if the reconciler has not drained recently, DRAINS the stream itself
22+
// (scripts/drain-workos-events.ts: the same replay the Worker's cron
23+
// runs, over this connection) and reads again — never merely waits for
24+
// the cron: this gate runs BEFORE the build that carries the cron may
25+
// have been deployed, and a gate that only waited could not pass until
26+
// the reconciler build had shipped on its own, by hand. A cron that is
27+
// already live is safe beside it (the cursor's compare-and-set gives
28+
// the stream one owner at a time);
29+
// 4. exits 0 only when the mirror is ready, and 1 with the reason otherwise.
30+
// Needs DATABASE_URL and WORKOS_API_KEY (the backfill and the drain read WorkOS).
31+
// ---------------------------------------------------------------------------
32+
33+
import { spawnSync } from "node:child_process";
34+
import { dirname, resolve } from "node:path";
35+
import { fileURLToPath } from "node:url";
36+
37+
import { drizzle } from "drizzle-orm/postgres-js";
38+
import postgres from "postgres";
39+
40+
import {
41+
MirrorReadinessState,
42+
describeMirrorReadiness,
43+
readMirrorReadiness,
44+
} from "../src/auth/mirror-readiness-store";
45+
46+
const __dirname = dirname(fileURLToPath(import.meta.url));
47+
const BACKFILL_SCRIPT = resolve(__dirname, "backfill-workos-mirror.ts");
48+
const DRAIN_SCRIPT = resolve(__dirname, "drain-workos-events.ts");
49+
50+
const connectionString = process.env.DATABASE_URL;
51+
if (!connectionString) {
52+
console.error("DATABASE_URL is not set");
53+
process.exit(1);
54+
}
55+
56+
const usesLocalDatabase =
57+
connectionString.includes("127.0.0.1") || connectionString.includes("localhost");
58+
59+
const sql = postgres(connectionString, {
60+
max: 1,
61+
prepare: false,
62+
...(usesLocalDatabase ? {} : { ssl: "require" as const }),
63+
});
64+
const db = drizzle(sql);
65+
66+
const log = (line: string) => console.log(`[mirror-ready] ${line}`);
67+
68+
const readiness = () => readMirrorReadiness(db, new Date());
69+
70+
// The backfill and drain scripts own their own WorkOS + database wiring;
71+
// running them as subprocesses (with this process's env) keeps that wiring
72+
// in one place.
73+
const runScript = (what: string, script: string) => {
74+
if (!process.env.WORKOS_API_KEY) {
75+
throw new Error(`WORKOS_API_KEY is not set; the mirror ${what} cannot run`);
76+
}
77+
const result = spawnSync("bun", ["run", script], {
78+
stdio: "inherit",
79+
env: process.env,
80+
});
81+
if (result.status !== 0) {
82+
throw new Error(`the mirror ${what} exited with status ${result.status ?? "unknown"}`);
83+
}
84+
};
85+
86+
try {
87+
let state = await readiness();
88+
log(describeMirrorReadiness(state));
89+
90+
if (MirrorReadinessState.$is("BackfillPending")(state)) {
91+
log("backfill not completed; running scripts/backfill-workos-mirror.ts");
92+
runScript("backfill", BACKFILL_SCRIPT);
93+
state = await readiness();
94+
log(describeMirrorReadiness(state));
95+
}
96+
97+
if (MirrorReadinessState.$is("ReconcilerStale")(state)) {
98+
log("events stream not drained recently; running scripts/drain-workos-events.ts");
99+
runScript("drain", DRAIN_SCRIPT);
100+
state = await readiness();
101+
log(describeMirrorReadiness(state));
102+
}
103+
104+
if (!MirrorReadinessState.$is("Ready")(state)) {
105+
console.error(
106+
`[mirror-ready] the membership mirror is not ready: ${describeMirrorReadiness(state)}. ` +
107+
"The deployed build would read membership from WorkOS on every request until it is. " +
108+
"Check that WorkOS is reachable and the backfill has run, then rerun the deploy.",
109+
);
110+
process.exit(1);
111+
}
112+
log("the membership mirror is ready");
113+
} finally {
114+
await sql.end({ timeout: 5 });
115+
}

‎apps/cloud/src/account/account-api.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010

1111
import { ApiKeyService } from "../auth/api-keys";
1212
import { UserStoreService } from "../auth/context";
13+
import { MirrorReadiness } from "../auth/mirror-readiness";
1314
import { WorkOsMirror } from "../auth/workos-mirror";
1415
import { sessionFromSealed, type Session } from "../auth/middleware";
1516
import { WorkOSClient } from "../auth/workos";
@@ -99,11 +100,15 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi
99100
* (the seat-gate) stays a residual requirement, satisfied by the app `boot`.
100101
*/
101102
export const workosAccountMiddleware = (
102-
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
103+
rsLive: Layer.Layer<
104+
DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
105+
>,
103106
) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
104107

105108
export const makeAccountApiLive = (
106-
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
109+
rsLive: Layer.Layer<
110+
DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness
111+
>,
107112
) => {
108113
// Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
109114
// closes over the per-request postgres socket), so it can't be a self-

‎apps/cloud/src/account/org-api-key-revoke.node.test.ts‎

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AccountError, AccountForbidden } from "@executor-js/api";
66

77
import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
88
import { UserStoreService } from "../auth/context";
9+
import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness";
910
import { ORG_SELECTOR_HEADER } from "../auth/organization";
1011
import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
1112
import { WorkOsMirror } from "../auth/workos-mirror";
@@ -63,32 +64,12 @@ const session = (accountId: string) => ({
6364
refreshedSession: null,
6465
});
6566

66-
/** Membership roles: only ADMIN carries the `admin` role slug. */
67+
// Membership is read from the mirror, never from WorkOS: revoke makes no
68+
// WorkOS call at all.
6769
const stubWorkOS = Layer.succeed(
6870
WorkOSClient,
6971
new Proxy({} as WorkOSClientService, {
70-
get: (_target, prop) => {
71-
if (prop === "listUserMemberships") {
72-
return (userId: string) =>
73-
Effect.succeed({
74-
data: [{ userId, organizationId: ORG, status: "active" }],
75-
});
76-
}
77-
if (prop === "getUserOrgMembership") {
78-
return (organizationId: string, userId: string) =>
79-
Effect.succeed(
80-
organizationId === ORG
81-
? {
82-
id: `om_${userId}`,
83-
userId,
84-
organizationId,
85-
role: { slug: userId === ADMIN ? "admin" : "member" },
86-
}
87-
: null,
88-
);
89-
}
90-
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
91-
},
72+
get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`),
9273
}),
9374
);
9475

@@ -101,7 +82,7 @@ const stubUsers = Layer.succeed(UserStoreService)({
10182
upsertOrganization: async (org: { id: string; name: string }) => ({
10283
...org,
10384
slug: org.id,
104-
backfilledAt: null,
85+
backfilledAt: createdAt,
10586
deletedAt: null,
10687
workosUpdatedAt: null,
10788
createdAt,
@@ -110,7 +91,7 @@ const stubUsers = Layer.succeed(UserStoreService)({
11091
id,
11192
name: `Org ${id}`,
11293
slug: id,
113-
backfilledAt: null,
94+
backfilledAt: createdAt,
11495
deletedAt: null,
11596
workosUpdatedAt: null,
11697
createdAt,
@@ -119,11 +100,12 @@ const stubUsers = Layer.succeed(UserStoreService)({
119100
id: slug,
120101
name: `Org ${slug}`,
121102
slug,
122-
backfilledAt: null,
103+
backfilledAt: createdAt,
123104
deletedAt: null,
124105
workosUpdatedAt: null,
125106
createdAt,
126107
}),
108+
markOrganizationDeleted: async () => null,
127109
deleteOrganizationCascade: async () => {},
128110
}),
129111
),
@@ -147,12 +129,37 @@ const stubMirror = Layer.succeed(WorkOsMirror)({
147129
organizationBackfilledAt: () => Effect.die("revoke does not report seats"),
148130
});
149131

150-
// Revoke lists no members either.
132+
// The mirror as the directory reads it: both are active members of ORG, and
133+
// only ADMIN carries the `admin` role. Revoke reads the caller's membership
134+
// (the org check and the admin gate) and nothing else.
135+
// The mirror is READY in these tests (backfill complete, reconciler caught
136+
// up), so membership is read from the stubbed directory, never from WorkOS.
137+
const stubReadiness = Layer.succeed(MirrorReadiness)({
138+
state: () => Effect.succeed(MirrorReadinessState.Ready()),
139+
});
140+
151141
const stubDirectory = Layer.succeed(MemberDirectory)({
152-
membership: () => Effect.die("revoke does not read the member directory"),
153-
members: () => Effect.die("revoke does not read the member directory"),
154-
membersById: () => Effect.die("revoke does not read the member directory"),
155-
findByEmail: () => Effect.die("revoke does not read the member directory"),
142+
membership: (accountId, organizationId) =>
143+
Effect.succeed(
144+
organizationId === ORG
145+
? {
146+
accountId,
147+
membershipId: `om_${accountId}`,
148+
organizationId,
149+
email: null,
150+
name: null,
151+
avatarUrl: null,
152+
role: accountId === ADMIN ? "admin" : "member",
153+
status: "active" as const,
154+
lastActiveAt: null,
155+
}
156+
: null,
157+
),
158+
membershipById: () => Effect.die("revoke does not look up by membership id"),
159+
membershipsOf: () => Effect.die("revoke does not list the caller's memberships"),
160+
members: () => Effect.die("revoke does not list members"),
161+
membersById: () => Effect.die("revoke does not batch members"),
162+
findByEmail: () => Effect.die("revoke does not resolve emails"),
156163
});
157164

158165
const stubAutumn = Layer.succeed(AutumnService)({
@@ -194,6 +201,7 @@ const providerWith = (accountId: string) => {
194201
stubUsers,
195202
stubMirror,
196203
stubDirectory,
204+
stubReadiness,
197205
stubApiKeys,
198206
stubAutumn,
199207
Layer.succeed(AccountCaller)({ session: session(accountId) }),

0 commit comments

Comments
 (0)