store.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. // Package authd — store.go: Postgres-backed data access. The
  2. // schema is in migrations/009_auth.up.sql. This file is the only
  3. // place in the package that touches pgx directly; the rest of the
  4. // package uses the higher-level methods on *Authd.
  5. //
  6. // All methods take a context and respect cancellation.
  7. package authd
  8. import (
  9. "context"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "time"
  14. "github.com/jackc/pgx/v5"
  15. "github.com/jackc/pgx/v5/pgconn"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  17. )
  18. // Store wraps a pgx pool with auth-specific queries. Construct via
  19. // NewStore; do not instantiate directly.
  20. type Store struct {
  21. pool *postgres.Pool
  22. }
  23. // NewStore constructs a Store.
  24. func NewStore(pool *postgres.Pool) *Store {
  25. return &Store{pool: pool}
  26. }
  27. // ---------------------------------------------------------------------------
  28. // User types
  29. // ---------------------------------------------------------------------------
  30. // User is the row from auth.users, plus a couple of join fields
  31. // flattened for convenience.
  32. type User struct {
  33. ID string
  34. GlobalID string
  35. TenantID string
  36. Email string
  37. Role string
  38. Status string
  39. DisplayName string
  40. PasswordHash string
  41. LastLoginAt *time.Time
  42. CreatedAt time.Time
  43. UpdatedAt time.Time
  44. }
  45. // ---------------------------------------------------------------------------
  46. // User CRUD
  47. // ---------------------------------------------------------------------------
  48. // TenantIDBySlug resolves a tenant slug to its id. Returns
  49. // ErrUserNotFound if the slug doesn't exist (the error is
  50. // misnamed; v1.1 should split it).
  51. func (s *Store) TenantIDBySlug(ctx context.Context, slug string) (string, error) {
  52. if s.pool == nil {
  53. return "", errors.New("authd: no DB pool (test mode)")
  54. }
  55. const q = `SELECT id::text FROM auth.tenants WHERE slug = $1`
  56. var id string
  57. err := s.pool.QueryRow(ctx, q, slug).Scan(&id)
  58. if err != nil {
  59. if errors.Is(err, pgx.ErrNoRows) {
  60. return "", ErrUserNotFound
  61. }
  62. return "", fmt.Errorf("tenant id by slug: %w", err)
  63. }
  64. return id, nil
  65. }
  66. // GetUserByEmail looks up a user by email. Email is unique per
  67. // tenant (or globally for super_admins), so we use the most recent
  68. // matching row if there are multiple.
  69. func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
  70. const q = `
  71. SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
  72. email, role, status, COALESCE(display_name, ''),
  73. COALESCE(password_hash, ''), last_login_at, created_at, updated_at
  74. FROM auth.users
  75. WHERE email = $1
  76. ORDER BY (tenant_id IS NULL) DESC, created_at DESC
  77. LIMIT 1
  78. `
  79. u := &User{}
  80. err := s.pool.QueryRow(ctx, q, email).Scan(
  81. &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
  82. &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
  83. )
  84. if err != nil {
  85. if errors.Is(err, pgx.ErrNoRows) {
  86. return nil, ErrUserNotFound
  87. }
  88. return nil, fmt.Errorf("get user by email: %w", err)
  89. }
  90. return u, nil
  91. }
  92. // GetUserByID looks up a user by primary key.
  93. func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
  94. const q = `
  95. SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
  96. email, role, status, COALESCE(display_name, ''),
  97. COALESCE(password_hash, ''), last_login_at, created_at, updated_at
  98. FROM auth.users
  99. WHERE id = $1
  100. `
  101. u := &User{}
  102. err := s.pool.QueryRow(ctx, q, id).Scan(
  103. &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
  104. &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
  105. )
  106. if err != nil {
  107. if errors.Is(err, pgx.ErrNoRows) {
  108. return nil, ErrUserNotFound
  109. }
  110. return nil, fmt.Errorf("get user by id: %w", err)
  111. }
  112. return u, nil
  113. }
  114. // CreateUser inserts a new pending user. Returns the new id.
  115. // Fails with a wrapped pg error if the email/tenant pair is not
  116. // unique.
  117. func (s *Store) CreateUser(ctx context.Context, tenantID, email, role string) (string, error) {
  118. const q = `
  119. INSERT INTO auth.users (tenant_id, email, role, status)
  120. VALUES (
  121. CASE WHEN $1 = '' THEN NULL ELSE $1::uuid END,
  122. $2, $3, 'pending'
  123. )
  124. RETURNING id::text
  125. `
  126. var id string
  127. err := s.pool.QueryRow(ctx, q, tenantID, email, role).Scan(&id)
  128. if err != nil {
  129. var pgErr *pgconn.PgError
  130. if errors.As(err, &pgErr) && pgErr.Code == "23505" {
  131. return "", fmt.Errorf("user %q already exists: %w", email, err)
  132. }
  133. return "", fmt.Errorf("create user: %w", err)
  134. }
  135. return id, nil
  136. }
  137. // SetUserPassword updates password_hash and status. Used by both
  138. // magic-link-first-login and password-reset paths.
  139. func (s *Store) SetUserPassword(ctx context.Context, userID, hash, newStatus string) error {
  140. const q = `UPDATE auth.users SET password_hash = $2, status = $3 WHERE id = $1`
  141. tag, err := s.pool.Exec(ctx, q, userID, hash, newStatus)
  142. if err != nil {
  143. return fmt.Errorf("set password: %w", err)
  144. }
  145. if tag.RowsAffected() == 0 {
  146. return ErrUserNotFound
  147. }
  148. return nil
  149. }
  150. // TouchUserLogin updates last_login_at. Fire-and-forget from caller.
  151. func (s *Store) TouchUserLogin(ctx context.Context, userID string) error {
  152. const q = `UPDATE auth.users SET last_login_at = NOW() WHERE id = $1`
  153. _, err := s.pool.Exec(ctx, q, userID)
  154. return err
  155. }
  156. // ---------------------------------------------------------------------------
  157. // Magic links
  158. // ---------------------------------------------------------------------------
  159. // InsertMagicLink stores the hash of a magic link token. The
  160. // plaintext is never persisted.
  161. func (s *Store) InsertMagicLink(ctx context.Context, userID string, hash []byte, purpose string, expiresAt time.Time) error {
  162. if s.pool == nil {
  163. return errors.New("authd: no DB pool (test mode)")
  164. }
  165. const q = `
  166. INSERT INTO auth.magic_links (token_hash, user_id, purpose, expires_at)
  167. VALUES ($1, $2::uuid, $3, $4)
  168. `
  169. _, err := s.pool.Exec(ctx, q, hash, userID, purpose, expiresAt)
  170. if err != nil {
  171. return fmt.Errorf("insert magic link: %w", err)
  172. }
  173. return nil
  174. }
  175. // ConsumeMagicLink atomically marks a link consumed and returns the
  176. // user_id. Returns ErrMagicLinkInvalid for unknown, expired, or
  177. // already-consumed links.
  178. func (s *Store) ConsumeMagicLink(ctx context.Context, hash []byte, ip, ua string) (string, error) {
  179. const q = `
  180. UPDATE auth.magic_links
  181. SET consumed_at = NOW(),
  182. consumed_ip = $2::inet,
  183. consumed_ua = $3
  184. WHERE token_hash = $1
  185. AND consumed_at IS NULL
  186. AND expires_at > NOW()
  187. RETURNING user_id::text
  188. `
  189. var userID string
  190. err := s.pool.QueryRow(ctx, q, hash, ip, ua).Scan(&userID)
  191. if err != nil {
  192. if errors.Is(err, pgx.ErrNoRows) {
  193. return "", ErrMagicLinkInvalid
  194. }
  195. return "", fmt.Errorf("consume magic link: %w", err)
  196. }
  197. return userID, nil
  198. }
  199. // ---------------------------------------------------------------------------
  200. // Refresh tokens (delegate to SQL functions)
  201. // ---------------------------------------------------------------------------
  202. // IssueRefreshToken asks Postgres for a new token row. Returns the
  203. // plaintext token (the caller returns it to the client once and
  204. // discards it).
  205. func (s *Store) IssueRefreshToken(ctx context.Context, userID, accessJTI string, ttlSeconds int, ip, ua string) (string, error) {
  206. const q = `SELECT token FROM auth.issue_refresh_token($1::uuid, $2, $3, $4::inet, $5)`
  207. var token string
  208. err := s.pool.QueryRow(ctx, q, userID, accessJTI, ttlSeconds, ip, ua).Scan(&token)
  209. if err != nil {
  210. return "", fmt.Errorf("issue refresh token: %w", err)
  211. }
  212. return token, nil
  213. }
  214. // RotateRefreshToken swaps a refresh token for a new one. On
  215. // re-use, returns ErrTokenReuse and the whole family is killed in
  216. // the SQL function. Returns the user, the new plaintext token, and
  217. // the JTI to use for the new access JWT.
  218. func (s *Store) RotateRefreshToken(ctx context.Context, presentedToken string, ttlSeconds int, ip, ua string) (*User, string, string, error) {
  219. // We need the JTI for the new access token. The SQL function
  220. // returns (id, token, family_id, expires_at, killed_family) but
  221. // NOT a new JTI. We generate the JTI here and pass it in.
  222. // Patch: the SQL function signature is fixed (p_new_access_jti
  223. // is the last text arg), so we generate it client-side and pass
  224. // it in, then read it back from the returned columns.
  225. newJTI := newJTI()
  226. const q = `
  227. SELECT id::text, token, family_id, expires_at, killed_family
  228. FROM auth.rotate_refresh_token($1, $2, $3, $4::inet, $5)
  229. `
  230. var (
  231. id string
  232. token string
  233. familyID string
  234. exp time.Time
  235. killed bool
  236. )
  237. err := s.pool.QueryRow(ctx, q, presentedToken, newJTI, ttlSeconds, ip, ua).Scan(&id, &token, &familyID, &exp, &killed)
  238. if err != nil {
  239. // Distinguish re-use (22023 with refresh_token_reuse) from
  240. // other failures. The SQL raises different messages; we
  241. // match on the message.
  242. msg := err.Error()
  243. if contains(msg, "refresh_token_reuse") {
  244. return nil, "", "", ErrTokenReuse
  245. }
  246. if contains(msg, "expired_refresh_token") {
  247. return nil, "", "", errors.New("authd: refresh token expired")
  248. }
  249. if contains(msg, "unknown_refresh_token") {
  250. return nil, "", "", errors.New("authd: refresh token unknown")
  251. }
  252. return nil, "", "", fmt.Errorf("rotate refresh token: %w", err)
  253. }
  254. _ = killed // logged separately
  255. _ = familyID // future: pass back for client-side display
  256. // Fetch the user (we need tenant + role for the new access JWT)
  257. user, err := s.getUserByRefreshID(ctx, id)
  258. if err != nil {
  259. return nil, "", "", err
  260. }
  261. return user, token, newJTI, nil
  262. }
  263. // RevokeRefreshToken revokes a single token by plaintext. Returns
  264. // true if a row was actually revoked.
  265. func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error {
  266. const q = `SELECT auth.revoke_refresh_token($1)`
  267. var revoked bool
  268. err := s.pool.QueryRow(ctx, q, token).Scan(&revoked)
  269. if err != nil {
  270. return fmt.Errorf("revoke refresh token: %w", err)
  271. }
  272. _ = revoked
  273. return nil
  274. }
  275. // getUserByRefreshID is a join used by RotateRefreshToken.
  276. func (s *Store) getUserByRefreshID(ctx context.Context, refreshID string) (*User, error) {
  277. const q = `
  278. SELECT u.id::text, u.global_id::text, COALESCE(u.tenant_id::text, ''),
  279. u.email, u.role, u.status, COALESCE(u.display_name, ''),
  280. COALESCE(u.password_hash, ''), u.last_login_at, u.created_at, u.updated_at
  281. FROM auth.refresh_tokens rt
  282. JOIN auth.users u ON u.id = rt.user_id
  283. WHERE rt.id = $1::uuid
  284. `
  285. u := &User{}
  286. err := s.pool.QueryRow(ctx, q, refreshID).Scan(
  287. &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
  288. &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
  289. )
  290. if err != nil {
  291. return nil, fmt.Errorf("get user by refresh id: %w", err)
  292. }
  293. return u, nil
  294. }
  295. // ---------------------------------------------------------------------------
  296. // Audit log
  297. // ---------------------------------------------------------------------------
  298. // WriteAudit records an event. targetID and tenantID are optional
  299. // (empty string → NULL in the column). payload may be nil.
  300. func (s *Store) WriteAudit(
  301. ctx context.Context,
  302. action string,
  303. actorUserID string,
  304. actorIP string,
  305. actorUA string,
  306. targetID string,
  307. tenantID string,
  308. payload map[string]any,
  309. ) error {
  310. var payloadJSON []byte
  311. if payload != nil {
  312. var err error
  313. payloadJSON, err = json.Marshal(payload)
  314. if err != nil {
  315. return fmt.Errorf("marshal audit payload: %w", err)
  316. }
  317. } else {
  318. payloadJSON = []byte("{}")
  319. }
  320. // Empty string → NULL for actor_user_id
  321. var actorArg any
  322. if actorUserID == "" {
  323. actorArg = nil
  324. } else {
  325. actorArg = actorUserID
  326. }
  327. const q = `
  328. INSERT INTO auth.audit_log
  329. (action, actor_user_id, actor_ip, actor_ua, target_id, tenant_id, payload)
  330. VALUES
  331. ($1, $2::uuid, NULLIF($3, '')::inet, NULLIF($4, ''), NULLIF($5, '')::uuid, NULLIF($6, '')::uuid, $7::jsonb)
  332. `
  333. _, err := s.pool.Exec(ctx, q, action, actorArg, actorIP, actorUA, targetID, tenantID, payloadJSON)
  334. if err != nil {
  335. return fmt.Errorf("write audit: %w", err)
  336. }
  337. return nil
  338. }
  339. // ---------------------------------------------------------------------------
  340. // helpers
  341. // ---------------------------------------------------------------------------
  342. func contains(s, substr string) bool {
  343. for i := 0; i+len(substr) <= len(s); i++ {
  344. if s[i:i+len(substr)] == substr {
  345. return true
  346. }
  347. }
  348. return false
  349. }