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:
@@ -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