fix(pam): key grants by gscSID, not NextAuth user.id

The kit's session.user.id is a NextAuth UUID — opaque, per-session
plumbing. The canonical cross-service identity is the FreeIPA uid
exposed as `gscSid` in the kit session. Grants written by gscMy
must use that key so gscAdmin's authz lookup (which uses the
same gscSID-as-key convention) hits them.

- All /api/pam/* routes now require `user.gscSid` instead of `user.id`
- authz.hasRole/requireRole/hasAnyRole take `{gscSid,roles}` shape
- whoami debug endpoint now shows gscSid for verification
- New helper src/lib/session-helpers.ts:getGscsid() for callers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Super User
2026-05-18 14:08:10 +02:00
parent cb85c1de7a
commit ccf601c178
9 changed files with 66 additions and 43 deletions

View File

@@ -31,7 +31,7 @@ spec:
spec:
containers:
- name: my-ui
image: registry.gosec.internal/gsc-my/ui:v0.1.2
image: registry.gosec.internal/gsc-my/ui:v0.1.3
imagePullPolicy: Always
ports:
- containerPort: 3000

View File

@@ -7,8 +7,9 @@ export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const grants = await listActiveGrants(user.id);
const user = session?.user as { gscSid?: string } | undefined;
const gscsid = user?.gscSid;
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
const grants = await listActiveGrants(gscsid);
return Response.json({ active: grants });
}

View File

@@ -7,8 +7,9 @@ export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const rows = await listAudit(user.id, 100);
const user = session?.user as { gscSid?: string } | undefined;
const gscsid = user?.gscSid;
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
const rows = await listAudit(gscsid, 100);
return Response.json({ audit: rows });
}

View File

@@ -11,10 +11,11 @@ export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string; roles?: string[] } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const user = session?.user as { gscSid?: string; roles?: string[] } | undefined;
const gscsid = user?.gscSid;
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
const targetRoles = eligibleRoles(user as { roles: string[] });
const targetRoles = eligibleRoles({ roles: user!.roles ?? [] });
if (targetRoles.length === 0) return Response.json({ eligible: [] });
const [policies, activeGrants] = await Promise.all([
@@ -31,7 +32,7 @@ export async function GET() {
}),
prisma.privilegeGrant.findMany({
where: {
gscsid: user.id,
gscsid,
roleName: { in: targetRoles },
status: "active",
expiresAt: { gt: new Date() },

View File

@@ -42,9 +42,10 @@ function publicOrigin(req: Request): string {
export async function POST(req: Request) {
const session = await auth();
const user = session?.user as
| { id?: string; email?: string; displayName?: string; roles?: string[] }
| { gscSid?: string; email?: string; displayName?: string; roles?: string[] }
| undefined;
if (!user?.id) {
const gscsid = user?.gscSid;
if (!gscsid) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
@@ -59,7 +60,7 @@ export async function POST(req: Request) {
if (!eligibleRoles({ roles: user.roles ?? [] }).includes(body.role)) {
await recordAudit({
event: "denied",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
detail: { reason: "not_eligible" },
});
@@ -70,7 +71,7 @@ export async function POST(req: Request) {
if (!policy) {
await recordAudit({
event: "denied",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
detail: { reason: "no_policy" },
});
@@ -90,11 +91,11 @@ export async function POST(req: Request) {
// MFA check if policy requires it.
let mfaEvidence: Record<string, unknown> | undefined;
if (policy.requiresMfa) {
const v = await verifyMfa({ gscsid: user.id, token: body.mfaToken ?? "" });
const v = await verifyMfa({ gscsid: gscsid, token: body.mfaToken ?? "" });
if (!v.ok) {
await recordAudit({
event: "denied",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
detail: { reason: "mfa_failed", mfa_reason: v.reason },
});
@@ -110,7 +111,7 @@ export async function POST(req: Request) {
// Auto-approve + activate.
const grant = await prisma.privilegeGrant.create({
data: {
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
status: "active",
requestedAt: new Date(),
@@ -124,24 +125,24 @@ export async function POST(req: Request) {
});
await recordAudit({
event: "requested",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
grantId: grant.id,
detail: { durationHours: body.durationHours, justification: body.justification },
});
await recordAudit({
event: "approved-auto",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
grantId: grant.id,
});
await recordAudit({
event: "activated",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
grantId: grant.id,
});
invalidateAuthzCache(user.id, body.role);
invalidateAuthzCache(gscsid, body.role);
return Response.json({
status: "active",
grant: { id: grant.id, role: grant.roleName, expiresAt: grant.expiresAt },
@@ -162,7 +163,7 @@ export async function POST(req: Request) {
const token = generateApprovalToken();
const grant = await prisma.privilegeGrant.create({
data: {
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
status: "pending",
approvalToken: token,
@@ -175,7 +176,7 @@ export async function POST(req: Request) {
});
await recordAudit({
event: "requested",
gscsid: user.id,
gscsid: gscsid,
roleName: body.role,
grantId: grant.id,
detail: { durationHours: body.durationHours, justification: body.justification },
@@ -187,8 +188,8 @@ export async function POST(req: Request) {
to: policy.approverEmail,
approveUrl,
requesterDisplay: user.displayName
? `${user.displayName} (${user.email ?? user.id})`
: (user.email ?? user.id),
? `${user.displayName} (${user.email ?? gscsid})`
: (user.email ?? gscsid),
roleName: body.role,
durationHours: body.durationHours,
justification: body.justification,

View File

@@ -12,8 +12,9 @@ export const dynamic = "force-dynamic";
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const user = session?.user as { gscSid?: string } | undefined;
const gscsid = user?.gscSid;
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
const { id } = await ctx.params;
const grant = await prisma.privilegeGrant.findUnique({
@@ -21,7 +22,7 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
select: { gscsid: true, status: true },
});
if (!grant) return Response.json({ error: "not_found" }, { status: 404 });
if (grant.gscsid !== user.id) {
if (grant.gscsid !== gscsid) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
@@ -33,6 +34,6 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
/* no body, fine */
}
await revokeGrant(id, user.id, reason);
await revokeGrant(id, gscsid, reason);
return Response.json({ status: "revoked" });
}

View File

@@ -12,6 +12,7 @@ export async function GET() {
| {
id?: string;
keycloakId?: string;
gscSid?: string;
tenantId?: string;
customerId?: string;
email?: string;
@@ -39,6 +40,7 @@ export async function GET() {
return Response.json({
id: u.id,
keycloakId: u.keycloakId,
gscSid: u.gscSid,
tenantId: u.tenantId,
customerId: u.customerId,
email: u.email,

View File

@@ -94,13 +94,19 @@ async function hasActiveGrant(gscsid: string, role: string): Promise<boolean> {
* active JIT grant. Safe to call on every privileged code path —
* single indexed DB hit at most every 15 s per (user, role).
*/
// Identity for PAM grants is the gscSID — see lib/session-helpers.ts
// and the gsc-identity-boundaries memory. The kit's session.user.id
// is a NextAuth UUID and must NOT be used as a cross-service key.
type AuthzUser = Pick<SessionUser, "roles"> & { gscSid?: string };
export async function hasRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
user: AuthzUser | null | undefined,
role: string,
): Promise<boolean> {
if (!user?.id) return false;
if (user.roles.includes(role)) return true;
return await hasActiveGrant(user.id, role);
const gscsid = user?.gscSid;
if (!gscsid) return false;
if (user!.roles.includes(role)) return true;
return await hasActiveGrant(gscsid, role);
}
/**
@@ -108,7 +114,7 @@ export async function hasRole(
* actions / route handlers / pages where missing access should 403.
*/
export async function requireRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
user: AuthzUser | null | undefined,
role: string,
): Promise<void> {
if (!(await hasRole(user, role))) {
@@ -118,14 +124,10 @@ export async function requireRole(
/**
* Page-friendly variant: if the user can't reach `role`, bounce them
* to /access?need=<role> instead of throwing. Use at the top of
* server components so the user sees the elevation UI rather than
* a generic error page.
*
* await requireElevation(user, "gscadmin_admins");
* to /access?need=<role> instead of throwing.
*/
export async function requireElevation(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
user: AuthzUser | null | undefined,
role: string,
): Promise<void> {
if (await hasRole(user, role)) return;
@@ -138,7 +140,7 @@ export async function requireElevation(
* for "operator OR admin" gates.
*/
export async function hasAnyRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
user: AuthzUser | null | undefined,
roles: readonly string[],
): Promise<boolean> {
for (const r of roles) {
@@ -151,7 +153,7 @@ export async function hasAnyRole(
* Variant of requireRole that admits any of the listed roles.
*/
export async function requireAnyRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
user: AuthzUser | null | undefined,
roles: readonly string[],
): Promise<void> {
if (!(await hasAnyRole(user, roles))) {

View File

@@ -0,0 +1,14 @@
// Pulls the canonical gscSID off a session user. The kit's
// `session.user.id` is a NextAuth UUID; the real FreeIPA identity is
// in `gscSid`. Everything that needs to address the user across
// services (PAM grants, audit rows, anything joined to FreeIPA uid)
// must key on this — not on `user.id`.
export interface SessionWithGsc {
gscSid?: string;
id?: string;
}
export function getGscsid(user: SessionWithGsc | null | undefined): string | null {
return user?.gscSid ?? null;
}