Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
608 lines
17 KiB
Go
608 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
contextUserKey contextKey = "user"
|
|
contextSessionKey contextKey = "session"
|
|
)
|
|
|
|
type setupStatusResponse struct {
|
|
Completed bool `json:"completed"`
|
|
ClientIP string `json:"client_ip"`
|
|
}
|
|
|
|
type setupTOTPBeginRequest struct {
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type setupTOTPBeginResponse struct {
|
|
Secret string `json:"secret"`
|
|
OTPAuthURL string `json:"otpauth_url"`
|
|
}
|
|
|
|
type setupTOTPVerifyRequest struct {
|
|
Secret string `json:"secret"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type setupCompleteRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TOTPSecret string `json:"totp_secret"`
|
|
TOTPConfirmed bool `json:"totp_confirmed"`
|
|
Network settings.NetworkSettings `json:"network"`
|
|
Security settings.SecuritySettings `json:"security"`
|
|
}
|
|
|
|
type setupPreviewRequest struct {
|
|
Network settings.NetworkSettings `json:"network"`
|
|
}
|
|
|
|
type setupPreviewResponse struct {
|
|
WouldLockOut bool `json:"would_lock_out"`
|
|
Reason string `json:"reason,omitempty"`
|
|
ClientIP string `json:"client_ip"`
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TOTPCode string `json:"totp_code"`
|
|
}
|
|
|
|
type meResponse struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Groups []string `json:"groups"`
|
|
TOTPConfirmed bool `json:"totp_confirmed"`
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
// App holds shared API dependencies.
|
|
type App struct {
|
|
ConfigDir string
|
|
Key []byte
|
|
Sessions *auth.SessionManager
|
|
Executor *runner.Executor
|
|
|
|
pendingTOTPMu sync.Mutex
|
|
pendingTOTP map[string]string // username -> secret (setup only)
|
|
}
|
|
|
|
func newApp(configDir string) (*App, error) {
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
// Key is required for setup complete / auth; status still works without it.
|
|
app := &App{
|
|
ConfigDir: configDir,
|
|
pendingTOTP: map[string]string{},
|
|
Executor: runner.NewExecutor(configDir, nil),
|
|
}
|
|
return app, nil
|
|
}
|
|
app := &App{
|
|
ConfigDir: configDir,
|
|
Key: key,
|
|
Sessions: auth.NewSessionManager(configDir, key),
|
|
pendingTOTP: map[string]string{},
|
|
Executor: runner.NewExecutor(configDir, key),
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
func (app *App) requireKey() error {
|
|
if len(app.Key) == 0 {
|
|
return fmtErrorfKeyMissing()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fmtErrorfKeyMissing() error {
|
|
return errors.New(settings.ConfigKeyEnvVar + " is not set")
|
|
}
|
|
|
|
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, setupStatusResponse{
|
|
Completed: completed,
|
|
ClientIP: auth.ClientIP(request),
|
|
})
|
|
}
|
|
|
|
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupTOTPBeginRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
username := strings.TrimSpace(payload.Username)
|
|
if username == "" {
|
|
username = "Admin"
|
|
}
|
|
|
|
key, err := auth.GenerateTOTPSecret(username)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
app.pendingTOTPMu.Lock()
|
|
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
|
|
app.pendingTOTPMu.Unlock()
|
|
|
|
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
|
|
Secret: key.Secret(),
|
|
OTPAuthURL: key.URL(),
|
|
})
|
|
}
|
|
|
|
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupTOTPVerifyRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
|
|
}
|
|
|
|
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
|
|
var payload setupPreviewRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
clientIP := auth.ClientIP(request)
|
|
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
|
|
writeJSON(writer, http.StatusOK, setupPreviewResponse{
|
|
WouldLockOut: wouldLockOut,
|
|
Reason: reason,
|
|
ClientIP: clientIP,
|
|
})
|
|
}
|
|
|
|
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if err := app.requireKey(); err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupCompleteRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(payload.Username)
|
|
if username == "" {
|
|
username = "Admin"
|
|
}
|
|
if err := auth.ValidatePassword(payload.Password, username); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
switch payload.Network.AccessMode {
|
|
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
|
|
default:
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
|
|
return
|
|
}
|
|
if err := validateSecuritySettings(payload.Security); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
if payload.TOTPConfirmed {
|
|
if strings.TrimSpace(payload.TOTPSecret) == "" {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
|
|
return
|
|
}
|
|
payload.Security.TotpEnabled = true
|
|
}
|
|
|
|
passwordHash, err := auth.HashPassword(payload.Password)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
userID, err := newUserID()
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
user := settings.UserCredential{
|
|
ID: userID,
|
|
Username: username,
|
|
PasswordHash: passwordHash,
|
|
TOTPSecret: payload.TOTPSecret,
|
|
TOTPConfirmed: payload.TOTPConfirmed,
|
|
Enabled: true,
|
|
GroupNames: []string{settings.AdministratorsGroupName},
|
|
CreatedAt: now,
|
|
PasswordChangedAt: now,
|
|
}
|
|
|
|
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
|
|
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
adminGroup := settings.Group{
|
|
Name: settings.AdministratorsGroupName,
|
|
ScopeKind: settings.GroupScopeGroup,
|
|
ScopeName: "Admin Group",
|
|
Permissions: allPermissionList(),
|
|
}
|
|
|
|
network := settings.EffectiveNetwork(payload.Network)
|
|
settingsPayload := settings.Settings{
|
|
SetupCompleted: true,
|
|
Groups: []settings.Group{adminGroup},
|
|
Security: payload.Security,
|
|
Network: network,
|
|
}
|
|
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, map[string]any{
|
|
"completed": true,
|
|
"username": username,
|
|
})
|
|
}
|
|
|
|
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if err := app.requireKey(); err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if app.Sessions == nil {
|
|
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
|
|
}
|
|
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if !completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
|
|
return
|
|
}
|
|
|
|
var payload loginRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
var matched *settings.UserCredential
|
|
for index := range store.Users {
|
|
user := &store.Users[index]
|
|
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
|
|
matched = user
|
|
break
|
|
}
|
|
}
|
|
attemptedUsername := strings.TrimSpace(payload.Username)
|
|
if matched == nil || !matched.Enabled {
|
|
appendAuthAudit(
|
|
app.ConfigDir,
|
|
"login",
|
|
settings.AuthAuditOutcomeFailure,
|
|
settings.AuthAuditCategoryAuth,
|
|
attemptedUsername,
|
|
attemptedUsername,
|
|
"invalid credentials",
|
|
)
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
|
if err != nil || !ok {
|
|
appendAuthAudit(
|
|
app.ConfigDir,
|
|
"login",
|
|
settings.AuthAuditOutcomeFailure,
|
|
settings.AuthAuditCategoryAuth,
|
|
attemptedUsername,
|
|
matched.Username,
|
|
"invalid credentials",
|
|
)
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
security := effectiveSecurity(settingsPayload.Security)
|
|
|
|
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
|
|
if requiresTOTP {
|
|
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
|
|
appendAuthAudit(
|
|
app.ConfigDir,
|
|
"login",
|
|
settings.AuthAuditOutcomeFailure,
|
|
settings.AuthAuditCategoryAuth,
|
|
matched.Username,
|
|
matched.Username,
|
|
"invalid or missing TOTP code",
|
|
)
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
appendAuthAudit(
|
|
app.ConfigDir,
|
|
"login",
|
|
settings.AuthAuditOutcomeSuccess,
|
|
settings.AuthAuditCategoryAuth,
|
|
matched.Username,
|
|
matched.Username,
|
|
"",
|
|
)
|
|
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
|
}
|
|
|
|
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
|
actor := actorUsernameFromRequest(request)
|
|
if app.Sessions != nil {
|
|
_ = app.Sessions.DestroySession(writer, request)
|
|
}
|
|
if actor != "" {
|
|
appendAuthAudit(
|
|
app.ConfigDir,
|
|
"logout",
|
|
settings.AuthAuditOutcomeSuccess,
|
|
settings.AuthAuditCategoryAuth,
|
|
actor,
|
|
actor,
|
|
"",
|
|
)
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
|
|
user, ok := UserFromContext(request.Context())
|
|
if !ok {
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
|
return
|
|
}
|
|
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) {
|
|
user, ok := ctx.Value(contextUserKey).(settings.UserCredential)
|
|
return user, ok
|
|
}
|
|
|
|
func allPermissionList() []string {
|
|
list := make([]string, 0, len(allowedPermissions))
|
|
for permission := range allowedPermissions {
|
|
list = append(list, permission)
|
|
}
|
|
// Stable-ish order for tests: sort manually via known catalog order.
|
|
ordered := []string{
|
|
"nodes.read",
|
|
"nodes.exec",
|
|
"nodes.update",
|
|
"nodes.delete",
|
|
"jobs.read",
|
|
"jobs.run",
|
|
"actions.create",
|
|
"actions.update",
|
|
"actions.delete",
|
|
"actions.execute",
|
|
"users.manage",
|
|
"secrets.manage",
|
|
"roles.manage",
|
|
"logs.read",
|
|
}
|
|
_ = list
|
|
return ordered
|
|
}
|
|
|
|
func newUserID() (string, error) {
|
|
return auth.NewRandomID()
|
|
}
|
|
|
|
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return settings.Settings{
|
|
Groups: []settings.Group{},
|
|
Security: settings.DefaultSecuritySettings(),
|
|
Network: settings.DefaultNetworkSettings(),
|
|
}, nil
|
|
}
|
|
return settings.Settings{}, err
|
|
}
|
|
if payload.Groups == nil {
|
|
payload.Groups = []settings.Group{}
|
|
}
|
|
payload.Network = settings.EffectiveNetwork(payload.Network)
|
|
changed := false
|
|
if migrateLogsReadPermission(&payload) {
|
|
changed = true
|
|
}
|
|
if migrateActionsExecutePermission(&payload) {
|
|
changed = true
|
|
}
|
|
if changed {
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
return settings.Settings{}, err
|
|
}
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// migrateLogsReadPermission grants logs.read to groups that previously could
|
|
// view logs via nodes.read or users.manage. Returns true when settings changed.
|
|
func migrateLogsReadPermission(payload *settings.Settings) bool {
|
|
changed := false
|
|
for index := range payload.Groups {
|
|
group := &payload.Groups[index]
|
|
hasLogsRead := false
|
|
hadLogAccess := false
|
|
for _, permission := range group.Permissions {
|
|
switch permission {
|
|
case "logs.read":
|
|
hasLogsRead = true
|
|
case "nodes.read", "users.manage":
|
|
hadLogAccess = true
|
|
}
|
|
}
|
|
if hasLogsRead || !hadLogAccess {
|
|
continue
|
|
}
|
|
group.Permissions = append(group.Permissions, "logs.read")
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// migrateActionsExecutePermission grants actions.execute to groups that
|
|
// previously could run actions via jobs.run. Returns true when settings changed.
|
|
func migrateActionsExecutePermission(payload *settings.Settings) bool {
|
|
changed := false
|
|
for index := range payload.Groups {
|
|
group := &payload.Groups[index]
|
|
hasActionsExecute := false
|
|
hasJobsRun := false
|
|
for _, permission := range group.Permissions {
|
|
switch permission {
|
|
case "actions.execute":
|
|
hasActionsExecute = true
|
|
case "jobs.run":
|
|
hasJobsRun = true
|
|
}
|
|
}
|
|
if hasActionsExecute || !hasJobsRun {
|
|
continue
|
|
}
|
|
group.Permissions = append(group.Permissions, "actions.execute")
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|