From ccf601c17898738d4f50ac9f4e622bf9a17ba301 Mon Sep 17 00:00:00 2001 From: Super User Date: Mon, 18 May 2026 14:08:10 +0200 Subject: [PATCH] fix(pam): key grants by gscSID, not NextAuth user.id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- k8s/deployment.yaml | 2 +- src/app/api/pam/active/route.ts | 7 ++++--- src/app/api/pam/audit/route.ts | 7 ++++--- src/app/api/pam/eligible/route.ts | 9 ++++---- src/app/api/pam/request/route.ts | 31 ++++++++++++++-------------- src/app/api/pam/revoke/[id]/route.ts | 9 ++++---- src/app/api/pam/whoami/route.ts | 2 ++ src/lib/authz.ts | 28 +++++++++++++------------ src/lib/session-helpers.ts | 14 +++++++++++++ 9 files changed, 66 insertions(+), 43 deletions(-) create mode 100644 src/lib/session-helpers.ts diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index c76bd8c..75170a5 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -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 diff --git a/src/app/api/pam/active/route.ts b/src/app/api/pam/active/route.ts index d59d444..00f0cf3 100644 --- a/src/app/api/pam/active/route.ts +++ b/src/app/api/pam/active/route.ts @@ -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 }); } diff --git a/src/app/api/pam/audit/route.ts b/src/app/api/pam/audit/route.ts index 660c6dc..fa50688 100644 --- a/src/app/api/pam/audit/route.ts +++ b/src/app/api/pam/audit/route.ts @@ -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 }); } diff --git a/src/app/api/pam/eligible/route.ts b/src/app/api/pam/eligible/route.ts index a7ec472..db10a86 100644 --- a/src/app/api/pam/eligible/route.ts +++ b/src/app/api/pam/eligible/route.ts @@ -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() }, diff --git a/src/app/api/pam/request/route.ts b/src/app/api/pam/request/route.ts index e296c06..7efbed2 100644 --- a/src/app/api/pam/request/route.ts +++ b/src/app/api/pam/request/route.ts @@ -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 | 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, diff --git a/src/app/api/pam/revoke/[id]/route.ts b/src/app/api/pam/revoke/[id]/route.ts index beaa178..9936fa5 100644 --- a/src/app/api/pam/revoke/[id]/route.ts +++ b/src/app/api/pam/revoke/[id]/route.ts @@ -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" }); } diff --git a/src/app/api/pam/whoami/route.ts b/src/app/api/pam/whoami/route.ts index 8ad850d..17c2ef6 100644 --- a/src/app/api/pam/whoami/route.ts +++ b/src/app/api/pam/whoami/route.ts @@ -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, diff --git a/src/lib/authz.ts b/src/lib/authz.ts index 187c2e2..059e1ef 100644 --- a/src/lib/authz.ts +++ b/src/lib/authz.ts @@ -94,13 +94,19 @@ async function hasActiveGrant(gscsid: string, role: string): Promise { * 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 & { gscSid?: string }; + export async function hasRole( - user: Pick | null | undefined, + user: AuthzUser | null | undefined, role: string, ): Promise { - 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 | null | undefined, + user: AuthzUser | null | undefined, role: string, ): Promise { 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= 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= instead of throwing. */ export async function requireElevation( - user: Pick | null | undefined, + user: AuthzUser | null | undefined, role: string, ): Promise { 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 | null | undefined, + user: AuthzUser | null | undefined, roles: readonly string[], ): Promise { 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 | null | undefined, + user: AuthzUser | null | undefined, roles: readonly string[], ): Promise { if (!(await hasAnyRole(user, roles))) { diff --git a/src/lib/session-helpers.ts b/src/lib/session-helpers.ts new file mode 100644 index 0000000..88bf69c --- /dev/null +++ b/src/lib/session-helpers.ts @@ -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; +}