Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/admin-sso/jit-provisioning.server.ts
T

226 lines
6.7 KiB
TypeScript

/**
* Phase 09 Task #82 — JIT-provisioning for Admin SSO.
*
* Lookup order:
* 1. AdminUser by (ssoProvider, ssoSubjectId)
* 2. AdminUser by linked User.email (case-insensitive) — legacy linking
* 3. New User + AdminUser if at least one allowed group is in claims
*
* Idempotent under concurrent logins thanks to the unique
* (ssoProvider, ssoSubjectId) constraint added in migration
* 20260517161240_09_admin_user_sso_fields.
*/
import { randomBytes } from "node:crypto";
import type { AdminSsoClaims, AdminSsoRole } from "./claims.server";
import { hasAllowedAdminGroup, mapGroupsToRole } from "./claims.server";
export const ADMIN_SSO_PROVIDER = "authentik";
export type JitProvisioningInput = {
readonly claims: AdminSsoClaims;
readonly now?: Date;
};
export type JitProvisionedAdmin = {
readonly userId: string;
readonly email: string;
readonly role: AdminSsoRole;
readonly created: boolean;
readonly linkedExisting: boolean;
};
export type JitProvisioningResult =
| { readonly ok: true; readonly admin: JitProvisionedAdmin }
| { readonly ok: false; readonly reason: "forbidden-group" };
export class JitProvisioningError extends Error {
readonly reason: "forbidden-group";
constructor(reason: "forbidden-group", message: string) {
super(message);
this.name = "JitProvisioningError";
this.reason = reason;
}
}
/**
* Narrow Prisma-shaped surface used by jitProvisionAdmin. Defining it as a
* structural type rather than importing PrismaClient keeps this module
* unit-testable with a hand-rolled fake.
*/
export type JitProvisioningDbClient = {
adminUser: {
findUnique: (args: { where: { ssoProvider_ssoSubjectId: { ssoProvider: string; ssoSubjectId: string } } }) =>
| Promise<({ userId: string } & AdminSsoUserRecord) | null>;
update: (args: {
where: { userId: string };
data: {
ssoProvider?: string;
ssoSubjectId?: string;
ssoGroups?: string[];
lastSsoLoginAt?: Date;
};
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
create: (args: {
data: {
userId: string;
ssoProvider: string;
ssoSubjectId: string;
ssoGroups: string[];
lastSsoLoginAt: Date;
};
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
};
user: {
findUnique: (args: { where: { email: string } }) => Promise<{ id: string; email: string } | null>;
update: (args: { where: { id: string }; data: { firstName?: string; lastName?: string } }) => Promise<unknown>;
create: (args: {
data: {
email: string;
passwordHash: string;
firstName: string;
lastName: string;
active: boolean;
};
}) => Promise<{ id: string; email: string }>;
};
};
type AdminSsoUserRecord = {
ssoProvider: string | null;
ssoSubjectId: string | null;
ssoGroups: string[];
lastSsoLoginAt: Date | null;
};
function splitName(fullName: string): { firstName: string; lastName: string } {
const cleaned = fullName.trim();
if (!cleaned) return { firstName: "", lastName: "" };
const parts = cleaned.split(/\s+/);
if (parts.length === 1) return { firstName: parts[0]!, lastName: "" };
return { firstName: parts[0]!, lastName: parts.slice(1).join(" ") };
}
/**
* Random throw-away password hash — placeholder so the User row can satisfy
* the existing non-null `passwordHash` constraint. SSO-only users never use
* it; they always authenticate through Authentik. Stored as an opaque
* 32-byte token prefixed to make the source obvious in audits.
*/
function generateSsoOnlyPasswordHash(): string {
return `sso-only!${randomBytes(32).toString("hex")}`;
}
/**
* JIT-provision (or refresh) an AdminUser based on a verified Authentik
* claim bundle.
*/
export async function jitProvisionAdmin(
input: JitProvisioningInput,
db: JitProvisioningDbClient
): Promise<JitProvisioningResult> {
const { claims } = input;
const role = mapGroupsToRole(claims.groups);
if (!role || !hasAllowedAdminGroup(claims.groups)) {
return { ok: false, reason: "forbidden-group" };
}
const now = input.now ?? new Date();
const email = claims.email.toLowerCase();
// (1) Existing SSO-linked admin?
const existingBySso = await db.adminUser.findUnique({
where: { ssoProvider_ssoSubjectId: { ssoProvider: ADMIN_SSO_PROVIDER, ssoSubjectId: claims.subject } },
});
if (existingBySso) {
await db.adminUser.update({
where: { userId: existingBySso.userId },
data: {
ssoGroups: [...claims.groups],
lastSsoLoginAt: now,
},
});
const { firstName, lastName } = splitName(claims.name);
if (firstName || lastName) {
await db.user.update({ where: { id: existingBySso.userId }, data: { firstName, lastName } });
}
return {
ok: true,
admin: {
userId: existingBySso.userId,
email,
role,
created: false,
linkedExisting: false,
},
};
}
// (2) Legacy admin matched by email — attach SSO fields.
const existingUserByEmail = await db.user.findUnique({ where: { email } });
if (existingUserByEmail) {
const updated = await db.adminUser.update({
where: { userId: existingUserByEmail.id },
data: {
ssoProvider: ADMIN_SSO_PROVIDER,
ssoSubjectId: claims.subject,
ssoGroups: [...claims.groups],
lastSsoLoginAt: now,
},
}).catch(async (err) => {
// If the user exists but has no AdminUser row, create one.
// Prisma's `update` throws P2025 for record-not-found.
const code = (err as { code?: string }).code;
if (code !== "P2025") throw err;
return db.adminUser.create({
data: {
userId: existingUserByEmail.id,
ssoProvider: ADMIN_SSO_PROVIDER,
ssoSubjectId: claims.subject,
ssoGroups: [...claims.groups],
lastSsoLoginAt: now,
},
});
});
return {
ok: true,
admin: {
userId: updated.userId,
email,
role,
created: false,
linkedExisting: true,
},
};
}
// (3) Brand-new user + admin from SSO.
const { firstName, lastName } = splitName(claims.name);
const newUser = await db.user.create({
data: {
email,
passwordHash: generateSsoOnlyPasswordHash(),
firstName: firstName || "Admin",
lastName: lastName || "User",
active: true,
},
});
await db.adminUser.create({
data: {
userId: newUser.id,
ssoProvider: ADMIN_SSO_PROVIDER,
ssoSubjectId: claims.subject,
ssoGroups: [...claims.groups],
lastSsoLoginAt: now,
},
});
return {
ok: true,
admin: {
userId: newUser.id,
email,
role,
created: true,
linkedExisting: false,
},
};
}