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>
64 lines
2.9 KiB
SQL
64 lines
2.9 KiB
SQL
-- 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");
|