// Package authd — store.go: Postgres-backed data access. The // schema is in migrations/009_auth.up.sql. This file is the only // place in the package that touches pgx directly; the rest of the // package uses the higher-level methods on *Authd. // // All methods take a context and respect cancellation. package authd import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "git3.techno-world.net/lrosales/broad-announce/internal/postgres" ) // Store wraps a pgx pool with auth-specific queries. Construct via // NewStore; do not instantiate directly. type Store struct { pool *postgres.Pool } // NewStore constructs a Store. func NewStore(pool *postgres.Pool) *Store { return &Store{pool: pool} } // --------------------------------------------------------------------------- // User types // --------------------------------------------------------------------------- // User is the row from auth.users, plus a couple of join fields // flattened for convenience. type User struct { ID string GlobalID string TenantID string Email string Role string Status string DisplayName string PasswordHash string LastLoginAt *time.Time CreatedAt time.Time UpdatedAt time.Time } // --------------------------------------------------------------------------- // User CRUD // --------------------------------------------------------------------------- // TenantIDBySlug resolves a tenant slug to its id. Returns // ErrUserNotFound if the slug doesn't exist (the error is // misnamed; v1.1 should split it). func (s *Store) TenantIDBySlug(ctx context.Context, slug string) (string, error) { if s.pool == nil { return "", errors.New("authd: no DB pool (test mode)") } const q = `SELECT id::text FROM auth.tenants WHERE slug = $1` var id string err := s.pool.QueryRow(ctx, q, slug).Scan(&id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrUserNotFound } return "", fmt.Errorf("tenant id by slug: %w", err) } return id, nil } // GetUserByEmail looks up a user by email. Email is unique per // tenant (or globally for super_admins), so we use the most recent // matching row if there are multiple. func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) { const q = ` SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''), email, role, status, COALESCE(display_name, ''), COALESCE(password_hash, ''), last_login_at, created_at, updated_at FROM auth.users WHERE email = $1 ORDER BY (tenant_id IS NULL) DESC, created_at DESC LIMIT 1 ` u := &User{} err := s.pool.QueryRow(ctx, q, email).Scan( &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status, &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("get user by email: %w", err) } return u, nil } // GetUserByID looks up a user by primary key. func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { const q = ` SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''), email, role, status, COALESCE(display_name, ''), COALESCE(password_hash, ''), last_login_at, created_at, updated_at FROM auth.users WHERE id = $1 ` u := &User{} err := s.pool.QueryRow(ctx, q, id).Scan( &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status, &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("get user by id: %w", err) } return u, nil } // CreateUser inserts a new pending user. Returns the new id. // Fails with a wrapped pg error if the email/tenant pair is not // unique. func (s *Store) CreateUser(ctx context.Context, tenantID, email, role string) (string, error) { const q = ` INSERT INTO auth.users (tenant_id, email, role, status) VALUES ( CASE WHEN $1 = '' THEN NULL ELSE $1::uuid END, $2, $3, 'pending' ) RETURNING id::text ` var id string err := s.pool.QueryRow(ctx, q, tenantID, email, role).Scan(&id) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return "", fmt.Errorf("user %q already exists: %w", email, err) } return "", fmt.Errorf("create user: %w", err) } return id, nil } // SetUserPassword updates password_hash and status. Used by both // magic-link-first-login and password-reset paths. func (s *Store) SetUserPassword(ctx context.Context, userID, hash, newStatus string) error { const q = `UPDATE auth.users SET password_hash = $2, status = $3 WHERE id = $1` tag, err := s.pool.Exec(ctx, q, userID, hash, newStatus) if err != nil { return fmt.Errorf("set password: %w", err) } if tag.RowsAffected() == 0 { return ErrUserNotFound } return nil } // TouchUserLogin updates last_login_at. Fire-and-forget from caller. func (s *Store) TouchUserLogin(ctx context.Context, userID string) error { const q = `UPDATE auth.users SET last_login_at = NOW() WHERE id = $1` _, err := s.pool.Exec(ctx, q, userID) return err } // --------------------------------------------------------------------------- // Magic links // --------------------------------------------------------------------------- // InsertMagicLink stores the hash of a magic link token. The // plaintext is never persisted. func (s *Store) InsertMagicLink(ctx context.Context, userID string, hash []byte, purpose string, expiresAt time.Time) error { if s.pool == nil { return errors.New("authd: no DB pool (test mode)") } const q = ` INSERT INTO auth.magic_links (token_hash, user_id, purpose, expires_at) VALUES ($1, $2::uuid, $3, $4) ` _, err := s.pool.Exec(ctx, q, hash, userID, purpose, expiresAt) if err != nil { return fmt.Errorf("insert magic link: %w", err) } return nil } // ConsumeMagicLink atomically marks a link consumed and returns the // user_id. Returns ErrMagicLinkInvalid for unknown, expired, or // already-consumed links. func (s *Store) ConsumeMagicLink(ctx context.Context, hash []byte, ip, ua string) (string, error) { const q = ` UPDATE auth.magic_links SET consumed_at = NOW(), consumed_ip = $2::inet, consumed_ua = $3 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > NOW() RETURNING user_id::text ` var userID string err := s.pool.QueryRow(ctx, q, hash, ip, ua).Scan(&userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrMagicLinkInvalid } return "", fmt.Errorf("consume magic link: %w", err) } return userID, nil } // --------------------------------------------------------------------------- // Refresh tokens (delegate to SQL functions) // --------------------------------------------------------------------------- // IssueRefreshToken asks Postgres for a new token row. Returns the // plaintext token (the caller returns it to the client once and // discards it). func (s *Store) IssueRefreshToken(ctx context.Context, userID, accessJTI string, ttlSeconds int, ip, ua string) (string, error) { const q = `SELECT token FROM auth.issue_refresh_token($1::uuid, $2, $3, $4::inet, $5)` var token string err := s.pool.QueryRow(ctx, q, userID, accessJTI, ttlSeconds, ip, ua).Scan(&token) if err != nil { return "", fmt.Errorf("issue refresh token: %w", err) } return token, nil } // RotateRefreshToken swaps a refresh token for a new one. On // re-use, returns ErrTokenReuse and the whole family is killed in // the SQL function. Returns the user, the new plaintext token, and // the JTI to use for the new access JWT. func (s *Store) RotateRefreshToken(ctx context.Context, presentedToken string, ttlSeconds int, ip, ua string) (*User, string, string, error) { // We need the JTI for the new access token. The SQL function // returns (id, token, family_id, expires_at, killed_family) but // NOT a new JTI. We generate the JTI here and pass it in. // Patch: the SQL function signature is fixed (p_new_access_jti // is the last text arg), so we generate it client-side and pass // it in, then read it back from the returned columns. newJTI := newJTI() const q = ` SELECT id::text, token, family_id, expires_at, killed_family FROM auth.rotate_refresh_token($1, $2, $3, $4::inet, $5) ` var ( id string token string familyID string exp time.Time killed bool ) err := s.pool.QueryRow(ctx, q, presentedToken, newJTI, ttlSeconds, ip, ua).Scan(&id, &token, &familyID, &exp, &killed) if err != nil { // Distinguish re-use (22023 with refresh_token_reuse) from // other failures. The SQL raises different messages; we // match on the message. msg := err.Error() if contains(msg, "refresh_token_reuse") { return nil, "", "", ErrTokenReuse } if contains(msg, "expired_refresh_token") { return nil, "", "", errors.New("authd: refresh token expired") } if contains(msg, "unknown_refresh_token") { return nil, "", "", errors.New("authd: refresh token unknown") } return nil, "", "", fmt.Errorf("rotate refresh token: %w", err) } _ = killed // logged separately _ = familyID // future: pass back for client-side display // Fetch the user (we need tenant + role for the new access JWT) user, err := s.getUserByRefreshID(ctx, id) if err != nil { return nil, "", "", err } return user, token, newJTI, nil } // RevokeRefreshToken revokes a single token by plaintext. Returns // true if a row was actually revoked. func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error { const q = `SELECT auth.revoke_refresh_token($1)` var revoked bool err := s.pool.QueryRow(ctx, q, token).Scan(&revoked) if err != nil { return fmt.Errorf("revoke refresh token: %w", err) } _ = revoked return nil } // getUserByRefreshID is a join used by RotateRefreshToken. func (s *Store) getUserByRefreshID(ctx context.Context, refreshID string) (*User, error) { const q = ` SELECT u.id::text, u.global_id::text, COALESCE(u.tenant_id::text, ''), u.email, u.role, u.status, COALESCE(u.display_name, ''), COALESCE(u.password_hash, ''), u.last_login_at, u.created_at, u.updated_at FROM auth.refresh_tokens rt JOIN auth.users u ON u.id = rt.user_id WHERE rt.id = $1::uuid ` u := &User{} err := s.pool.QueryRow(ctx, q, refreshID).Scan( &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status, &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("get user by refresh id: %w", err) } return u, nil } // --------------------------------------------------------------------------- // Audit log // --------------------------------------------------------------------------- // WriteAudit records an event. targetID and tenantID are optional // (empty string → NULL in the column). payload may be nil. func (s *Store) WriteAudit( ctx context.Context, action string, actorUserID string, actorIP string, actorUA string, targetID string, tenantID string, payload map[string]any, ) error { var payloadJSON []byte if payload != nil { var err error payloadJSON, err = json.Marshal(payload) if err != nil { return fmt.Errorf("marshal audit payload: %w", err) } } else { payloadJSON = []byte("{}") } // Empty string → NULL for actor_user_id var actorArg any if actorUserID == "" { actorArg = nil } else { actorArg = actorUserID } const q = ` INSERT INTO auth.audit_log (action, actor_user_id, actor_ip, actor_ua, target_id, tenant_id, payload) VALUES ($1, $2::uuid, NULLIF($3, '')::inet, NULLIF($4, ''), NULLIF($5, '')::uuid, NULLIF($6, '')::uuid, $7::jsonb) ` _, err := s.pool.Exec(ctx, q, action, actorArg, actorIP, actorUA, targetID, tenantID, payloadJSON) if err != nil { return fmt.Errorf("write audit: %w", err) } return nil } // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- func contains(s, substr string) bool { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return true } } return false }