package auth import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "github.com/go-webauthn/webauthn/webauthn" ) type Repository struct { db *sql.DB } func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) { now := time.Now().UTC() if user.ID == "" { user.ID = "user:" + user.Email } if user.Role == "" { user.Role = RoleViewer } if user.DisplayName == "" { user.DisplayName = user.Email } if _, err := r.db.ExecContext(ctx, ` INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name, password_hash = COALESCE(excluded.password_hash, users.password_hash), role = excluded.role, last_seen_at = excluded.last_seen_at `, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(user.Role), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { return User{}, fmt.Errorf("upsert user: %w", err) } return r.GetUserByEmail(ctx, user.Email) } func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) { var user User var created, lastSeen, passwordHash, role string var disabled int err := r.db.QueryRowContext(ctx, ` SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '') FROM users WHERE email = ? `, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &disabled, &created, &lastSeen) if err != nil { return User{}, err } user.PasswordHash = passwordHash user.Role = Role(role) user.Disabled = disabled == 1 user.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { return User{}, fmt.Errorf("parse user created_at: %w", err) } if lastSeen != "" { user.LastSeenAt, err = time.Parse(time.RFC3339, lastSeen) if err != nil { return User{}, fmt.Errorf("parse user last_seen_at: %w", err) } } user.Credentials, err = r.ListCredentials(ctx, user.ID) if err != nil { return User{}, err } return user, nil } func (r *Repository) GetUserByID(ctx context.Context, userID string) (User, error) { var email string if err := r.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, userID).Scan(&email); err != nil { return User{}, err } return r.GetUserByEmail(ctx, email) } func (r *Repository) CountUsers(ctx context.Context) (int, error) { var count int if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { return 0, err } return count, nil } func (r *Repository) CountAdmins(ctx context.Context) (int, error) { var count int if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&count); err != nil { return 0, err } return count, nil } func (r *Repository) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) { var setupCompleted string var signupsEnabled int if err := r.db.QueryRowContext(ctx, ` SELECT COALESCE(setup_completed_at, ''), signups_enabled FROM instance_settings WHERE id = 1 `).Scan(&setupCompleted, &signupsEnabled); err != nil { return InstanceSettings{}, fmt.Errorf("get instance settings: %w", err) } return InstanceSettings{ SetupComplete: setupCompleted != "", SignupsEnabled: signupsEnabled == 1, }, nil } func (r *Repository) CompleteInitialSetup(ctx context.Context, user User, signupsEnabled bool) (User, error) { now := time.Now().UTC() if user.ID == "" { user.ID = "user:" + user.Email } if user.DisplayName == "" { user.DisplayName = user.Email } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return User{}, fmt.Errorf("begin initial setup: %w", err) } defer func() { _ = tx.Rollback() }() var setupCompleted string if err := tx.QueryRowContext(ctx, ` SELECT COALESCE(setup_completed_at, '') FROM instance_settings WHERE id = 1 `).Scan(&setupCompleted); err != nil { return User{}, fmt.Errorf("get initial setup state: %w", err) } if setupCompleted != "" { return User{}, fmt.Errorf("initial setup has already been completed") } var userCount int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { return User{}, fmt.Errorf("count users during initial setup: %w", err) } if userCount != 0 { return User{}, fmt.Errorf("initial setup requires an empty user database") } if _, err := tx.ExecContext(ctx, ` INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(RoleAdmin), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { return User{}, fmt.Errorf("create initial admin: %w", err) } if _, err := tx.ExecContext(ctx, ` UPDATE instance_settings SET setup_completed_at = ?, signups_enabled = ?, updated_at = ? WHERE id = 1 AND setup_completed_at IS NULL `, now.Format(time.RFC3339), boolInt(signupsEnabled), now.Format(time.RFC3339)); err != nil { return User{}, fmt.Errorf("complete initial setup: %w", err) } if err := tx.Commit(); err != nil { return User{}, fmt.Errorf("commit initial setup: %w", err) } return r.GetUserByEmail(ctx, user.Email) } func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) error { result, err := r.db.ExecContext(ctx, ` UPDATE instance_settings SET signups_enabled = ?, updated_at = ? WHERE id = 1 AND setup_completed_at IS NOT NULL `, boolInt(enabled), time.Now().UTC().Format(time.RFC3339)) if err != nil { return fmt.Errorf("update signup setting: %w", err) } if rows, _ := result.RowsAffected(); rows == 0 { return fmt.Errorf("initial setup is required") } return nil } func (r *Repository) ListUsers(ctx context.Context) ([]User, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '') FROM users ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []User for rows.Next() { var user User var created, lastSeen, role string var disabled int if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &disabled, &created, &lastSeen); err != nil { return nil, fmt.Errorf("scan user: %w", err) } user.Role = Role(role) user.Disabled = disabled == 1 user.CreatedAt, _ = time.Parse(time.RFC3339, created) if lastSeen != "" { user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen) } users = append(users, user) } return users, rows.Err() } func (r *Repository) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error { result, err := r.db.ExecContext(ctx, ` UPDATE users SET password_hash = ?, last_seen_at = ? WHERE id = ? `, passwordHash, time.Now().UTC().Format(time.RFC3339), userID) if err != nil { return err } if rows, _ := result.RowsAffected(); rows == 0 { return sql.ErrNoRows } return nil } func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Role) error { result, err := r.db.ExecContext(ctx, ` UPDATE users SET role = ?, last_seen_at = ? WHERE id = ? `, string(role), time.Now().UTC().Format(time.RFC3339), userID) if err != nil { return err } if rows, _ := result.RowsAffected(); rows == 0 { return sql.ErrNoRows } return nil } func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessUpdate) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin user access update: %w", err) } defer func() { _ = tx.Rollback() }() for _, update := range updates { result, err := tx.ExecContext(ctx, ` UPDATE users SET role = ?, disabled = ? WHERE id = ? `, string(update.Role), boolInt(update.Disabled), update.ID) if err != nil { return fmt.Errorf("update user access: %w", err) } if rows, _ := result.RowsAffected(); rows == 0 { return sql.ErrNoRows } } var admins int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&admins); err != nil { return fmt.Errorf("count enabled admins: %w", err) } if admins == 0 { return fmt.Errorf("cannot disable or demote the last admin account") } if err := tx.Commit(); err != nil { return fmt.Errorf("commit user access update: %w", err) } return nil } func (r *Repository) DeleteUser(ctx context.Context, userID string) error { result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) if err != nil { return err } if rows, _ := result.RowsAffected(); rows == 0 { return sql.ErrNoRows } return nil } func (r *Repository) SaveCredential(ctx context.Context, userID string, credential webauthn.Credential) error { now := time.Now().UTC().Format(time.RFC3339) payload, err := json.Marshal(credential) if err != nil { return fmt.Errorf("marshal credential: %w", err) } if _, err := r.db.ExecContext(ctx, ` INSERT INTO webauthn_credentials (id, user_id, credential_id, credential_json, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(credential_id) DO UPDATE SET credential_json = excluded.credential_json, last_used_at = excluded.last_used_at `, "cred:"+hashSecret(string(credential.ID))[:24], userID, credential.ID, string(payload), now, now); err != nil { return fmt.Errorf("save credential: %w", err) } return nil } func (r *Repository) ListCredentials(ctx context.Context, userID string) ([]webauthn.Credential, error) { rows, err := r.db.QueryContext(ctx, ` SELECT credential_json FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC `, userID) if err != nil { return nil, fmt.Errorf("list credentials: %w", err) } defer rows.Close() var credentials []webauthn.Credential for rows.Next() { var raw string if err := rows.Scan(&raw); err != nil { return nil, fmt.Errorf("scan credential: %w", err) } var credential webauthn.Credential if err := json.Unmarshal([]byte(raw), &credential); err != nil { return nil, fmt.Errorf("unmarshal credential: %w", err) } credentials = append(credentials, credential) } return credentials, rows.Err() } func (r *Repository) SaveChallenge(ctx context.Context, userID, challengeType string, session webauthn.SessionData, ttl time.Duration) (string, error) { idPart, err := randomToken(18) if err != nil { return "", err } payload, err := json.Marshal(session) if err != nil { return "", fmt.Errorf("marshal challenge: %w", err) } now := time.Now().UTC() id := "chal:" + idPart if _, err := r.db.ExecContext(ctx, ` INSERT INTO auth_challenges (id, user_id, type, session_json, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) `, id, userID, challengeType, string(payload), now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339)); err != nil { return "", fmt.Errorf("save challenge: %w", err) } return id, nil } func (r *Repository) ConsumeChallenge(ctx context.Context, id, challengeType string) (webauthn.SessionData, string, error) { var raw, userID, expires, used string err := r.db.QueryRowContext(ctx, ` SELECT session_json, user_id, expires_at, COALESCE(used_at, '') FROM auth_challenges WHERE id = ? AND type = ? `, id, challengeType).Scan(&raw, &userID, &expires, &used) if err != nil { return webauthn.SessionData{}, "", err } if used != "" { return webauthn.SessionData{}, "", fmt.Errorf("challenge already used") } expiresAt, err := time.Parse(time.RFC3339, expires) if err != nil { return webauthn.SessionData{}, "", fmt.Errorf("parse challenge expiry: %w", err) } if time.Now().UTC().After(expiresAt) { return webauthn.SessionData{}, "", fmt.Errorf("challenge expired") } var session webauthn.SessionData if err := json.Unmarshal([]byte(raw), &session); err != nil { return webauthn.SessionData{}, "", fmt.Errorf("unmarshal challenge: %w", err) } if _, err := r.db.ExecContext(ctx, `UPDATE auth_challenges SET used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil { return webauthn.SessionData{}, "", fmt.Errorf("consume challenge: %w", err) } return session, userID, nil } func (r *Repository) CreateSession(ctx context.Context, userID, tokenHash, ip, userAgent string, ttl time.Duration) (Session, error) { idPart, err := randomToken(18) if err != nil { return Session{}, err } now := time.Now().UTC() session := Session{ ID: "sess:" + idPart, UserID: userID, TokenHash: tokenHash, CreatedAt: now, ExpiresAt: now.Add(ttl), } if _, err := r.db.ExecContext(ctx, ` INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, last_seen_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, session.ID, session.UserID, session.TokenHash, session.CreatedAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), ip, userAgent); err != nil { return Session{}, fmt.Errorf("create session: %w", err) } return session, nil } func (r *Repository) ValidateSession(ctx context.Context, tokenHash string) (Session, User, error) { var session Session var created, expires, revoked string err := r.db.QueryRowContext(ctx, ` SELECT id, user_id, token_hash, created_at, expires_at, COALESCE(revoked_at, '') FROM sessions WHERE token_hash = ? `, tokenHash).Scan(&session.ID, &session.UserID, &session.TokenHash, &created, &expires, &revoked) if err != nil { return Session{}, User{}, err } session.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { return Session{}, User{}, err } session.ExpiresAt, err = time.Parse(time.RFC3339, expires) if err != nil { return Session{}, User{}, err } if revoked != "" { return Session{}, User{}, fmt.Errorf("session revoked") } if time.Now().UTC().After(session.ExpiresAt) { return Session{}, User{}, fmt.Errorf("session expired") } user, err := r.GetUserByID(ctx, session.UserID) if err != nil { return Session{}, User{}, err } _, _ = r.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), session.ID) return session, user, nil } func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error { _, err := r.db.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), sessionID) return err } func (r *Repository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin password reset token: %w", err) } defer func() { _ = tx.Rollback() }() now := time.Now().UTC().Format(time.RFC3339) if _, err := tx.ExecContext(ctx, ` UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL `, now, token.UserID); err != nil { return fmt.Errorf("expire prior password reset tokens: %w", err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO password_reset_tokens (id, user_id, token_hash, created_at, expires_at, initiated_by) VALUES (?, ?, ?, ?, ?, ?) `, token.ID, token.UserID, token.TokenHash, token.CreatedAt.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339), nullString(token.InitiatedBy)); err != nil { return fmt.Errorf("create password reset token: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit password reset token: %w", err) } return nil } func (r *Repository) ExpirePasswordResetToken(ctx context.Context, tokenHash string) { _, _ = r.db.ExecContext(ctx, ` UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL `, time.Now().UTC().Format(time.RFC3339), tokenHash) } func (r *Repository) CompletePasswordReset(ctx context.Context, tokenHash, passwordHash string) (string, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return "", fmt.Errorf("begin password reset: %w", err) } defer func() { _ = tx.Rollback() }() var id, userID, expires, used string if err := tx.QueryRowContext(ctx, ` SELECT id, user_id, expires_at, COALESCE(used_at, '') FROM password_reset_tokens WHERE token_hash = ? `, tokenHash).Scan(&id, &userID, &expires, &used); err != nil { return "", err } if used != "" { return "", fmt.Errorf("password reset link has already been used") } expiresAt, err := time.Parse(time.RFC3339, expires) if err != nil { return "", fmt.Errorf("parse password reset expiry: %w", err) } if time.Now().UTC().After(expiresAt) { return "", fmt.Errorf("password reset link has expired") } now := time.Now().UTC().Format(time.RFC3339) result, err := tx.ExecContext(ctx, `UPDATE password_reset_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL`, now, id) if err != nil { return "", fmt.Errorf("consume password reset token: %w", err) } if rows, _ := result.RowsAffected(); rows == 0 { return "", fmt.Errorf("password reset link has already been used") } if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID); err != nil { return "", fmt.Errorf("update reset password: %w", err) } if _, err := tx.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, now, userID); err != nil { return "", fmt.Errorf("revoke password reset sessions: %w", err) } if err := tx.Commit(); err != nil { return "", fmt.Errorf("commit password reset: %w", err) } return userID, nil } func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error { if _, err := r.db.ExecContext(ctx, ` INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, key.ID, key.UserID, key.Name, key.KeyHash, scopesToString(key.Scopes), key.CreatedAt.Format(time.RFC3339), timePtrString(key.ExpiresAt), timePtrString(key.LastUsedAt), timePtrString(key.RevokedAt)); err != nil { return fmt.Errorf("create api key: %w", err) } return nil } func (r *Repository) ValidateAPIKey(ctx context.Context, keyID, keyHash string) (APIKey, User, error) { var key APIKey var scopesRaw, created, expires, lastUsed, revoked string err := r.db.QueryRowContext(ctx, ` SELECT id, user_id, name, key_hash, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '') FROM api_keys WHERE id = ? `, keyID).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyHash, &scopesRaw, &created, &expires, &lastUsed, &revoked) if err != nil { return APIKey{}, User{}, err } if !constantTimeEqualHex(key.KeyHash, keyHash) { return APIKey{}, User{}, sql.ErrNoRows } key.Scopes = scopesFromString(scopesRaw) key.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { return APIKey{}, User{}, err } if expires != "" { parsed, err := time.Parse(time.RFC3339, expires) if err != nil { return APIKey{}, User{}, err } if time.Now().UTC().After(parsed) { return APIKey{}, User{}, fmt.Errorf("api key expired") } key.ExpiresAt = &parsed } if revoked != "" { return APIKey{}, User{}, fmt.Errorf("api key revoked") } if lastUsed != "" { parsed, err := time.Parse(time.RFC3339, lastUsed) if err == nil { key.LastUsedAt = &parsed } } user, err := r.GetUserByID(ctx, key.UserID) if err != nil { return APIKey{}, User{}, err } _, _ = r.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), key.ID) return key, user, nil } func (r *Repository) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, user_id, name, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '') FROM api_keys WHERE user_id = ? ORDER BY created_at DESC `, userID) if err != nil { return nil, err } defer rows.Close() var keys []APIKey for rows.Next() { var key APIKey var scopesRaw, created, expires, lastUsed, revoked string if err := rows.Scan(&key.ID, &key.UserID, &key.Name, &scopesRaw, &created, &expires, &lastUsed, &revoked); err != nil { return nil, err } key.Scopes = scopesFromString(scopesRaw) key.CreatedAt, _ = time.Parse(time.RFC3339, created) if expires != "" { parsed, _ := time.Parse(time.RFC3339, expires) key.ExpiresAt = &parsed } if lastUsed != "" { parsed, _ := time.Parse(time.RFC3339, lastUsed) key.LastUsedAt = &parsed } if revoked != "" { parsed, _ := time.Parse(time.RFC3339, revoked) key.RevokedAt = &parsed } keys = append(keys, key) } return keys, rows.Err() } func (r *Repository) RevokeAPIKey(ctx context.Context, userID, keyID string) error { result, err := r.db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?`, time.Now().UTC().Format(time.RFC3339), keyID, userID) if err != nil { return err } if rows, _ := result.RowsAffected(); rows == 0 { return sql.ErrNoRows } return nil } func (r *Repository) CreateDeviceCode(ctx context.Context, device DeviceCode) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO device_codes (id, device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, device.ID, device.DeviceCodeHash, device.UserCodeHash, device.UserCodeDisplay, device.Name, scopesToString(device.Scopes), device.Status, device.CreatedAt.Format(time.RFC3339), device.ExpiresAt.Format(time.RFC3339)) return err } func (r *Repository) GetDeviceCodeByUserCode(ctx context.Context, userCodeHash string) (DeviceCode, error) { return r.getDeviceCode(ctx, `user_code_hash = ?`, userCodeHash) } func (r *Repository) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCodeHash string) (DeviceCode, error) { return r.getDeviceCode(ctx, `device_code_hash = ?`, deviceCodeHash) } func (r *Repository) getDeviceCode(ctx context.Context, predicate string, value string) (DeviceCode, error) { var device DeviceCode var scopesRaw, created, expires, approved, lastPolled, userID string row := r.db.QueryRowContext(ctx, ` SELECT id, COALESCE(user_id, ''), device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at, COALESCE(approved_at, ''), COALESCE(last_polled_at, '') FROM device_codes WHERE `+predicate, value) if err := row.Scan(&device.ID, &userID, &device.DeviceCodeHash, &device.UserCodeHash, &device.UserCodeDisplay, &device.Name, &scopesRaw, &device.Status, &created, &expires, &approved, &lastPolled); err != nil { return DeviceCode{}, err } device.UserID = userID device.Scopes = scopesFromString(scopesRaw) device.CreatedAt, _ = time.Parse(time.RFC3339, created) device.ExpiresAt, _ = time.Parse(time.RFC3339, expires) if approved != "" { parsed, _ := time.Parse(time.RFC3339, approved) device.ApprovedAt = &parsed } if lastPolled != "" { parsed, _ := time.Parse(time.RFC3339, lastPolled) device.LastPolledAt = &parsed } if time.Now().UTC().After(device.ExpiresAt) && device.Status == "pending" { device.Status = "expired" _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired' WHERE id = ?`, device.ID) } return device, nil } func (r *Repository) ApproveDeviceCode(ctx context.Context, userCodeHash, userID string) (DeviceCode, error) { now := time.Now().UTC().Format(time.RFC3339) result, err := r.db.ExecContext(ctx, ` UPDATE device_codes SET status = 'approved', user_id = ?, approved_at = ? WHERE user_code_hash = ? AND status = 'pending' `, userID, now, userCodeHash) if err != nil { return DeviceCode{}, err } if rows, _ := result.RowsAffected(); rows == 0 { return DeviceCode{}, sql.ErrNoRows } return r.GetDeviceCodeByUserCode(ctx, userCodeHash) } func (r *Repository) TouchDevicePoll(ctx context.Context, id string) { _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) } func (r *Repository) FinishDeviceCode(ctx context.Context, id string) { _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired', last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) } func (r *Repository) Audit(ctx context.Context, actorUserID, eventType, resourceType, resourceID, ip, userAgent string, metadata map[string]any) { idPart, err := randomToken(18) if err != nil { return } if metadata == nil { metadata = map[string]any{} } payload, err := json.Marshal(metadata) if err != nil { payload = []byte("{}") } _, _ = r.db.ExecContext(ctx, ` INSERT INTO audit_log (id, actor_user_id, event_type, resource_type, resource_id, ip_address, user_agent, created_at, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, "audit:"+idPart, nullString(actorUserID), eventType, nullString(resourceType), nullString(resourceID), ip, userAgent, time.Now().UTC().Format(time.RFC3339), string(payload)) } func nullString(value string) any { if value == "" { return nil } return value } func boolInt(value bool) int { if value { return 1 } return 0 } func timePtrString(value *time.Time) any { if value == nil { return nil } return value.UTC().Format(time.RFC3339) } func isNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) }