feat(phase-4a): groups sync + X-Project-Key header substrate
Foundational work for the upcoming group-scoped sharing feature.
Schema (migration 0003_groups.sql + drizzle schema):
- memory_access enum ('ro' | 'rw') reserved for Agent B's project_shares
- groups (id, oidc_iss, name, display_name, …) keyed by (oidc_iss, name)
so different IdPs can both have e.g. "platform" without colliding
- user_groups (user_id, group_id, synced_at) PK (user_id, group_id)
Auth (auth.ts + lib/auth/sync-groups.ts):
- jwt callback now syncs `profile.groups` after upserting the user
- syncUserGroupsFromClaim runs in a single tx: upserts each group,
inserts new memberships, deletes ones no longer in the claim
- missing/empty claim → user has zero groups (wipe memberships)
- EntraID GUID-vs-name edge case: we treat whatever strings the claim
emits as names verbatim; groups overage (>200 groups → no claim)
is documented as unsupported in v1
UserContext + JWT (lib/mcp/context.ts, lib/auth/jwt.ts):
- AuthenticatedClaims.groups surfaced from verified JWT payload
- UserContext.groups: string[] — live from OIDC token claim, falls
back to DB snapshot for CLI (HMAC) tokens which carry no claim
- UserContext.defaultProjectKey: optional, set from header
MCP route (app/api/mcp/route.ts):
- reads X-Project-Key header, validates against ProjectKey Zod schema,
400 on invalid; empty/missing leaves defaultProjectKey undefined
- auto-upserts the header-supplied project so first-use works without
a separate project.identify call
Tools (lib/mcp/tools.ts):
- withDefaultProject helper injects ctx.defaultProjectKey when the
caller omits `project`. Per-tool defaultScope hint avoids breaking
snippet.put (user-scope default) while making memory.write
(project-scope default) honor the header
- applied to memory.write/list/search/update and all snippet.* tools
Web UI:
- /settings/groups debug page lists current memberships with synced_at
and a clear empty state pointing at README troubleshooting
- /settings/tokens grows a "Pin to project" dropdown; selected key is
baked into the generated `claude mcp add` snippet as
`--header "X-Project-Key: <key>"`. The JWT itself stays
identity-only — pinning is purely a UX shortcut
- settings landing page links to /settings/groups
- README troubleshooting bullet covers the empty-groups path for
Authentik / EntraID / Keycloak
Refactor:
- extracted resolveProjectId + upsertProject from memory-actions.ts
into lib/projects.ts so the MCP route can reuse upsertProject
Verification:
- pnpm typecheck clean
- SKIP_ENV_VALIDATION=true pnpm build clean; /settings/groups in route table
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,14 @@ function jwks() {
|
||||
export interface AuthenticatedClaims extends JWTPayload {
|
||||
sub: string;
|
||||
iss: string;
|
||||
/**
|
||||
* Group names from the OIDC `groups` claim. Authentik / Keycloak / properly-
|
||||
* configured EntraID emit `string[]` here. We coerce non-array / non-string
|
||||
* entries away and present an empty array if the claim is absent. For CLI
|
||||
* (HMAC) tokens this is always undefined — the consumer (userContextFromClaims)
|
||||
* falls back to the DB snapshot from the user's last interactive sign-in.
|
||||
*/
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
@@ -52,6 +60,23 @@ export class UnauthorizedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `groups` off a verified OIDC payload as a clean `string[]`. Non-
|
||||
* string entries are dropped silently. Returns undefined when the claim
|
||||
* is absent so callers can distinguish "no claim emitted" from "user is
|
||||
* in zero groups" (`[]`).
|
||||
*/
|
||||
function extractGroupsClaim(payload: JWTPayload): string[] | undefined {
|
||||
const raw = (payload as { groups?: unknown }).groups;
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: string[] = [];
|
||||
for (const v of raw) {
|
||||
if (typeof v === "string" && v.trim().length > 0) out.push(v.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildWwwAuthenticate(error?: string, description?: string): string {
|
||||
const parts: string[] = [`Bearer realm="OAuth"`];
|
||||
// RFC 9728 — point clients at our protected-resource metadata so they can
|
||||
@@ -82,7 +107,9 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
||||
const claims = await verifyCliToken(token);
|
||||
// CLI tokens carry the user's real Authentik identity in oidc_iss /
|
||||
// oidc_sub. Surface those on the standard claims shape so user
|
||||
// context resolution is identical to the Authentik path.
|
||||
// context resolution is identical to the Authentik path. CLI tokens
|
||||
// never carry a groups claim — leave `groups` undefined; the user-
|
||||
// context resolver falls back to the DB snapshot.
|
||||
return {
|
||||
...claims,
|
||||
iss: claims.oidc_iss,
|
||||
@@ -100,7 +127,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
||||
buildWwwAuthenticate("invalid_token", "missing sub"),
|
||||
);
|
||||
}
|
||||
return payload as AuthenticatedClaims;
|
||||
return { ...payload, groups: extractGroupsClaim(payload) } as AuthenticatedClaims;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) throw err;
|
||||
const desc =
|
||||
|
||||
Reference in New Issue
Block a user