184_auth_cache_invalidation_outbox.sql 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. -- Durable, transactionally-enqueued API-key auth cache invalidation.
  2. -- cache_key is always SHA-256 hex; plaintext credentials never leave api_keys.
  3. CREATE TABLE IF NOT EXISTS auth_cache_invalidation_outbox (
  4. id BIGSERIAL PRIMARY KEY,
  5. cache_key CHAR(64) NOT NULL CHECK (cache_key ~ '^[0-9a-f]{64}$'),
  6. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  7. available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  8. delivery_stage SMALLINT NOT NULL DEFAULT 0 CHECK (delivery_stage IN (0, 1)),
  9. attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
  10. last_error TEXT,
  11. claimed_at TIMESTAMPTZ,
  12. claimed_by TEXT
  13. );
  14. CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_available
  15. ON auth_cache_invalidation_outbox (available_at, id)
  16. WHERE claimed_at IS NULL;
  17. CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_lease
  18. ON auth_cache_invalidation_outbox (claimed_at)
  19. WHERE claimed_at IS NOT NULL;
  20. CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_cache_key
  21. ON auth_cache_invalidation_outbox (cache_key);
  22. CREATE INDEX IF NOT EXISTS idx_auth_cache_invalidation_outbox_created_at
  23. ON auth_cache_invalidation_outbox (created_at);
  24. CREATE OR REPLACE FUNCTION enqueue_auth_cache_invalidation(raw_key TEXT)
  25. RETURNS VOID
  26. LANGUAGE plpgsql
  27. AS $$
  28. BEGIN
  29. IF raw_key IS NULL OR raw_key = '' THEN
  30. RETURN;
  31. END IF;
  32. INSERT INTO auth_cache_invalidation_outbox (cache_key)
  33. VALUES (encode(sha256(convert_to(raw_key, 'UTF8')), 'hex'));
  34. END;
  35. $$;
  36. CREATE OR REPLACE FUNCTION enqueue_api_key_auth_cache_invalidation()
  37. RETURNS TRIGGER
  38. LANGUAGE plpgsql
  39. AS $$
  40. BEGIN
  41. IF TG_OP = 'DELETE' THEN
  42. PERFORM enqueue_auth_cache_invalidation(OLD.key);
  43. RETURN OLD;
  44. END IF;
  45. IF OLD.key IS DISTINCT FROM NEW.key
  46. OR OLD.status IS DISTINCT FROM NEW.status
  47. OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
  48. OR OLD.user_id IS DISTINCT FROM NEW.user_id
  49. OR OLD.group_id IS DISTINCT FROM NEW.group_id
  50. OR OLD.ip_whitelist IS DISTINCT FROM NEW.ip_whitelist
  51. OR OLD.ip_blacklist IS DISTINCT FROM NEW.ip_blacklist
  52. OR OLD.expires_at IS DISTINCT FROM NEW.expires_at THEN
  53. PERFORM enqueue_auth_cache_invalidation(OLD.key);
  54. IF NEW.deleted_at IS NULL AND NEW.key IS DISTINCT FROM OLD.key THEN
  55. PERFORM enqueue_auth_cache_invalidation(NEW.key);
  56. END IF;
  57. END IF;
  58. RETURN NEW;
  59. END;
  60. $$;
  61. DROP TRIGGER IF EXISTS trg_api_keys_auth_cache_invalidation ON api_keys;
  62. CREATE TRIGGER trg_api_keys_auth_cache_invalidation
  63. AFTER UPDATE OR DELETE ON api_keys
  64. FOR EACH ROW EXECUTE FUNCTION enqueue_api_key_auth_cache_invalidation();
  65. CREATE OR REPLACE FUNCTION enqueue_user_auth_cache_invalidation()
  66. RETURNS TRIGGER
  67. LANGUAGE plpgsql
  68. AS $$
  69. DECLARE
  70. target_user_id BIGINT;
  71. BEGIN
  72. target_user_id := OLD.id;
  73. IF TG_OP = 'UPDATE'
  74. AND OLD.status IS NOT DISTINCT FROM NEW.status
  75. AND OLD.role IS NOT DISTINCT FROM NEW.role
  76. AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
  77. RETURN NEW;
  78. END IF;
  79. INSERT INTO auth_cache_invalidation_outbox (cache_key)
  80. SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex')
  81. FROM api_keys AS k
  82. WHERE k.user_id = target_user_id
  83. AND k.deleted_at IS NULL
  84. AND k.key <> '';
  85. IF TG_OP = 'DELETE' THEN
  86. RETURN OLD;
  87. END IF;
  88. RETURN NEW;
  89. END;
  90. $$;
  91. DROP TRIGGER IF EXISTS trg_users_auth_cache_invalidation ON users;
  92. CREATE TRIGGER trg_users_auth_cache_invalidation
  93. AFTER UPDATE OR DELETE ON users
  94. FOR EACH ROW EXECUTE FUNCTION enqueue_user_auth_cache_invalidation();
  95. CREATE OR REPLACE FUNCTION enqueue_group_auth_cache_invalidation()
  96. RETURNS TRIGGER
  97. LANGUAGE plpgsql
  98. AS $$
  99. DECLARE
  100. target_group_id BIGINT;
  101. BEGIN
  102. target_group_id := OLD.id;
  103. IF TG_OP = 'UPDATE'
  104. AND OLD.status IS NOT DISTINCT FROM NEW.status
  105. AND OLD.is_exclusive IS NOT DISTINCT FROM NEW.is_exclusive
  106. AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
  107. RETURN NEW;
  108. END IF;
  109. INSERT INTO auth_cache_invalidation_outbox (cache_key)
  110. SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex')
  111. FROM api_keys AS k
  112. WHERE k.group_id = target_group_id
  113. AND k.deleted_at IS NULL
  114. AND k.key <> '';
  115. IF TG_OP = 'DELETE' THEN
  116. RETURN OLD;
  117. END IF;
  118. RETURN NEW;
  119. END;
  120. $$;
  121. DROP TRIGGER IF EXISTS trg_groups_auth_cache_invalidation ON groups;
  122. CREATE TRIGGER trg_groups_auth_cache_invalidation
  123. AFTER UPDATE OR DELETE ON groups
  124. FOR EACH ROW EXECUTE FUNCTION enqueue_group_auth_cache_invalidation();
  125. CREATE OR REPLACE FUNCTION enqueue_allowed_group_auth_cache_invalidation()
  126. RETURNS TRIGGER
  127. LANGUAGE plpgsql
  128. AS $$
  129. DECLARE
  130. target_user_id BIGINT;
  131. target_group_id BIGINT;
  132. BEGIN
  133. IF TG_OP = 'UPDATE'
  134. AND (OLD.user_id IS DISTINCT FROM NEW.user_id
  135. OR OLD.group_id IS DISTINCT FROM NEW.group_id) THEN
  136. IF EXISTS (
  137. SELECT 1 FROM groups g
  138. WHERE g.id = OLD.group_id AND g.is_exclusive = TRUE
  139. ) THEN
  140. INSERT INTO auth_cache_invalidation_outbox (cache_key)
  141. SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex')
  142. FROM api_keys AS k
  143. WHERE k.user_id = OLD.user_id
  144. AND k.group_id = OLD.group_id
  145. AND k.deleted_at IS NULL
  146. AND k.key <> '';
  147. END IF;
  148. target_user_id := NEW.user_id;
  149. target_group_id := NEW.group_id;
  150. ELSIF TG_OP = 'UPDATE' THEN
  151. RETURN NEW;
  152. ELSIF TG_OP = 'INSERT' THEN
  153. target_user_id := NEW.user_id;
  154. target_group_id := NEW.group_id;
  155. ELSE
  156. target_user_id := OLD.user_id;
  157. target_group_id := OLD.group_id;
  158. END IF;
  159. IF EXISTS (
  160. SELECT 1 FROM groups g
  161. WHERE g.id = target_group_id AND g.is_exclusive = TRUE
  162. ) THEN
  163. INSERT INTO auth_cache_invalidation_outbox (cache_key)
  164. SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex')
  165. FROM api_keys AS k
  166. WHERE k.user_id = target_user_id
  167. AND k.group_id = target_group_id
  168. AND k.deleted_at IS NULL
  169. AND k.key <> '';
  170. END IF;
  171. IF TG_OP = 'DELETE' THEN
  172. RETURN OLD;
  173. END IF;
  174. RETURN NEW;
  175. END;
  176. $$;
  177. DROP TRIGGER IF EXISTS trg_user_allowed_groups_auth_cache_invalidation ON user_allowed_groups;
  178. CREATE TRIGGER trg_user_allowed_groups_auth_cache_invalidation
  179. AFTER INSERT OR UPDATE OR DELETE ON user_allowed_groups
  180. FOR EACH ROW EXECUTE FUNCTION enqueue_allowed_group_auth_cache_invalidation();
  181. COMMENT ON TABLE auth_cache_invalidation_outbox IS
  182. 'Durable cross-instance auth cache invalidations; cache_key is SHA-256 hex, never plaintext API key';