Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
489 lines
14 KiB
Go
489 lines
14 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/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
|
|
|
|
pendingTOTPMu sync.Mutex
|
|
pendingTOTP map[string]string // username -> secret (setup only)
|
|
}
|
|
|
|
func newApp(configDir string) (*App, error) {
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
// Key is required for setup complete / auth; status still works without it.
|
|
return &App{
|
|
ConfigDir: configDir,
|
|
pendingTOTP: map[string]string{},
|
|
}, nil
|
|
}
|
|
return &App{
|
|
ConfigDir: configDir,
|
|
Key: key,
|
|
Sessions: auth.NewSessionManager(configDir, key),
|
|
pendingTOTP: map[string]string{},
|
|
}, nil
|
|
}
|
|
|
|
func (app *App) requireKey() error {
|
|
if len(app.Key) == 0 {
|
|
return fmtErrorfKeyMissing()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fmtErrorfKeyMissing() error {
|
|
return errors.New(settings.ConfigKeyEnvVar + " is not set")
|
|
}
|
|
|
|
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, setupStatusResponse{
|
|
Completed: completed,
|
|
ClientIP: auth.ClientIP(request),
|
|
})
|
|
}
|
|
|
|
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupTOTPBeginRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
username := strings.TrimSpace(payload.Username)
|
|
if username == "" {
|
|
username = "Admin"
|
|
}
|
|
|
|
key, err := auth.GenerateTOTPSecret(username)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
app.pendingTOTPMu.Lock()
|
|
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
|
|
app.pendingTOTPMu.Unlock()
|
|
|
|
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
|
|
Secret: key.Secret(),
|
|
OTPAuthURL: key.URL(),
|
|
})
|
|
}
|
|
|
|
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupTOTPVerifyRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
|
|
}
|
|
|
|
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
|
|
var payload setupPreviewRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
clientIP := auth.ClientIP(request)
|
|
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
|
|
writeJSON(writer, http.StatusOK, setupPreviewResponse{
|
|
WouldLockOut: wouldLockOut,
|
|
Reason: reason,
|
|
ClientIP: clientIP,
|
|
})
|
|
}
|
|
|
|
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if err := app.requireKey(); err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
|
|
return
|
|
}
|
|
|
|
var payload setupCompleteRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(payload.Username)
|
|
if username == "" {
|
|
username = "Admin"
|
|
}
|
|
if err := auth.ValidatePassword(payload.Password, username); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
switch payload.Network.AccessMode {
|
|
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
|
|
default:
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
|
|
return
|
|
}
|
|
if err := validateSecuritySettings(payload.Security); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
if payload.TOTPConfirmed {
|
|
if strings.TrimSpace(payload.TOTPSecret) == "" {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
|
|
return
|
|
}
|
|
payload.Security.TotpEnabled = true
|
|
}
|
|
|
|
passwordHash, err := auth.HashPassword(payload.Password)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
userID, err := newUserID()
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
user := settings.UserCredential{
|
|
ID: userID,
|
|
Username: username,
|
|
PasswordHash: passwordHash,
|
|
TOTPSecret: payload.TOTPSecret,
|
|
TOTPConfirmed: payload.TOTPConfirmed,
|
|
Enabled: true,
|
|
GroupNames: []string{settings.AdministratorsGroupName},
|
|
CreatedAt: now,
|
|
PasswordChangedAt: now,
|
|
}
|
|
|
|
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
|
|
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
adminGroup := settings.Group{
|
|
Name: settings.AdministratorsGroupName,
|
|
ScopeKind: settings.GroupScopeGroup,
|
|
ScopeName: "Admin Group",
|
|
Permissions: allPermissionList(),
|
|
}
|
|
|
|
network := settings.EffectiveNetwork(payload.Network)
|
|
settingsPayload := settings.Settings{
|
|
SetupCompleted: true,
|
|
Groups: []settings.Group{adminGroup},
|
|
Security: payload.Security,
|
|
Network: network,
|
|
}
|
|
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, map[string]any{
|
|
"completed": true,
|
|
"username": username,
|
|
})
|
|
}
|
|
|
|
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if err := app.requireKey(); err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if app.Sessions == nil {
|
|
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
|
|
}
|
|
|
|
completed, err := settings.IsSetupCompleted(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if !completed {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
|
|
return
|
|
}
|
|
|
|
var payload loginRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
var matched *settings.UserCredential
|
|
for index := range store.Users {
|
|
user := &store.Users[index]
|
|
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
|
|
matched = user
|
|
break
|
|
}
|
|
}
|
|
if matched == nil || !matched.Enabled {
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
|
if err != nil || !ok {
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
security := effectiveSecurity(settingsPayload.Security)
|
|
|
|
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
|
|
if requiresTOTP {
|
|
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
|
}
|
|
|
|
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if app.Sessions != nil {
|
|
_ = app.Sessions.DestroySession(writer, request)
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
|
|
user, ok := UserFromContext(request.Context())
|
|
if !ok {
|
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
|
return
|
|
}
|
|
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",
|
|
"users.manage",
|
|
"secrets.manage",
|
|
"roles.manage",
|
|
}
|
|
_ = list
|
|
return ordered
|
|
}
|
|
|
|
func newUserID() (string, error) {
|
|
return auth.NewRandomID()
|
|
}
|
|
|
|
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return settings.Settings{
|
|
Groups: []settings.Group{},
|
|
Security: settings.DefaultSecuritySettings(),
|
|
Network: settings.DefaultNetworkSettings(),
|
|
}, nil
|
|
}
|
|
return settings.Settings{}, err
|
|
}
|
|
if payload.Groups == nil {
|
|
payload.Groups = []settings.Group{}
|
|
}
|
|
payload.Network = settings.EffectiveNetwork(payload.Network)
|
|
return payload, nil
|
|
}
|