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:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: my-ui
|
- 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
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user as { id?: string } | undefined;
|
const user = session?.user as { gscSid?: string } | undefined;
|
||||||
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
|
const gscsid = user?.gscSid;
|
||||||
const grants = await listActiveGrants(user.id);
|
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
const grants = await listActiveGrants(gscsid);
|
||||||
return Response.json({ active: grants });
|
return Response.json({ active: grants });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user as { id?: string } | undefined;
|
const user = session?.user as { gscSid?: string } | undefined;
|
||||||
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
|
const gscsid = user?.gscSid;
|
||||||
const rows = await listAudit(user.id, 100);
|
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
const rows = await listAudit(gscsid, 100);
|
||||||
return Response.json({ audit: rows });
|
return Response.json({ audit: rows });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user as { id?: string; roles?: string[] } | undefined;
|
const user = session?.user as { gscSid?: string; roles?: string[] } | undefined;
|
||||||
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
|
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: [] });
|
if (targetRoles.length === 0) return Response.json({ eligible: [] });
|
||||||
|
|
||||||
const [policies, activeGrants] = await Promise.all([
|
const [policies, activeGrants] = await Promise.all([
|
||||||
@@ -31,7 +32,7 @@ export async function GET() {
|
|||||||
}),
|
}),
|
||||||
prisma.privilegeGrant.findMany({
|
prisma.privilegeGrant.findMany({
|
||||||
where: {
|
where: {
|
||||||
gscsid: user.id,
|
gscsid,
|
||||||
roleName: { in: targetRoles },
|
roleName: { in: targetRoles },
|
||||||
status: "active",
|
status: "active",
|
||||||
expiresAt: { gt: new Date() },
|
expiresAt: { gt: new Date() },
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ function publicOrigin(req: Request): string {
|
|||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user as
|
const user = session?.user as
|
||||||
| { id?: string; email?: string; displayName?: string; roles?: string[] }
|
| { gscSid?: string; email?: string; displayName?: string; roles?: string[] }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!user?.id) {
|
const gscsid = user?.gscSid;
|
||||||
|
if (!gscsid) {
|
||||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
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)) {
|
if (!eligibleRoles({ roles: user.roles ?? [] }).includes(body.role)) {
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "denied",
|
event: "denied",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
detail: { reason: "not_eligible" },
|
detail: { reason: "not_eligible" },
|
||||||
});
|
});
|
||||||
@@ -70,7 +71,7 @@ export async function POST(req: Request) {
|
|||||||
if (!policy) {
|
if (!policy) {
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "denied",
|
event: "denied",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
detail: { reason: "no_policy" },
|
detail: { reason: "no_policy" },
|
||||||
});
|
});
|
||||||
@@ -90,11 +91,11 @@ export async function POST(req: Request) {
|
|||||||
// MFA check if policy requires it.
|
// MFA check if policy requires it.
|
||||||
let mfaEvidence: Record<string, unknown> | undefined;
|
let mfaEvidence: Record<string, unknown> | undefined;
|
||||||
if (policy.requiresMfa) {
|
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) {
|
if (!v.ok) {
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "denied",
|
event: "denied",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
detail: { reason: "mfa_failed", mfa_reason: v.reason },
|
detail: { reason: "mfa_failed", mfa_reason: v.reason },
|
||||||
});
|
});
|
||||||
@@ -110,7 +111,7 @@ export async function POST(req: Request) {
|
|||||||
// Auto-approve + activate.
|
// Auto-approve + activate.
|
||||||
const grant = await prisma.privilegeGrant.create({
|
const grant = await prisma.privilegeGrant.create({
|
||||||
data: {
|
data: {
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
status: "active",
|
status: "active",
|
||||||
requestedAt: new Date(),
|
requestedAt: new Date(),
|
||||||
@@ -124,24 +125,24 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "requested",
|
event: "requested",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
grantId: grant.id,
|
grantId: grant.id,
|
||||||
detail: { durationHours: body.durationHours, justification: body.justification },
|
detail: { durationHours: body.durationHours, justification: body.justification },
|
||||||
});
|
});
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "approved-auto",
|
event: "approved-auto",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
grantId: grant.id,
|
grantId: grant.id,
|
||||||
});
|
});
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "activated",
|
event: "activated",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
grantId: grant.id,
|
grantId: grant.id,
|
||||||
});
|
});
|
||||||
invalidateAuthzCache(user.id, body.role);
|
invalidateAuthzCache(gscsid, body.role);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
status: "active",
|
status: "active",
|
||||||
grant: { id: grant.id, role: grant.roleName, expiresAt: grant.expiresAt },
|
grant: { id: grant.id, role: grant.roleName, expiresAt: grant.expiresAt },
|
||||||
@@ -162,7 +163,7 @@ export async function POST(req: Request) {
|
|||||||
const token = generateApprovalToken();
|
const token = generateApprovalToken();
|
||||||
const grant = await prisma.privilegeGrant.create({
|
const grant = await prisma.privilegeGrant.create({
|
||||||
data: {
|
data: {
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
approvalToken: token,
|
approvalToken: token,
|
||||||
@@ -175,7 +176,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
event: "requested",
|
event: "requested",
|
||||||
gscsid: user.id,
|
gscsid: gscsid,
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
grantId: grant.id,
|
grantId: grant.id,
|
||||||
detail: { durationHours: body.durationHours, justification: body.justification },
|
detail: { durationHours: body.durationHours, justification: body.justification },
|
||||||
@@ -187,8 +188,8 @@ export async function POST(req: Request) {
|
|||||||
to: policy.approverEmail,
|
to: policy.approverEmail,
|
||||||
approveUrl,
|
approveUrl,
|
||||||
requesterDisplay: user.displayName
|
requesterDisplay: user.displayName
|
||||||
? `${user.displayName} (${user.email ?? user.id})`
|
? `${user.displayName} (${user.email ?? gscsid})`
|
||||||
: (user.email ?? user.id),
|
: (user.email ?? gscsid),
|
||||||
roleName: body.role,
|
roleName: body.role,
|
||||||
durationHours: body.durationHours,
|
durationHours: body.durationHours,
|
||||||
justification: body.justification,
|
justification: body.justification,
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user as { id?: string } | undefined;
|
const user = session?.user as { gscSid?: string } | undefined;
|
||||||
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
|
const gscsid = user?.gscSid;
|
||||||
|
if (!gscsid) return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const grant = await prisma.privilegeGrant.findUnique({
|
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 },
|
select: { gscsid: true, status: true },
|
||||||
});
|
});
|
||||||
if (!grant) return Response.json({ error: "not_found" }, { status: 404 });
|
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 });
|
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 */
|
/* no body, fine */
|
||||||
}
|
}
|
||||||
|
|
||||||
await revokeGrant(id, user.id, reason);
|
await revokeGrant(id, gscsid, reason);
|
||||||
return Response.json({ status: "revoked" });
|
return Response.json({ status: "revoked" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function GET() {
|
|||||||
| {
|
| {
|
||||||
id?: string;
|
id?: string;
|
||||||
keycloakId?: string;
|
keycloakId?: string;
|
||||||
|
gscSid?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -39,6 +40,7 @@ export async function GET() {
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
keycloakId: u.keycloakId,
|
keycloakId: u.keycloakId,
|
||||||
|
gscSid: u.gscSid,
|
||||||
tenantId: u.tenantId,
|
tenantId: u.tenantId,
|
||||||
customerId: u.customerId,
|
customerId: u.customerId,
|
||||||
email: u.email,
|
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 —
|
* active JIT grant. Safe to call on every privileged code path —
|
||||||
* single indexed DB hit at most every 15 s per (user, role).
|
* 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(
|
export async function hasRole(
|
||||||
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
|
user: AuthzUser | null | undefined,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!user?.id) return false;
|
const gscsid = user?.gscSid;
|
||||||
if (user.roles.includes(role)) return true;
|
if (!gscsid) return false;
|
||||||
return await hasActiveGrant(user.id, role);
|
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.
|
* actions / route handlers / pages where missing access should 403.
|
||||||
*/
|
*/
|
||||||
export async function requireRole(
|
export async function requireRole(
|
||||||
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
|
user: AuthzUser | null | undefined,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await hasRole(user, role))) {
|
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
|
* Page-friendly variant: if the user can't reach `role`, bounce them
|
||||||
* to /access?need=<role> instead of throwing. Use at the top of
|
* to /access?need=<role> instead of throwing.
|
||||||
* server components so the user sees the elevation UI rather than
|
|
||||||
* a generic error page.
|
|
||||||
*
|
|
||||||
* await requireElevation(user, "gscadmin_admins");
|
|
||||||
*/
|
*/
|
||||||
export async function requireElevation(
|
export async function requireElevation(
|
||||||
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
|
user: AuthzUser | null | undefined,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (await hasRole(user, role)) return;
|
if (await hasRole(user, role)) return;
|
||||||
@@ -138,7 +140,7 @@ export async function requireElevation(
|
|||||||
* for "operator OR admin" gates.
|
* for "operator OR admin" gates.
|
||||||
*/
|
*/
|
||||||
export async function hasAnyRole(
|
export async function hasAnyRole(
|
||||||
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
|
user: AuthzUser | null | undefined,
|
||||||
roles: readonly string[],
|
roles: readonly string[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
@@ -151,7 +153,7 @@ export async function hasAnyRole(
|
|||||||
* Variant of requireRole that admits any of the listed roles.
|
* Variant of requireRole that admits any of the listed roles.
|
||||||
*/
|
*/
|
||||||
export async function requireAnyRole(
|
export async function requireAnyRole(
|
||||||
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
|
user: AuthzUser | null | undefined,
|
||||||
roles: readonly string[],
|
roles: readonly string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await hasAnyRole(user, roles))) {
|
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