diff --git a/README.md b/README.md index 99ce412..4794789 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,15 @@ The OIDC client you use locally must accept testing to avoid hitting the production rate limit. - **`pg_isready` healthcheck loops** — check that `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` are all set in `.env`. +- **`/settings/groups` is empty even though I'm in groups** — your IdP isn't + emitting a `groups` claim. On Authentik, edit the OIDC provider and add + the built-in `authentik default OAuth Mapping: OpenID 'profile'` (or a + custom property mapping that returns `{"groups": [g.name for g in + request.user.ak_groups.all()]}`), then sign out and back in. On EntraID, + add a "groups" optional claim under **Token configuration → Optional + claims**; tick "Emit groups as group names" if you want names (we treat + GUIDs as opaque strings). Keycloak: add a Group Membership mapper with + "Full group path" off and the token claim name `groups`. --- diff --git a/apps/web/app/(authed)/settings/groups/page.tsx b/apps/web/app/(authed)/settings/groups/page.tsx new file mode 100644 index 0000000..afadb2f --- /dev/null +++ b/apps/web/app/(authed)/settings/groups/page.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { groups, userGroups } from "@/lib/db/schema"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card } from "@/app/_components/ui/card"; +import { EmptyState } from "@/app/_components/ui/empty-state"; + +export const dynamic = "force-dynamic"; + +/** + * Debug page showing the OIDC groups currently associated with the signed-in + * user. The list is rewritten on every sign-in from the IdP's `groups` + * claim (see `lib/auth/sync-groups.ts`), so this view is effectively a + * snapshot of "what your IdP told us about you at last login". + * + * Mainly intended as a sanity check for the upcoming sharing feature — + * if the user expects to see "platform" and doesn't, the IdP probably + * isn't emitting the claim, and the empty state points them at the + * README troubleshooting section. + */ +export default async function GroupsSettingsPage() { + const session = await auth(); + const userId = session!.user.id; + + const rows = await db + .select({ + id: groups.id, + name: groups.name, + oidcIss: groups.oidcIss, + syncedAt: userGroups.syncedAt, + }) + .from(userGroups) + .innerJoin(groups, eq(userGroups.groupId, groups.id)) + .where(eq(userGroups.userId, userId)) + .orderBy(groups.name); + + return ( + + + + {rows.length === 0 ? ( + + ) : ( + + {rows.map((g, i) => ( +
0 ? "border-t border-border" : ""}`} + > +
+
+ {g.name} +
+
+ synced {new Date(g.syncedAt).toLocaleString()} +
+
+
+ {g.oidcIss} +
+
+ ))} +
+ )} + +

+ Groups refresh on every sign-in. If something looks stale,{" "} + sign out and sign back in. +

+
+ ); +} diff --git a/apps/web/app/(authed)/settings/page.tsx b/apps/web/app/(authed)/settings/page.tsx index 1772bbf..6a4c23b 100644 --- a/apps/web/app/(authed)/settings/page.tsx +++ b/apps/web/app/(authed)/settings/page.tsx @@ -53,6 +53,19 @@ export default async function SettingsPage() { and revoke them. + + + + Groups + + + + + + OIDC group memberships from your IdP, refreshed at sign-in. Used + by the upcoming sharing feature to scope project visibility. + + ); diff --git a/apps/web/app/(authed)/settings/tokens/page.tsx b/apps/web/app/(authed)/settings/tokens/page.tsx index ee9d13f..ea4e3a5 100644 --- a/apps/web/app/(authed)/settings/tokens/page.tsx +++ b/apps/web/app/(authed)/settings/tokens/page.tsx @@ -1,29 +1,68 @@ import { revalidatePath } from "next/cache"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, asc, desc, eq, isNull } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; -import { cliTokens, users } from "@/lib/db/schema"; +import { cliTokens, projects, users } from "@/lib/db/schema"; import { mintCliToken, revokeCliToken, CLI_TOKEN_TTL_SECONDS, } from "@/lib/auth/cli-token"; +import { ProjectKey } from "@shared-memory/schemas"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; import { EmptyState } from "@/app/_components/ui/empty-state"; -import TokensManager from "./tokens-manager"; +import TokensManager, { type CreateTokenState } from "./tokens-manager"; export const dynamic = "force-dynamic"; -async function createTokenAction(_prev: { token: string | null; error: string | null }, formData: FormData): Promise<{ token: string | null; error: string | null }> { +async function createTokenAction( + _prev: CreateTokenState, + formData: FormData, +): Promise { "use server"; try { const session = await auth(); - if (!session?.user?.id) return { token: null, error: "not authenticated" }; + if (!session?.user?.id) { + return { token: null, error: "not authenticated", projectKey: null }; + } const name = String(formData.get("name") ?? "").trim() || `Token ${new Date().toISOString().slice(0, 10)}`; + // Optional pin-to-project. The token JWT itself does NOT need a project + // claim — pinning is purely a UX shortcut so the generated `claude mcp + // add` snippet bakes in `X-Project-Key: ` and every call from + // that client lands on the right project by default. + const rawProject = String(formData.get("projectKey") ?? "").trim(); + let projectKey: string | null = null; + if (rawProject.length > 0) { + const parsed = ProjectKey.safeParse(rawProject); + if (!parsed.success) { + return { + token: null, + error: `invalid project key: ${parsed.error.issues.map((i) => i.message).join("; ")}`, + projectKey: null, + }; + } + // Cross-check the project belongs to this user (defense in depth — + // the dropdown is built from the user's projects, but the form is + // re-submittable so don't trust the value). + const found = await db + .select({ key: projects.key }) + .from(projects) + .where(and(eq(projects.userId, session.user.id), eq(projects.key, parsed.data))) + .limit(1); + if (!found[0]) { + return { + token: null, + error: `unknown project '${parsed.data}'`, + projectKey: null, + }; + } + projectKey = found[0].key; + } + const userRow = await db .select({ oidcIss: users.oidcIss, @@ -35,7 +74,7 @@ async function createTokenAction(_prev: { token: string | null; error: string | .where(eq(users.id, session.user.id)) .limit(1); const u = userRow[0]; - if (!u) return { token: null, error: "user row not found" }; + if (!u) return { token: null, error: "user row not found", projectKey: null }; const minted = await mintCliToken( { @@ -49,9 +88,13 @@ async function createTokenAction(_prev: { token: string | null; error: string | ); revalidatePath("/settings/tokens"); - return { token: minted.token, error: null }; + return { token: minted.token, error: null, projectKey }; } catch (e) { - return { token: null, error: e instanceof Error ? e.message : "unknown error" }; + return { + token: null, + error: e instanceof Error ? e.message : "unknown error", + projectKey: null, + }; } } @@ -68,19 +111,29 @@ export default async function TokensPage() { const session = await auth(); const userId = session!.user.id; - const tokens = await db - .select({ - id: cliTokens.id, - name: cliTokens.name, - jti: cliTokens.jti, - createdAt: cliTokens.createdAt, - lastUsedAt: cliTokens.lastUsedAt, - expiresAt: cliTokens.expiresAt, - revokedAt: cliTokens.revokedAt, - }) - .from(cliTokens) - .where(eq(cliTokens.userId, userId)) - .orderBy(desc(cliTokens.createdAt)); + const [tokens, projectRows] = await Promise.all([ + db + .select({ + id: cliTokens.id, + name: cliTokens.name, + jti: cliTokens.jti, + createdAt: cliTokens.createdAt, + lastUsedAt: cliTokens.lastUsedAt, + expiresAt: cliTokens.expiresAt, + revokedAt: cliTokens.revokedAt, + }) + .from(cliTokens) + .where(eq(cliTokens.userId, userId)) + .orderBy(desc(cliTokens.createdAt)), + db + .select({ + key: projects.key, + displayName: projects.displayName, + }) + .from(projects) + .where(eq(projects.userId, userId)) + .orderBy(asc(projects.key)), + ]); const active = tokens.filter((t) => !t.revokedAt && t.expiresAt > new Date()); const inactive = tokens.filter((t) => t.revokedAt || t.expiresAt <= new Date()); @@ -96,7 +149,14 @@ export default async function TokensPage() { Generate a new token - + ({ + key: p.key, + displayName: p.displayName, + }))} + /> diff --git a/apps/web/app/(authed)/settings/tokens/tokens-manager.tsx b/apps/web/app/(authed)/settings/tokens/tokens-manager.tsx index 3b539bf..d38bdf9 100644 --- a/apps/web/app/(authed)/settings/tokens/tokens-manager.tsx +++ b/apps/web/app/(authed)/settings/tokens/tokens-manager.tsx @@ -4,19 +4,35 @@ import { useActionState } from "react"; import { Button } from "@/app/_components/ui/button"; import { Input, Label } from "@/app/_components/ui/input"; -interface State { +/** + * State returned by the `createTokenAction` server action. + * + * `projectKey` is the project the user chose to pin the token to. It's NOT + * baked into the JWT itself — the token remains identity-only — it just + * lets us bake `--header "X-Project-Key: "` into the generated + * `claude mcp add` snippet so calls from this client default to that + * project without the model having to pass it explicitly. + */ +export interface CreateTokenState { token: string | null; error: string | null; + projectKey: string | null; +} + +export interface ProjectOption { + key: string; + displayName: string | null; } interface Props { - action: (prev: State, formData: FormData) => Promise; + action: (prev: CreateTokenState, formData: FormData) => Promise; ttlDays: number; + projects: ProjectOption[]; } -const initial: State = { token: null, error: null }; +const initial: CreateTokenState = { token: null, error: null, projectKey: null }; -export default function TokensManager({ action, ttlDays }: Props) { +export default function TokensManager({ action, ttlDays, projects }: Props) { const [state, formAction, pending] = useActionState(action, initial); if (state.token) { @@ -33,12 +49,18 @@ export default function TokensManager({ action, ttlDays }: Props) {
claude mcp add command -
{`claude mcp add --transport http --scope user \\
-  --header "Authorization: Bearer ${state.token}" \\
-  shared-memory https://memory.dnspegasus.net/api/mcp`}
+
{buildMcpAddSnippet(state.token, state.projectKey)}

Valid for {ttlDays} days. Revoke individually below if it leaks. + {state.projectKey ? ( + <> + {" "}This token is pinned to project{" "} + {state.projectKey} via the{" "} + X-Project-Key header in the + snippet above — the JWT itself is identity-only. + + ) : null}

); @@ -56,6 +78,10 @@ export default function TokensManager({ action, ttlDays }: Props) { autoComplete="off" /> +
+ + +
@@ -65,3 +91,43 @@ export default function TokensManager({ action, ttlDays }: Props) { ); } + +function ProjectSelect({ projects }: { projects: ProjectOption[] }) { + // Match Input styling — Tailwind v4 classes from `lib/ui/input.tsx`. + const cls = + "mt-1 block w-full h-9 px-3 text-sm rounded-md bg-surface-1 " + + "border border-border text-fg focus:border-accent-400 focus:outline-none " + + "disabled:opacity-50 transition-colors"; + + if (projects.length === 0) { + return ( + + ); + } + return ( + + ); +} + +function buildMcpAddSnippet(token: string, projectKey: string | null): string { + const headerLines = [` --header "Authorization: Bearer ${token}"`]; + if (projectKey) { + headerLines.push(` --header "X-Project-Key: ${projectKey}"`); + } + return [ + "claude mcp add --transport http --scope user \\", + ...headerLines.map((l) => `${l} \\`), + " shared-memory https://memory.dnspegasus.net/api/mcp", + ].join("\n"); +} diff --git a/apps/web/app/api/mcp/route.ts b/apps/web/app/api/mcp/route.ts index 29e1885..6888dbb 100644 --- a/apps/web/app/api/mcp/route.ts +++ b/apps/web/app/api/mcp/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; +import { ProjectKey } from "@shared-memory/schemas"; import { authenticateBearer, UnauthorizedError } from "@/lib/auth/jwt"; import { userContextFromClaims } from "@/lib/mcp/context"; import { dispatchMcpMessage } from "@/lib/mcp/server"; +import { upsertProject } from "@/lib/projects"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -49,8 +51,37 @@ export async function POST(req: Request) { ); } + // ---- optional X-Project-Key header → default project for this request ---- + // The header lets a client (e.g. a `claude mcp add` snippet generated from + // /settings/tokens) pin every call to a specific project without having to + // pass `project` on each tool invocation. Tools that take an optional + // `project` arg fall back to this when the caller omits it. + let defaultProjectKey: string | undefined; + const rawProjectKey = req.headers.get("x-project-key"); + if (rawProjectKey !== null && rawProjectKey !== "") { + const parsed = ProjectKey.safeParse(rawProjectKey); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid X-Project-Key", + detail: parsed.error.issues.map((i) => i.message).join("; "), + }, + { status: 400 }, + ); + } + defaultProjectKey = parsed.data; + } + // ---- resolve user, dispatch ---- - const ctx = await userContextFromClaims(claims); + const ctx = await userContextFromClaims(claims, { defaultProjectKey }); + + // Auto-create the header-supplied project if it doesn't exist yet. This + // makes pinning via `X-Project-Key` work transparently — the user doesn't + // have to call `project.identify` first when they paste the generated + // `claude mcp add` snippet from /settings/tokens. + if (defaultProjectKey) { + await upsertProject(ctx.userId, defaultProjectKey); + } // MCP supports batched requests (array) and single. Handle both. if (Array.isArray(body)) { diff --git a/apps/web/auth.ts b/apps/web/auth.ts index d3c4e68..6dbb286 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -2,6 +2,7 @@ import NextAuth from "next-auth"; import { env } from "@/lib/env"; import { db } from "@/lib/db/client"; import { users } from "@/lib/db/schema"; +import { syncUserGroupsFromClaim } from "@/lib/auth/sync-groups"; /** * NextAuth (Auth.js v5) configuration. @@ -60,9 +61,21 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ }) .returning({ id: users.id }); - token.userId = row[0]?.id; + const userId = row[0]?.id; + token.userId = userId; token.sub = sub; token.iss = iss; + + // Sync group memberships from the OIDC `groups` claim. Missing or + // empty claim is treated as "user is in zero groups" — that path + // wipes the user's existing memberships, which is the conservative + // choice (don't keep stale grants alive if the IdP stopped + // asserting them). + if (userId) { + // `profile.groups` is untyped at the next-auth boundary — coerce. + const claimGroups = (profile as { groups?: unknown }).groups; + await syncUserGroupsFromClaim(userId, iss, claimGroups); + } } return token; }, diff --git a/apps/web/drizzle/0003_groups.sql b/apps/web/drizzle/0003_groups.sql new file mode 100644 index 0000000..d006571 --- /dev/null +++ b/apps/web/drizzle/0003_groups.sql @@ -0,0 +1,63 @@ +-- Groups + per-user group memberships, plus the `memory_access` enum. +-- +-- This migration is the substrate for the upcoming group-scoped sharing +-- feature (project_shares). It owns: +-- +-- * memory_access enum — reserved for project_shares to reference. +-- * groups table — one row per distinct group seen in any user's +-- OIDC `groups` claim, keyed by (oidc_iss, name) +-- so different IdPs can both have a group called +-- e.g. "platform" without colliding. +-- * user_groups table — current group memberships for each user. Synced +-- on every sign-in: rows are inserted/deleted to +-- mirror the freshly-issued claim, so IdP +-- membership changes propagate at next login. +-- +-- We deliberately do NOT add project_shares here — that's Agent B's 0004. +-- Defining the enum in 0003 lets 0004 reference it without sequencing +-- gymnastics. + +-- ============================================================================= +-- Enums +-- ============================================================================= + +CREATE TYPE "memory_access" AS ENUM ('ro', 'rw'); + +-- ============================================================================= +-- groups +-- ============================================================================= + +CREATE TABLE "groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + -- OIDC issuer this group's identity comes from. Pairs with `name` to + -- form the natural key — same group name in two IdPs are distinct rows. + "oidc_iss" text NOT NULL, + -- The group name as it appears in the OIDC `groups` claim. + "name" text NOT NULL, + -- Optional human-friendly label. Most IdPs only emit names so this is + -- typically NULL; reserved for future enrichment. + "display_name" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX "groups_iss_name_uq" ON "groups" ("oidc_iss", "name"); + +CREATE TRIGGER groups_set_updated_at BEFORE UPDATE ON "groups" + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================================= +-- user_groups +-- ============================================================================= + +CREATE TABLE "user_groups" ( + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "group_id" uuid NOT NULL REFERENCES "groups"("id") ON DELETE CASCADE, + -- When this membership was last observed in a sign-in claim. The auth + -- callback rewrites this on every login (insert ... on conflict do + -- update) so it's effectively "last sign-in seen this membership". + "synced_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("user_id", "group_id") +); + +CREATE INDEX "user_groups_user_idx" ON "user_groups" ("user_id"); diff --git a/apps/web/lib/auth/jwt.ts b/apps/web/lib/auth/jwt.ts index 512f0a6..c84c188 100644 --- a/apps/web/lib/auth/jwt.ts +++ b/apps/web/lib/auth/jwt.ts @@ -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 { + 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(); + 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); +} + diff --git a/apps/web/lib/db/schema.ts b/apps/web/lib/db/schema.ts index 669a98c..563c408 100644 --- a/apps/web/lib/db/schema.ts +++ b/apps/web/lib/db/schema.ts @@ -7,6 +7,7 @@ import { jsonb, uniqueIndex, index, + primaryKey, customType, vector, varchar, @@ -37,6 +38,10 @@ const textArray = customType<{ data: string[]; driverData: string }>({ export const memoryScope = pgEnum("memory_scope", ["project", "user"]); export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]); export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]); +// Reserved here so 0003 (this file's matching migration) owns it. Used +// by Agent B's upcoming `project_shares` table to express RO vs RW +// grants per shared group. +export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]); // ---------- tables ---------- @@ -152,6 +157,43 @@ export const cliTokens = pgTable( }), ); +export const groups = pgTable( + "groups", + { + id: uuid("id").primaryKey().defaultRandom(), + // OIDC issuer this group originates from — pairs with `name` so two + // IdPs can both have a "platform" group without collision. + oidcIss: text("oidc_iss").notNull(), + name: text("name").notNull(), + // Optional human-friendly label. Most IdPs only emit names, so this + // is usually NULL; reserved for future enrichment. + displayName: text("display_name"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + uniqueIssName: uniqueIndex("groups_iss_name_uq").on(t.oidcIss, t.name), + }), +); + +export const userGroups = pgTable( + "user_groups", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupId: uuid("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + // Refreshed on every sign-in that re-observes this membership. + syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.groupId] }), + userIdx: index("user_groups_user_idx").on(t.userId), + }), +); + export const auditLog = pgTable( "audit_log", { @@ -188,3 +230,7 @@ export type CliToken = typeof cliTokens.$inferSelect; export type NewCliToken = typeof cliTokens.$inferInsert; export type AuditEntry = typeof auditLog.$inferSelect; export type NewAuditEntry = typeof auditLog.$inferInsert; +export type Group = typeof groups.$inferSelect; +export type NewGroup = typeof groups.$inferInsert; +export type UserGroup = typeof userGroups.$inferSelect; +export type NewUserGroup = typeof userGroups.$inferInsert; diff --git a/apps/web/lib/mcp/context.ts b/apps/web/lib/mcp/context.ts index 2821092..092af76 100644 --- a/apps/web/lib/mcp/context.ts +++ b/apps/web/lib/mcp/context.ts @@ -1,28 +1,50 @@ import { db } from "@/lib/db/client"; -import { users } from "@/lib/db/schema"; +import { users, groups, userGroups } from "@/lib/db/schema"; import { and, eq } from "drizzle-orm"; import type { AuthenticatedClaims } from "@/lib/auth/jwt"; /** * Per-request user context for MCP tool handlers. * - * Resolves (or creates) the internal `users` row from the Authentik OIDC - * claims so tools work with stable UUID foreign keys rather than raw `sub` - * strings. + * Resolves (or creates) the internal `users` row from the OIDC claims so + * tools work with stable UUID foreign keys rather than raw `sub` strings. */ export interface UserContext { /** Internal users.id UUID. */ userId: string; - /** OIDC sub claim (stable identifier from Authentik). */ + /** OIDC sub claim (stable identifier from the IdP). */ sub: string; /** OIDC issuer. */ iss: string; /** Optional profile fields if present in the access token. */ email: string | null; name: string | null; + /** + * Group *names* the user is a member of. For OIDC bearer tokens these are + * the live values from the verified token's `groups` claim. For CLI tokens + * (which carry no groups claim), this is the DB snapshot from the user's + * last interactive sign-in — necessarily stale, but the only signal we + * have without going back to the IdP. + */ + groups: string[]; + /** + * Project key supplied via the `X-Project-Key` request header. Tools that + * accept an optional `project` argument use this as a fallback when the + * caller didn't pass one explicitly. Always validated upstream against + * the same Zod schema as the tool argument. + */ + defaultProjectKey?: string; } -export async function userContextFromClaims(claims: AuthenticatedClaims): Promise { +export interface UserContextOverrides { + /** Project key from the X-Project-Key request header (already validated). */ + defaultProjectKey?: string; +} + +export async function userContextFromClaims( + claims: AuthenticatedClaims, + overrides: UserContextOverrides = {}, +): Promise { const email = (claims.email as string | undefined) ?? null; const name = (claims.name as string | undefined) ?? null; const picture = (claims.picture as string | undefined) ?? null; @@ -47,7 +69,7 @@ export async function userContextFromClaims(claims: AuthenticatedClaims): Promis }) .returning({ id: users.id }); - const userId = row[0]?.id; + let userId = row[0]?.id; if (!userId) { // Race against another upsert — fall back to a select. const existing = await db @@ -56,8 +78,32 @@ export async function userContextFromClaims(claims: AuthenticatedClaims): Promis .where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub))) .limit(1); if (!existing[0]) throw new Error("user upsert failed and not found on re-read"); - return { userId: existing[0].id, sub: claims.sub, iss: claims.iss, email, name }; + userId = existing[0].id; } - return { userId, sub: claims.sub, iss: claims.iss, email, name }; + // OIDC bearer tokens carry a `groups` claim (when the IdP is configured to + // emit it). CLI tokens never do — they go through verifyCliToken which + // doesn't set claims.groups. In that case fall back to the DB snapshot + // from the user's last interactive sign-in. + const groupNames = + claims.groups ?? (await loadUserGroups(userId)); + + return { + userId, + sub: claims.sub, + iss: claims.iss, + email, + name, + groups: groupNames, + defaultProjectKey: overrides.defaultProjectKey, + }; +} + +async function loadUserGroups(userId: string): Promise { + const rows = await db + .select({ name: groups.name }) + .from(userGroups) + .innerJoin(groups, eq(userGroups.groupId, groups.id)) + .where(eq(userGroups.userId, userId)); + return rows.map((r) => r.name); } diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 927b3d2..2c04169 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -73,6 +73,42 @@ async function resolveProjectId( return row[0]?.id ?? null; } +/** + * If the args object has no explicit `project` key, inject the request- + * scoped `defaultProjectKey` from the `X-Project-Key` header (when set). + * This lets a client pin every call to one project without restating it + * per tool invocation. Returns a new object — the original is untouched. + * + * The injection rule is: inject when the caller plausibly intends a + * project scope. Concretely we inject when EITHER: + * + * * `scope` is explicitly `'project'`, OR + * * `scope` is omitted AND the tool's natural default IS project-scope + * (memory.write defaults to project; snippet.put defaults to user). + * + * We never inject when `scope === 'user'` is explicit — the schemas refine + * `(scope='user', project=)` as invalid. An explicit `project` + * argument always wins and we never overwrite it. + * + * `defaultScope` is the tool's own default (e.g. 'project' for memory.*, + * 'user' for snippet.*). For filter tools that have no scope default + * (memory.list, memory.search, snippet.list), pass 'project' — those + * cases treat the header as a project filter and benefit from injection. + */ +function withDefaultProject( + args: unknown, + ctx: UserContext, + defaultScope: "project" | "user" = "project", +): unknown { + if (!ctx.defaultProjectKey) return args; + if (args === null || typeof args !== "object" || Array.isArray(args)) return args; + const obj = args as Record; + if (obj.project !== undefined) return args; + if (obj.scope === "user") return args; + if (obj.scope === undefined && defaultScope === "user") return args; + return { ...obj, project: ctx.defaultProjectKey }; +} + // ---------- tools ---------- const projectIdentify: ToolDef = { @@ -150,7 +186,7 @@ const memoryWrite: ToolDef = { required: ["content"], }, async handler(args, ctx) { - const parsed = MemoryWriteInput.safeParse(args); + const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const scope = parsed.data.scope; @@ -213,7 +249,7 @@ const memoryList: ToolDef = { }, }, async handler(args, ctx) { - const parsed = MemoryListInput.safeParse(args); + const parsed = MemoryListInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const where = [eq(memories.userId, ctx.userId), isNull(memories.deletedAt)]; @@ -343,7 +379,7 @@ const memoryUpdate: ToolDef = { required: ["id"], }, async handler(args, ctx) { - const parsed = MemoryUpdateInput.safeParse(args); + const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const existingRows = await db @@ -456,7 +492,7 @@ const memorySearch: ToolDef = { required: ["query"], }, async handler(args, ctx) { - const parsed = MemorySearchInput.safeParse(args); + const parsed = MemorySearchInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const { query, scope, tags, limit } = parsed.data; @@ -549,7 +585,9 @@ const snippetPut: ToolDef = { required: ["name", "body"], }, async handler(args, ctx) { - const parsed = SnippetPutInput.safeParse(args); + // snippet.put defaults to user-scope, so a header-supplied project key + // is only honored when the caller explicitly says `scope='project'`. + const parsed = SnippetPutInput.safeParse(withDefaultProject(args, ctx, "user")); if (!parsed.success) return err(parsed.error.message); if (parsed.data.scope === "project") { @@ -620,7 +658,7 @@ const snippetGet: ToolDef = { required: ["name"], }, async handler(args, ctx) { - const parsed = SnippetGetInput.safeParse(args); + const parsed = SnippetGetInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const snippet = await getSnippet(ctx.userId, { @@ -669,7 +707,7 @@ const snippetList: ToolDef = { }, }, async handler(args, ctx) { - const parsed = SnippetListInput.safeParse(args); + const parsed = SnippetListInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const rows = await listSnippets(ctx.userId, { @@ -708,7 +746,7 @@ const snippetDelete: ToolDef = { required: ["name"], }, async handler(args, ctx) { - const parsed = SnippetDeleteInput.safeParse(args); + const parsed = SnippetDeleteInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); const deleted = await softDeleteSnippet(ctx.userId, { diff --git a/apps/web/lib/memory-actions.ts b/apps/web/lib/memory-actions.ts index 0f83dc4..a488f7c 100644 --- a/apps/web/lib/memory-actions.ts +++ b/apps/web/lib/memory-actions.ts @@ -7,6 +7,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db/client"; import { memories, projects, auditLog } from "@/lib/db/schema"; import { embedText } from "@/lib/embedder"; +import { upsertProject } from "@/lib/projects"; import { MemoryWriteInput, MemoryUpdateInput, @@ -27,29 +28,6 @@ async function requireUserId(): Promise { return session.user.id; } -async function resolveProjectId(userId: string, key: string): Promise { - const row = await db - .select({ id: projects.id }) - .from(projects) - .where(and(eq(projects.userId, userId), eq(projects.key, key))) - .limit(1); - return row[0]?.id ?? null; -} - -async function upsertProject( - userId: string, - key: string, - displayName?: string, -): Promise { - const existing = await resolveProjectId(userId, key); - if (existing) return existing; - const row = await db - .insert(projects) - .values({ userId, key, displayName: displayName ?? null }) - .returning({ id: projects.id }); - return row[0]!.id; -} - function parseTags(raw: FormDataEntryValue | null): string[] { if (typeof raw !== "string") return []; return raw diff --git a/apps/web/lib/projects.ts b/apps/web/lib/projects.ts new file mode 100644 index 0000000..9901dff --- /dev/null +++ b/apps/web/lib/projects.ts @@ -0,0 +1,43 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { projects } from "@/lib/db/schema"; + +/** + * Look up a project id by (user, key). Returns null when not found. + * No write side-effects. + */ +export async function resolveProjectId( + userId: string, + key: string, +): Promise { + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.userId, userId), eq(projects.key, key))) + .limit(1); + return row[0]?.id ?? null; +} + +/** + * Idempotent project creation. Returns the existing row's id when one + * exists, otherwise inserts and returns the new id. Tolerates concurrent + * inserts via ON CONFLICT — two simultaneous calls converge on one row. + */ +export async function upsertProject( + userId: string, + key: string, + displayName?: string, +): Promise { + const existing = await resolveProjectId(userId, key); + if (existing) return existing; + const row = await db + .insert(projects) + .values({ userId, key, displayName: displayName ?? null }) + .onConflictDoNothing({ target: [projects.userId, projects.key] }) + .returning({ id: projects.id }); + if (row[0]) return row[0].id; + // ON CONFLICT DO NOTHING returns no rows on conflict — re-read. + const reread = await resolveProjectId(userId, key); + if (!reread) throw new Error("project upsert raced and re-read still empty"); + return reread; +}