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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user