This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/auth.go
Jeffrey Smith e4f0bdbd36
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s
Feat v0.7.7 api tokens ext permissions (#61)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 19:11:47 +00:00

734 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"armature/auth"
"armature/config"
"armature/crypto"
"armature/models"
"armature/store"
)
// Claims represents the JWT payload.
//
// bcryptCost is shared across auth.go, settings.go, admin.go
const bcryptCost = 12
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
type AuthHandler struct {
cfg *config.Config
stores store.Stores
uekCache *crypto.UEKCache
provider auth.Provider
}
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache, provider auth.Provider) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache, provider: provider}
}
func (h *AuthHandler) Register(c *gin.Context) {
if !h.provider.SupportsRegistration() {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "registration not supported for this auth mode"})
return
}
result, err := h.provider.Register(c, h.stores)
if err != nil {
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if result.VaultHint != "" {
if err := h.initVault(c.Request.Context(), result.User.ID, result.VaultHint); err != nil {
log.Printf("⚠ Failed to init vault for user %s: %v", result.User.ID, err)
}
}
if !result.User.IsActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created but requires admin approval",
"user_id": result.User.ID,
})
return
}
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusCreated, tokens)
}
func (h *AuthHandler) Login(c *gin.Context) {
result, err := h.provider.Authenticate(c, h.stores)
if err != nil {
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if result.VaultHint != "" {
h.unlockVault(c.Request.Context(), result.User, result.VaultHint)
}
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
// Check for keep_login flag from request body.
// The provider already consumed the body, so we read from gin context
// if the provider stashed it there; otherwise default to false.
keepLogin, _ := c.Get("keep_login")
keep, _ := keepLogin.(bool)
tokens, err := h.generateTokens(result.User, keep)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenHash := hashToken(req.RefreshToken)
info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// Idle timeout check
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
return
}
}
// Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *AuthHandler) Logout(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
c.ShouldBindJSON(&req)
if req.RefreshToken != "" {
tokenHash := hashToken(req.RefreshToken)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
}
// Evict UEK from session cache
if userID, exists := c.Get("user_id"); exists {
h.uekCache.Evict(userID.(string))
}
// Clear the arm_token cookie so SSR middleware doesn't trust a stale token
c.SetCookie("arm_token", "", -1, "/", "", false, false)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// Activity updates last_activity_at on the caller's active refresh tokens.
// Called by the client SDK on user interaction (debounced, max 1/min).
// POST /api/v1/auth/activity (requires auth middleware)
func (h *AuthHandler) Activity(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── OIDC Flow ──────────────────────────────
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
// GET /api/v1/auth/oidc/login
func (h *AuthHandler) OIDCLogin(c *gin.Context) {
oidcProv, ok := h.provider.(*auth.OIDCProvider)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
return
}
state := uuid.New().String()
nonce := uuid.New().String()
// Determine redirect URI
redirectURI := h.cfg.OIDCRedirectURL
if redirectURI == "" {
// Auto-derive from request
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
}
// Store state for callback verification
if err := h.stores.GlobalConfig.SaveOIDCState(c.Request.Context(), state, nonce, c.Query("redirect")); err != nil {
log.Printf("[auth/oidc] warn: could not store state: %v", err)
}
authURL := oidcProv.AuthorizationURL(state, nonce, redirectURI)
c.Redirect(http.StatusFound, authURL)
}
// OIDCCallback handles the IdP redirect after successful authentication.
// GET /api/v1/auth/oidc/callback?code=...&state=...
func (h *AuthHandler) OIDCCallback(c *gin.Context) {
oidcProv, ok := h.provider.(*auth.OIDCProvider)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
return
}
code := c.Query("code")
state := c.Query("state")
// Check for IdP error response (Keycloak returns ?error=...&error_description=...)
if errCode := c.Query("error"); errCode != "" {
errDesc := c.Query("error_description")
log.Printf("[auth/oidc] IdP returned error: %s — %s", errCode, errDesc)
c.JSON(http.StatusUnauthorized, gin.H{"error": "IdP error: " + errCode, "description": errDesc})
return
}
if code == "" || state == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
return
}
// Verify state and retrieve nonce for ID token validation
storedNonce, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
return
}
// Clean up stale states (> 10 minutes old)
_ = h.stores.GlobalConfig.CleanupOIDCState(c.Request.Context())
// Determine redirect URI (must match what was sent in the login request)
redirectURI := h.cfg.OIDCRedirectURL
if redirectURI == "" {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
}
// Exchange code for tokens
tokenResp, err := oidcProv.ExchangeCode(c.Request.Context(), code, redirectURI)
if err != nil {
log.Printf("[auth/oidc] code exchange failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
// Validate nonce in the ID token to prevent token replay/substitution
if tokenResp.IDToken != "" && storedNonce != "" {
if err := oidcProv.ValidateIDTokenNonce(tokenResp.IDToken, storedNonce); err != nil {
log.Printf("[auth/oidc] nonce validation failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "nonce validation failed"})
return
}
}
// Use the ID token (or access token) to authenticate via the provider
// Temporarily set the Authorization header so Authenticate() can read it
tokenToValidate := tokenResp.IDToken
if tokenToValidate == "" {
tokenToValidate = tokenResp.AccessToken
}
c.Request.Header.Set("Authorization", "Bearer "+tokenToValidate)
result, err := oidcProv.Authenticate(c, h.stores)
if err != nil {
log.Printf("[auth/oidc] authenticate after code exchange failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
// Issue internal JWT
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
// For browser flow: encode tokens into a fragment that the login page JS
// can pick up, store in localStorage, and redirect to the app.
// This avoids httpOnly cookie issues — JS needs the token in localStorage.
accessToken, _ := tokens["access_token"].(string)
refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware).
// MaxAge matches refresh token lifetime so the cookie
// survives until JS can proactively refresh the access token.
refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
if refreshExpiresIn <= 0 {
refreshExpiresIn = 604800
}
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
// Base64-encode the token payload for the fragment
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
accessToken, refreshToken, string(userJSON))
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
dest := h.cfg.BasePath + "/login#oidc_result=" + encoded
c.Redirect(http.StatusFound, dest)
}
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, err
}
// Refresh token — keepLogin=false caps at 24h (session-length)
refreshTTL := sc.RefreshTokenTTL
if !keepLogin && refreshTTL > 24*time.Hour {
refreshTTL = 24 * time.Hour
}
refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(refreshTTL)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
log.Printf("warn: failed to store refresh token: %v", err)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"display_name": user.DisplayName,
"handle": user.Handle,
"auth_source": user.AuthSource,
},
}, nil
}
func hashToken(token string) string {
return auth.HashToken(token)
}
// ── Vault Lifecycle ─────────────────────────
// DestroyVaultDB nullifies a user's vault columns and deletes personal
// provider configs. This is the shared DB-level operation used by:
// - unlockVault (stale seal recovery)
// - BootstrapAdmin / SeedUsers (password rotation at startup)
// - AdminHandler.destroyVault (admin-initiated reset)
//
// Does NOT evict from UEK cache or write audit logs — callers handle that.
func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (deleted int64) {
if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil {
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
}
return 0
}
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
// the given password. If the vault doesn't exist (vault_set=false), this is
// a no-op. If the vault exists but the seal is stale (encrypted_uek was
// wrapped with a different password), the vault and personal providers are
// destroyed so initVault fires cleanly on next login.
//
// When uekCache is non-nil and the vault unlocks successfully, the UEK is
// cached so that BYOK operations work immediately without requiring a fresh
// login. This is important for bootstrapped/seeded users whose browser
// sessions survive server restarts.
//
// Used by BootstrapAdmin and SeedUsers where the password is known at startup.
func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string, uekCache ...*crypto.UEKCache) {
vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID)
if err != nil || !vaultSet {
return // no vault to probe
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err == nil {
// Vault seal matches current password — cache UEK if cache provided
if len(uekCache) > 0 && uekCache[0] != nil {
uekCache[0].Store(userID, uek)
}
return
}
// Stale seal: password has actually changed since the vault was sealed
deleted := DestroyVaultDB(ctx, stores, userID)
if deleted > 0 {
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
} else {
log.Printf(" 🔐 Vault stale-seal repair for user %s: vault columns cleared", userID)
}
}
// initVault generates a UEK, wraps it with the user's password, and stores
// the encrypted UEK + salt + nonce on the user record. Called on registration
// and on first login for pre-migration users.
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
if h.uekCache == nil {
return nil // Vault not configured (e.g. tests)
}
uek, err := crypto.GenerateUEK()
if err != nil {
return err
}
salt, err := crypto.GenerateSalt()
if err != nil {
return err
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
if err != nil {
return err
}
if err := h.stores.Users.InitVaultKeys(ctx, userID, encryptedUEK, salt, nonce); err != nil {
return err
}
// Cache the UEK for the session
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault initialized for user %s", userID)
return nil
}
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
// have a vault yet (pre-migration account), initializes one.
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
if h.uekCache == nil {
return
}
vaultSet, encryptedUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(ctx, user.ID)
if err != nil {
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
return
}
if !vaultSet {
// Pre-migration user: initialize vault on first login
if err := h.initVault(ctx, user.ID, password); err != nil {
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
}
return
}
// Derive PDK from password and unwrap UEK
pdk := crypto.DeriveKeyFromPassword(password, salt)
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err != nil {
// Stale seal: encrypted_uek was wrapped with a different password
deleted := DestroyVaultDB(ctx, h.stores, user.ID)
if deleted > 0 {
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
} else {
log.Printf("⚠ Vault stale-seal recovery for user %s: no personal providers to clear", user.ID)
}
if err := h.initVault(ctx, user.ID, password); err != nil {
log.Printf("⚠ Vault re-init failed for user %s: %v", user.ID, err)
}
return
}
h.uekCache.Store(user.ID, uek)
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
// When uekCache is provided, the admin's vault is pre-warmed so BYOK
// operations work immediately without requiring a fresh login.
func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
ctx := context.Background()
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if existing != nil {
// Update password
s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash),
"is_active": true,
})
// If the actual password changed, the vault seal is stale. Probe it
// with the current password and destroy only if unwrap fails.
// Ensure handle exists (backfill for users)
if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
var cache *crypto.UEKCache
if len(uekCache) > 0 {
cache = uekCache[0]
}
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache)
auth.EnsureEveryoneGroup(ctx, s, existing.ID)
auth.AddToAdminsGroup(ctx, s, existing.ID)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@armature.local"
}
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
user := &models.User{
Username: strings.ToLower(cfg.AdminUsername),
Email: strings.ToLower(email),
PasswordHash: string(hash),
IsActive: true,
AuthSource: "builtin",
Handle: handle,
}
if err := s.Users.Create(ctx, user); err != nil {
log.Printf("⚠ Failed to create admin user: %v", err)
return
}
auth.EnsureEveryoneGroup(ctx, s, user.ID)
auth.AddToAdminsGroup(ctx, s, user.ID)
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
}
// BootstrapPAT creates a personal access token for the admin user when
// ARMATURE_BOOTSTRAP_PAT=true. Writes the token to /tmp/armature-admin-pat.txt
// for use by CI scripts. The token has all permissions and no expiry.
func BootstrapPAT(cfg *config.Config, s store.Stores) {
if os.Getenv("ARMATURE_BOOTSTRAP_PAT") != "true" {
return
}
if cfg.AdminUsername == "" || s.APITokens == nil {
return
}
ctx := context.Background()
admin, err := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if err != nil || admin == nil {
log.Printf("⚠ BootstrapPAT: admin user not found")
return
}
// Check if a bootstrap token already exists
existing, _ := s.APITokens.ListForUser(ctx, admin.ID)
for _, t := range existing {
if t.Name == "bootstrap-ci" {
log.Printf(" Bootstrap PAT already exists (prefix: %s)", t.Prefix)
return
}
}
// Generate and store
raw, tokenHash, prefix := generateToken()
allPerms := auth.AllPermissions
permsCopy := make([]string, len(allPerms))
copy(permsCopy, allPerms)
token := &models.APIToken{
UserID: admin.ID,
Name: "bootstrap-ci",
TokenHash: tokenHash,
Prefix: prefix,
Permissions: permsCopy,
CreatedBy: &admin.ID,
}
if err := s.APITokens.Create(ctx, token); err != nil {
log.Printf("⚠ BootstrapPAT: failed to create token: %v", err)
return
}
// Write token to file for CI consumption
if err := os.WriteFile("/tmp/armature-admin-pat.txt", []byte(raw), 0600); err != nil {
log.Printf("⚠ BootstrapPAT: failed to write token file: %v", err)
// Still log it for docker-compose stdout capture
}
log.Printf(" ✅ Bootstrap PAT created (prefix: %s), written to /tmp/armature-admin-pat.txt", prefix)
}
// SeedUsers creates or updates users from the SEED_USERS env var.
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
// Upsert: existing users get their password refreshed on every restart.
// Gated to non-production environments.
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.SeedUsers == "" {
log.Printf(" SEED_USERS not set, skipping")
return
}
if cfg.Environment == "production" {
log.Printf("⚠ SEED_USERS ignored in production environment")
return
}
ctx := context.Background()
entries := strings.Split(cfg.SeedUsers, ",")
log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, ":", 3)
if len(parts) < 2 {
log.Printf("⚠ Seed user skipped (bad format, want user:pass[:admin]): %q", entry)
continue
}
username := strings.ToLower(strings.TrimSpace(parts[0]))
password := strings.TrimSpace(parts[1])
isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin"
if username == "" || password == "" {
log.Printf("⚠ Seed user skipped (empty username or password)")
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err)
continue
}
// Upsert: update password if user exists, create if not
existing, _ := s.Users.GetByUsername(ctx, username)
if existing != nil {
s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash),
"is_active": true,
})
if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
var cache *crypto.UEKCache
if len(uekCache) > 0 {
cache = uekCache[0]
}
ProbeAndRepairVault(ctx, s, existing.ID, password, cache)
auth.EnsureEveryoneGroup(ctx, s, existing.ID)
if isAdmin {
auth.AddToAdminsGroup(ctx, s, existing.ID)
}
log.Printf(" 🌱 Seed user '%s' updated (admin=%v)", username, isAdmin)
continue
}
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
user := &models.User{
Username: username,
Email: username + "@armature.local",
PasswordHash: string(hash),
IsActive: true,
AuthSource: "builtin",
Handle: handle,
}
if err := s.Users.Create(ctx, user); err != nil {
log.Printf("⚠ Seed user '%s' failed: %v", username, err)
continue
}
auth.EnsureEveryoneGroup(ctx, s, user.ID)
if isAdmin {
auth.AddToAdminsGroup(ctx, s, user.ID)
}
log.Printf(" 🌱 Seed user '%s' created (admin=%v, active=true)", username, isAdmin)
}
}
// IsRegistrationEnabled checks the platform policy.
func IsRegistrationEnabled(s store.Stores) bool {
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
return val
}