| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- // Package authd — tenants.go: Tenant (a.k.a. company) CRUD.
- //
- // The M13a schema (009_auth.up.sql) introduced the auth.tenants
- // table. M13b W1 turns that into a fully-managed resource in the
- // admin UI: super-admins can create / list / edit / suspend /
- // activate tenants; tenant-admins get a read-only view of their
- // own tenant. All state transitions write an audit_log row so the
- // Audit log UI (M13c) can show "who suspended tenant X, when".
- //
- // Threading: safe for concurrent use (pgx pool is goroutine-safe).
- package authd
- import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- )
- // Tenant is the wire shape returned to handlers / JSON callers.
- // The wire shape is kept flat and snake_case to match the rest
- // of the M13a / M13b admin API.
- type Tenant struct {
- ID string `json:"id"`
- Slug string `json:"slug"`
- DisplayName string `json:"display_name"`
- Status string `json:"status"`
- ContactEmail string `json:"contact_email"`
- RateLimitPerSec int `json:"rate_limit_per_sec"`
- FCMShared bool `json:"fcm_shared"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- ArchivedAt *time.Time `json:"archived_at,omitempty"`
- }
- // ErrTenantNotFound is returned when a tenant id or slug does not
- // exist. Distinct from ErrUserNotFound so callers can disambiguate.
- var ErrTenantNotFound = errors.New("authd: tenant not found")
- // ErrTenantSlugTaken is returned when CreateTenant sees a slug
- // collision. UI surfaces this as a 409.
- var ErrTenantSlugTaken = errors.New("authd: tenant slug already taken")
- // ErrTenantInvalid is returned when input validation fails (e.g.
- // slug doesn't match the regex, or rate_limit_per_sec is out of
- // range). The wrapped error string is safe to surface to the UI.
- var ErrTenantInvalid = errors.New("authd: tenant input invalid")
- // TenantFilter controls ListTenants. Empty fields mean "no filter".
- // Limit caps the result count; 0 → default of 100. Max 500.
- type TenantFilter struct {
- Q string // matches slug OR display_name (ILIKE)
- Status string // exact match: "active" | "suspended" | "archived" | ""
- Limit int
- Offset int
- // Scope controls what's visible.
- // "all" — super_admin only: every tenant
- // "self" — returns the single tenant matching CallerTenantID
- CallerRole string
- CallerTenantID string
- }
- // ListTenants returns the tenants visible to the caller under the
- // given filter, plus the total count (for pagination in the UI).
- func (s *Store) ListTenants(ctx context.Context, f TenantFilter) ([]Tenant, int, error) {
- if s.pool == nil {
- return nil, 0, errors.New("authd: no DB pool (test mode)")
- }
- if f.Limit <= 0 {
- f.Limit = 100
- }
- if f.Limit > 500 {
- f.Limit = 500
- }
- // Build the WHERE clause. We use $N-style placeholders that
- // we count as we go so it's safe to extend.
- args := []any{}
- conds := []string{}
- if strings.TrimSpace(f.Status) != "" {
- args = append(args, f.Status)
- conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
- }
- if strings.TrimSpace(f.Q) != "" {
- args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
- conds = append(conds, fmt.Sprintf("(slug ILIKE $%d OR display_name ILIKE $%d)", len(args), len(args)))
- }
- // Scope: tenant_admin only sees their own tenant.
- if f.CallerRole != "super_admin" {
- if f.CallerTenantID == "" {
- // A non-super_admin without a tenant_id has no business
- // listing tenants. Return an empty page so the UI
- // shows "0 results" rather than leaking the existence
- // of other tenants.
- return []Tenant{}, 0, nil
- }
- args = append(args, f.CallerTenantID)
- conds = append(conds, fmt.Sprintf("id = $%d", len(args)))
- }
- where := ""
- if len(conds) > 0 {
- where = "WHERE " + strings.Join(conds, " AND ")
- }
- // Count first (cheap, uses the same WHERE).
- var total int
- countQ := "SELECT COUNT(*) FROM auth.tenants " + where
- if err := s.pool.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
- return nil, 0, fmt.Errorf("count tenants: %w", err)
- }
- // Then the page.
- args = append(args, f.Limit, f.Offset)
- pageQ := fmt.Sprintf(`
- SELECT id::text, slug, display_name, status, contact_email,
- rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
- FROM auth.tenants
- %s
- ORDER BY created_at DESC
- LIMIT $%d OFFSET $%d
- `, where, len(args)-1, len(args))
- rows, err := s.pool.Query(ctx, pageQ, args...)
- if err != nil {
- return nil, 0, fmt.Errorf("list tenants: %w", err)
- }
- defer rows.Close()
- out := make([]Tenant, 0, f.Limit)
- for rows.Next() {
- var t Tenant
- if err := rows.Scan(
- &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
- &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
- ); err != nil {
- return nil, 0, fmt.Errorf("scan tenant: %w", err)
- }
- out = append(out, t)
- }
- if err := rows.Err(); err != nil {
- return nil, 0, fmt.Errorf("rows: %w", err)
- }
- return out, total, nil
- }
- // GetTenant fetches a single tenant by id.
- func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) {
- if s.pool == nil {
- return nil, errors.New("authd: no DB pool (test mode)")
- }
- const q = `
- SELECT id::text, slug, display_name, status, contact_email,
- rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
- FROM auth.tenants
- WHERE id = $1
- `
- t := &Tenant{}
- err := s.pool.QueryRow(ctx, q, id).Scan(
- &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
- &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
- )
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, ErrTenantNotFound
- }
- return nil, fmt.Errorf("get tenant: %w", err)
- }
- return t, nil
- }
- // CreateTenantInput is the validated create payload.
- type CreateTenantInput struct {
- Slug string
- DisplayName string
- ContactEmail string
- RateLimitPerSec int
- FCMShared *bool // nil → use default true
- }
- // Validate runs the constraints the DB enforces, but earlier and
- // with friendlier error messages for the UI.
- func (in *CreateTenantInput) Validate() error {
- if !validSlug(in.Slug) {
- return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTenantInvalid)
- }
- if strings.TrimSpace(in.DisplayName) == "" {
- return fmt.Errorf("%w: display_name is required", ErrTenantInvalid)
- }
- if !looksLikeEmail(in.ContactEmail) {
- return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
- }
- if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
- return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
- }
- return nil
- }
- // CreateTenant inserts a new tenant in 'active' status and writes
- // an audit_log row. Returns the new id. Duplicate slug →
- // ErrTenantSlugTaken (so the UI can show a 409).
- func (s *Store) CreateTenant(ctx context.Context, in CreateTenantInput, actorUserID, actorIP, actorUA string) (*Tenant, error) {
- if s.pool == nil {
- return nil, errors.New("authd: no DB pool (test mode)")
- }
- if err := in.Validate(); err != nil {
- return nil, err
- }
- fcmShared := true
- if in.FCMShared != nil {
- fcmShared = *in.FCMShared
- }
- const q = `
- INSERT INTO auth.tenants
- (slug, display_name, contact_email, rate_limit_per_sec, fcm_shared, status)
- VALUES
- ($1, $2, $3, $4, $5, 'active')
- RETURNING id::text
- `
- var id string
- err := s.pool.QueryRow(ctx, q,
- in.Slug, in.DisplayName, in.ContactEmail, in.RateLimitPerSec, fcmShared,
- ).Scan(&id)
- if err != nil {
- var pgErr *pgconn.PgError
- if errors.As(err, &pgErr) && pgErr.Code == "23505" {
- return nil, ErrTenantSlugTaken
- }
- return nil, fmt.Errorf("create tenant: %w", err)
- }
- if err := s.WriteAudit(ctx, "tenant.create", actorUserID, actorIP, actorUA, id, id, map[string]any{
- "slug": in.Slug,
- "display_name": in.DisplayName,
- "contact_email": in.ContactEmail,
- "rate_limit_per_sec": in.RateLimitPerSec,
- "fcm_shared": fcmShared,
- }); err != nil {
- // Audit failure is non-fatal: log and continue. The tenant
- // was created; the audit row is observability, not authz.
- // Errors are returned via fmt.Errorf wrapping; callers may
- // log them. We don't return the error to the caller.
- _ = err
- }
- return s.GetTenant(ctx, id)
- }
- // UpdateTenantInput is the validated update payload. Pointer
- // fields mean "leave unchanged" when nil — this is the standard
- // PATCH semantics.
- type UpdateTenantInput struct {
- DisplayName *string
- ContactEmail *string
- RateLimitPerSec *int
- FCMShared *bool
- }
- // Validate runs the constraints the DB enforces, but earlier.
- func (in *UpdateTenantInput) Validate() error {
- if in.DisplayName != nil && strings.TrimSpace(*in.DisplayName) == "" {
- return fmt.Errorf("%w: display_name cannot be empty", ErrTenantInvalid)
- }
- if in.ContactEmail != nil && !looksLikeEmail(*in.ContactEmail) {
- return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
- }
- if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
- return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
- }
- return nil
- }
- // UpdateTenant applies a partial update and writes an audit_log
- // row with the changed fields. Returns the new state.
- //
- // "actorScopeAll" controls whether the caller can edit every
- // field (super_admin) or only display_name + contact_email
- // (tenant_admin on their own tenant). If false and the patch
- // includes a restricted field, returns ErrTenantInvalid.
- func (s *Store) UpdateTenant(
- ctx context.Context,
- id string,
- in UpdateTenantInput,
- actorScopeAll bool,
- actorUserID, actorIP, actorUA string,
- ) (*Tenant, error) {
- if s.pool == nil {
- return nil, errors.New("authd: no DB pool (test mode)")
- }
- if err := in.Validate(); err != nil {
- return nil, err
- }
- // Tenant_admin is restricted to display_name + contact_email.
- if !actorScopeAll {
- if in.RateLimitPerSec != nil || in.FCMShared != nil {
- return nil, fmt.Errorf("%w: only super_admin can change rate_limit_per_sec or fcm_shared", ErrTenantInvalid)
- }
- }
- // Build the SET clause incrementally so unset fields don't
- // touch the row.
- sets := []string{}
- args := []any{pgid(id)}
- if in.DisplayName != nil {
- args = append(args, strings.TrimSpace(*in.DisplayName))
- sets = append(sets, fmt.Sprintf("display_name = $%d", len(args)))
- }
- if in.ContactEmail != nil {
- args = append(args, *in.ContactEmail)
- sets = append(sets, fmt.Sprintf("contact_email = $%d", len(args)))
- }
- if in.RateLimitPerSec != nil {
- args = append(args, *in.RateLimitPerSec)
- sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
- }
- if in.FCMShared != nil {
- args = append(args, *in.FCMShared)
- sets = append(sets, fmt.Sprintf("fcm_shared = $%d", len(args)))
- }
- if len(sets) == 0 {
- // Nothing to change. Return the current state.
- return s.GetTenant(ctx, id)
- }
- q := fmt.Sprintf(`UPDATE auth.tenants SET %s WHERE id = $1`, strings.Join(sets, ", "))
- tag, err := s.pool.Exec(ctx, q, args...)
- if err != nil {
- return nil, fmt.Errorf("update tenant: %w", err)
- }
- if tag.RowsAffected() == 0 {
- return nil, ErrTenantNotFound
- }
- // Build audit payload (only the fields the caller sent).
- payload := map[string]any{}
- if in.DisplayName != nil {
- payload["display_name"] = *in.DisplayName
- }
- if in.ContactEmail != nil {
- payload["contact_email"] = *in.ContactEmail
- }
- if in.RateLimitPerSec != nil {
- payload["rate_limit_per_sec"] = *in.RateLimitPerSec
- }
- if in.FCMShared != nil {
- payload["fcm_shared"] = *in.FCMShared
- }
- if err := s.WriteAudit(ctx, "tenant.update", actorUserID, actorIP, actorUA, id, id, payload); err != nil {
- _ = err
- }
- return s.GetTenant(ctx, id)
- }
- // SetTenantStatus changes the status. Allowed transitions:
- // active -> suspended, archived
- // suspended -> active, archived
- // archived -> (terminal — no transitions out of archived)
- //
- // archived is terminal. Setting status=archived also stamps
- // archived_at = NOW(). Writes an audit_log row with the
- // {from, to} transition.
- func (s *Store) SetTenantStatus(
- ctx context.Context,
- id, newStatus string,
- actorUserID, actorIP, actorUA string,
- ) (*Tenant, error) {
- if s.pool == nil {
- return nil, errors.New("authd: no DB pool (test mode)")
- }
- switch newStatus {
- case "active", "suspended", "archived":
- default:
- return nil, fmt.Errorf("%w: status must be active|suspended|archived", ErrTenantInvalid)
- }
- cur, err := s.GetTenant(ctx, id)
- if err != nil {
- return nil, err
- }
- if cur.Status == "archived" {
- return nil, fmt.Errorf("%w: tenant is archived (terminal)", ErrTenantInvalid)
- }
- if cur.Status == newStatus {
- // No-op transition. Return the current state.
- return cur, nil
- }
- var q string
- var args []any
- if newStatus == "archived" {
- q = `UPDATE auth.tenants SET status = $2, archived_at = NOW() WHERE id = $1`
- args = []any{pgid(id), newStatus}
- } else {
- q = `UPDATE auth.tenants SET status = $2, archived_at = NULL WHERE id = $1`
- args = []any{pgid(id), newStatus}
- }
- tag, err := s.pool.Exec(ctx, q, args...)
- if err != nil {
- return nil, fmt.Errorf("set tenant status: %w", err)
- }
- if tag.RowsAffected() == 0 {
- return nil, ErrTenantNotFound
- }
- if err := s.WriteAudit(ctx, "tenant.status", actorUserID, actorIP, actorUA, id, id, map[string]any{
- "from": cur.Status,
- "to": newStatus,
- }); err != nil {
- _ = err
- }
- return s.GetTenant(ctx, id)
- }
- // pgid is a tiny helper that keeps the call sites readable: we
- // only ever pass a single id as the first arg, and we want it to
- // be parsed as a UUID by Postgres.
- func pgid(id string) any { return id }
- // -------------------------------------------------------------------
- // input validation
- // -------------------------------------------------------------------
- // validSlug matches the regex on the slug column:
- //
- // ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
- //
- // Inlined (not via regexp package) because the pattern is fixed
- // and the package would add 50KB of binary.
- func validSlug(s string) bool {
- if len(s) < 2 || len(s) > 64 {
- return false
- }
- if !isAlnumOrDash(s[0]) || s[0] == '-' {
- return false
- }
- if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
- return false
- }
- for i := 1; i < len(s)-1; i++ {
- if !isAlnumOrDash(s[i]) {
- return false
- }
- }
- return true
- }
- func isAlnumOrDash(c byte) bool {
- return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
- }
- // looksLikeEmail is intentionally permissive: we just enforce
- // the local-part, the '@', and a non-empty domain with at least
- // one dot that isn't at the edge. Bounces are caught by the
- // actual mail server, not by the admin UI.
- func looksLikeEmail(s string) bool {
- s = strings.TrimSpace(s)
- if s == "" || len(s) > 254 {
- return false
- }
- at := strings.IndexByte(s, '@')
- if at < 1 || at == len(s)-1 {
- return false
- }
- domain := s[at+1:]
- if len(domain) < 3 {
- return false
- }
- if domain[0] == '.' || domain[len(domain)-1] == '.' {
- return false
- }
- return strings.Contains(domain, ".")
- }
|