tenants.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. // Package authd — tenants.go: Tenant (a.k.a. company) CRUD.
  2. //
  3. // The M13a schema (009_auth.up.sql) introduced the auth.tenants
  4. // table. M13b W1 turns that into a fully-managed resource in the
  5. // admin UI: super-admins can create / list / edit / suspend /
  6. // activate tenants; tenant-admins get a read-only view of their
  7. // own tenant. All state transitions write an audit_log row so the
  8. // Audit log UI (M13c) can show "who suspended tenant X, when".
  9. //
  10. // Threading: safe for concurrent use (pgx pool is goroutine-safe).
  11. package authd
  12. import (
  13. "context"
  14. "errors"
  15. "fmt"
  16. "strings"
  17. "time"
  18. "github.com/jackc/pgx/v5"
  19. "github.com/jackc/pgx/v5/pgconn"
  20. )
  21. // Tenant is the wire shape returned to handlers / JSON callers.
  22. // The wire shape is kept flat and snake_case to match the rest
  23. // of the M13a / M13b admin API.
  24. type Tenant struct {
  25. ID string `json:"id"`
  26. Slug string `json:"slug"`
  27. DisplayName string `json:"display_name"`
  28. Status string `json:"status"`
  29. ContactEmail string `json:"contact_email"`
  30. RateLimitPerSec int `json:"rate_limit_per_sec"`
  31. FCMShared bool `json:"fcm_shared"`
  32. CreatedAt time.Time `json:"created_at"`
  33. UpdatedAt time.Time `json:"updated_at"`
  34. ArchivedAt *time.Time `json:"archived_at,omitempty"`
  35. }
  36. // ErrTenantNotFound is returned when a tenant id or slug does not
  37. // exist. Distinct from ErrUserNotFound so callers can disambiguate.
  38. var ErrTenantNotFound = errors.New("authd: tenant not found")
  39. // ErrTenantSlugTaken is returned when CreateTenant sees a slug
  40. // collision. UI surfaces this as a 409.
  41. var ErrTenantSlugTaken = errors.New("authd: tenant slug already taken")
  42. // ErrTenantInvalid is returned when input validation fails (e.g.
  43. // slug doesn't match the regex, or rate_limit_per_sec is out of
  44. // range). The wrapped error string is safe to surface to the UI.
  45. var ErrTenantInvalid = errors.New("authd: tenant input invalid")
  46. // TenantFilter controls ListTenants. Empty fields mean "no filter".
  47. // Limit caps the result count; 0 → default of 100. Max 500.
  48. type TenantFilter struct {
  49. Q string // matches slug OR display_name (ILIKE)
  50. Status string // exact match: "active" | "suspended" | "archived" | ""
  51. Limit int
  52. Offset int
  53. // Scope controls what's visible.
  54. // "all" — super_admin only: every tenant
  55. // "self" — returns the single tenant matching CallerTenantID
  56. CallerRole string
  57. CallerTenantID string
  58. }
  59. // ListTenants returns the tenants visible to the caller under the
  60. // given filter, plus the total count (for pagination in the UI).
  61. func (s *Store) ListTenants(ctx context.Context, f TenantFilter) ([]Tenant, int, error) {
  62. if s.pool == nil {
  63. return nil, 0, errors.New("authd: no DB pool (test mode)")
  64. }
  65. if f.Limit <= 0 {
  66. f.Limit = 100
  67. }
  68. if f.Limit > 500 {
  69. f.Limit = 500
  70. }
  71. // Build the WHERE clause. We use $N-style placeholders that
  72. // we count as we go so it's safe to extend.
  73. args := []any{}
  74. conds := []string{}
  75. if strings.TrimSpace(f.Status) != "" {
  76. args = append(args, f.Status)
  77. conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
  78. }
  79. if strings.TrimSpace(f.Q) != "" {
  80. args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
  81. conds = append(conds, fmt.Sprintf("(slug ILIKE $%d OR display_name ILIKE $%d)", len(args), len(args)))
  82. }
  83. // Scope: tenant_admin only sees their own tenant.
  84. if f.CallerRole != "super_admin" {
  85. if f.CallerTenantID == "" {
  86. // A non-super_admin without a tenant_id has no business
  87. // listing tenants. Return an empty page so the UI
  88. // shows "0 results" rather than leaking the existence
  89. // of other tenants.
  90. return []Tenant{}, 0, nil
  91. }
  92. args = append(args, f.CallerTenantID)
  93. conds = append(conds, fmt.Sprintf("id = $%d", len(args)))
  94. }
  95. where := ""
  96. if len(conds) > 0 {
  97. where = "WHERE " + strings.Join(conds, " AND ")
  98. }
  99. // Count first (cheap, uses the same WHERE).
  100. var total int
  101. countQ := "SELECT COUNT(*) FROM auth.tenants " + where
  102. if err := s.pool.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
  103. return nil, 0, fmt.Errorf("count tenants: %w", err)
  104. }
  105. // Then the page.
  106. args = append(args, f.Limit, f.Offset)
  107. pageQ := fmt.Sprintf(`
  108. SELECT id::text, slug, display_name, status, contact_email,
  109. rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
  110. FROM auth.tenants
  111. %s
  112. ORDER BY created_at DESC
  113. LIMIT $%d OFFSET $%d
  114. `, where, len(args)-1, len(args))
  115. rows, err := s.pool.Query(ctx, pageQ, args...)
  116. if err != nil {
  117. return nil, 0, fmt.Errorf("list tenants: %w", err)
  118. }
  119. defer rows.Close()
  120. out := make([]Tenant, 0, f.Limit)
  121. for rows.Next() {
  122. var t Tenant
  123. if err := rows.Scan(
  124. &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
  125. &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
  126. ); err != nil {
  127. return nil, 0, fmt.Errorf("scan tenant: %w", err)
  128. }
  129. out = append(out, t)
  130. }
  131. if err := rows.Err(); err != nil {
  132. return nil, 0, fmt.Errorf("rows: %w", err)
  133. }
  134. return out, total, nil
  135. }
  136. // GetTenant fetches a single tenant by id.
  137. func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) {
  138. if s.pool == nil {
  139. return nil, errors.New("authd: no DB pool (test mode)")
  140. }
  141. const q = `
  142. SELECT id::text, slug, display_name, status, contact_email,
  143. rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
  144. FROM auth.tenants
  145. WHERE id = $1
  146. `
  147. t := &Tenant{}
  148. err := s.pool.QueryRow(ctx, q, id).Scan(
  149. &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
  150. &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
  151. )
  152. if err != nil {
  153. if errors.Is(err, pgx.ErrNoRows) {
  154. return nil, ErrTenantNotFound
  155. }
  156. return nil, fmt.Errorf("get tenant: %w", err)
  157. }
  158. return t, nil
  159. }
  160. // CreateTenantInput is the validated create payload.
  161. type CreateTenantInput struct {
  162. Slug string
  163. DisplayName string
  164. ContactEmail string
  165. RateLimitPerSec int
  166. FCMShared *bool // nil → use default true
  167. }
  168. // Validate runs the constraints the DB enforces, but earlier and
  169. // with friendlier error messages for the UI.
  170. func (in *CreateTenantInput) Validate() error {
  171. if !validSlug(in.Slug) {
  172. return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTenantInvalid)
  173. }
  174. if strings.TrimSpace(in.DisplayName) == "" {
  175. return fmt.Errorf("%w: display_name is required", ErrTenantInvalid)
  176. }
  177. if !looksLikeEmail(in.ContactEmail) {
  178. return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
  179. }
  180. if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
  181. return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
  182. }
  183. return nil
  184. }
  185. // CreateTenant inserts a new tenant in 'active' status and writes
  186. // an audit_log row. Returns the new id. Duplicate slug →
  187. // ErrTenantSlugTaken (so the UI can show a 409).
  188. func (s *Store) CreateTenant(ctx context.Context, in CreateTenantInput, actorUserID, actorIP, actorUA string) (*Tenant, error) {
  189. if s.pool == nil {
  190. return nil, errors.New("authd: no DB pool (test mode)")
  191. }
  192. if err := in.Validate(); err != nil {
  193. return nil, err
  194. }
  195. fcmShared := true
  196. if in.FCMShared != nil {
  197. fcmShared = *in.FCMShared
  198. }
  199. const q = `
  200. INSERT INTO auth.tenants
  201. (slug, display_name, contact_email, rate_limit_per_sec, fcm_shared, status)
  202. VALUES
  203. ($1, $2, $3, $4, $5, 'active')
  204. RETURNING id::text
  205. `
  206. var id string
  207. err := s.pool.QueryRow(ctx, q,
  208. in.Slug, in.DisplayName, in.ContactEmail, in.RateLimitPerSec, fcmShared,
  209. ).Scan(&id)
  210. if err != nil {
  211. var pgErr *pgconn.PgError
  212. if errors.As(err, &pgErr) && pgErr.Code == "23505" {
  213. return nil, ErrTenantSlugTaken
  214. }
  215. return nil, fmt.Errorf("create tenant: %w", err)
  216. }
  217. if err := s.WriteAudit(ctx, "tenant.create", actorUserID, actorIP, actorUA, id, id, map[string]any{
  218. "slug": in.Slug,
  219. "display_name": in.DisplayName,
  220. "contact_email": in.ContactEmail,
  221. "rate_limit_per_sec": in.RateLimitPerSec,
  222. "fcm_shared": fcmShared,
  223. }); err != nil {
  224. // Audit failure is non-fatal: log and continue. The tenant
  225. // was created; the audit row is observability, not authz.
  226. // Errors are returned via fmt.Errorf wrapping; callers may
  227. // log them. We don't return the error to the caller.
  228. _ = err
  229. }
  230. return s.GetTenant(ctx, id)
  231. }
  232. // UpdateTenantInput is the validated update payload. Pointer
  233. // fields mean "leave unchanged" when nil — this is the standard
  234. // PATCH semantics.
  235. type UpdateTenantInput struct {
  236. DisplayName *string
  237. ContactEmail *string
  238. RateLimitPerSec *int
  239. FCMShared *bool
  240. }
  241. // Validate runs the constraints the DB enforces, but earlier.
  242. func (in *UpdateTenantInput) Validate() error {
  243. if in.DisplayName != nil && strings.TrimSpace(*in.DisplayName) == "" {
  244. return fmt.Errorf("%w: display_name cannot be empty", ErrTenantInvalid)
  245. }
  246. if in.ContactEmail != nil && !looksLikeEmail(*in.ContactEmail) {
  247. return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
  248. }
  249. if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
  250. return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
  251. }
  252. return nil
  253. }
  254. // UpdateTenant applies a partial update and writes an audit_log
  255. // row with the changed fields. Returns the new state.
  256. //
  257. // "actorScopeAll" controls whether the caller can edit every
  258. // field (super_admin) or only display_name + contact_email
  259. // (tenant_admin on their own tenant). If false and the patch
  260. // includes a restricted field, returns ErrTenantInvalid.
  261. func (s *Store) UpdateTenant(
  262. ctx context.Context,
  263. id string,
  264. in UpdateTenantInput,
  265. actorScopeAll bool,
  266. actorUserID, actorIP, actorUA string,
  267. ) (*Tenant, error) {
  268. if s.pool == nil {
  269. return nil, errors.New("authd: no DB pool (test mode)")
  270. }
  271. if err := in.Validate(); err != nil {
  272. return nil, err
  273. }
  274. // Tenant_admin is restricted to display_name + contact_email.
  275. if !actorScopeAll {
  276. if in.RateLimitPerSec != nil || in.FCMShared != nil {
  277. return nil, fmt.Errorf("%w: only super_admin can change rate_limit_per_sec or fcm_shared", ErrTenantInvalid)
  278. }
  279. }
  280. // Build the SET clause incrementally so unset fields don't
  281. // touch the row.
  282. sets := []string{}
  283. args := []any{pgid(id)}
  284. if in.DisplayName != nil {
  285. args = append(args, strings.TrimSpace(*in.DisplayName))
  286. sets = append(sets, fmt.Sprintf("display_name = $%d", len(args)))
  287. }
  288. if in.ContactEmail != nil {
  289. args = append(args, *in.ContactEmail)
  290. sets = append(sets, fmt.Sprintf("contact_email = $%d", len(args)))
  291. }
  292. if in.RateLimitPerSec != nil {
  293. args = append(args, *in.RateLimitPerSec)
  294. sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
  295. }
  296. if in.FCMShared != nil {
  297. args = append(args, *in.FCMShared)
  298. sets = append(sets, fmt.Sprintf("fcm_shared = $%d", len(args)))
  299. }
  300. if len(sets) == 0 {
  301. // Nothing to change. Return the current state.
  302. return s.GetTenant(ctx, id)
  303. }
  304. q := fmt.Sprintf(`UPDATE auth.tenants SET %s WHERE id = $1`, strings.Join(sets, ", "))
  305. tag, err := s.pool.Exec(ctx, q, args...)
  306. if err != nil {
  307. return nil, fmt.Errorf("update tenant: %w", err)
  308. }
  309. if tag.RowsAffected() == 0 {
  310. return nil, ErrTenantNotFound
  311. }
  312. // Build audit payload (only the fields the caller sent).
  313. payload := map[string]any{}
  314. if in.DisplayName != nil {
  315. payload["display_name"] = *in.DisplayName
  316. }
  317. if in.ContactEmail != nil {
  318. payload["contact_email"] = *in.ContactEmail
  319. }
  320. if in.RateLimitPerSec != nil {
  321. payload["rate_limit_per_sec"] = *in.RateLimitPerSec
  322. }
  323. if in.FCMShared != nil {
  324. payload["fcm_shared"] = *in.FCMShared
  325. }
  326. if err := s.WriteAudit(ctx, "tenant.update", actorUserID, actorIP, actorUA, id, id, payload); err != nil {
  327. _ = err
  328. }
  329. return s.GetTenant(ctx, id)
  330. }
  331. // SetTenantStatus changes the status. Allowed transitions:
  332. // active -> suspended, archived
  333. // suspended -> active, archived
  334. // archived -> (terminal — no transitions out of archived)
  335. //
  336. // archived is terminal. Setting status=archived also stamps
  337. // archived_at = NOW(). Writes an audit_log row with the
  338. // {from, to} transition.
  339. func (s *Store) SetTenantStatus(
  340. ctx context.Context,
  341. id, newStatus string,
  342. actorUserID, actorIP, actorUA string,
  343. ) (*Tenant, error) {
  344. if s.pool == nil {
  345. return nil, errors.New("authd: no DB pool (test mode)")
  346. }
  347. switch newStatus {
  348. case "active", "suspended", "archived":
  349. default:
  350. return nil, fmt.Errorf("%w: status must be active|suspended|archived", ErrTenantInvalid)
  351. }
  352. cur, err := s.GetTenant(ctx, id)
  353. if err != nil {
  354. return nil, err
  355. }
  356. if cur.Status == "archived" {
  357. return nil, fmt.Errorf("%w: tenant is archived (terminal)", ErrTenantInvalid)
  358. }
  359. if cur.Status == newStatus {
  360. // No-op transition. Return the current state.
  361. return cur, nil
  362. }
  363. var q string
  364. var args []any
  365. if newStatus == "archived" {
  366. q = `UPDATE auth.tenants SET status = $2, archived_at = NOW() WHERE id = $1`
  367. args = []any{pgid(id), newStatus}
  368. } else {
  369. q = `UPDATE auth.tenants SET status = $2, archived_at = NULL WHERE id = $1`
  370. args = []any{pgid(id), newStatus}
  371. }
  372. tag, err := s.pool.Exec(ctx, q, args...)
  373. if err != nil {
  374. return nil, fmt.Errorf("set tenant status: %w", err)
  375. }
  376. if tag.RowsAffected() == 0 {
  377. return nil, ErrTenantNotFound
  378. }
  379. if err := s.WriteAudit(ctx, "tenant.status", actorUserID, actorIP, actorUA, id, id, map[string]any{
  380. "from": cur.Status,
  381. "to": newStatus,
  382. }); err != nil {
  383. _ = err
  384. }
  385. return s.GetTenant(ctx, id)
  386. }
  387. // pgid is a tiny helper that keeps the call sites readable: we
  388. // only ever pass a single id as the first arg, and we want it to
  389. // be parsed as a UUID by Postgres.
  390. func pgid(id string) any { return id }
  391. // -------------------------------------------------------------------
  392. // input validation
  393. // -------------------------------------------------------------------
  394. // validSlug matches the regex on the slug column:
  395. //
  396. // ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
  397. //
  398. // Inlined (not via regexp package) because the pattern is fixed
  399. // and the package would add 50KB of binary.
  400. func validSlug(s string) bool {
  401. if len(s) < 2 || len(s) > 64 {
  402. return false
  403. }
  404. if !isAlnumOrDash(s[0]) || s[0] == '-' {
  405. return false
  406. }
  407. if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
  408. return false
  409. }
  410. for i := 1; i < len(s)-1; i++ {
  411. if !isAlnumOrDash(s[i]) {
  412. return false
  413. }
  414. }
  415. return true
  416. }
  417. func isAlnumOrDash(c byte) bool {
  418. return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
  419. }
  420. // looksLikeEmail is intentionally permissive: we just enforce
  421. // the local-part, the '@', and a non-empty domain with at least
  422. // one dot that isn't at the edge. Bounces are caught by the
  423. // actual mail server, not by the admin UI.
  424. func looksLikeEmail(s string) bool {
  425. s = strings.TrimSpace(s)
  426. if s == "" || len(s) > 254 {
  427. return false
  428. }
  429. at := strings.IndexByte(s, '@')
  430. if at < 1 || at == len(s)-1 {
  431. return false
  432. }
  433. domain := s[at+1:]
  434. if len(domain) < 3 {
  435. return false
  436. }
  437. if domain[0] == '.' || domain[len(domain)-1] == '.' {
  438. return false
  439. }
  440. return strings.Contains(domain, ".")
  441. }