Add SSH-managed node registry with connection testing and reauth.

Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required")
// permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
groupByName := make(map[string]settings.Group, len(groups))
for _, group := range groups {
groupByName[group.Name] = group
}
result := make(map[string]struct{})
for _, groupName := range user.GroupNames {
group, ok := groupByName[groupName]
if !ok {
continue
}
for _, permission := range group.Permissions {
result[permission] = struct{}{}
}
}
return result
}
func userHasPermission(user settings.UserCredential, groups []settings.Group, permission string) bool {
_, ok := permissionsForUser(user, groups)[permission]
return ok
}
func permissionList(user settings.UserCredential, groups []settings.Group) []string {
set := permissionsForUser(user, groups)
ordered := allPermissionList()
result := make([]string, 0, len(set))
for _, permission := range ordered {
if _, ok := set[permission]; ok {
result = append(result, permission)
}
}
return result
}
func (app *App) authorizeUsersWrite(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "users.manage") {
return errUsersManageRequired
}
return nil
}
func (app *App) authorizeNodesRead(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.read") {
return errNodesReadRequired
}
return nil
}
func (app *App) authorizeNodesExec(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.exec") {
return errNodesExecRequired
}
return nil
}
+2 -4
View File
@@ -158,10 +158,8 @@ func validateGroup(group settings.Group) error {
return errors.New("group.scope_name is required")
}
if len(group.Permissions) == 0 {
return errors.New("group.permissions must be non-empty")
}
// Empty permissions are allowed so a newly scoped node group can be
// created first and roles assigned later in Configuration → Groups.
for _, permission := range group.Permissions {
if strings.TrimSpace(permission) == "" {
return errors.New("permission strings must be non-empty")
+17 -11
View File
@@ -18,7 +18,7 @@ type apiErrorResponseTest struct {
Error string `json:"error"`
}
func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
func TestGroupsGetIncludesAdministratorsFromSetup(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
@@ -36,8 +36,8 @@ func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
t.Fatalf("decode response: %v", err)
}
if payload.Groups == nil || len(payload.Groups) != 0 {
t.Fatalf("expected empty groups list, got %#v", payload.Groups)
if len(payload.Groups) != 1 || payload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("expected Administrators group from seed, got %#v", payload.Groups)
}
}
@@ -118,14 +118,20 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(getPayload.Groups))
if len(getPayload.Groups) != 2 {
t.Fatalf("expected 2 groups (Administrators + Admins), got %d", len(getPayload.Groups))
}
if getPayload.Groups[0].Name != "Admins" {
t.Fatalf("expected group name %q, got %q", "Admins", getPayload.Groups[0].Name)
var admins *settings.Group
for index := range getPayload.Groups {
if getPayload.Groups[index].Name == "Admins" {
admins = &getPayload.Groups[index]
}
}
if getPayload.Groups[0].ScopeName != "node-2" {
t.Fatalf("expected scope_name %q, got %q", "node-2", getPayload.Groups[0].ScopeName)
if admins == nil {
t.Fatal("expected Admins group")
}
if admins.ScopeName != "node-2" {
t.Fatalf("expected scope_name %q, got %q", "node-2", admins.ScopeName)
}
}
@@ -167,8 +173,8 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 0 {
t.Fatalf("expected empty groups, got %#v", getPayload.Groups)
if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("expected only Administrators remaining, got %#v", getPayload.Groups)
}
}
+323
View File
@@ -0,0 +1,323 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
TOTPCode string `json:"totp_code"`
}
type totpBeginRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
type totpConfirmRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
type totpDisableRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
func (app *App) reauthHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
session, ok := SessionFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
var payload reauthRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if err := verifyActorCredentials(user, payload.Password, payload.TOTPCode, security); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "sessions unavailable"})
return
}
if err := app.Sessions.MarkReauth(session.ID); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload changePasswordRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidatePassword(payload.NewPassword, user.Username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
hash, err := auth.HashPassword(payload.NewPassword)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, 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
}
found := false
now := time.Now().UTC()
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].PasswordHash = hash
store.Users[index].PasswordChangedAt = now
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
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(user.ID)
_ = app.Sessions.DestroySession(writer, request)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
var payload totpBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if !security.TotpEnabled {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
return
}
if user.TOTPConfirmed && user.TOTPSecret != "" {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is already enabled"})
return
}
key, err := auth.GenerateTOTPSecret(user.Username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload totpConfirmRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if !security.TotpEnabled {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
return
}
secret := strings.TrimSpace(payload.Secret)
if secret == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing secret"})
return
}
if !auth.VerifyTOTPCode(secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
found := false
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].TOTPSecret = secret
store.Users[index].TOTPConfirmed = true
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload totpDisableRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !user.TOTPConfirmed || user.TOTPSecret == "" {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled"})
return
}
// Always require password; require current TOTP code when enrolled.
okPassword, err := auth.VerifyPassword(payload.CurrentPassword, user.PasswordHash)
if err != nil || !okPassword {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
if !auth.VerifyTOTPCode(user.TOTPSecret, payload.TOTPCode) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
found := false
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].TOTPSecret = ""
store.Users[index].TOTPConfirmed = false
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
+1 -1
View File
@@ -136,7 +136,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
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-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions {
+460
View File
@@ -0,0 +1,460 @@
package api
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type nodesResponse struct {
Nodes []settings.Node `json:"nodes"`
}
type nodeResponse struct {
Node settings.Node `json:"node"`
}
type createNodeGenerate struct {
Algorithm string `json:"algorithm"`
RSABits int `json:"rsa_bits"`
KDFRounds int `json:"kdf_rounds"`
}
type createNodeNewGroup struct {
Name string `json:"name"`
}
type createNodeRequest struct {
ID string `json:"id"`
Kind settings.NodeKind `json:"kind"`
Name string `json:"name"`
HostIP string `json:"host_ip"`
Username string `json:"username"`
GroupName string `json:"group_name"`
NewGroup *createNodeNewGroup `json:"new_group"`
Generate *createNodeGenerate `json:"generate"`
PrivateKey string `json:"private_key"`
}
func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
filtered := make([]settings.Node, 0, len(store.Nodes))
for _, node := range store.Nodes {
if kindFilter != "" && string(node.Kind) != kindFilter {
continue
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
continue
}
filtered = append(filtered, node)
}
writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered})
}
func (app *App) nodesGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
for _, node := range store.Nodes {
if node.ID != nodeID {
continue
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
return
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
}
type nodeSSHTestResponse struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(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
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var node settings.Node
found := false
for _, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var keyEntry settings.NodeKeyEntry
keyFound := false
for _, entry := range keyStore.Keys {
if entry.NodeID == node.ID {
keyEntry = entry
keyFound = true
break
}
}
if !keyFound {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{
Error: "private key not found for node",
})
return
}
if err := auth.TestSSHConnection(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase); err != nil {
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: false,
Message: err.Error(),
})
return
}
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: true,
Message: "SSH connection succeeded",
})
}
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(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
}
var payload createNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
node, keyEntry, err := buildNodeFromCreateRequest(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if payload.NewGroup != nil {
groupName := strings.TrimSpace(payload.NewGroup.Name)
if groupName == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "new_group.name is required"})
return
}
for _, existing := range settingsPayload.Groups {
if existing.Name == groupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "group already exists"})
return
}
}
newGroup := settings.Group{
Name: groupName,
ScopeKind: settings.GroupScopeNode,
ScopeName: node.ID,
Permissions: []string{},
}
settingsPayload.Groups = append(settingsPayload.Groups, newGroup)
node.GroupName = groupName
} else {
groupName := strings.TrimSpace(payload.GroupName)
if groupName == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group_name is required"})
return
}
found := false
for _, existing := range settingsPayload.Groups {
if existing.Name == groupName {
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group not found"})
return
}
node.GroupName = groupName
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
for _, existing := range store.Nodes {
if existing.ID == node.ID {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node id already exists"})
return
}
if existing.Name == node.Name && existing.Kind == node.Kind {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node name already exists for this kind"})
return
}
}
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Nodes = append(store.Nodes, node)
keyStore.Keys = append(keyStore.Keys, keyEntry)
if payload.NewGroup != nil {
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
}
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusCreated, nodeResponse{Node: node})
}
func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) {
kind := payload.Kind
switch kind {
case settings.NodeKindContainer, settings.NodeKindVM, settings.NodeKindDocker:
// ok
default:
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("kind must be container, vm, or docker")
}
name := strings.TrimSpace(payload.Name)
if name == "" {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("name is required")
}
hostIP := strings.TrimSpace(payload.HostIP)
if hostIP == "" {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip is required")
}
if parsed := net.ParseIP(hostIP); parsed == nil {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip must be a valid IPv4 or IPv6 address")
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = auth.DefaultSSHUsername
}
nodeID := strings.TrimSpace(payload.ID)
if nodeID == "" {
generatedID, err := auth.NewUUID()
if err != nil {
return settings.Node{}, settings.NodeKeyEntry{}, err
}
nodeID = generatedID
} else if !isUUID(nodeID) {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("id must be a UUID")
}
var generated auth.GeneratedSSHKey
var err error
switch {
case payload.Generate != nil && strings.TrimSpace(payload.PrivateKey) != "":
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("provide either generate or private_key, not both")
case payload.Generate != nil:
generated, err = auth.GenerateSSHKey(
payload.Generate.Algorithm,
payload.Generate.RSABits,
payload.Generate.KDFRounds,
)
case strings.TrimSpace(payload.PrivateKey) != "":
generated, err = auth.ParseSSHPrivateKey(payload.PrivateKey)
default:
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("generate or private_key is required")
}
if err != nil {
return settings.Node{}, settings.NodeKeyEntry{}, err
}
now := time.Now().UTC()
node := settings.Node{
ID: nodeID,
Kind: kind,
Name: name,
HostIP: hostIP,
Username: username,
PublicKey: generated.PublicKey,
KeyAlgo: generated.Algorithm,
CreatedAt: now,
}
keyEntry := settings.NodeKeyEntry{
NodeID: nodeID,
PrivateKey: generated.PrivateKeyPEM,
Algorithm: generated.Algorithm,
RSABits: generated.RSABits,
KDFRounds: generated.KDFRounds,
}
return node, keyEntry, nil
}
func userCanAccessNode(user settings.UserCredential, groups []settings.Group, node settings.Node) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
for _, groupName := range user.GroupNames {
if groupName == node.GroupName {
return true
}
}
// Users with nodes.read via any group can list nodes they belong to above;
// Administrators already returned. Also allow if any of user's groups has
// scope_kind=node and scope_name matching this node id.
groupByName := make(map[string]settings.Group, len(groups))
for _, group := range groups {
groupByName[group.Name] = group
}
for _, groupName := range user.GroupNames {
group, ok := groupByName[groupName]
if !ok {
continue
}
if group.ScopeKind == settings.GroupScopeNode && group.ScopeName == node.ID {
return true
}
}
return false
}
func isUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, char := range value {
switch index {
case 8, 13, 18, 23:
if char != '-' {
return false
}
default:
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
return false
}
}
}
return true
}
+344
View File
@@ -0,0 +1,344 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestNodesCreateGeneratesEd25519AndStoresEncryptedKey(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := map[string]any{
"id": "11111111-2222-4333-8444-555555555555",
"kind": "container",
"name": "ct-alpha",
"host_ip": "192.168.10.20",
"username": "clustercanvas",
"group_name": settings.AdministratorsGroupName,
"generate": map[string]any{
"algorithm": "ed25519",
"kdf_rounds": 100,
},
}
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(payload)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.ID != "11111111-2222-4333-8444-555555555555" {
t.Fatalf("id = %q", response.Node.ID)
}
if response.Node.PublicKey == "" {
t.Fatal("expected public key")
}
if strings.Contains(recorder.Body.String(), "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("private key must not appear in API response")
}
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
keyStore, err := settings.LoadNodeKeys(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadNodeKeys: %v", err)
}
if len(keyStore.Keys) != 1 {
t.Fatalf("keys = %d", len(keyStore.Keys))
}
if keyStore.Keys[0].NodeID != response.Node.ID {
t.Fatalf("key node id = %q", keyStore.Keys[0].NodeID)
}
if !strings.Contains(keyStore.Keys[0].PrivateKey, "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("expected openssh private key in encrypted store")
}
if keyStore.Keys[0].KDFRounds != 100 {
t.Fatalf("kdf rounds = %d", keyStore.Keys[0].KDFRounds)
}
}
func TestNodesCreateRejectsWithoutNodesExec(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{
{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
},
{
Name: "Readers",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Readers",
Permissions: []string{"nodes.read"},
},
},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{
Users: []settings.UserCredential{
{
ID: "admin-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
{
ID: "reader-id",
Username: "reader",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Readers"},
CreatedAt: now,
PasswordChangedAt: now,
},
},
}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
router := NewRouter(configDir)
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/login",
bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)),
)
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String())
}
var readerCookie *http.Cookie
for _, candidate := range loginRecorder.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
readerCookie = candidate
}
}
if readerCookie == nil {
t.Fatal("missing session cookie")
}
createBody := []byte(`{
"kind":"container",
"name":"ct-beta",
"host_ip":"10.0.0.2",
"group_name":"Readers",
"generate":{"algorithm":"ed25519"}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
readerCookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestNodesCreateRSAAndListByKind(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"kind":"vm",
"name":"vm-one",
"host_ip":"10.0.0.8",
"group_name":"Administrators",
"generate":{"algorithm":"rsa","rsa_bits":2048}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String())
}
listRequest := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=vm", nil),
cookie,
)
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("list status = %d", listRecorder.Code)
}
var list nodesResponse
if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil {
t.Fatalf("decode: %v", err)
}
if len(list.Nodes) != 1 || list.Nodes[0].Name != "vm-one" {
t.Fatalf("nodes = %+v", list.Nodes)
}
if list.Nodes[0].KeyAlgo != "rsa" {
t.Fatalf("key algo = %q", list.Nodes[0].KeyAlgo)
}
emptyRequest := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=docker", nil),
cookie,
)
emptyRecorder := httptest.NewRecorder()
router.ServeHTTP(emptyRecorder, emptyRequest)
var emptyList nodesResponse
if err := json.Unmarshal(emptyRecorder.Body.Bytes(), &emptyList); err != nil {
t.Fatalf("decode: %v", err)
}
if len(emptyList.Nodes) != 0 {
t.Fatalf("expected no docker nodes, got %d", len(emptyList.Nodes))
}
}
func TestNodesCreateWithNewGroup(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"kind":"docker",
"name":"docker-host",
"host_ip":"10.1.1.1",
"new_group":{"name":"Docker Hosts"},
"generate":{"algorithm":"ed25519","kdf_rounds":64}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.GroupName != "Docker Hosts" {
t.Fatalf("group = %q", response.Node.GroupName)
}
settingsPayload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
found := false
for _, group := range settingsPayload.Groups {
if group.Name == "Docker Hosts" {
found = true
if group.ScopeKind != settings.GroupScopeNode {
t.Fatalf("scope kind = %q", group.ScopeKind)
}
if group.ScopeName != response.Node.ID {
t.Fatalf("scope name = %q want %q", group.ScopeName, response.Node.ID)
}
if len(group.Permissions) != 0 {
t.Fatalf("expected empty permissions, got %v", group.Permissions)
}
}
}
if !found {
t.Fatal("new group not saved")
}
}
func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"unreachable",
"host_ip":"127.0.0.1",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
// 127.0.0.1 with no SSH listener (or auth failure) should not panic; expect ok=false.
testRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/test-ssh",
nil,
),
cookie,
)
testRecorder := httptest.NewRecorder()
router.ServeHTTP(testRecorder, testRequest)
if testRecorder.Code != http.StatusOK {
t.Fatalf("test status = %d body=%s", testRecorder.Code, testRecorder.Body.String())
}
var response nodeSSHTestResponse
if err := json.Unmarshal(testRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.OK {
t.Fatal("expected SSH test to fail against localhost without matching key/auth")
}
if response.Message == "" {
t.Fatal("expected failure message")
}
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"context"
"errors"
"net/http"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const reauthRequiredMessage = "reauth_required"
type reauthRequest struct {
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
func SessionFromContext(ctx context.Context) (settings.SessionRecord, bool) {
session, ok := ctx.Value(contextSessionKey).(settings.SessionRecord)
return session, ok
}
func verifyActorCredentials(user settings.UserCredential, password string, totpCode string, security settings.SecuritySettings) error {
ok, err := auth.VerifyPassword(password, user.PasswordHash)
if err != nil || !ok {
return errors.New("invalid credentials")
}
requiresTOTP := security.TotpEnabled && user.TOTPConfirmed && user.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(user.TOTPSecret, totpCode) {
return errors.New("invalid or missing TOTP code")
}
}
return nil
}
func (app *App) requireInlineReauth(
request *http.Request,
password string,
totpCode string,
) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
security := effectiveSecurity(settingsPayload.Security)
return verifyActorCredentials(user, password, totpCode, security)
}
// requireGraceReauth returns an error with message reauth_required when the
// session is outside the grace window. When reauth_sensitive_actions is off, it
// is a no-op.
func (app *App) requireGraceReauth(request *http.Request) error {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
security := effectiveSecurity(settingsPayload.Security)
if !security.ReauthSensitiveActions {
return nil
}
session, ok := SessionFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
grace := time.Duration(security.ReauthGraceMinutes) * time.Minute
elapsed := time.Since(session.LastReauthAt)
// Allow a short post-reauth window so grace=0 still supports reauth-then-retry.
const justReauthedWindow = 30 * time.Second
if elapsed <= grace || elapsed <= justReauthedWindow {
return nil
}
return errors.New(reauthRequiredMessage)
}
func writeReauthRequired(writer http.ResponseWriter) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: reauthRequiredMessage})
}
+13
View File
@@ -27,6 +27,12 @@ func NewRouter(configDir string) http.Handler {
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("POST /api/v1/auth/reauth", app.reauthHandler)
mux.HandleFunc("PUT /api/v1/me/password", app.mePasswordHandler)
mux.HandleFunc("POST /api/v1/me/totp/begin", app.meTOTPBeginHandler)
mux.HandleFunc("POST /api/v1/me/totp/confirm", app.meTOTPConfirmHandler)
mux.HandleFunc("POST /api/v1/me/totp/disable", app.meTOTPDisableHandler)
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
@@ -36,7 +42,14 @@ func NewRouter(configDir string) http.Handler {
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("POST /api/v1/users", app.usersCreateHandler)
mux.HandleFunc("PATCH /api/v1/users", app.usersPatchHandler)
mux.HandleFunc("DELETE /api/v1/users", app.usersDeleteHandler)
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
return app.withCORS(app.withMiddleware(mux))
}
+33 -13
View File
@@ -66,9 +66,12 @@ type loginRequest struct {
}
type meResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
TOTPConfirmed bool `json:"totp_confirmed"`
TOTPEnabled bool `json:"totp_enabled"`
Permissions []string `json:"permissions"`
}
// App holds shared API dependencies.
@@ -388,11 +391,7 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: matched.ID,
Username: matched.Username,
Groups: matched.GroupNames,
})
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
@@ -408,11 +407,32 @@ func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: user.ID,
Username: user.Username,
Groups: user.GroupNames,
})
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, app.buildMeResponse(user, settingsPayload))
}
func (app *App) buildMeResponse(user settings.UserCredential, settingsPayload settings.Settings) meResponse {
security := effectiveSecurity(settingsPayload.Security)
groups := user.GroupNames
if groups == nil {
groups = []string{}
}
permissions := permissionList(user, settingsPayload.Groups)
if permissions == nil {
permissions = []string{}
}
return meResponse{
UserID: user.ID,
Username: user.Username,
Groups: groups,
TOTPConfirmed: user.TOTPConfirmed,
TOTPEnabled: security.TotpEnabled,
Permissions: permissions,
}
}
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
+10 -3
View File
@@ -42,9 +42,16 @@ func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie {
security := settings.DefaultSecuritySettings()
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{},
Security: security,
Network: settings.DefaultNetworkSettings(),
Groups: []settings.Group{
{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
},
},
Security: security,
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
+255 -4
View File
@@ -1,10 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -24,6 +27,23 @@ type usersResponse struct {
Users []userPublic `json:"users"`
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
GroupNames []string `json:"group_names"`
}
type patchUserRequest struct {
GroupNames *[]string `json:"group_names"`
Password *string `json:"password"`
DisableTOTP *bool `json:"disable_totp"`
}
type deleteUserRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
@@ -39,8 +59,206 @@ func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Reques
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.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
}
var payload createUserRequest
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 == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"})
return
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, 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
}
for _, existing := range store.Users {
if strings.EqualFold(existing.Username, username) {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"})
return
}
}
hash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
store.Users = append(store.Users, settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: hash,
Enabled: true,
GroupNames: groupNames,
CreatedAt: now,
PasswordChangedAt: now,
})
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.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
}
if err := app.requireGraceReauth(request); err != nil {
if err.Error() == reauthRequiredMessage {
writeReauthRequired(writer)
return
}
writeJSON(writer, http.StatusUnauthorized, 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
}
var payload patchUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, 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
}
index := -1
for i := range store.Users {
if store.Users[i].ID == userID {
index = i
break
}
}
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
target := &store.Users[index]
passwordChanged := false
if payload.GroupNames != nil {
groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
wasAdmin := userIsAdministrator(*target)
wouldBeAdmin := false
for _, name := range groupNames {
if name == settings.AdministratorsGroupName {
wouldBeAdmin = true
break
}
}
if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot remove the last administrator from the Administrators group",
})
return
}
target.GroupNames = groupNames
}
if payload.Password != nil {
if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
hash, err := auth.HashPassword(*payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
target.PasswordHash = hash
target.PasswordChangedAt = time.Now().UTC()
passwordChanged = true
}
if payload.DisableTOTP != nil && *payload.DisableTOTP {
target.TOTPSecret = ""
target.TOTPConfirmed = false
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if passwordChanged && app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(userID)
}
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 {
if err := app.authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
@@ -55,6 +273,17 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
return
}
var payload deleteUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
@@ -96,9 +325,31 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func authorizeUsersWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
func validateGroupNames(names []string, groups []settings.Group) ([]string, error) {
if names == nil {
return []string{}, nil
}
known := make(map[string]struct{}, len(groups))
for _, group := range groups {
known[group.Name] = struct{}{}
}
result := make([]string, 0, len(names))
seen := make(map[string]struct{}, len(names))
for _, name := range names {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, ok := known[trimmed]; !ok {
return nil, fmt.Errorf("unknown group: %s", trimmed)
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result, nil
}
func toPublicUsers(users []settings.UserCredential) []userPublic {
+275 -5
View File
@@ -64,9 +64,14 @@ func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -126,11 +131,13 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
}
router := NewRouter(configDir)
reauthBody := `{"current_password":"correct horse battery staple extra"}`
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
cookie,
)
deleteOperator.Header.Set("Content-Type", "application/json")
operatorRec := httptest.NewRecorder()
router.ServeHTTP(operatorRec, deleteOperator)
if operatorRec.Code != http.StatusOK {
@@ -138,9 +145,10 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
}
deleteSecondAdmin := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
cookie,
)
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
adminRec := httptest.NewRecorder()
router.ServeHTTP(adminRec, deleteSecondAdmin)
if adminRec.Code != http.StatusOK {
@@ -182,13 +190,275 @@ func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=missing",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
}
}
func TestUsersCreateAndPatch(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups = append(payload.Groups, settings.Group{
Name: "Operators",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Ops",
Permissions: []string{"nodes.read"},
})
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
),
cookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
}
var created usersResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
var aliceID string
for _, user := range created.Users {
if user.Username == "alice" {
aliceID = user.ID
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
t.Fatalf("unexpected groups: %#v", user.GroupNames)
}
}
}
if aliceID == "" {
t.Fatal("alice not found after create")
}
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id="+aliceID,
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
for _, user := range store.Users {
if user.ID != aliceID {
continue
}
if user.TOTPConfirmed || user.TOTPSecret != "" {
t.Fatal("expected TOTP cleared")
}
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
if err != nil || !ok {
t.Fatal("expected password updated")
}
}
}
func TestUsersWriteRequiresPermission(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
// Strip users.manage from Administrators
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
if permission == "users.manage" {
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
_ = key
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestUsersPatchRequiresGraceReauth(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Security.ReauthSensitiveActions = true
payload.Security.ReauthGraceMinutes = 0
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
store, err := settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
for index := range store.Sessions {
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
}
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusUnauthorized {
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
}
var errPayload apiErrorResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
t.Fatalf("decode: %v", err)
}
if errPayload.Error != "reauth_required" {
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
}
reauthReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/reauth",
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
),
cookie,
)
reauthReq.Header.Set("Content-Type", "application/json")
reauthRec := httptest.NewRecorder()
router.ServeHTTP(reauthRec, reauthReq)
if reauthRec.Code != http.StatusOK {
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
}
// Refresh cookie if rotated
for _, setCookie := range reauthRec.Result().Cookies() {
if setCookie.Name == auth.SessionCookieName {
cookie = setCookie
}
}
retryReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
retryReq.Header.Set("Content-Type", "application/json")
retryRec := httptest.NewRecorder()
router.ServeHTTP(retryRec, retryReq)
if retryRec.Code != http.StatusOK {
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
}
}
func TestMePasswordChange(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/me/password",
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
// Session should be invalidated
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusUnauthorized {
t.Fatalf("expected session invalidated, got %d", meRec.Code)
}
}