Skip to content

Commit b18965f

Browse files
mpstatonclaude
andcommitted
feat(workspace-service): the membership gate — identity must clear the instance's org
Build-order step 3. In DIDI_AUTH=required mode, a verified didi_session is necessary but no longer sufficient: the didi_id must hold a membership in REQUIRED_ORG_ID (the single-tenant instance's org — e.g. humain.vc) or the superuser role anywhere. Checked once per WS upgrade via /api/me with the cookie forwarded, cached 60s per session so reconnect storms don't hammer the id service, and failing CLOSED when the id service is unreachable — an identity outage must not silently open the tenant's door. Rejection closes 4403 (vs 4401 for no identity), so the shell can distinguish "sign in" from "no access". This is the users/orgs/workspaces triangulation ENFORCING for the first time: id.didi.sh says who you are and what orgs you hold; this gate says what that means here. Proven by the prove script's new GATE mode against the live container in required mode: anonymous 4401, superuser admitted, signed-in non-member 4403 — then optional mode restored and the base proof green (local dev workflow unchanged). Files changed: - services/workspace/src/didi.ts (checkMembership + cache + fail-closed) - services/workspace/src/ws.ts (gate on upgrade, 4403) - scripts/prove-didi-auth.mjs (GATE mode) - docker-compose.yml (REQUIRED_ORG_ID passthrough) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1d4acc0 commit b18965f

4 files changed

Lines changed: 141 additions & 5 deletions

File tree

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ services:
3636
ID_JWKS_URL: ${ID_JWKS_URL:-http://host.docker.internal:4000/.well-known/jwks.json}
3737
ID_ISSUER: ${ID_ISSUER:-http://localhost:4000}
3838
DIDI_AUTH: ${DIDI_AUTH:-optional}
39+
# Step 3's membership gate: in required mode, identity must also hold
40+
# a membership in this org (or superuser anywhere). Unset → identity
41+
# alone suffices. The single-tenant deploy sets humain.vc.
42+
REQUIRED_ORG_ID: ${REQUIRED_ORG_ID:-}
3943
volumes:
4044
- workspace-data:/data
4145
- ./clients:/clients:ro

scripts/prove-didi-auth.mjs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ const fail = (msg) => {
3333
};
3434
const step = (msg) => console.log(`\n\x1b[1m== ${msg}\x1b[0m`);
3535

36+
// ── GATE MODE (build-order step 3) — runs ONLY the gate tests ──────────────
37+
// The base steps below assume DIDI_AUTH=optional; gate mode assumes the
38+
// container is running with:
39+
// DIDI_AUTH=required REQUIRED_ORG_ID=humain.vc docker compose up -d workspace-service
40+
if (process.env.GATE === '1') {
41+
step('GATE 1. no cookie → rejected 4401');
42+
await expectClose(WS_URL, {}, 4401);
43+
console.log('anonymous rejected ✓');
44+
45+
step('GATE 2. superuser (michael, lossless.group) → admitted');
46+
const su = await signInAs('mpstaton@gmail.com');
47+
const suFrame = await firstFrame(WS_URL, { Cookie: `didi_session=${su}` });
48+
if (!suFrame.didi_id) fail('superuser should be admitted with identity');
49+
console.log('superuser admitted ✓');
50+
51+
step('GATE 3. signed-in NON-member (alice) → rejected 4403');
52+
const alice = await signInAs('alice@example.com');
53+
await expectClose(WS_URL, { Cookie: `didi_session=${alice}` }, 4403);
54+
console.log('non-member rejected ✓');
55+
56+
console.log('\n\x1b[32mMEMBERSHIP GATE PROVEN\x1b[0m');
57+
process.exit(0);
58+
}
59+
3660
// ── 1. magic link → didi_session cookie ────────────────────────────────────
3761
step('1. issue + redeem magic link against local id service');
3862
const issue = await fetch(`${ID_BASE}/api/magic-links`, {
@@ -86,6 +110,43 @@ console.log('tampered token rejected ✓');
86110
console.log('\n\x1b[32mDIDI AUTH PROVEN AGAINST LOCAL DEV\x1b[0m');
87111
process.exit(0);
88112

113+
async function signInAs(addr) {
114+
const issue = await fetch(`${ID_BASE}/api/magic-links`, {
115+
method: 'POST',
116+
headers: { 'content-type': 'application/json' },
117+
body: JSON.stringify({ email: addr, app: 'gate-test' }),
118+
}).then((r) => r.json());
119+
if (!issue.dev_token) fail(`no dev_token for ${addr} — seeded?`);
120+
const redeem = await fetch(`${ID_BASE}/api/magic-links/redeem`, {
121+
method: 'POST',
122+
headers: { 'content-type': 'application/json' },
123+
body: JSON.stringify({ token: issue.dev_token }),
124+
});
125+
const cookie = /didi_session=([^;]+)/.exec(redeem.headers.get('set-cookie') ?? '')?.[1];
126+
if (!cookie) fail(`no cookie for ${addr}`);
127+
return cookie;
128+
}
129+
130+
function expectClose(url, headers, wantCode) {
131+
return new Promise((resolve) => {
132+
const ws = new WebSocket(url, { headers });
133+
const timer = setTimeout(() => {
134+
ws.terminate();
135+
fail(`expected close ${wantCode}, got timeout`);
136+
}, 8000);
137+
ws.on('message', () => {
138+
clearTimeout(timer);
139+
fail(`expected close ${wantCode}, but got a frame (admitted)`);
140+
});
141+
ws.on('close', (code) => {
142+
clearTimeout(timer);
143+
if (code !== wantCode) fail(`expected close ${wantCode}, got ${code}`);
144+
resolve(undefined);
145+
});
146+
ws.on('error', () => {});
147+
});
148+
}
149+
89150
function firstFrame(url, headers) {
90151
return new Promise((resolve, reject) => {
91152
const ws = new WebSocket(url, { headers });

services/workspace/src/didi.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,68 @@ export async function verifyDidiCookie(
6464
}
6565
}
6666

67+
// ── Membership gate (build-order step 3) ────────────────────────────────
68+
// In 'required' mode, verified identity is necessary but not sufficient:
69+
// the didi_id must hold a membership in the instance's org
70+
// (REQUIRED_ORG_ID env — e.g. humain.vc for the single-tenant deploy) or
71+
// the superuser role anywhere. Checked once per WS upgrade via /api/me
72+
// with the cookie forwarded; cached briefly so reconnect storms don't
73+
// hammer the id service.
74+
75+
const ID_BASE = process.env.ID_BASE ?? deriveIdBase();
76+
const REQUIRED_ORG_ID = process.env.REQUIRED_ORG_ID;
77+
78+
function deriveIdBase(): string | undefined {
79+
// Convenience: ID_JWKS_URL is .../.well-known/jwks.json on the same host.
80+
if (!JWKS_URL) return undefined;
81+
try {
82+
return new URL(JWKS_URL).origin;
83+
} catch {
84+
return undefined;
85+
}
86+
}
87+
88+
type Membership = { org_id: string; role: string };
89+
const membershipCache = new Map<string, { at: number; ok: boolean }>();
90+
const MEMBERSHIP_CACHE_MS = 60_000;
91+
92+
/**
93+
* Does this identity clear the instance's org requirement?
94+
* - No REQUIRED_ORG_ID configured → gate is open (identity alone suffices).
95+
* - Membership in REQUIRED_ORG_ID, any role → admitted.
96+
* - Role 'superuser' in ANY org → admitted (the operating-team fast path).
97+
*/
98+
export async function checkMembership(
99+
identity: DidiIdentity,
100+
cookieHeader: string | string[] | undefined,
101+
): Promise<boolean> {
102+
if (!REQUIRED_ORG_ID) return true;
103+
if (!ID_BASE) return false;
104+
105+
const cached = membershipCache.get(identity.session_id);
106+
if (cached && Date.now() - cached.at < MEMBERSHIP_CACHE_MS) return cached.ok;
107+
108+
try {
109+
const raw = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : (cookieHeader ?? '');
110+
const res = await fetch(`${ID_BASE}/api/me`, { headers: { cookie: raw } });
111+
if (!res.ok) {
112+
membershipCache.set(identity.session_id, { at: Date.now(), ok: false });
113+
return false;
114+
}
115+
const me = (await res.json()) as { memberships?: Membership[] };
116+
const memberships = me.memberships ?? [];
117+
const ok =
118+
memberships.some((m) => m.org_id === REQUIRED_ORG_ID) ||
119+
memberships.some((m) => m.role === 'superuser');
120+
membershipCache.set(identity.session_id, { at: Date.now(), ok });
121+
return ok;
122+
} catch {
123+
// id service unreachable: fail CLOSED in required mode — an identity
124+
// instance outage should not silently open the tenant's door.
125+
return false;
126+
}
127+
}
128+
67129
function readCookie(
68130
header: string | string[] | undefined,
69131
name: string,

services/workspace/src/ws.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify';
1414
import type { WebSocket } from '@fastify/websocket';
1515
import { type Subscription } from '@nats-io/transport-node';
1616
import { isValid, mint } from './auth';
17-
import { verifyDidiCookie, didiMode, type DidiIdentity } from './didi';
17+
import { verifyDidiCookie, didiMode, checkMembership, type DidiIdentity } from './didi';
1818
import { dispatch } from './capabilities';
1919
import { dispatchChatTurn } from './chat';
2020
import { getNats } from './nats';
@@ -91,10 +91,19 @@ export async function registerWebsocket(app: FastifyInstance): Promise<void> {
9191
// rejected; in 'optional' mode the legacy continuity token still works
9292
// and identity rides along when present.
9393
const didi = (await verifyDidiCookie(req.headers.cookie)) ?? undefined;
94-
if (didiMode() === 'required' && !didi) {
95-
app.log.warn('ws reject: didi auth required, no valid didi_session');
96-
socket.close(4401, 'didi auth required');
97-
return;
94+
if (didiMode() === 'required') {
95+
if (!didi) {
96+
app.log.warn('ws reject: didi auth required, no valid didi_session');
97+
socket.close(4401, 'didi auth required');
98+
return;
99+
}
100+
// Step 3's gate: identity must also clear the instance's org
101+
// requirement (membership in REQUIRED_ORG_ID, or superuser anywhere).
102+
if (!(await checkMembership(didi, req.headers.cookie))) {
103+
app.log.warn({ didi_id: didi.didi_id }, 'ws reject: membership required');
104+
socket.close(4403, 'membership required');
105+
return;
106+
}
98107
}
99108

100109
const session: Session = { token, socket, seq: 0, didi };

0 commit comments

Comments
 (0)