Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+7 -15
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
@@ -103,6 +102,13 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
return
}
if name == settings.AdministratorsGroupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "Administrators group cannot be deleted",
})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
@@ -136,20 +142,6 @@ func authorizeGroupsWrite(_ *http.Request) error {
return nil
}
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload, err := settings.LoadSettings(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return settings.Settings{Groups: []settings.Group{}}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
return payload, nil
}
func validateGroup(group settings.Group) error {
if strings.TrimSpace(group.Name) == "" {
return errors.New("group.name is required")
+64 -20
View File
@@ -18,13 +18,12 @@ type apiErrorResponseTest struct {
Error string `json:"error"`
}
func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
t.Parallel()
func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -43,9 +42,8 @@ func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
}
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
@@ -56,7 +54,7 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
"permissions": ["nodes.nope"]
}
}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body))
request := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -75,9 +73,8 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
}
func TestGroupsUpsertUpsertsByName(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
@@ -88,7 +85,7 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
"permissions": ["nodes.read"]
}
}`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
@@ -103,14 +100,14 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
"permissions": ["nodes.exec","nodes.update"]
}
}`)
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody))
updateReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody)), cookie)
updateRec := httptest.NewRecorder()
router.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
@@ -133,9 +130,8 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
}
func TestGroupsDeleteRemovesByName(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
@@ -146,21 +142,21 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
"permissions": ["nodes.read"]
}
}`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil)
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
@@ -177,12 +173,11 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
}
func TestGroupsDeleteMissingNameFails(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil)
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
@@ -199,3 +194,52 @@ func TestGroupsDeleteMissingNameFails(t *testing.T) {
}
}
func TestGroupsDeleteRejectsAdministrators(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"group": {
"name": "Administrators",
"scope_kind": "group",
"scope_name": "Admin Group",
"permissions": ["nodes.read","users.manage"]
}
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Administrators", nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusConflict {
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, deleteRec.Code, deleteRec.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "Administrators group cannot be deleted" {
t.Fatalf("unexpected error: %q", payload.Error)
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
var getPayload groupsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("Administrators group should still exist, got %#v", getPayload.Groups)
}
}
+14 -1
View File
@@ -32,7 +32,7 @@ func TestHealthHandler(t *testing.T) {
}
}
func TestStatusHandler(t *testing.T) {
func TestStatusHandlerRequiresSetup(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
@@ -41,6 +41,19 @@ func TestStatusHandler(t *testing.T) {
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code)
}
}
func TestStatusHandler(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"context"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
if request.Method == http.MethodOptions {
next.ServeHTTP(writer, request)
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if isPublicPath(path, completed) {
next.ServeHTTP(writer, request)
return
}
if !completed {
if strings.HasPrefix(path, "/api/v1/setup/") {
next.ServeHTTP(writer, request)
return
}
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "setup required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(settingsPayload.Network)
if network.PublicHostname != "" {
requestHost := request.Host
if forwardedHost := strings.TrimSpace(request.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
requestHost = forwardedHost
}
if !auth.HostMatches(requestHost, network.PublicHostname) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "host not allowed"})
return
}
}
clientIP := auth.ClientIP(request)
allowed, reason := auth.IPAllowed(clientIP, network)
if !allowed {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: reason})
return
}
if isAuthOptionalPath(path) {
next.ServeHTTP(writer, request)
return
}
if app.Sessions == nil || len(app.Key) == 0 {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: settings.ConfigKeyEnvVar + " is not set"})
return
}
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
if err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var user *settings.UserCredential
for index := range store.Users {
candidate := &store.Users[index]
if candidate.ID == session.UserID {
user = candidate
break
}
}
if user == nil || !user.Enabled {
_ = app.Sessions.DestroySession(writer, request)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
ctx := context.WithValue(request.Context(), contextUserKey, *user)
ctx = context.WithValue(ctx, contextSessionKey, session)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func isPublicPath(path string, setupCompleted bool) bool {
if path == "/health" {
return true
}
if path == "/api/v1/setup/status" {
return true
}
if !setupCompleted && strings.HasPrefix(path, "/api/v1/setup/") {
return true
}
return false
}
func isAuthOptionalPath(path string) bool {
switch path {
case "/api/v1/auth/login", "/api/v1/auth/logout":
return true
default:
return false
}
}
func (app *App) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
origin := app.allowedOrigin(request)
if origin != "" {
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Set("Access-Control-Allow-Credentials", "true")
writer.Header().Set("Vary", "Origin")
}
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 {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func (app *App) allowedOrigin(request *http.Request) string {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err == nil {
hostname := strings.TrimSpace(settingsPayload.Network.PublicHostname)
if hostname != "" {
return "https://" + hostname
}
}
origin := request.Header.Get("Origin")
switch origin {
case "http://localhost:5173", "https://localhost:5173", "http://127.0.0.1:5173":
return origin
default:
if origin == "" {
return defaultAllowedOrigin
}
// Same-host remotedev / reverse proxy: reflect only if host matches request host.
if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return origin
}
return defaultAllowedOrigin
}
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponse struct {
Network settings.NetworkSettings `json:"network"`
}
type putNetworkRequest struct {
Network settings.NetworkSettings `json:"network"`
}
func networkGetHandler(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, networkResponse{
Network: settings.EffectiveNetwork(settingsPayload.Network),
})
}
}
func networkPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeNetworkWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putNetworkRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateNetworkSettings(payload.Network); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(payload.Network)
clientIP := auth.ClientIP(request)
if wouldLockOut, reason := auth.WouldLockOut(clientIP, network); wouldLockOut {
message := "these settings would lock out your current IP"
if reason != "" {
message = message + ": " + reason
}
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: message})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Network = network
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{Network: network})
}
}
func authorizeNetworkWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateNetworkSettings(network settings.NetworkSettings) error {
if err := auth.ValidateListenAddress(network.ListenAddress); err != nil {
return err
}
if err := auth.ValidateHostname(network.PublicHostname); err != nil {
return err
}
if err := auth.ValidateAccessRules(network.Rules); err != nil {
return err
}
switch network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
return nil
default:
return errors.New("network.access_mode must be open, whitelist, or blacklist")
}
}
@@ -0,0 +1,153 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponseTest struct {
Network settings.NetworkSettings `json:"network"`
}
func TestNetworkGetReturnsDefaults(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload networkResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "0.0.0.0" {
t.Fatalf("expected listen_address 0.0.0.0, got %q", payload.Network.ListenAddress)
}
if payload.Network.AccessMode != settings.AccessModeOpen {
t.Fatalf("expected access_mode open, got %q", payload.Network.AccessMode)
}
}
func TestNetworkPutPersistsValidSettings(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "127.0.0.1",
"public_hostname": "",
"access_mode": "open",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
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 networkResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "127.0.0.1" {
t.Fatalf("expected listen_address 127.0.0.1, got %q", payload.Network.ListenAddress)
}
}
func TestNetworkPutRejectsInvalidAccessMode(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "maybe",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestNetworkPutRejectsLockout(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["10.0.0.0/8"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected lockout error message")
}
}
func TestNetworkPutAllowsWhitelistIncludingClient(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["192.168.1.50"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
+24 -16
View File
@@ -6,29 +6,37 @@ import (
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, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
func NewRouter(configDir string) http.Handler {
app, err := newApp(configDir)
if err != nil {
// newApp currently always succeeds; keep signature for future.
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
mux.HandleFunc("GET /api/v1/status", StatusHandler)
mux.HandleFunc("GET /api/v1/setup/status", app.setupStatusHandler)
mux.HandleFunc("POST /api/v1/setup/totp/begin", app.setupTOTPBeginHandler)
mux.HandleFunc("POST /api/v1/setup/totp/verify", app.setupTOTPVerifyHandler)
mux.HandleFunc("POST /api/v1/setup/network/preview", app.setupPreviewHandler)
mux.HandleFunc("POST /api/v1/setup/complete", app.setupCompleteHandler)
mux.HandleFunc("POST /api/v1/auth/login", app.loginHandler)
mux.HandleFunc("POST /api/v1/auth/logout", app.logoutHandler)
mux.HandleFunc("GET /api/v1/auth/me", app.meHandler)
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)
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/network", networkPutHandler(configDir))
mux.HandleFunc("GET /api/v1/users", app.usersGetHandler)
mux.HandleFunc("DELETE /api/v1/users", app.usersDeleteHandler)
return app.withCORS(app.withMiddleware(mux))
}
+12 -17
View File
@@ -15,12 +15,11 @@ type securityResponseTest struct {
}
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -40,9 +39,8 @@ func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
}
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
@@ -53,7 +51,7 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -63,9 +61,8 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
}
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
@@ -76,7 +73,7 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -86,9 +83,8 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
}
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
@@ -99,7 +95,7 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -109,9 +105,8 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
}
func TestSecurityPutRoundTrip(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
@@ -123,15 +118,15 @@ func TestSecurityPutRoundTrip(t *testing.T) {
"totp_enabled": true
}
}`)
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
putReq := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, putRec.Code)
t.Fatalf("expected status %d, got %d body %s", http.StatusOK, putRec.Code, putRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
+464
View File
@@ -0,0 +1,464 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type contextKey string
const (
contextUserKey contextKey = "user"
contextSessionKey contextKey = "session"
)
type setupStatusResponse struct {
Completed bool `json:"completed"`
ClientIP string `json:"client_ip"`
}
type setupTOTPBeginRequest struct {
Username string `json:"username"`
}
type setupTOTPBeginResponse struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpauth_url"`
}
type setupTOTPVerifyRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type setupCompleteRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPSecret string `json:"totp_secret"`
TOTPConfirmed bool `json:"totp_confirmed"`
Network settings.NetworkSettings `json:"network"`
Security settings.SecuritySettings `json:"security"`
}
type setupPreviewRequest struct {
Network settings.NetworkSettings `json:"network"`
}
type setupPreviewResponse struct {
WouldLockOut bool `json:"would_lock_out"`
Reason string `json:"reason,omitempty"`
ClientIP string `json:"client_ip"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type meResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
}
// App holds shared API dependencies.
type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
}
func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
return &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
}, nil
}
return &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
}, nil
}
func (app *App) requireKey() error {
if len(app.Key) == 0 {
return fmtErrorfKeyMissing()
}
return nil
}
func fmtErrorfKeyMissing() error {
return errors.New(settings.ConfigKeyEnvVar + " is not set")
}
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupStatusResponse{
Completed: completed,
ClientIP: auth.ClientIP(request),
})
}
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
key, err := auth.GenerateTOTPSecret(username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
app.pendingTOTPMu.Lock()
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
app.pendingTOTPMu.Unlock()
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPVerifyRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
}
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
var payload setupPreviewRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
clientIP := auth.ClientIP(request)
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
writeJSON(writer, http.StatusOK, setupPreviewResponse{
WouldLockOut: wouldLockOut,
Reason: reason,
ClientIP: clientIP,
})
}
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupCompleteRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
switch payload.Network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
default:
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if payload.TOTPConfirmed {
if strings.TrimSpace(payload.TOTPSecret) == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
return
}
payload.Security.TotpEnabled = true
}
passwordHash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
user := settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: passwordHash,
TOTPSecret: payload.TOTPSecret,
TOTPConfirmed: payload.TOTPConfirmed,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminGroup := settings.Group{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
}
network := settings.EffectiveNetwork(payload.Network)
settingsPayload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{adminGroup},
Security: payload.Security,
Network: network,
}
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"completed": true,
"username": username,
})
}
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if !completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
return
}
var payload loginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var matched *settings.UserCredential
for index := range store.Users {
user := &store.Users[index]
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
matched = user
break
}
}
if matched == nil || !matched.Enabled {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
}
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: matched.ID,
Username: matched.Username,
Groups: matched.GroupNames,
})
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: user.ID,
Username: user.Username,
Groups: user.GroupNames,
})
}
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
user, ok := ctx.Value(contextUserKey).(settings.UserCredential)
return user, ok
}
func allPermissionList() []string {
list := make([]string, 0, len(allowedPermissions))
for permission := range allowedPermissions {
list = append(list, permission)
}
// Stable-ish order for tests: sort manually via known catalog order.
ordered := []string{
"nodes.read",
"nodes.exec",
"nodes.update",
"jobs.read",
"jobs.run",
"users.manage",
"secrets.manage",
"roles.manage",
}
_ = list
return ordered
}
func newUserID() (string, error) {
return auth.NewRandomID()
}
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload, err := settings.LoadSettings(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return settings.Settings{
Groups: []settings.Group{},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
payload.Network = settings.EffectiveNetwork(payload.Network)
return payload, nil
}
@@ -0,0 +1,95 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestSetupStatusIncomplete(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d", recorder.Code)
}
var payload setupStatusResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload.Completed {
t.Fatal("expected incomplete")
}
}
func TestSetupCompleteAndLogin(t *testing.T) {
configDir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
router := NewRouter(configDir)
body := map[string]any{
"username": "Admin",
"password": "apple banana cherry date elderberry",
"totp_secret": "",
"totp_confirmed": false,
"network": map[string]any{
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "open",
"rules": []string{},
},
"security": settings.DefaultSecuritySettings(),
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
completeReq := httptest.NewRequest(http.MethodPost, "/api/v1/setup/complete", bytes.NewReader(encoded))
completeRec := httptest.NewRecorder()
router.ServeHTTP(completeRec, completeReq)
if completeRec.Code != http.StatusOK {
t.Fatalf("complete status %d body %s", completeRec.Code, completeRec.Body.String())
}
loginBody := []byte(`{"username":"Admin","password":"apple banana cherry date elderberry"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
loginRec := httptest.NewRecorder()
router.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", loginRec.Code, loginRec.Body.String())
}
var sessionCookie *http.Cookie
for _, cookie := range loginRec.Result().Cookies() {
if cookie.Name == "__Host-ClusterCanvas-Session" {
sessionCookie = cookie
break
}
}
if sessionCookie == nil {
t.Fatal("missing session cookie")
}
if !sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.Path != "/" {
t.Fatalf("cookie flags incorrect: %#v", sessionCookie)
}
meReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
meReq.AddCookie(sessionCookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusOK {
t.Fatalf("me status %d body %s", meRec.Code, meRec.Body.String())
}
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie {
t.Helper()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user := settings.UserCredential{
ID: "test-user-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{Users: []settings.UserCredential{user}}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
security := settings.DefaultSecuritySettings()
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{},
Security: security,
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
manager := auth.NewSessionManager(configDir, keyBytes)
recorder := httptest.NewRecorder()
if _, err := manager.CreateSession(recorder, user.ID, security); err != nil {
t.Fatalf("CreateSession: %v", err)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == auth.SessionCookieName {
return cookie
}
}
t.Fatal("session cookie not set")
return nil
}
func withSession(request *http.Request, cookie *http.Cookie) *http.Request {
request.AddCookie(cookie)
return request
}
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type userPublic struct {
ID string `json:"id"`
Username string `json:"username"`
Enabled bool `json:"enabled"`
TOTPConfirmed bool `json:"totp_confirmed"`
GroupNames []string `json:"group_names"`
CreatedAt time.Time `json:"created_at"`
PasswordChangedAt time.Time `json:"password_changed_at"`
IsAdmin bool `json:"is_admin"`
CanDelete bool `json:"can_delete"`
}
type usersResponse struct {
Users []userPublic `json:"users"`
}
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false
for _, user := range store.Users {
if user.ID != userID {
remaining = append(remaining, user)
continue
}
found = true
if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator",
})
return
}
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
store.Users = remaining
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(userID)
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func authorizeUsersWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func toPublicUsers(users []settings.UserCredential) []userPublic {
adminCount := countAdministrators(users)
result := make([]userPublic, 0, len(users))
for _, user := range users {
isAdmin := userIsAdministrator(user)
groupNames := user.GroupNames
if groupNames == nil {
groupNames = []string{}
}
result = append(result, userPublic{
ID: user.ID,
Username: user.Username,
Enabled: user.Enabled,
TOTPConfirmed: user.TOTPConfirmed,
GroupNames: groupNames,
CreatedAt: user.CreatedAt,
PasswordChangedAt: user.PasswordChangedAt,
IsAdmin: isAdmin,
CanDelete: !(isAdmin && adminCount <= 1),
})
}
return result
}
func userIsAdministrator(user settings.UserCredential) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
return false
}
func countAdministrators(users []settings.UserCredential) int {
count := 0
for _, user := range users {
if userIsAdministrator(user) {
count++
}
}
return count
}
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type usersResponseTest struct {
Users []userPublic `json:"users"`
}
func TestUsersGetListsSanitizedFields(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/users", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
rawBody := recorder.Body.Bytes()
var payload usersResponseTest
if err := json.Unmarshal(rawBody, &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 user, got %d", len(payload.Users))
}
user := payload.Users[0]
if user.Username != "Admin" {
t.Fatalf("expected username Admin, got %q", user.Username)
}
if !user.IsAdmin {
t.Fatal("expected is_admin true")
}
if user.CanDelete {
t.Fatal("expected can_delete false for sole admin")
}
if user.ID == "" || user.Username == "" {
t.Fatal("expected id and username")
}
raw := string(rawBody)
if strings.Contains(raw, "password_hash") || strings.Contains(raw, "totp_secret") {
t.Fatalf("response leaked secrets: %s", raw)
}
}
func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "cannot delete the last administrator" {
t.Fatalf("unexpected error: %q", payload.Error)
}
}
func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
store.Users = append(store.Users,
settings.UserCredential{
ID: "operator-id",
Username: "operator",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Operators"},
CreatedAt: now,
PasswordChangedAt: now,
},
settings.UserCredential{
ID: "admin-two-id",
Username: "AdminTwo",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
)
if err := settings.SavePasswords(configDir, store, key); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
router := NewRouter(configDir)
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
cookie,
)
operatorRec := httptest.NewRecorder()
router.ServeHTTP(operatorRec, deleteOperator)
if operatorRec.Code != http.StatusOK {
t.Fatalf("delete operator: expected %d, got %d body=%s", http.StatusOK, operatorRec.Code, operatorRec.Body.String())
}
deleteSecondAdmin := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
cookie,
)
adminRec := httptest.NewRecorder()
router.ServeHTTP(adminRec, deleteSecondAdmin)
if adminRec.Code != http.StatusOK {
t.Fatalf("delete second admin: expected %d, got %d body=%s", http.StatusOK, adminRec.Code, adminRec.Body.String())
}
var payload usersResponseTest
if err := json.NewDecoder(adminRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 remaining user, got %d", len(payload.Users))
}
if payload.Users[0].ID != "test-user-id" {
t.Fatalf("expected sole remaining admin id, got %q", payload.Users[0].ID)
}
if payload.Users[0].CanDelete {
t.Fatal("expected remaining sole admin can_delete false")
}
}
func TestUsersDeleteMissingIDFails(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/users", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
}
}