chore: bootstrap gscMy on @gsc/web-kit + PAM/JIT request flow

Initial commit for gscMy carved out as its own repo (was tracked
loosely under the monorepo's web/ which is gitignored).

What this contains:
- Auth: next-auth v5 via @gsc/web-kit createAuth (Keycloak only,
  identity sourced from claims, no admin.users writes)
- Chrome: @gsc/web-kit AdminShell — replaces the legacy MyShell.
  Sidebar JSON config carried over and mapped to DbMenuItem.
- Middleware: createAuthMiddleware. Public: /access-denied,
  /auth/keycloak, /signed-out, /api/health, /api/pam/approve.
- RP-initiated signout at /api/auth/signout → Keycloak end_session →
  /signed-out (mirrors gscAdmin).
- Phosphor-iconned access-denied + signed-out landing pages.

PAM/JIT request flow (ported from gscAdmin's pre-strip git history):
- /access page (Active + Eligible tables, request modal with
  duration slider + justification + optional MFA)
- API: /api/pam/{eligible, active, audit, request, approve/:token,
  revoke/:id}
- src/lib/{authz, pam, pam-mail, pam-mfa}.ts — same files as
  gscAdmin had before the strip. PAM tables (admin.privilege_*)
  are shared with gscAdmin; gscMy uses the same Prisma model defs.
- Top-bar widget shows active grants with countdown + revoke.

Build/Deploy: Dockerfile (monorepo-root context), k8s manifests for
my.gosec.internal, self-signed TLS placeholder, DNS A record.
Keycloak gsc-my client extended to include my.gosec.internal/* in
redirect_uris + web_origins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Super User
2026-05-18 13:46:13 +02:00
commit be1c4fe5f9
96 changed files with 49849 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { getAuthenticatedUser } from "@/auth";
import { getUserEffectiveSettings } from "@/database/settings";
import { getLoginEvents, getUserSessions, getUserHas2FA } from "@/lib/keycloak";
import AccountSecurity from "@/components/account/AccountSecurity";
export default async function SecurityPage() {
const user = await getAuthenticatedUser();
let loginSessions: Array<{
id: string;
loginTime: string;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}> = [];
let securityEvents: Array<{
id: string;
event: string;
detail: string;
timestamp: string;
icon: string;
color: string;
}> = [];
let twoFactorEnabled = false;
let loginNotifications = true;
let activeSessionCount = 0;
if (user?.keycloakId) {
try {
const [events, has2FA, sessions] = await Promise.all([
getLoginEvents(user.keycloakId, 20),
getUserHas2FA(user.keycloakId),
getUserSessions(user.keycloakId),
]);
twoFactorEnabled = has2FA;
activeSessionCount = sessions.length;
// Map Keycloak events to login sessions
loginSessions = events.map((e, i) => ({
id: String(i),
loginTime: new Date(e.time).toISOString(),
ipAddress: e.ipAddress || "Unknown",
device: e.details?.user_agent
? parseUserAgent(e.details.user_agent)
: "Unknown",
location: "—",
status: e.type === "LOGIN" ? "success" as const : "failed" as const,
}));
// Map to security events timeline
securityEvents = events.slice(0, 10).map((e, i) => ({
id: String(i),
event: e.type === "LOGIN" ? "Login" : "Failed Login",
detail: e.type === "LOGIN"
? `Successful login from ${e.ipAddress || "unknown"}`
: `Failed login attempt: ${e.error || "invalid credentials"}`,
timestamp: new Date(e.time).toISOString(),
icon: e.type === "LOGIN" ? "ph-sign-in" : "ph-warning",
color: e.type === "LOGIN" ? "success" : "danger",
}));
} catch (err) {
console.warn("[security page] Keycloak fetch error:", err);
}
}
// Load loginNotifications preference from DB
if (user?.gscUserId) {
try {
const effective = await getUserEffectiveSettings(
user.gscUserId,
user.tenantId,
user.gscCustomerId,
["user.security"]
);
const secSettings = effective["user.security"];
if (secSettings?.loginNotifications) {
loginNotifications = secSettings.loginNotifications.value as boolean;
}
} catch {
// Use default
}
}
return (
<AccountSecurity
displayName={user?.displayName || "User"}
email={user?.email || ""}
loginSessions={loginSessions}
securityEvents={securityEvents}
twoFactorEnabled={twoFactorEnabled}
initialLoginNotifications={loginNotifications}
activeSessionCount={activeSessionCount}
/>
);
}
function parseUserAgent(ua: string): string {
if (ua.includes("Chrome") && !ua.includes("Edge")) {
if (ua.includes("Windows")) return "Chrome on Windows";
if (ua.includes("Mac")) return "Chrome on macOS";
if (ua.includes("Linux")) return "Chrome on Linux";
return "Chrome";
}
if (ua.includes("Firefox")) {
if (ua.includes("Windows")) return "Firefox on Windows";
if (ua.includes("Mac")) return "Firefox on macOS";
return "Firefox";
}
if (ua.includes("Safari") && !ua.includes("Chrome")) {
if (ua.includes("iPhone") || ua.includes("iPad")) return "Safari on iOS";
return "Safari on macOS";
}
if (ua.includes("Edge")) return "Edge on Windows";
return "Unknown";
}