From 885b470f09771904f8c4aaba28f2ccca3ca7a017 Mon Sep 17 00:00:00 2001 From: Lukas Kohlmaier Date: Sun, 10 May 2026 04:10:21 +0200 Subject: [PATCH 1/3] feat(ci-oidc): admin UI for CI OIDC provider and identity mapping management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated CI OIDC management interface under Settings → SSO → CI Providers, allowing administrators to configure keyless CI/CD authentication without storing static secrets. Types (src/types/ci-oidc.ts): - CiOidcProvider, CiOidcIdentityMapping, and their request/response types matching the backend API schema API client (src/lib/api/ci-oidc.ts): - CRUD helpers for providers and identity mappings - Toggle enable/disable for both providers and individual mappings Pages: - src/app/(app)/(admin)/settings/sso/ci/page.tsx Full management page: provider list, create/edit/delete providers, nested identity mapping management with claim-filter editing, priority ordering, and role assignment - src/app/(app)/(admin)/settings/sso/page.tsx Extended SSO settings index with CI Providers tab alongside existing OIDC SSO configuration --- .../(app)/(admin)/settings/sso/ci/page.tsx | 913 ++++++++++++++++++ src/app/(app)/(admin)/settings/sso/page.tsx | 8 + src/lib/api/ci-oidc.ts | 110 +++ src/types/ci-oidc.ts | 89 ++ 4 files changed, 1120 insertions(+) create mode 100644 src/app/(app)/(admin)/settings/sso/ci/page.tsx create mode 100644 src/lib/api/ci-oidc.ts create mode 100644 src/types/ci-oidc.ts diff --git a/src/app/(app)/(admin)/settings/sso/ci/page.tsx b/src/app/(app)/(admin)/settings/sso/ci/page.tsx new file mode 100644 index 00000000..58b4ad78 --- /dev/null +++ b/src/app/(app)/(admin)/settings/sso/ci/page.tsx @@ -0,0 +1,913 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Plus, + Pencil, + Trash2, + ToggleLeft, + ToggleRight, + Loader2, + GitBranch, + Cpu, + ChevronDown, + ChevronRight, + Filter, + ShieldCheck, +} from "lucide-react"; + +import { ciOidcApi } from "@/lib/api/ci-oidc"; +import { mutationErrorToast } from "@/lib/error-utils"; +import type { + CiOidcProvider, + CiOidcIdentityMapping, + CiOidcProviderType, + ClaimFilters, + CreateCiOidcProviderRequest, + UpdateCiOidcProviderRequest, + CreateCiOidcMappingRequest, + UpdateCiOidcMappingRequest, +} from "@/types/ci-oidc"; + +import { PageHeader } from "@/components/common/page-header"; +import { StatusBadge } from "@/components/common/status-badge"; +import { ConfirmDialog } from "@/components/common/confirm-dialog"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +// --------------------------------------------------------------------------- +// Provider type helpers +// --------------------------------------------------------------------------- + +const PROVIDER_TYPE_LABELS: Record = { + gitlab: "GitLab", + github: "GitHub Actions", + generic: "Generic OIDC", +}; + +const DEFAULT_ISSUER: Record = { + gitlab: "https://gitlab.com", + github: "https://token.actions.githubusercontent.com", + generic: "", +}; + +const CLAIM_PLACEHOLDER: Record = { + gitlab: `{\n "namespace_path": "my-org/my-group",\n "ref_protected": "true"\n}`, + github: `{\n "repository": "my-org/my-repo",\n "ref": "refs/heads/main"\n}`, + generic: `{\n "sub": "system:serviceaccount:prod"\n}`, +}; + +function ProviderTypeIcon({ type }: { type: string }) { + if (type === "gitlab" || type === "github") + return ; + return ; +} + +// --------------------------------------------------------------------------- +// Provider form +// --------------------------------------------------------------------------- + +const BLANK_PROVIDER_FORM = { + name: "", + provider_type: "generic" as CiOidcProviderType, + issuer_url: "", + audience: "artifact-keeper", +}; +type ProviderForm = typeof BLANK_PROVIDER_FORM; + +function providerFormFromRow(p: CiOidcProvider): ProviderForm { + return { + name: p.name, + provider_type: p.provider_type, + issuer_url: p.issuer_url, + audience: p.audience, + }; +} + +// --------------------------------------------------------------------------- +// Mapping form +// --------------------------------------------------------------------------- + +const BLANK_MAPPING_FORM = { + name: "", + priority: "100", + claim_filters_raw: "", + role_id: "", + allowed_repo_ids_raw: "", + is_enabled: true, +}; +type MappingForm = typeof BLANK_MAPPING_FORM; + +function mappingFormFromRow(m: CiOidcIdentityMapping): MappingForm { + return { + name: m.name, + priority: String(m.priority), + claim_filters_raw: JSON.stringify(m.claim_filters, null, 2), + role_id: m.role_id ?? "", + allowed_repo_ids_raw: m.allowed_repo_ids ? m.allowed_repo_ids.join("\n") : "", + is_enabled: m.is_enabled, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseClaimFilters(raw: string): ClaimFilters | undefined { + const t = raw.trim(); + if (!t) return {}; + try { + return JSON.parse(t) as ClaimFilters; + } catch { + return undefined; + } +} + +function parseRepoIds(raw: string): string[] | null { + const ids = raw + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean); + return ids.length ? ids : null; +} + +function claimFilterSummary(filters: ClaimFilters): string { + const keys = Object.keys(filters); + if (!keys.length) return "No filters (any JWT accepted)"; + return keys + .map((k) => { + const v = filters[k]; + return Array.isArray(v) ? `${k} ∈ [${v.join(", ")}]` : `${k} = ${v}`; + }) + .join(", "); +} + +// --------------------------------------------------------------------------- +// Mappings sub-panel +// --------------------------------------------------------------------------- + +interface MappingsPanelProps { + provider: CiOidcProvider; +} + +function MappingsPanel({ provider }: MappingsPanelProps) { + const queryClient = useQueryClient(); + const qKey = ["ci-oidc-mappings", provider.id]; + + const { data: mappings, isLoading } = useQuery({ + queryKey: qKey, + queryFn: () => ciOidcApi.listMappings(provider.id), + }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [form, setForm] = useState(BLANK_MAPPING_FORM); + const [filtersError, setFiltersError] = useState(null); + + function invalidate() { + queryClient.invalidateQueries({ queryKey: qKey }); + queryClient.invalidateQueries({ queryKey: ["ci-oidc"] }); + } + + const createMutation = useMutation({ + mutationFn: (req: CreateCiOidcMappingRequest) => + ciOidcApi.createMapping(provider.id, req), + onSuccess: () => { + invalidate(); + toast.success("Mapping created"); + closeDialog(); + }, + onError: mutationErrorToast("Failed to create mapping"), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, req }: { id: string; req: UpdateCiOidcMappingRequest }) => + ciOidcApi.updateMapping(provider.id, id, req), + onSuccess: () => { + invalidate(); + toast.success("Mapping updated"); + closeDialog(); + }, + onError: mutationErrorToast("Failed to update mapping"), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => ciOidcApi.deleteMapping(provider.id, id), + onSuccess: () => { + invalidate(); + toast.success("Mapping deleted"); + setDeleteTarget(null); + }, + onError: mutationErrorToast("Failed to delete mapping"), + }); + + const toggleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + ciOidcApi.toggleMapping(provider.id, id, { enabled }), + onSuccess: () => invalidate(), + onError: mutationErrorToast("Failed to toggle mapping"), + }); + + function closeDialog() { + setDialogOpen(false); + setEditTarget(null); + setForm(BLANK_MAPPING_FORM); + setFiltersError(null); + } + + function openCreate() { + setEditTarget(null); + setForm({ + ...BLANK_MAPPING_FORM, + claim_filters_raw: CLAIM_PLACEHOLDER[provider.provider_type] ?? "", + }); + setFiltersError(null); + setDialogOpen(true); + } + + function openEdit(m: CiOidcIdentityMapping) { + setEditTarget(m); + setForm(mappingFormFromRow(m)); + setFiltersError(null); + setDialogOpen(true); + } + + function setField(key: K, value: MappingForm[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function handleSubmit() { + setFiltersError(null); + const filters = parseClaimFilters(form.claim_filters_raw); + if (filters === undefined) { + setFiltersError("Invalid JSON — please fix claim_filters before saving."); + return; + } + const priority = parseInt(form.priority, 10); + if (isNaN(priority)) { + setFiltersError("Priority must be a number."); + return; + } + + const payload = { + name: form.name, + priority, + claim_filters: filters, + role_id: form.role_id.trim() || null, + allowed_repo_ids: parseRepoIds(form.allowed_repo_ids_raw), + is_enabled: form.is_enabled, + }; + + if (editTarget) { + updateMutation.mutate({ id: editTarget.id, req: payload }); + } else { + createMutation.mutate(payload as CreateCiOidcMappingRequest); + } + } + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+ + Identity Mappings + + +
+ + {isLoading ? ( +
+ + +
+ ) : mappings && mappings.length > 0 ? ( + + + + Priority + Name + Claim Filters + Role + Status + Actions + + + + {mappings.map((m) => ( + + + + {m.priority} + + + {m.name} + + {claimFilterSummary(m.claim_filters)} + + + {m.role_id ? ( + m.role_id.slice(0, 8) + "…" + ) : ( + none + )} + + + + + +
+ + + +
+
+
+ ))} +
+
+ ) : ( +
+ + No identity mappings yet. Add one to allow pipelines to authenticate. +
+ )} + + {/* Mapping Create/Edit Dialog */} + !open && closeDialog()}> + + + + {editTarget ? "Edit Identity Mapping" : "Add Identity Mapping"} + + + Mappings are evaluated in priority order (lowest number first). + The first enabled mapping whose claim filters all match the CI JWT + wins. + + + +
+
+ + setField("name", e.target.value)} + /> +
+ +
+ + setField("priority", e.target.value)} + /> +

+ Lower = evaluated first. Use 10, 20, 30 … to leave room for + reordering. +

+
+ +
+ +