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 =
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { and, eq, notInArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { groups, userGroups } from "@/lib/db/schema";
|
||||
|
||||
/**
|
||||
* Sync a user's group memberships from the OIDC `groups` claim on sign-in.
|
||||
*
|
||||
* Claim shape: `string[]`. Authentik emits group *names* directly here;
|
||||
* Keycloak and Okta likewise (with the right mappers configured). EntraID,
|
||||
* when correctly configured per README, emits names too — but the default
|
||||
* "groups" optional-claim variant emits object-id GUIDs instead, and if the
|
||||
* user is in too many groups EntraID switches to a "groups overage"
|
||||
* indicator (no group list at all). We take the conservative path:
|
||||
*
|
||||
* - whatever strings appear in the claim are treated as names verbatim
|
||||
* and stored as-is. If your IdP emits GUIDs, the UI will show GUIDs;
|
||||
* fix it at the IdP layer (we don't attempt resolution).
|
||||
* - if the claim is missing/empty, the user is treated as having zero
|
||||
* groups and all existing memberships are deleted.
|
||||
* - groups overage (where EntraID emits `_claim_names.groups` instead of
|
||||
* `groups`) is not handled in v1 — the user appears as having no
|
||||
* groups. Documented limit; revisit if it bites someone.
|
||||
*
|
||||
* The whole operation runs in a single transaction so the membership
|
||||
* snapshot is atomic (no window where a user partially has new memberships
|
||||
* and still has stale ones).
|
||||
*/
|
||||
export async function syncUserGroupsFromClaim(
|
||||
userId: string,
|
||||
oidcIss: string,
|
||||
rawClaim: unknown,
|
||||
): Promise<void> {
|
||||
const names = normalizeGroupsClaim(rawClaim);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (names.length === 0) {
|
||||
// Claim missing/empty → user has zero groups now.
|
||||
await tx.delete(userGroups).where(eq(userGroups.userId, userId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert each group row keyed by (oidc_iss, name) and collect ids.
|
||||
// We use a single multi-row insert for the round-trip win; the DB
|
||||
// resolves duplicates via the unique index.
|
||||
const inserted = await tx
|
||||
.insert(groups)
|
||||
.values(names.map((name) => ({ oidcIss, name })))
|
||||
.onConflictDoUpdate({
|
||||
target: [groups.oidcIss, groups.name],
|
||||
// Touch updated_at so we have a "last seen" signal at the group
|
||||
// level too; otherwise this would be a do-nothing on conflict.
|
||||
set: { updatedAt: new Date() },
|
||||
})
|
||||
.returning({ id: groups.id, name: groups.name });
|
||||
|
||||
const groupIds = inserted.map((g) => g.id);
|
||||
|
||||
// Insert (or refresh synced_at on) every current membership.
|
||||
await tx
|
||||
.insert(userGroups)
|
||||
.values(groupIds.map((groupId) => ({ userId, groupId })))
|
||||
.onConflictDoUpdate({
|
||||
target: [userGroups.userId, userGroups.groupId],
|
||||
set: { syncedAt: sql`now()` },
|
||||
});
|
||||
|
||||
// Delete memberships that no longer appear in the claim. We could
|
||||
// alternatively rely on `synced_at < now()` to find stale rows, but
|
||||
// an explicit NOT IN is cheaper and clearer.
|
||||
await tx
|
||||
.delete(userGroups)
|
||||
.where(
|
||||
and(eq(userGroups.userId, userId), notInArray(userGroups.groupId, groupIds)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce whatever the IdP put in `profile.groups` into a clean string[]
|
||||
* of distinct, trimmed, non-empty names. Anything non-string is dropped.
|
||||
*/
|
||||
function normalizeGroupsClaim(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out = new Set<string>();
|
||||
for (const v of raw) {
|
||||
if (typeof v !== "string") continue;
|
||||
const t = v.trim();
|
||||
if (t.length === 0) continue;
|
||||
out.add(t);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user