Files
gscMy/src/database/settings.ts
Super User be1c4fe5f9 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>
2026-05-18 13:46:13 +02:00

152 lines
4.4 KiB
TypeScript

import prisma from "./prisma";
export interface EffectiveSetting {
category: string;
key: string;
value: unknown;
source: "user" | "tenant" | "customer" | "default";
dataType: string;
description: string | null;
allowedValues: unknown;
}
/**
* Resolve effective settings using the 3-level cascade:
* definition default → customer override → tenant override → user override
*
* Only user-overridable settings are included.
*/
export async function getUserEffectiveSettings(
userId: string,
tenantId: string,
customerId: string | undefined,
categories: string[]
): Promise<Record<string, Record<string, EffectiveSetting>>> {
// 1. Fetch all definitions for requested categories
const definitions = await prisma.settingsDefinition.findMany({
where: { category: { in: categories }, allow_user_override: true },
orderBy: [{ category: "asc" }, { display_order: "asc" }],
});
// 2. Fetch customer overrides
const customerSettings = customerId
? await prisma.customerSetting.findMany({
where: {
customerId,
settingId: { in: definitions.map((d) => d.id) },
},
})
: [];
// 3. Fetch tenant overrides
const tenantSettings = await prisma.tenantSetting.findMany({
where: {
tenantId,
settingId: { in: definitions.map((d) => d.id) },
},
});
// 4. Fetch user overrides
const userSettings = await prisma.userSetting.findMany({
where: {
userId,
settingId: { in: definitions.map((d) => d.id) },
},
});
// Build lookup maps
const custMap = new Map(customerSettings.map((s) => [s.settingId, s]));
const tenantMap = new Map(tenantSettings.map((s) => [s.settingId, s]));
const userMap = new Map(userSettings.map((s) => [s.settingId, s]));
// 5. Resolve cascade
const result: Record<string, Record<string, EffectiveSetting>> = {};
for (const def of definitions) {
let value: unknown = def.defaultValue;
let source: EffectiveSetting["source"] = "default";
const custSetting = custMap.get(def.id);
if (custSetting) {
value = custSetting.value;
source = "customer";
}
const tenantSetting = tenantMap.get(def.id);
if (tenantSetting) {
// Only override if customer didn't mark as mandatory
if (!custSetting?.is_mandatory || custSetting.allow_tenant_override) {
value = tenantSetting.value;
source = "tenant";
}
}
const userSetting = userMap.get(def.id);
if (userSetting) {
// Only override if tenant didn't mark as mandatory
const canUserOverride = tenantSetting ? tenantSetting.allow_user_override !== false : true;
if (canUserOverride) {
value = userSetting.value;
source = "user";
}
}
if (!result[def.category]) result[def.category] = {};
result[def.category][def.key] = {
category: def.category,
key: def.key,
value,
source,
dataType: def.dataType,
description: def.description,
allowedValues: def.allowed_values,
};
}
return result;
}
/**
* Batch upsert user settings. Each entry is { category, key, value }.
*/
export async function batchUpsertUserSettings(
userId: string,
settings: Array<{ category: string; key: string; value: unknown }>
) {
// Look up setting definition IDs
const keys = settings.map((s) => ({ category: s.category, key: s.key }));
const definitions = await prisma.settingsDefinition.findMany({
where: {
OR: keys.map((k) => ({ category: k.category, key: k.key })),
},
select: { id: true, category: true, key: true },
});
const defMap = new Map(definitions.map((d) => [`${d.category}:${d.key}`, d.id]));
// Build upserts
const operations = settings
.map((s) => {
const settingId = defMap.get(`${s.category}:${s.key}`);
if (!settingId) return null;
return prisma.userSetting.upsert({
where: { userId_settingId: { userId, settingId } },
update: { value: s.value as never },
create: { userId, settingId, value: s.value as never },
});
})
.filter(Boolean) as ReturnType<typeof prisma.userSetting.upsert>[];
await prisma.$transaction(operations);
}
/**
* Get settings definitions for given categories.
*/
export async function getSettingsDefinitions(categories: string[]) {
return prisma.settingsDefinition.findMany({
where: { category: { in: categories } },
orderBy: [{ category: "asc" }, { display_order: "asc" }],
});
}