package db import ( "context" "crypto/sha256" "encoding/hex" "fmt" "time" "github.com/jackc/pgx/v5" ) // User represents a user in the database. type User struct { ID string Username string DisplayName string Email string AuthSource string OIDCSubject *string Role string IsActive bool LastLoginAt *time.Time CreatedAt time.Time UpdatedAt time.Time } // TokenInfo holds metadata about an API token (never the raw token or hash). type TokenInfo struct { ID string UserID string Name string TokenPrefix string Scopes []string LastUsedAt *time.Time ExpiresAt *time.Time CreatedAt time.Time } // UserRepository provides user database operations. type UserRepository struct { db *DB } // NewUserRepository creates a new user repository. func NewUserRepository(db *DB) *UserRepository { return &UserRepository{db: db} } // GetByID returns a user by ID. func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) { u := &User{} var email, oidcSubject *string err := r.db.pool.QueryRow(ctx, ` SELECT id, username, display_name, email, auth_source, oidc_subject, role, is_active, last_login_at, created_at, updated_at FROM users WHERE id = $1 `, id).Scan( &u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("getting user by id: %w", err) } if email != nil { u.Email = *email } u.OIDCSubject = oidcSubject return u, nil } // GetByUsername returns a user by username. func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*User, error) { u := &User{} var email, oidcSubject *string err := r.db.pool.QueryRow(ctx, ` SELECT id, username, display_name, email, auth_source, oidc_subject, role, is_active, last_login_at, created_at, updated_at FROM users WHERE username = $1 `, username).Scan( &u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("getting user by username: %w", err) } if email != nil { u.Email = *email } u.OIDCSubject = oidcSubject return u, nil } // GetWithPasswordHash returns a user and their password hash for local authentication. func (r *UserRepository) GetWithPasswordHash(ctx context.Context, username string) (*User, string, error) { u := &User{} var email, oidcSubject, passwordHash *string err := r.db.pool.QueryRow(ctx, ` SELECT id, username, display_name, email, password_hash, auth_source, oidc_subject, role, is_active, last_login_at, created_at, updated_at FROM users WHERE username = $1 `, username).Scan( &u.ID, &u.Username, &u.DisplayName, &email, &passwordHash, &u.AuthSource, &oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", fmt.Errorf("getting user with password: %w", err) } if email != nil { u.Email = *email } u.OIDCSubject = oidcSubject hash := "" if passwordHash != nil { hash = *passwordHash } return u, hash, nil } // GetByOIDCSubject returns a user by their OIDC subject claim. func (r *UserRepository) GetByOIDCSubject(ctx context.Context, subject string) (*User, error) { u := &User{} var email, oidcSubject *string err := r.db.pool.QueryRow(ctx, ` SELECT id, username, display_name, email, auth_source, oidc_subject, role, is_active, last_login_at, created_at, updated_at FROM users WHERE oidc_subject = $1 `, subject).Scan( &u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("getting user by oidc subject: %w", err) } if email != nil { u.Email = *email } u.OIDCSubject = oidcSubject return u, nil } // Upsert inserts a new user or updates an existing one by username. // Used when LDAP/OIDC users log in to sync their external attributes. func (r *UserRepository) Upsert(ctx context.Context, u *User) error { var email *string if u.Email != "" { email = &u.Email } err := r.db.pool.QueryRow(ctx, ` INSERT INTO users (username, display_name, email, auth_source, oidc_subject, role, last_login_at) VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, email = EXCLUDED.email, auth_source = EXCLUDED.auth_source, oidc_subject = COALESCE(EXCLUDED.oidc_subject, users.oidc_subject), role = EXCLUDED.role, last_login_at = now(), updated_at = now() RETURNING id `, u.Username, u.DisplayName, email, u.AuthSource, u.OIDCSubject, u.Role).Scan(&u.ID) if err != nil { return fmt.Errorf("upserting user: %w", err) } return nil } // Create inserts a new local user with a password hash. func (r *UserRepository) Create(ctx context.Context, u *User, passwordHash string) error { var email *string if u.Email != "" { email = &u.Email } err := r.db.pool.QueryRow(ctx, ` INSERT INTO users (username, display_name, email, password_hash, auth_source, role) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, u.Username, u.DisplayName, email, passwordHash, u.AuthSource, u.Role).Scan(&u.ID) if err != nil { return fmt.Errorf("creating user: %w", err) } return nil } // UpdateLastLogin sets last_login_at to now. func (r *UserRepository) UpdateLastLogin(ctx context.Context, id string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1 `, id) return err } // SetPassword updates the password hash for a local user. func (r *UserRepository) SetPassword(ctx context.Context, id string, hash string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 `, id, hash) return err } // SetActive enables or disables a user. func (r *UserRepository) SetActive(ctx context.Context, id string, active bool) error { _, err := r.db.pool.Exec(ctx, ` UPDATE users SET is_active = $2, updated_at = now() WHERE id = $1 `, id, active) return err } // SetRole updates the role for a user. func (r *UserRepository) SetRole(ctx context.Context, id string, role string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE users SET role = $2, updated_at = now() WHERE id = $1 `, id, role) return err } // List returns all users ordered by username. func (r *UserRepository) List(ctx context.Context) ([]*User, error) { rows, err := r.db.pool.Query(ctx, ` SELECT id, username, display_name, email, auth_source, oidc_subject, role, is_active, last_login_at, created_at, updated_at FROM users ORDER BY username `) if err != nil { return nil, fmt.Errorf("listing users: %w", err) } defer rows.Close() var users []*User for rows.Next() { u := &User{} var email, oidcSubject *string if err := rows.Scan( &u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err } if email != nil { u.Email = *email } u.OIDCSubject = oidcSubject users = append(users, u) } return users, rows.Err() } // TokenRepository provides API token database operations. type TokenRepository struct { db *DB } // NewTokenRepository creates a new token repository. func NewTokenRepository(db *DB) *TokenRepository { return &TokenRepository{db: db} } // Create inserts a new API token record. func (r *TokenRepository) Create(ctx context.Context, userID, name, tokenHash, tokenPrefix string, scopes []string, expiresAt *time.Time) (*TokenInfo, error) { t := &TokenInfo{} if scopes == nil { scopes = []string{} } err := r.db.pool.QueryRow(ctx, ` INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at `, userID, name, tokenHash, tokenPrefix, scopes, expiresAt).Scan( &t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes, &t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt, ) if err != nil { return nil, fmt.Errorf("creating api token: %w", err) } return t, nil } // ValidateToken hashes the raw token, looks it up, and checks expiry and user active status. // Returns the token info and user ID on success. func (r *TokenRepository) ValidateToken(ctx context.Context, rawToken string) (*TokenInfo, error) { hash := sha256.Sum256([]byte(rawToken)) tokenHash := hex.EncodeToString(hash[:]) t := &TokenInfo{} var isActive bool err := r.db.pool.QueryRow(ctx, ` SELECT t.id, t.user_id, t.name, t.token_prefix, t.scopes, t.last_used_at, t.expires_at, t.created_at, u.is_active FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = $1 `, tokenHash).Scan( &t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes, &t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt, &isActive, ) if err == pgx.ErrNoRows { return nil, fmt.Errorf("invalid token") } if err != nil { return nil, fmt.Errorf("validating token: %w", err) } if !isActive { return nil, fmt.Errorf("user account is disabled") } if t.ExpiresAt != nil && t.ExpiresAt.Before(time.Now()) { return nil, fmt.Errorf("token has expired") } return t, nil } // ListByUser returns all tokens for a user (without hashes). func (r *TokenRepository) ListByUser(ctx context.Context, userID string) ([]*TokenInfo, error) { rows, err := r.db.pool.Query(ctx, ` SELECT id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC `, userID) if err != nil { return nil, fmt.Errorf("listing tokens: %w", err) } defer rows.Close() var tokens []*TokenInfo for rows.Next() { t := &TokenInfo{} if err := rows.Scan( &t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes, &t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt, ); err != nil { return nil, err } tokens = append(tokens, t) } return tokens, rows.Err() } // Delete removes a token, ensuring it belongs to the given user. func (r *TokenRepository) Delete(ctx context.Context, userID, tokenID string) error { tag, err := r.db.pool.Exec(ctx, ` DELETE FROM api_tokens WHERE id = $1 AND user_id = $2 `, tokenID, userID) if err != nil { return fmt.Errorf("deleting token: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("token not found") } return nil } // TouchLastUsed updates the last_used_at timestamp for a token. func (r *TokenRepository) TouchLastUsed(ctx context.Context, tokenID string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE api_tokens SET last_used_at = now() WHERE id = $1 `, tokenID) return err }