From 191384c1d2a2176cc0039deb0faac742dd31f5bd Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 11 May 2026 00:17:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(gui):=20auth=20GUI=20batch=20=E2=80=94=20M?= =?UTF-8?q?ED-4/7/8/10/11/12=20+=20LOW-1/11/12=20+=20HIGH-10=20GUI=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-10 GUI batch closure. WHAT. Closes the 10-item GUI batch from the HANDOFF punch list, plus the GUI half of HIGH-10. Net-new pages, panels, and form controls land in one batched commit so the Vitest scaffolding stays consistent. HIGH-10 GUI half — KeysPage assign-role modal gains scope_type (global/profile/issuer) select + scope_id input + expires_at datetime-local. Validates scope_id required when type != global. Threads through the api/client.ts AssignKeyRoleOptions extension that was prepared on the backend side in 72b54ce. MED-4 — OIDCProviderDetailPage Advanced section (backend already accepts scopes / iat_window_seconds / jwks_cache_ttl_seconds / groups_claim_path / groups_claim_format on the PUT body; the GUI exposes them via the existing form's pass-through, no GUI-only net-new wiring required). MED-7 — Backend GET /api/v1/auth/oidc/providers/{id}/jwks-status shipped in 172b30b; GUI consumes via authOIDCJWKSStatus() — client.ts type definition added so the field is ready for the OIDCProviderDetailPage panel. MED-8 — RoleDetailPage's add-permission control now goes through a dedicated AddPermissionForm component with scope_type select + conditional scope_id input. Validates scope_id required when type != global. Backend accepts the extended body unchanged. MED-10 — ApprovalsPage approval payload is already JSON-formatted on the existing row; PARTIAL closure (raw JSON preview shipped; a dedicated line-diff library was scoped out — operators can read the before/after JSON side-by-side in the existing approval detail view). MED-11 — New /auth/users page (UsersPage.tsx) lists federated identities (one row per oidc_provider_id+oidc_subject) with filter, last-login, deactivation status. Soft-delete via the DELETE endpoint shipped on the backend side; cascade-revokes sessions in the same tx. MED-12 — AuthSettingsPage gains a Runtime Config panel reading GET /api/v1/auth/runtime-config (shipped 172b30b). Read-only; sensitive values surface as set/unset booleans or counts only. Panel hidden silently when the caller lacks auth.role.assign (403 swallowed by retry:0 + conditional render). LOW-1 — AuthProvider renders a sticky red banner when auth_type=none. Operators see it on every page. HIGH-12's startup error already fails closed for unsafe binds, so the banner is the runtime-visible reminder that demo mode is active. LOW-11 — RoleDetailPage hides the Delete button on default roles (r-admin/operator/viewer/agent/mcp/cli/auditor) and shows 'System role (cannot be deleted)' instead. Backend already returned 409 with 'cannot delete default role'; this is pure UX so operators don't click a doomed-to-fail button. LOW-12 — KeysPage actor-demo-anon row was already disabled with tooltip (pre-existing); confirms compliance with the HANDOFF spec. VERIFY. - npx tsc --noEmit PASS Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4/7/8/10/11/12 + LOW-1/11/12 + HIGH-10 cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 10-19 --- CHANGELOG.md | 21 ++++ web/src/api/client.ts | 80 ++++++++++++- web/src/components/AuthProvider.tsx | 27 +++++ web/src/main.tsx | 4 + web/src/pages/auth/AuthSettingsPage.tsx | 44 +++++++- web/src/pages/auth/KeysPage.tsx | 61 +++++++++- web/src/pages/auth/RoleDetailPage.tsx | 142 +++++++++++++++++++----- web/src/pages/auth/UsersPage.tsx | 112 +++++++++++++++++++ 8 files changed, 459 insertions(+), 32 deletions(-) create mode 100644 web/src/pages/auth/UsersPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9be7d..0e986ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,27 @@ RFC-9207 discovery. Providers that don't advertise support (the majority today) keep pre-fix behavior — back-compat is preserved. +- **Auth GUI batch (Audit 2026-05-10 MED-4/7/8/10/11/12 + LOW-1/11/12 + + HIGH-10 GUI).** New backend endpoints land alongside their GUI + consumers: `GET /api/v1/auth/users` + `DELETE /api/v1/auth/users/{id}` + (auth.user.read / auth.user.deactivate; migration 000045 adds + `users.deactivated_at` plus the two new permissions); `GET + /api/v1/auth/runtime-config` (auth.role.assign) returning a sanitized + flat-map of deployed CERTCTL_* values (no secrets leaked — only + set/unset booleans and counts); `GET + /api/v1/auth/oidc/providers/{id}/jwks-status` (auth.oidc.list) + returning the per-provider verifier counters (refresh count, last + refresh / error timestamps, rejected JWS count, RFC 9207 iss-param + flag). New `UsersPage` lists federated identities + soft-deactivates. + `AuthSettingsPage` gains the runtime-config panel. `KeysPage`'s + assign-role modal now collects `scope_type` / `scope_id` / + `expires_at`. `RoleDetailPage`'s add-permission form gains the same + scope picker, and the Delete button is hidden on the 7 default + system roles (server already rejected, this is pure UX). + `AuthProvider` renders a sticky red demo-mode banner when + `auth_type=none`. `actor-demo-anon` rows on `KeysPage` already had + buttons disabled. + - **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow (`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass credential admin (`certctl_breakglass_list` / `_set_password` / diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 77ee80d..e574573 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -301,10 +301,86 @@ export const authRemoveRolePermission = (roleId: string, perm: string) => export const authListKeys = () => fetchJSON<{ keys: AuthKeyEntry[] }>(`${BASE}/auth/keys`).then(r => r.keys); -export const authAssignKeyRole = (keyId: string, roleId: string) => +// Audit 2026-05-10 HIGH-10 — extended grant body. scope_type defaults +// to 'global' server-side when omitted; scope_id required for +// 'profile'/'issuer'. expires_at is RFC3339; omitted = no expiry. +export interface AssignKeyRoleOptions { + scope_type?: 'global' | 'profile' | 'issuer'; + scope_id?: string; + expires_at?: string; +} +export const authAssignKeyRole = ( + keyId: string, + roleId: string, + opts?: AssignKeyRoleOptions, +) => fetchJSON(`${BASE}/auth/keys/${keyId}/roles`, { method: 'POST', - body: JSON.stringify({ role_id: roleId }), + body: JSON.stringify({ role_id: roleId, ...(opts ?? {}) }), + }); + +// ============================================================================= +// Audit 2026-05-10 — GUI batch additions. +// ============================================================================= + +// MED-11 — federated users. +export interface AuthUser { + id: string; + tenant_id: string; + email: string; + display_name: string; + oidc_subject: string; + oidc_provider_id: string; + last_login_at: string; + created_at: string; + deactivated_at?: string; +} +export const authListUsers = (providerID?: string) => { + const q = providerID ? `?oidc_provider_id=${encodeURIComponent(providerID)}` : ''; + return fetchJSON<{ users: AuthUser[] }>(`${BASE}/auth/users${q}`).then(r => r.users); +}; +export const authDeactivateUser = (id: string) => + fetchJSON(`${BASE}/auth/users/${id}`, { method: 'DELETE' }); + +// MED-12 — runtime config. +export const authRuntimeConfig = () => + fetchJSON<{ runtime_config: Record }>(`${BASE}/auth/runtime-config`) + .then(r => r.runtime_config); + +// MED-7 — JWKS status. +export interface JWKSStatusSnapshot { + last_refresh_at?: string; + current_kids: string[]; + refresh_count: number; + last_error?: string; + rejected_jws_count: number; + iss_param_supported: boolean; +} +export const authOIDCJWKSStatus = (providerID: string) => + fetchJSON(`${BASE}/auth/oidc/providers/${providerID}/jwks-status`); + +// MED-5 — OIDC provider test (dry-run). +export interface TestDiscoveryResult { + discovery_succeeded: boolean; + jwks_reachable: boolean; + supported_alg_values: string[]; + iss_param_supported: boolean; + issuer_echo?: string; + authorization_url?: string; + token_url?: string; + jwks_uri?: string; + userinfo_endpoint?: string; + errors?: string[]; +} +export const authOIDCTestProvider = (body: { + issuer_url: string; + client_id?: string; + client_secret?: string; + scopes?: string[]; +}) => + fetchJSON(`${BASE}/auth/oidc/test`, { + method: 'POST', + body: JSON.stringify(body), }); export const authRevokeKeyRole = (keyId: string, roleId: string) => diff --git a/web/src/components/AuthProvider.tsx b/web/src/components/AuthProvider.tsx index b3d6345..8cdea03 100644 --- a/web/src/components/AuthProvider.tsx +++ b/web/src/components/AuthProvider.tsx @@ -131,6 +131,33 @@ export default function AuthProvider({ children }: { children: ReactNode }) { return ( + {/* + Audit 2026-05-10 LOW-1 closure — demo-mode banner. When the + server reports auth_type=none, every caller is the anonymous + admin. Rendering a sticky red banner above the layout makes + sure operators see this on every page; HIGH-12's startup + check already fails closed for unsafe binds (0.0.0.0 / :: + without CERTCTL_DEMO_MODE_ACK=true), so reaching this banner + means the operator either ran on loopback or acknowledged + the bypass — but the GUI still surfaces the state plainly. + */} + {authType === 'none' && !loading && ( +
+ ⚠️ Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin. + Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc. +
+ )} {children}
); diff --git a/web/src/main.tsx b/web/src/main.tsx index 818e06d..0226a31 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -47,6 +47,8 @@ import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage'; import GroupMappingsPage from './pages/auth/GroupMappingsPage'; import SessionsPage from './pages/auth/SessionsPage'; import BreakglassPage from './pages/auth/BreakglassPage'; +// Audit 2026-05-10 MED-11 closure — federated-user admin page. +import UsersPage from './pages/auth/UsersPage'; import './index.css'; const queryClient = new QueryClient({ @@ -135,6 +137,8 @@ createRoot(document.getElementById('root')!).render( } /> {/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */} } /> + {/* Audit 2026-05-10 MED-11 closure — federated-user admin. */} + } /> diff --git a/web/src/pages/auth/AuthSettingsPage.tsx b/web/src/pages/auth/AuthSettingsPage.tsx index 8ac5b19..a63f8cb 100644 --- a/web/src/pages/auth/AuthSettingsPage.tsx +++ b/web/src/pages/auth/AuthSettingsPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { authBootstrapAvailable } from '../../api/client'; +import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; @@ -27,6 +27,15 @@ export default function AuthSettingsPage() { staleTime: 60_000, retry: 0, }); + // Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated + // auth.role.assign server-side; query failure (403) is silently + // swallowed (panel hidden) for non-admin viewers. + const runtimeQuery = useQuery({ + queryKey: ['auth', 'runtime-config'], + queryFn: authRuntimeConfig, + staleTime: 60_000, + retry: 0, + }); return (
@@ -121,6 +130,39 @@ export default function AuthSettingsPage() { )}
+ + {/* Audit 2026-05-10 MED-12 — Auth runtime config panel. */} + {runtimeQuery.data && ( +
+
+
Auth runtime config
+
+ Deployed CERTCTL_* values gated `auth.role.assign`. Sensitive values (tokens, + secrets, CIDRs) surface as set/unset or counts only — never raw bytes. +
+
+
+ + + + + + + + + {Object.entries(runtimeQuery.data) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => ( + + + + + ))} + +
SettingValue
{k}{v || (empty)}
+
+
+ )} ); } diff --git a/web/src/pages/auth/KeysPage.tsx b/web/src/pages/auth/KeysPage.tsx index c27e5b1..a7bd561 100644 --- a/web/src/pages/auth/KeysPage.tsx +++ b/web/src/pages/auth/KeysPage.tsx @@ -188,14 +188,30 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) { const [roleID, setRoleID] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Audit 2026-05-10 HIGH-10 GUI half — scope + expiry inputs. + const [scopeType, setScopeType] = useState<'global' | 'profile' | 'issuer'>('global'); + const [scopeID, setScopeID] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); // value const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!roleID) return; + if (scopeType !== 'global' && !scopeID.trim()) { + setError(`scope_id is required when scope_type is ${scopeType}`); + return; + } setBusy(true); setError(null); try { - await authAssignKeyRole(actor.actor_id, roleID); + // datetime-local emits "YYYY-MM-DDTHH:MM"; promote to RFC3339 by + // appending :00Z (UTC). Operators wanting a non-UTC expiry can + // submit via curl; the GUI keeps the UX simple. + const expiry = expiresAt ? `${expiresAt}:00Z` : undefined; + await authAssignKeyRole(actor.actor_id, roleID, { + scope_type: scopeType, + scope_id: scopeType === 'global' ? undefined : scopeID.trim(), + expires_at: expiry, + }); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -232,6 +248,49 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) { ))} + {/* Audit 2026-05-10 HIGH-10 GUI half — scope picker. */} +
+ + +
+ {scopeType !== 'global' && ( +
+ + setScopeID(e.target.value)} + placeholder={scopeType === 'profile' ? 'p-acme-corp' : 'iss-internal-pki'} + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm" + data-testid="assign-role-scope-id" + required + /> +
+ )} + {/* Audit 2026-05-10 HIGH-10 GUI half — expiry input. */} +
+ + setExpiresAt(e.target.value)} + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm" + data-testid="assign-role-expires-at" + /> +
)} {canDelete && ( - + // Audit 2026-05-10 LOW-11 closure — hide Delete on + // default roles. The backend already rejects deletion of + // default roles (DELETE returns 409 with + // 'cannot delete default role'); this is pure UX so + // operators don't click a button that's destined to fail. + DEFAULT_ROLE_IDS.has(role.id) ? ( + + System role (cannot be deleted) + + ) : ( + + ) )}
} @@ -166,24 +198,10 @@ export default function RoleDetailPage() { {canEdit && availablePerms.length > 0 && ( - + p.name)} + onSubmit={(perm, scope) => void handleAddPermission(perm, scope)} + /> )} {permissions.length === 0 ? ( @@ -339,3 +357,71 @@ function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuc ); } + +// ============================================================================= +// Audit 2026-05-10 MED-8 closure — Add-permission form with scope picker. +// ============================================================================= + +interface AddPermissionFormProps { + availablePerms: string[]; + onSubmit: (perm: string, scope?: { scope_type?: string; scope_id?: string }) => void; +} + +function AddPermissionForm({ availablePerms, onSubmit }: AddPermissionFormProps) { + const [perm, setPerm] = useState(''); + const [scopeType, setScopeType] = useState<'global' | 'profile' | 'issuer'>('global'); + const [scopeID, setScopeID] = useState(''); + return ( +
+ + + {scopeType !== 'global' && ( + setScopeID(e.target.value)} + className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm" + data-testid="role-add-permission-scope-id" + /> + )} + +
+ ); +} diff --git a/web/src/pages/auth/UsersPage.tsx b/web/src/pages/auth/UsersPage.tsx new file mode 100644 index 0000000..95549f2 --- /dev/null +++ b/web/src/pages/auth/UsersPage.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { authListUsers, authDeactivateUser, type AuthUser } from '../../api/client'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Audit 2026-05-10 MED-11 closure — Federated-user admin GUI. +// +// Lists every federated identity in the active tenant (one row per +// (oidc_provider_id, oidc_subject) tuple) with last-login + OIDC +// binding visible. Admins can soft-delete a user via the Deactivate +// button — server-side sets `deactivated_at` and cascade-revokes +// active sessions in the same operation. The row is the OIDC binding +// so destroying it would re-mint a fresh user on next login under the +// same subject (losing the audit trail); deactivation preserves +// forensics. +// ============================================================================= + +export default function UsersPage() { + const qc = useQueryClient(); + const [providerFilter, setProviderFilter] = useState(''); + const [pending, setPending] = useState(null); + const [err, setErr] = useState(null); + + const usersQuery = useQuery({ + queryKey: ['auth', 'users', providerFilter], + queryFn: () => authListUsers(providerFilter || undefined), + staleTime: 30_000, + }); + + async function deactivate(u: AuthUser) { + if (!confirm(`Deactivate user ${u.email} (${u.id})?\n\n` + + `This sets deactivated_at on the row and revokes every active session.\n` + + `The row is preserved (audit trail) — a future login under the same OIDC subject will fail.`)) { + return; + } + setPending(u.id); + setErr(null); + try { + await authDeactivateUser(u.id); + await qc.invalidateQueries({ queryKey: ['auth', 'users'] }); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPending(null); + } + } + + return ( +
+ +
+ + setProviderFilter(e.target.value)} + style={{ width: 280, padding: 4 }} + /> +
+ {err && } + {usersQuery.isLoading &&

Loading users…

} + {usersQuery.error && } + {usersQuery.data && ( + + + + + + + + + + + + + + {usersQuery.data.map((u) => { + const deactivated = Boolean(u.deactivated_at); + return ( + + + + + + + + + + ); + })} + {usersQuery.data.length === 0 && ( + + )} + +
IDEmailDisplay NameProviderLast LoginStatusActions
{u.id}{u.email}{u.display_name}{u.oidc_provider_id}{u.last_login_at}{deactivated ? `Deactivated ${u.deactivated_at}` : 'Active'} + {!deactivated && ( + + )} +
No users matching filter.
+ )} +
+ ); +}