// 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, ".") }