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