sources.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. // Package authd — sources.go: Source CRUD for M13b W2.
  2. //
  3. // Sources are the *runtime* ingest-side counterpart of tenants.
  4. // Each (company_id, id) row represents a single source that can
  5. // POST events into ingestd. M13b W2 turns the existing
  6. // public.sources table (created in migration 003) into a
  7. // fully-managed resource in the admin UI.
  8. //
  9. // Schema (post-migration 011):
  10. // id TEXT
  11. // company_id TEXT FK -> public.companies(id)
  12. // name TEXT
  13. // type TEXT (http | mqtt | ws | grpc)
  14. // rate_limit_per_sec INTEGER
  15. // allowed_targets JSONB (M13c routing UI will edit; W2 read-only)
  16. // match_expr JSONB (M13c routing UI will edit; W2 read-only)
  17. // status TEXT (active | suspended)
  18. // hmac_secret_hash TEXT (bcrypt; returned only on create/rotate)
  19. // api_key_hash TEXT (bcrypt; returned only on create/rotate)
  20. // mtls_required BOOLEAN (M14 reads; W2 just stores)
  21. // description TEXT
  22. // created_at TIMESTAMPTZ
  23. //
  24. // Bridge to auth.tenants:
  25. // auth.tenants.id is a UUID; public.sources.company_id is a
  26. // TEXT FK to public.companies.id (also TEXT). The two schemas
  27. // predate each other and were never formally linked. The
  28. // convention this code enforces: public.companies.id ==
  29. // auth.tenants.id::text. CreateSource uses ON CONFLICT
  30. // DO NOTHING to ensure a public.companies row exists before
  31. // the FK is hit, so creating a source for an auth tenant
  32. // without a corresponding public.companies row is a no-op
  33. // (idempotent) rather than an error.
  34. //
  35. // Threading: safe for concurrent use (pgx pool is goroutine-safe).
  36. package authd
  37. import (
  38. "context"
  39. "crypto/rand"
  40. "encoding/hex"
  41. "encoding/json"
  42. "golang.org/x/crypto/bcrypt"
  43. "errors"
  44. "fmt"
  45. "strings"
  46. "time"
  47. "github.com/jackc/pgx/v5"
  48. "github.com/jackc/pgx/v5/pgconn"
  49. )
  50. // Source is the wire shape returned to handlers / JSON callers.
  51. // Secrets are NEVER included — only the booleans flagging their
  52. // presence (`hmac_set`, `api_key_set`). Plaintext values live
  53. // in the one-time SecretsPayload returned by CreateSource and
  54. // RotateSecrets.
  55. type Source struct {
  56. ID string `json:"id"`
  57. CompanyID string `json:"company_id"`
  58. Name string `json:"name"`
  59. Type string `json:"type"`
  60. RateLimitPerSec int `json:"rate_limit_per_sec"`
  61. AllowedTargets json.RawMessage `json:"allowed_targets"`
  62. MatchExpr json.RawMessage `json:"match_expr"`
  63. Status string `json:"status"`
  64. MTLSRequired bool `json:"mtls_required"`
  65. Description string `json:"description,omitempty"`
  66. HMACSet bool `json:"hmac_set"`
  67. APIKeySet bool `json:"api_key_set"`
  68. CreatedAt time.Time `json:"created_at"`
  69. }
  70. // SecretsPayload is the one-time plaintext payload returned at
  71. // create and rotate time. The UI shows it in a modal that
  72. // requires the operator to confirm "I have saved these before
  73. // continuing." After that, the values are gone from the server
  74. // and the modal can never re-display them.
  75. type SecretsPayload struct {
  76. HMACSecret string `json:"hmac_secret"`
  77. APIKey string `json:"api_key"`
  78. }
  79. // ErrSourceNotFound is returned when (company_id, id) doesn't exist.
  80. var ErrSourceNotFound = errors.New("authd: source not found")
  81. // ErrSourceIDTaken is returned when CreateSource sees a
  82. // duplicate (company_id, id) for a tenant that already has it.
  83. var ErrSourceIDTaken = errors.New("authd: source id already in use")
  84. // ErrSourceInvalid is returned when input validation fails.
  85. var ErrSourceInvalid = errors.New("authd: source input invalid")
  86. // validSourceTypes is the whitelist of `type` values. Mirrors
  87. // the schema default comment in 003.
  88. var validSourceTypes = map[string]struct{}{
  89. "http": {},
  90. "mqtt": {},
  91. "ws": {},
  92. "grpc": {},
  93. }
  94. // SourceFilter controls ListSources. Empty fields mean "no filter".
  95. type SourceFilter struct {
  96. Q string // matches id OR name (ILIKE)
  97. Type string // exact match
  98. Status string // exact match
  99. Limit int
  100. Offset int
  101. CallerRole string
  102. CallerTenant string // auth.tenants.id (UUID string)
  103. }
  104. // CreateSourceInput is the validated create payload. The optional
  105. // HMAC + API key fields, if non-empty, are bcrypt-hashed by
  106. // CreateSource. The plaintext is NEVER persisted; the caller
  107. // receives it in the returned SecretsPayload.
  108. type CreateSourceInput struct {
  109. ID string
  110. Name string
  111. Type string
  112. RateLimitPerSec int
  113. AllowedTargets json.RawMessage
  114. MatchExpr json.RawMessage
  115. Description string
  116. MTLSRequired bool
  117. HMACSecret string // optional; if non-empty, will be hashed
  118. APIKey string // optional; if non-empty, will be hashed
  119. }
  120. // UpdateSourceInput is the PATCH payload. Pointer / non-nil
  121. // fields mean "apply this." Nil raw-message means "leave the
  122. // JSON column as-is." All fields are optional; an empty patch
  123. // is a no-op.
  124. type UpdateSourceInput struct {
  125. Name *string
  126. Type *string
  127. RateLimitPerSec *int
  128. Description *string
  129. MTLSRequired *bool
  130. AllowedTargets json.RawMessage
  131. MatchExpr json.RawMessage
  132. }
  133. // Validate runs the constraints the DB enforces, but earlier
  134. // and with friendlier error messages for the UI.
  135. func (in *CreateSourceInput) Validate() error {
  136. if !validSourceID(in.ID) {
  137. return fmt.Errorf("%w: id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrSourceInvalid)
  138. }
  139. if strings.TrimSpace(in.Name) == "" {
  140. return fmt.Errorf("%w: name is required", ErrSourceInvalid)
  141. }
  142. if len(in.Name) > 200 {
  143. return fmt.Errorf("%w: name must be \u2264 200 characters", ErrSourceInvalid)
  144. }
  145. if _, ok := validSourceTypes[in.Type]; !ok {
  146. return fmt.Errorf("%w: type must be http|mqtt|ws|grpc", ErrSourceInvalid)
  147. }
  148. if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
  149. return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrSourceInvalid)
  150. }
  151. if len(in.Description) > 500 {
  152. return fmt.Errorf("%w: description must be \u2264 500 characters", ErrSourceInvalid)
  153. }
  154. // Empty raw messages are allowed; CreateSource defaults them to '[]' / '{}'.
  155. if len(in.AllowedTargets) > 0 && !json.Valid(in.AllowedTargets) {
  156. return fmt.Errorf("%w: allowed_targets must be valid JSON", ErrSourceInvalid)
  157. }
  158. if len(in.MatchExpr) > 0 && !json.Valid(in.MatchExpr) {
  159. return fmt.Errorf("%w: match_expr must be valid JSON", ErrSourceInvalid)
  160. }
  161. if in.HMACSecret != "" && !validSecretFormat(in.HMACSecret) {
  162. return fmt.Errorf("%w: hmac_secret, if provided, must be 32..128 [A-Za-z0-9_-] chars", ErrSourceInvalid)
  163. }
  164. if in.APIKey != "" && !validAPIKeyFormat(in.APIKey) {
  165. return fmt.Errorf("%w: api_key, if provided, must be 16..128 [A-Za-z0-9_-] chars", ErrSourceInvalid)
  166. }
  167. return nil
  168. }
  169. // Validate is the same for Update. We don't enforce presence
  170. // of fields (PATCH can be empty), just per-field constraints.
  171. func (in *UpdateSourceInput) Validate() error {
  172. if in.Name != nil {
  173. s := strings.TrimSpace(*in.Name)
  174. if s == "" {
  175. return fmt.Errorf("%w: name cannot be empty", ErrSourceInvalid)
  176. }
  177. if len(s) > 200 {
  178. return fmt.Errorf("%w: name must be \u2264 200 characters", ErrSourceInvalid)
  179. }
  180. }
  181. if in.Type != nil {
  182. if _, ok := validSourceTypes[*in.Type]; !ok {
  183. return fmt.Errorf("%w: type must be http|mqtt|ws|grpc", ErrSourceInvalid)
  184. }
  185. }
  186. if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
  187. return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrSourceInvalid)
  188. }
  189. if in.Description != nil && len(*in.Description) > 500 {
  190. return fmt.Errorf("%w: description must be \u2264 500 characters", ErrSourceInvalid)
  191. }
  192. if in.AllowedTargets != nil && !json.Valid(in.AllowedTargets) {
  193. return fmt.Errorf("%w: allowed_targets must be valid JSON", ErrSourceInvalid)
  194. }
  195. if in.MatchExpr != nil && !json.Valid(in.MatchExpr) {
  196. return fmt.Errorf("%w: match_expr must be valid JSON", ErrSourceInvalid)
  197. }
  198. return nil
  199. }
  200. // ensurePublicCompanyRow makes sure public.companies has a row
  201. // keyed by the auth.tenants.id (cast to text). This is the
  202. // bridge between the M13a auth schema and the M0 data-plane
  203. // schema. Idempotent; safe to call from CreateSource. The row
  204. // carries just the minimum data: id, name (display_name), a
  205. // default rate_limit, status='active'. Operators who want to
  206. // manage the public.companies row's fields (rate_limit, etc.)
  207. // can do so via the W1 tenant API; this code path only ensures
  208. // the FK target exists.
  209. func (s *Store) ensurePublicCompanyRow(ctx context.Context, tenantID, displayName string) error {
  210. if s.pool == nil {
  211. return errors.New("authd: no DB pool (test mode)")
  212. }
  213. const q = `
  214. INSERT INTO public.companies (id, name, status, rate_limit_per_sec)
  215. VALUES ($1::text, $2, 'active', 10000)
  216. ON CONFLICT (id) DO NOTHING
  217. `
  218. _, err := s.pool.Exec(ctx, q, tenantID, displayName)
  219. if err != nil {
  220. return fmt.Errorf("ensure public.companies row: %w", err)
  221. }
  222. return nil
  223. }
  224. // ListSources returns sources visible to the caller under the
  225. // given filter, plus the total count. Scope: super_admin sees
  226. // all tenants' sources; non-super_admin sees only the caller's
  227. // tenant. CallerTenant is the auth.tenants.id (UUID string).
  228. func (s *Store) ListSources(ctx context.Context, f SourceFilter) ([]Source, int, error) {
  229. if s.pool == nil {
  230. return nil, 0, errors.New("authd: no DB pool (test mode)")
  231. }
  232. if f.Limit <= 0 {
  233. f.Limit = 100
  234. }
  235. if f.Limit > 500 {
  236. f.Limit = 500
  237. }
  238. args := []any{}
  239. conds := []string{}
  240. if f.CallerRole != "super_admin" {
  241. if f.CallerTenant == "" {
  242. return []Source{}, 0, nil
  243. }
  244. // Bridge: public.sources.company_id (TEXT) = auth.tenants.id::text
  245. args = append(args, f.CallerTenant)
  246. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  247. }
  248. if strings.TrimSpace(f.Type) != "" {
  249. args = append(args, f.Type)
  250. conds = append(conds, fmt.Sprintf("type = $%d", len(args)))
  251. }
  252. if strings.TrimSpace(f.Status) != "" {
  253. args = append(args, f.Status)
  254. conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
  255. }
  256. if strings.TrimSpace(f.Q) != "" {
  257. args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
  258. conds = append(conds, fmt.Sprintf("(id ILIKE $%d OR name ILIKE $%d)", len(args), len(args)))
  259. }
  260. where := ""
  261. if len(conds) > 0 {
  262. where = "WHERE " + strings.Join(conds, " AND ")
  263. }
  264. var total int
  265. if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.sources "+where, args...).Scan(&total); err != nil {
  266. return nil, 0, fmt.Errorf("count sources: %w", err)
  267. }
  268. args = append(args, f.Limit, f.Offset)
  269. q := fmt.Sprintf(`
  270. SELECT id, company_id, name, type, rate_limit_per_sec,
  271. allowed_targets, match_expr, status, mtls_required,
  272. COALESCE(description, ''),
  273. (hmac_secret_hash IS NOT NULL),
  274. (api_key_hash IS NOT NULL),
  275. created_at
  276. FROM public.sources
  277. %s
  278. ORDER BY created_at DESC
  279. LIMIT $%d OFFSET $%d
  280. `, where, len(args)-1, len(args))
  281. rows, err := s.pool.Query(ctx, q, args...)
  282. if err != nil {
  283. return nil, 0, fmt.Errorf("list sources: %w", err)
  284. }
  285. defer rows.Close()
  286. out := make([]Source, 0, f.Limit)
  287. for rows.Next() {
  288. var src Source
  289. if err := rows.Scan(
  290. &src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
  291. &src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
  292. &src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
  293. ); err != nil {
  294. return nil, 0, fmt.Errorf("scan source: %w", err)
  295. }
  296. out = append(out, src)
  297. }
  298. if err := rows.Err(); err != nil {
  299. return nil, 0, fmt.Errorf("rows: %w", err)
  300. }
  301. return out, total, nil
  302. }
  303. // GetSource fetches a single source by (company_id, id). Returns
  304. // ErrSourceNotFound if missing. Caller is responsible for the
  305. // per-id scope check (canAccessSource); this method is a
  306. // straight DB lookup.
  307. func (s *Store) GetSource(ctx context.Context, companyID, id string) (*Source, error) {
  308. if s.pool == nil {
  309. return nil, errors.New("authd: no DB pool (test mode)")
  310. }
  311. const q = `
  312. SELECT id, company_id, name, type, rate_limit_per_sec,
  313. allowed_targets, match_expr, status, mtls_required,
  314. COALESCE(description, ''),
  315. (hmac_secret_hash IS NOT NULL),
  316. (api_key_hash IS NOT NULL),
  317. created_at
  318. FROM public.sources
  319. WHERE company_id = $1 AND id = $2
  320. `
  321. src := &Source{}
  322. err := s.pool.QueryRow(ctx, q, companyID, id).Scan(
  323. &src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
  324. &src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
  325. &src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
  326. )
  327. if err != nil {
  328. if errors.Is(err, pgx.ErrNoRows) {
  329. return nil, ErrSourceNotFound
  330. }
  331. return nil, fmt.Errorf("get source: %w", err)
  332. }
  333. return src, nil
  334. }
  335. // CreateSource inserts a new source and returns the row plus a
  336. // one-time SecretsPayload. The bridge to public.companies is
  337. // handled inside (ensurePublicCompanyRow). Audit log written.
  338. //
  339. // Behavior:
  340. // - Duplicate (company_id, id) returns ErrSourceIDTaken (409).
  341. // - If HMACSecret / APIKey are empty in the input, no hash is
  342. // stored; the resulting Source has hmac_set=false.
  343. // - If non-empty, they're bcrypt-hashed at cost 10 and the
  344. // plaintext is returned in SecretsPayload. The plaintext
  345. // is the ONLY time the UI can see it.
  346. func (s *Store) CreateSource(
  347. ctx context.Context,
  348. tenantID, tenantDisplayName string,
  349. in CreateSourceInput,
  350. actorUserID, actorIP, actorUA string,
  351. ) (*Source, *SecretsPayload, error) {
  352. if s.pool == nil {
  353. return nil, nil, errors.New("authd: no DB pool (test mode)")
  354. }
  355. if err := in.Validate(); err != nil {
  356. return nil, nil, err
  357. }
  358. // Bridge: ensure public.companies has a row keyed by the
  359. // auth.tenants.id cast to text. This is the only place
  360. // authd writes to public.companies; everything else is
  361. // via the M13a auth.tenants API.
  362. if err := s.ensurePublicCompanyRow(ctx, tenantID, tenantDisplayName); err != nil {
  363. return nil, nil, err
  364. }
  365. // Default the JSONB columns if the caller didn't send
  366. // anything: '[]' for allowed_targets, '{}' for match_expr.
  367. allowedTargets := in.AllowedTargets
  368. if len(allowedTargets) == 0 {
  369. allowedTargets = json.RawMessage(`[]`)
  370. }
  371. matchExpr := in.MatchExpr
  372. if len(matchExpr) == 0 {
  373. matchExpr = json.RawMessage(`{}`)
  374. }
  375. // Hash the optional secrets. cost=10 mirrors the bootstrap
  376. // path; v1.1 will bump to 12 in prod.
  377. hmacHash, err := hashSecret(in.HMACSecret, "hmac")
  378. if err != nil {
  379. return nil, nil, err
  380. }
  381. apiKeyHash, err := hashSecret(in.APIKey, "api_key")
  382. if err != nil {
  383. return nil, nil, err
  384. }
  385. const q = `
  386. INSERT INTO public.sources
  387. (company_id, id, name, type, rate_limit_per_sec,
  388. allowed_targets, match_expr, status, mtls_required,
  389. description, hmac_secret_hash, api_key_hash)
  390. VALUES
  391. ($1::text, $2, $3, $4, $5,
  392. $6, $7, 'active', $8,
  393. NULLIF($9, ''), $10, $11)
  394. RETURNING id, company_id, name, type, rate_limit_per_sec,
  395. allowed_targets, match_expr, status, mtls_required,
  396. COALESCE(description, ''),
  397. (hmac_secret_hash IS NOT NULL),
  398. (api_key_hash IS NOT NULL),
  399. created_at
  400. `
  401. src := &Source{}
  402. err = s.pool.QueryRow(ctx, q,
  403. tenantID, in.ID, in.Name, in.Type, in.RateLimitPerSec,
  404. allowedTargets, matchExpr, in.MTLSRequired,
  405. in.Description, nullableString(hmacHash), nullableString(apiKeyHash),
  406. ).Scan(
  407. &src.ID, &src.CompanyID, &src.Name, &src.Type, &src.RateLimitPerSec,
  408. &src.AllowedTargets, &src.MatchExpr, &src.Status, &src.MTLSRequired,
  409. &src.Description, &src.HMACSet, &src.APIKeySet, &src.CreatedAt,
  410. )
  411. if err != nil {
  412. var pgErr *pgconn.PgError
  413. if errors.As(err, &pgErr) && pgErr.Code == "23505" {
  414. return nil, nil, ErrSourceIDTaken
  415. }
  416. return nil, nil, fmt.Errorf("create source: %w", err)
  417. }
  418. // Audit. The plaintext secrets are NOT included in the
  419. // audit payload — we only audit the fact that a source
  420. // was created, not the values.
  421. if err := s.WriteAudit(ctx, "source.create", actorUserID, actorIP, actorUA, src.CompanyID, src.ID, map[string]any{
  422. "name": src.Name,
  423. "type": src.Type,
  424. "rate_limit_per_sec": src.RateLimitPerSec,
  425. "mtls_required": src.MTLSRequired,
  426. "hmac_set": src.HMACSet,
  427. "api_key_set": src.APIKeySet,
  428. }); err != nil {
  429. _ = err
  430. }
  431. // Build the secrets payload only if the caller actually
  432. // provided one. Empty input => no payload, so the UI knows
  433. // not to render the secrets modal.
  434. var payload *SecretsPayload
  435. if in.HMACSecret != "" || in.APIKey != "" {
  436. payload = &SecretsPayload{
  437. HMACSecret: in.HMACSecret,
  438. APIKey: in.APIKey,
  439. }
  440. }
  441. return src, payload, nil
  442. }
  443. // UpdateSource applies a partial update and writes an audit row.
  444. func (s *Store) UpdateSource(
  445. ctx context.Context,
  446. companyID, id string,
  447. in UpdateSourceInput,
  448. actorUserID, actorIP, actorUA string,
  449. ) (*Source, error) {
  450. if s.pool == nil {
  451. return nil, errors.New("authd: no DB pool (test mode)")
  452. }
  453. if err := in.Validate(); err != nil {
  454. return nil, err
  455. }
  456. sets := []string{}
  457. args := []any{companyID, id}
  458. if in.Name != nil {
  459. args = append(args, strings.TrimSpace(*in.Name))
  460. sets = append(sets, fmt.Sprintf("name = $%d", len(args)))
  461. }
  462. if in.Type != nil {
  463. args = append(args, *in.Type)
  464. sets = append(sets, fmt.Sprintf("type = $%d", len(args)))
  465. }
  466. if in.RateLimitPerSec != nil {
  467. args = append(args, *in.RateLimitPerSec)
  468. sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
  469. }
  470. if in.Description != nil {
  471. args = append(args, *in.Description)
  472. sets = append(sets, fmt.Sprintf("description = $%d", len(args)))
  473. }
  474. if in.MTLSRequired != nil {
  475. args = append(args, *in.MTLSRequired)
  476. sets = append(sets, fmt.Sprintf("mtls_required = $%d", len(args)))
  477. }
  478. if in.AllowedTargets != nil {
  479. args = append(args, in.AllowedTargets)
  480. sets = append(sets, fmt.Sprintf("allowed_targets = $%d", len(args)))
  481. }
  482. if in.MatchExpr != nil {
  483. args = append(args, in.MatchExpr)
  484. sets = append(sets, fmt.Sprintf("match_expr = $%d", len(args)))
  485. }
  486. if len(sets) == 0 {
  487. return s.GetSource(ctx, companyID, id)
  488. }
  489. q := fmt.Sprintf("UPDATE public.sources SET %s WHERE company_id = $1 AND id = $2", strings.Join(sets, ", "))
  490. tag, err := s.pool.Exec(ctx, q, args...)
  491. if err != nil {
  492. return nil, fmt.Errorf("update source: %w", err)
  493. }
  494. if tag.RowsAffected() == 0 {
  495. return nil, ErrSourceNotFound
  496. }
  497. payload := map[string]any{}
  498. if in.Name != nil {
  499. payload["name"] = *in.Name
  500. }
  501. if in.Type != nil {
  502. payload["type"] = *in.Type
  503. }
  504. if in.RateLimitPerSec != nil {
  505. payload["rate_limit_per_sec"] = *in.RateLimitPerSec
  506. }
  507. if in.Description != nil {
  508. payload["description"] = *in.Description
  509. }
  510. if in.MTLSRequired != nil {
  511. payload["mtls_required"] = *in.MTLSRequired
  512. }
  513. if in.AllowedTargets != nil {
  514. payload["allowed_targets_set"] = true
  515. }
  516. if in.MatchExpr != nil {
  517. payload["match_expr_set"] = true
  518. }
  519. if err := s.WriteAudit(ctx, "source.update", actorUserID, actorIP, actorUA, companyID, id, payload); err != nil {
  520. _ = err
  521. }
  522. return s.GetSource(ctx, companyID, id)
  523. }
  524. // SetSourceStatus flips status. Allowed transitions:
  525. // active -> suspended
  526. // suspended -> active
  527. // No archive state for sources (per the schema; only
  528. // active|suspended).
  529. func (s *Store) SetSourceStatus(
  530. ctx context.Context,
  531. companyID, id, newStatus string,
  532. actorUserID, actorIP, actorUA string,
  533. ) (*Source, error) {
  534. if s.pool == nil {
  535. return nil, errors.New("authd: no DB pool (test mode)")
  536. }
  537. switch newStatus {
  538. case "active", "suspended":
  539. default:
  540. return nil, fmt.Errorf("%w: status must be active|suspended", ErrSourceInvalid)
  541. }
  542. cur, err := s.GetSource(ctx, companyID, id)
  543. if err != nil {
  544. return nil, err
  545. }
  546. if cur.Status == newStatus {
  547. return cur, nil
  548. }
  549. if _, err := s.pool.Exec(ctx,
  550. "UPDATE public.sources SET status = $3 WHERE company_id = $1 AND id = $2",
  551. companyID, id, newStatus); err != nil {
  552. return nil, fmt.Errorf("set source status: %w", err)
  553. }
  554. if err := s.WriteAudit(ctx, "source.status", actorUserID, actorIP, actorUA, companyID, id, map[string]any{
  555. "from": cur.Status,
  556. "to": newStatus,
  557. }); err != nil {
  558. _ = err
  559. }
  560. return s.GetSource(ctx, companyID, id)
  561. }
  562. // RotateSecrets generates a new HMAC secret + API key, hashes
  563. // them, and returns the plaintext ONCE in SecretsPayload. The
  564. // old secrets are immediately invalidated (overwritten in the
  565. // DB). Use this when a source's credentials are suspected to
  566. // have leaked.
  567. func (s *Store) RotateSecrets(
  568. ctx context.Context,
  569. companyID, id string,
  570. actorUserID, actorIP, actorUA string,
  571. ) (*Source, *SecretsPayload, error) {
  572. if s.pool == nil {
  573. return nil, nil, errors.New("authd: no DB pool (test mode)")
  574. }
  575. // Confirm the source exists before we generate anything.
  576. // If it doesn't, we don't want to surface that a random
  577. // pair was generated and then discarded.
  578. cur, err := s.GetSource(ctx, companyID, id)
  579. if err != nil {
  580. return nil, nil, err
  581. }
  582. hmacPlain, err := generateSecret(32)
  583. if err != nil {
  584. return nil, nil, fmt.Errorf("generate hmac: %w", err)
  585. }
  586. apiPlain, err := generateAPIKey()
  587. if err != nil {
  588. return nil, nil, fmt.Errorf("generate api_key: %w", err)
  589. }
  590. hmacHash, err := hashSecret(hmacPlain, "hmac")
  591. if err != nil {
  592. return nil, nil, err
  593. }
  594. apiKeyHash, err := hashSecret(apiPlain, "api_key")
  595. if err != nil {
  596. return nil, nil, err
  597. }
  598. if _, err := s.pool.Exec(ctx,
  599. "UPDATE public.sources SET hmac_secret_hash = $3, api_key_hash = $4 WHERE company_id = $1 AND id = $2",
  600. companyID, id, hmacHash, apiKeyHash); err != nil {
  601. return nil, nil, fmt.Errorf("rotate secrets: %w", err)
  602. }
  603. if err := s.WriteAudit(ctx, "source.rotate_secrets", actorUserID, actorIP, actorUA, companyID, id, map[string]any{
  604. "hmac_rotated": true,
  605. "api_key_rotated": true,
  606. }); err != nil {
  607. _ = err
  608. }
  609. updated, err := s.GetSource(ctx, companyID, id)
  610. if err != nil {
  611. return nil, nil, err
  612. }
  613. _ = cur
  614. return updated, &SecretsPayload{
  615. HMACSecret: hmacPlain,
  616. APIKey: apiPlain,
  617. }, nil
  618. }
  619. // -------------------------------------------------------------------
  620. // helpers
  621. // -------------------------------------------------------------------
  622. // validSourceID matches the same regex as auth.tenants.slug:
  623. // ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
  624. func validSourceID(s string) bool {
  625. if len(s) < 2 || len(s) > 64 {
  626. return false
  627. }
  628. if !isAlnumOrDash(s[0]) || s[0] == '-' {
  629. return false
  630. }
  631. if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
  632. return false
  633. }
  634. for i := 1; i < len(s)-1; i++ {
  635. if !isAlnumOrDash(s[i]) {
  636. return false
  637. }
  638. }
  639. return true
  640. }
  641. // validSecretFormat — HMAC secret: 32..128 [A-Za-z0-9_-] chars.
  642. // Long enough to be cryptographically meaningful, short enough
  643. // to paste into a config file by hand.
  644. func validSecretFormat(s string) bool {
  645. if len(s) < 32 || len(s) > 128 {
  646. return false
  647. }
  648. for i := 0; i < len(s); i++ {
  649. c := s[i]
  650. if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
  651. (c >= '0' && c <= '9') || c == '_' || c == '-') {
  652. return false
  653. }
  654. }
  655. return true
  656. }
  657. // validAPIKeyFormat — API key: 16..128 [A-Za-z0-9_-] chars.
  658. func validAPIKeyFormat(s string) bool {
  659. if len(s) < 16 || len(s) > 128 {
  660. return false
  661. }
  662. for i := 0; i < len(s); i++ {
  663. c := s[i]
  664. if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
  665. (c >= '0' && c <= '9') || c == '_' || c == '-') {
  666. return false
  667. }
  668. }
  669. return true
  670. }
  671. // generateSecret returns n random bytes hex-encoded.
  672. func generateSecret(n int) (string, error) {
  673. buf := make([]byte, n)
  674. if _, err := rand.Read(buf); err != nil {
  675. return "", err
  676. }
  677. return hex.EncodeToString(buf), nil
  678. }
  679. // generateAPIKey returns 24 random bytes hex-encoded (48 chars).
  680. // Hex is fine because the key never appears in URLs.
  681. func generateAPIKey() (string, error) {
  682. return generateSecret(24)
  683. }
  684. // hashSecret bcrypts the plaintext at cost 10. Returns the
  685. // empty string if the plaintext is empty (caller checks
  686. // separately for "no secret was provided"). The `kind` arg is
  687. // only used in the error path; the actual hash doesn't care.
  688. func hashSecret(plain, kind string) (string, error) {
  689. if plain == "" {
  690. return "", nil
  691. }
  692. h, err := bcrypt.GenerateFromPassword([]byte(plain), 10)
  693. if err != nil {
  694. return "", fmt.Errorf("hash %s secret: %w", kind, err)
  695. }
  696. return string(h), nil
  697. }
  698. // nullableString returns nil for empty input, otherwise &s.
  699. // Used to pass optional columns to pgx Exec / QueryRow.
  700. func nullableString(s string) any {
  701. if s == "" {
  702. return nil
  703. }
  704. return s
  705. }