diff --git a/service/internal/api/router.go b/service/internal/api/router.go index df947f2..559b7ed 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -9,7 +9,7 @@ const defaultAllowedOrigin = "http://localhost:5173" func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin) - writer.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") if request.Method == http.MethodOptions { @@ -28,5 +28,7 @@ func NewRouter(configDir string) http.Handler { mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir)) mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir)) mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir)) + mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir)) + mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir)) return withCORS(mux) } diff --git a/service/internal/api/security_handlers.go b/service/internal/api/security_handlers.go new file mode 100644 index 0000000..76637af --- /dev/null +++ b/service/internal/api/security_handlers.go @@ -0,0 +1,98 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +const ( + minIdleTimeoutMinutes = 1 + maxIdleTimeoutMinutes = 240 // 4 hours + minSessionLifetimeHours = 1 + minReauthGraceMinutes = 0 +) + +type securityResponse struct { + Security settings.SecuritySettings `json:"security"` +} + +type putSecurityRequest struct { + Security settings.SecuritySettings `json:"security"` +} + +func securityGetHandler(configDir string) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + settingsPayload, err := loadSettingsOrDefault(configDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, securityResponse{ + Security: effectiveSecurity(settingsPayload.Security), + }) + } +} + +func securityPutHandler(configDir string) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + if err := authorizeSecurityWrite(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + var payload putSecurityRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + if err := validateSecuritySettings(payload.Security); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + + settingsPayload, err := loadSettingsOrDefault(configDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + settingsPayload.Security = payload.Security + if err := settings.SaveSettings(configDir, settingsPayload); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, securityResponse{Security: settingsPayload.Security}) + } +} + +func authorizeSecurityWrite(_ *http.Request) error { + // TODO: integrate authz middleware. + return nil +} + +func effectiveSecurity(security settings.SecuritySettings) settings.SecuritySettings { + if err := validateSecuritySettings(security); err != nil { + return settings.DefaultSecuritySettings() + } + return security +} + +func validateSecuritySettings(security settings.SecuritySettings) error { + if security.IdleTimeoutMinutes < minIdleTimeoutMinutes || + security.IdleTimeoutMinutes > maxIdleTimeoutMinutes { + return errors.New("idle_timeout_minutes must be between 1 and 240") + } + if security.SessionLifetimeHours < minSessionLifetimeHours { + return errors.New("session_lifetime_hours must be at least 1") + } + if security.ReauthGraceMinutes < minReauthGraceMinutes { + return errors.New("reauth_grace_minutes must be at least 0") + } + return nil +} diff --git a/service/internal/api/security_handlers_test.go b/service/internal/api/security_handlers_test.go new file mode 100644 index 0000000..e352810 --- /dev/null +++ b/service/internal/api/security_handlers_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type securityResponseTest struct { + Security settings.SecuritySettings `json:"security"` +} + +func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + router := NewRouter(configDir) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + var payload securityResponseTest + if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + + want := settings.DefaultSecuritySettings() + if payload.Security != want { + t.Fatalf("got %#v, want %#v", payload.Security, want) + } +} + +func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + router := NewRouter(configDir) + + body := []byte(`{ + "security": { + "idle_timeout_minutes": 0, + "session_lifetime_hours": 24, + "reauth_sensitive_actions": false, + "reauth_grace_minutes": 15 + } + }`) + request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code) + } +} + +func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + router := NewRouter(configDir) + + body := []byte(`{ + "security": { + "idle_timeout_minutes": 241, + "session_lifetime_hours": 24, + "reauth_sensitive_actions": false, + "reauth_grace_minutes": 15 + } + }`) + request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code) + } +} + +func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + router := NewRouter(configDir) + + body := []byte(`{ + "security": { + "idle_timeout_minutes": 30, + "session_lifetime_hours": 0, + "reauth_sensitive_actions": false, + "reauth_grace_minutes": 15 + } + }`) + request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code) + } +} + +func TestSecurityPutRoundTrip(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + router := NewRouter(configDir) + + body := []byte(`{ + "security": { + "idle_timeout_minutes": 45, + "session_lifetime_hours": 12, + "reauth_sensitive_actions": true, + "reauth_grace_minutes": 0, + "totp_enabled": true + } + }`) + putReq := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) + putRec := httptest.NewRecorder() + router.ServeHTTP(putRec, putReq) + + if putRec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, putRec.Code) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil) + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code) + } + + var payload securityResponseTest + if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + + want := settings.SecuritySettings{ + IdleTimeoutMinutes: 45, + SessionLifetimeHours: 12, + ReauthSensitiveActions: true, + ReauthGraceMinutes: 0, + TotpEnabled: true, + } + if payload.Security != want { + t.Fatalf("got %#v, want %#v", payload.Security, want) + } +} diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 1026867..ac644c4 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -18,10 +18,31 @@ type Group struct { Permissions []string `json:"permissions"` } +// SecuritySettings holds session and re-auth policy for the admin UI / auth middleware. +type SecuritySettings struct { + IdleTimeoutMinutes int `json:"idle_timeout_minutes"` + SessionLifetimeHours int `json:"session_lifetime_hours"` + ReauthSensitiveActions bool `json:"reauth_sensitive_actions"` + ReauthGraceMinutes int `json:"reauth_grace_minutes"` + TotpEnabled bool `json:"totp_enabled"` +} + +// DefaultSecuritySettings returns the built-in security defaults. +func DefaultSecuritySettings() SecuritySettings { + return SecuritySettings{ + IdleTimeoutMinutes: 30, + SessionLifetimeHours: 24, + ReauthSensitiveActions: false, + ReauthGraceMinutes: 15, + TotpEnabled: false, + } +} + // Settings holds plain (non-secret) base/CLI configuration. type Settings struct { - LogLevel string `json:"log_level"` - Groups []Group `json:"groups"` + LogLevel string `json:"log_level"` + Groups []Group `json:"groups"` + Security SecuritySettings `json:"security"` } // Secrets holds sensitive configuration encrypted at rest. diff --git a/webui/src/App.css b/webui/src/App.css index daf2c30..85854c4 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -52,6 +52,9 @@ } .sidebar-footer { + display: flex; + flex-direction: column; + gap: 0.25rem; padding: 0.75rem; border-top: 1px solid var(--ctp-surface0); } @@ -89,14 +92,23 @@ font-weight: 600; } -.config-item { +.config-item, +.profile-item { font-weight: 500; } -.cog-icon { +.cog-icon, +.user-icon { flex-shrink: 0; } +.profile-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; +} + .content { flex: 1; padding: 2rem 2.5rem; @@ -259,6 +271,13 @@ max-width: 40rem; } +.security-panel { + display: flex; + flex-direction: column; + gap: 0.9rem; + max-width: 40rem; +} + .groups-list { display: flex; flex-direction: column; diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 428c2d2..cbb071b 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -34,6 +34,21 @@ function stubFetchOk() { }) } + if (url.includes('/api/v1/security')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + security: { + idle_timeout_minutes: 30, + session_lifetime_hours: 24, + reauth_sensitive_actions: false, + reauth_grace_minutes: 15, + totp_enabled: false, + }, + }), + }) + } + return Promise.resolve({ ok: true, json: async () => ({}), @@ -95,6 +110,7 @@ describe('App', () => { expect( screen.getByRole('button', { name: 'Configuration' }), ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument() expect( screen.getByRole('heading', { name: 'Overview', level: 2 }), @@ -102,6 +118,38 @@ describe('App', () => { expect(screen.getByText('Coming soon')).toBeInTheDocument() }) + it('opens Profile from the sidebar and updates the URL', async () => { + const user = userEvent.setup() + stubFetchOk() + + render() + + await user.click(screen.getByRole('button', { name: 'Profile' })) + + expect( + screen.getByRole('heading', { name: 'Profile', level: 2 }), + ).toBeInTheDocument() + expect(window.location.pathname).toBe('/profile') + expect( + screen.getByText(/Your account settings will live here/i), + ).toBeInTheDocument() + }) + + it('restores Profile from the URL on load', async () => { + stubFetchOk() + window.history.replaceState(null, '', '/profile') + + render() + + expect( + screen.getByRole('heading', { name: 'Profile', level: 2 }), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Profile' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + it('switches the content panel when a nav item is selected', async () => { const user = userEvent.setup() stubFetchOk() @@ -215,9 +263,18 @@ describe('App', () => { 'aria-selected', 'true', ) + expect(await screen.findByLabelText('Idle timeout (minutes)')).toHaveValue( + 30, + ) + expect(screen.getByLabelText('Session lifetime (hours)')).toHaveValue(24) + expect(screen.getByLabelText('Enable TOTP')).not.toBeChecked() expect( - screen.getByText(/Service SSH keys and related credentials/i), - ).toBeInTheDocument() + screen.getByLabelText('Re-auth for sensitive actions'), + ).not.toBeChecked() + expect(screen.getByLabelText('Re-auth grace period (minutes)')).toHaveValue( + 15, + ) + expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled() expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument() await user.click(screen.getByRole('tab', { name: 'Integrations' })) @@ -228,6 +285,29 @@ describe('App', () => { expect(screen.getByText('Coming soon')).toBeInTheDocument() }) + it('rejects invalid idle timeout on Security save', async () => { + const user = userEvent.setup() + stubFetchOk() + + render() + + await user.click(screen.getByRole('button', { name: 'Configuration' })) + await user.click(screen.getByRole('tab', { name: 'Security' })) + + const idleInput = await screen.findByLabelText('Idle timeout (minutes)') + await user.clear(idleInput) + await user.type(idleInput, '0') + await user.click( + screen.getByRole('button', { name: 'Save security settings' }), + ) + + expect( + await screen.findByText( + /Idle timeout must be an integer between 1 and 240 minutes/i, + ), + ).toBeInTheDocument() + }) + it('shows unavailable when backend status fetch fails', async () => { vi.stubGlobal( 'fetch', diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8a79351..b940a12 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,10 +1,14 @@ import { useEffect, useState } from 'react' import { deleteGroup, + DEFAULT_SECURITY_SETTINGS, fetchGroups, + fetchSecurity, fetchStatus, type Group, type GroupScopeKind, + type SecuritySettings, + saveSecurity, upsertGroup, } from './api/client' import { @@ -34,6 +38,7 @@ const SECTION_TITLES: Record = { overview: 'Overview', vms: 'VMs', containers: 'Containers', + profile: 'Profile', configuration: 'Configuration', } @@ -77,6 +82,36 @@ function CogIcon() { ) } +function UserIcon() { + return ( + + ) +} + +function ProfilePanel() { + return ( +
+

+ Your account settings will live here — enroll TOTP when the + administrator has enabled it, and manage other profile details. +

+

Coming soon

+
+ ) +} + function useThemePreference() { const [themePreference, setThemePreference] = useState(readThemePreference) @@ -345,6 +380,259 @@ function GroupsConfigTab() { ) } +function validateSecurityDraft(security: SecuritySettings): string | null { + if ( + !Number.isInteger(security.idle_timeout_minutes) || + security.idle_timeout_minutes < 1 || + security.idle_timeout_minutes > 240 + ) { + return 'Idle timeout must be an integer between 1 and 240 minutes.' + } + if ( + !Number.isInteger(security.session_lifetime_hours) || + security.session_lifetime_hours < 1 + ) { + return 'Session lifetime must be an integer of at least 1 hour.' + } + if ( + !Number.isInteger(security.reauth_grace_minutes) || + security.reauth_grace_minutes < 0 + ) { + return 'Re-auth grace period must be an integer of at least 0 minutes.' + } + return null +} + +function parsePositiveIntegerInput(raw: string): number | null { + if (raw.trim() === '') { + return null + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return null + } + return parsed +} + +function SecurityConfigTab() { + const [draft, setDraft] = useState(DEFAULT_SECURITY_SETTINGS) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [saveSucceeded, setSaveSucceeded] = useState(false) + + useEffect(() => { + let isCancelled = false + + async function loadSecurity() { + setIsLoading(true) + setLoadError(null) + try { + const response = await fetchSecurity() + if (!isCancelled) { + setDraft(response.security) + } + } catch (err) { + if (!isCancelled) { + setLoadError( + err instanceof Error ? err.message : 'Failed to load security settings', + ) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void loadSecurity() + + return () => { + isCancelled = true + } + }, []) + + async function handleSave() { + setSubmitError(null) + setSaveSucceeded(false) + + const validationError = validateSecurityDraft(draft) + if (validationError) { + setSubmitError(validationError) + return + } + + try { + const response = await saveSecurity(draft) + setDraft(response.security) + setSaveSucceeded(true) + } catch (err) { + setSubmitError( + err instanceof Error ? err.message : 'Failed to save security settings', + ) + } + } + + if (isLoading) { + return

Loading…

+ } + + if (loadError) { + return ( +

+ {loadError} +

+ ) + } + + return ( +
+

+ Session and re-authentication policy for administrators. +

+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + {saveSucceeded ? ( +

+ Security settings saved. +

+ ) : null} + +
+ + { + const parsed = parsePositiveIntegerInput(event.target.value) + if (parsed === null) { + setDraft((prev) => ({ ...prev, idle_timeout_minutes: 0 })) + return + } + setDraft((prev) => ({ ...prev, idle_timeout_minutes: parsed })) + setSaveSucceeded(false) + }} + /> +

1–240 minutes (max 4 hours). Default 30.

+
+ +
+ + { + const parsed = parsePositiveIntegerInput(event.target.value) + if (parsed === null) { + setDraft((prev) => ({ ...prev, session_lifetime_hours: 0 })) + return + } + setDraft((prev) => ({ ...prev, session_lifetime_hours: parsed })) + setSaveSucceeded(false) + }} + /> +

At least 1 hour. Default 24.

+
+ +
+ +

+ When enabled, users will be able to use authenticator apps (enrollment + comes later). +

+
+ +
+ +
+ +
+ + { + const raw = event.target.value + if (raw.trim() === '') { + setDraft((prev) => ({ ...prev, reauth_grace_minutes: 0 })) + return + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return + } + setDraft((prev) => ({ ...prev, reauth_grace_minutes: parsed })) + setSaveSucceeded(false) + }} + /> +

+ Skip re-auth if done within this many minutes. 0 means re-auth for + every sensitive action. Default 15. +

+
+ + + +

+ Service SSH keys and related credentials will also live here later — + add keys with a description and an expiry time limit or non-expiry. +

+
+ ) +} + function ConfigTabPanelContent({ activeConfigTab, draftThemePreference, @@ -385,12 +673,7 @@ function ConfigTabPanelContent({ } if (activeConfigTab === 'security') { - return ( -

- Service SSH keys and related credentials will live here — add keys with - a description and an expiry time limit or non-expiry. -

- ) + return } return

Coming soon

@@ -484,9 +767,13 @@ function App() { const [activeConfigTab, setActiveConfigTab] = useState( initialLocation.configTab, ) + // Stub until auth provides the logged-in display name. + const [profileDisplayName] = useState(null) const [backendVersion, setBackendVersion] = useState('…') const { themePreference, updateThemePreference } = useThemePreference() + const profileLabel = profileDisplayName ?? 'Profile' + useEffect(() => { return subscribeToLocation((pathname) => { const location = parseLocation(pathname) @@ -561,6 +848,19 @@ function App() {
+