2026-05-17 09:39:47 -07:00
|
|
|
import { and, eq, notInArray, sql } from "drizzle-orm";
|
|
|
|
|
import { db } from "@/lib/db/client";
|
|
|
|
|
import { groups, userGroups } from "@/lib/db/schema";
|
|
|
|
|
|
2026-08-12 10:51:29 -07:00
|
|
|
/**
|
|
|
|
|
* Raised when the IdP signals that it holds group memberships it declined to
|
|
|
|
|
* enumerate (EntraID's "groups overage"). Callers must abort — see
|
|
|
|
|
* `detectGroupsOverage` for why this cannot be treated as "no groups".
|
|
|
|
|
*/
|
|
|
|
|
export class GroupsOverageError extends Error {
|
|
|
|
|
constructor() {
|
|
|
|
|
super(
|
|
|
|
|
"OIDC groups overage: the identity provider signalled group membership " +
|
|
|
|
|
"it did not enumerate, so the user's groups cannot be determined. On " +
|
|
|
|
|
"EntraID, set the app registration's `groupMembershipClaims` to " +
|
|
|
|
|
'"ApplicationGroup" (portal: "Groups assigned to the application") and ' +
|
|
|
|
|
"assign the groups you share projects with. See docs/oidc-entra-id.md " +
|
|
|
|
|
"§10b.",
|
|
|
|
|
);
|
|
|
|
|
this.name = "GroupsOverageError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Does this token say "there are groups, but I'm not listing them"?
|
|
|
|
|
*
|
|
|
|
|
* EntraID stops emitting `groups` past 200 entries in a JWT (150 in SAML, 5 in
|
|
|
|
|
* implicit flow) and substitutes a pointer:
|
|
|
|
|
*
|
|
|
|
|
* "_claim_names": { "groups": "src1" },
|
|
|
|
|
* "_claim_sources": { "src1": { "endpoint": "https://graph.windows.net/…" } }
|
|
|
|
|
*
|
|
|
|
|
* or, for implicit flow, `"hasgroups": true`.
|
|
|
|
|
*
|
|
|
|
|
* This is NOT a truncated list — it is no list at all, and it is materially
|
|
|
|
|
* different from "this user belongs to zero groups". Conflating the two is
|
|
|
|
|
* what made this dangerous: the absent-claim branch below deletes every one of
|
|
|
|
|
* the user's memberships, so a user crossing the 200-group line would silently
|
|
|
|
|
* lose access to every shared project on both surfaces, with no error raised
|
|
|
|
|
* anywhere.
|
|
|
|
|
*
|
|
|
|
|
* We refuse instead. Group state gates `readableProjectIds` / `canWriteProject`,
|
|
|
|
|
* and granting or revoking access on state we know we don't have is guesswork
|
|
|
|
|
* either way. Failing loudly destroys nothing and names its own fix.
|
|
|
|
|
*/
|
|
|
|
|
export function detectGroupsOverage(claims: unknown): boolean {
|
|
|
|
|
const c = claims as
|
|
|
|
|
| { _claim_names?: unknown; hasgroups?: unknown }
|
|
|
|
|
| null
|
|
|
|
|
| undefined;
|
|
|
|
|
if (!c || typeof c !== "object") return false;
|
|
|
|
|
if (c.hasgroups === true) return true;
|
|
|
|
|
const names = c._claim_names;
|
|
|
|
|
return (
|
|
|
|
|
typeof names === "object" &&
|
|
|
|
|
names !== null &&
|
|
|
|
|
"groups" in (names as Record<string, unknown>)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 09:39:47 -07:00
|
|
|
/**
|
|
|
|
|
* Sync a user's group memberships from the OIDC `groups` claim on sign-in.
|
|
|
|
|
*
|
2026-08-12 10:51:29 -07:00
|
|
|
* Takes the whole claims object, not just the claim value, because deciding
|
|
|
|
|
* what an absent `groups` means requires seeing the overage markers that sit
|
|
|
|
|
* beside it.
|
|
|
|
|
*
|
2026-05-17 09:39:47 -07:00
|
|
|
* Claim shape: `string[]`. Authentik emits group *names* directly here;
|
2026-08-12 10:51:29 -07:00
|
|
|
* Keycloak and Okta likewise (with the right mappers configured). EntraID
|
|
|
|
|
* emits object-id GUIDs by default — `cloud_displayname` gets you names, but
|
|
|
|
|
* only under `groupMembershipClaims: "ApplicationGroup"`, and only for
|
|
|
|
|
* directly assigned groups. See docs/oidc-entra-id.md §10a.
|
2026-05-17 09:39:47 -07:00
|
|
|
*
|
|
|
|
|
* - 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
|
2026-08-12 10:51:29 -07:00
|
|
|
* groups and all existing memberships are deleted. That is the
|
|
|
|
|
* conservative reading: don't keep stale grants alive once the IdP has
|
|
|
|
|
* stopped asserting them.
|
|
|
|
|
* - if the IdP signals an overage, we throw rather than apply either
|
|
|
|
|
* reading. See `detectGroupsOverage`.
|
2026-05-17 09:39:47 -07:00
|
|
|
*
|
|
|
|
|
* 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).
|
2026-08-12 10:51:29 -07:00
|
|
|
*
|
|
|
|
|
* @throws {GroupsOverageError} when the claims carry an overage indicator.
|
2026-05-17 09:39:47 -07:00
|
|
|
*/
|
|
|
|
|
export async function syncUserGroupsFromClaim(
|
|
|
|
|
userId: string,
|
|
|
|
|
oidcIss: string,
|
2026-08-12 10:51:29 -07:00
|
|
|
claims: unknown,
|
2026-05-17 09:39:47 -07:00
|
|
|
): Promise<void> {
|
2026-08-12 10:51:29 -07:00
|
|
|
if (detectGroupsOverage(claims)) throw new GroupsOverageError();
|
|
|
|
|
|
|
|
|
|
const rawClaim = (claims as { groups?: unknown } | null | undefined)?.groups;
|
2026-05-17 09:39:47 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|