sources.go 25 KB

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