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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
14
src/lib/session-helpers.ts
Normal file
14
src/lib/session-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user