postkit 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {postkit-0.4.0 → postkit-0.5.0}/PKG-INFO +9 -1
  2. postkit-0.5.0/README.md +47 -0
  3. {postkit-0.4.0 → postkit-0.5.0}/pyproject.toml +1 -1
  4. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/base.py +23 -0
  5. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/errors.py +15 -0
  6. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/outbox/client.py +13 -0
  7. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/queue/client.py +20 -4
  8. postkit-0.5.0/tests/authn/test_rls_active.py +26 -0
  9. postkit-0.5.0/tests/authz/test_rls_active.py +26 -0
  10. postkit-0.5.0/tests/config/test_rls_active.py +26 -0
  11. postkit-0.5.0/tests/lease/test_rls_active.py +26 -0
  12. postkit-0.5.0/tests/meter/test_rls_active.py +55 -0
  13. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_horizon.py +38 -0
  14. postkit-0.5.0/tests/outbox/test_rls_active.py +63 -0
  15. postkit-0.5.0/tests/presence/test_rls_active.py +57 -0
  16. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_ack.py +26 -9
  17. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_nack_ack_race.py +7 -4
  18. postkit-0.5.0/tests/queue/test_rls_active.py +57 -0
  19. postkit-0.5.0/tests/queue/test_rollback_recovery.py +162 -0
  20. {postkit-0.4.0 → postkit-0.5.0}/uv.lock +1 -1
  21. postkit-0.4.0/README.md +0 -39
  22. {postkit-0.4.0 → postkit-0.5.0}/.gitignore +0 -0
  23. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/__init__.py +0 -0
  24. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authn/__init__.py +0 -0
  25. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authn/client.py +0 -0
  26. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authz/__init__.py +0 -0
  27. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authz/client.py +0 -0
  28. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/config/__init__.py +0 -0
  29. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/config/client.py +0 -0
  30. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/lease/__init__.py +0 -0
  31. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/lease/client.py +0 -0
  32. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/meter/__init__.py +0 -0
  33. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/meter/client.py +0 -0
  34. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/outbox/__init__.py +0 -0
  35. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/presence/__init__.py +0 -0
  36. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/presence/client.py +0 -0
  37. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/py.typed +0 -0
  38. {postkit-0.4.0 → postkit-0.5.0}/src/postkit/queue/__init__.py +0 -0
  39. {postkit-0.4.0 → postkit-0.5.0}/tests/__init__.py +0 -0
  40. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/__init__.py +0 -0
  41. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/conftest.py +0 -0
  42. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/helpers.py +0 -0
  43. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_audit.py +0 -0
  44. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_base.py +0 -0
  45. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_credentials.py +0 -0
  46. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_disabled_user.py +0 -0
  47. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_e2e_demo.py +0 -0
  48. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_impersonation.py +0 -0
  49. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_lockout.py +0 -0
  50. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_maintenance.py +0 -0
  51. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_operator_impersonation.py +0 -0
  52. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_refresh_tokens.py +0 -0
  53. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_rls.py +0 -0
  54. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_sessions.py +0 -0
  55. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_tokens.py +0 -0
  56. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_users.py +0 -0
  57. {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_validation.py +0 -0
  58. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/__init__.py +0 -0
  59. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/conftest.py +0 -0
  60. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/helpers.py +0 -0
  61. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_access_patterns.py +0 -0
  62. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_audit.py +0 -0
  63. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_concurrency.py +0 -0
  64. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_consistency.py +0 -0
  65. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_e2e_demo.py +0 -0
  66. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_expiration.py +0 -0
  67. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_groups.py +0 -0
  68. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_hierarchy.py +0 -0
  69. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_listing.py +0 -0
  70. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_maintenance.py +0 -0
  71. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_namespaces.py +0 -0
  72. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_nested_teams.py +0 -0
  73. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_resource_hierarchy.py +0 -0
  74. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_rls.py +0 -0
  75. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_sdk.py +0 -0
  76. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_stress.py +0 -0
  77. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_transactions.py +0 -0
  78. {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_validation.py +0 -0
  79. {postkit-0.4.0 → postkit-0.5.0}/tests/config/__init__.py +0 -0
  80. {postkit-0.4.0 → postkit-0.5.0}/tests/config/conftest.py +0 -0
  81. {postkit-0.4.0 → postkit-0.5.0}/tests/config/helpers.py +0 -0
  82. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_audit.py +0 -0
  83. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_entries.py +0 -0
  84. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_rls.py +0 -0
  85. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_schemas.py +0 -0
  86. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_tenancy.py +0 -0
  87. {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_validation.py +0 -0
  88. {postkit-0.4.0 → postkit-0.5.0}/tests/conftest.py +0 -0
  89. {postkit-0.4.0 → postkit-0.5.0}/tests/helpers.py +0 -0
  90. {postkit-0.4.0 → postkit-0.5.0}/tests/integration/conftest.py +0 -0
  91. {postkit-0.4.0 → postkit-0.5.0}/tests/integration/test_api_keys.py +0 -0
  92. {postkit-0.4.0 → postkit-0.5.0}/tests/integration/test_presence_hooks.py +0 -0
  93. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/__init__.py +0 -0
  94. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/conftest.py +0 -0
  95. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/helpers.py +0 -0
  96. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_acquire.py +0 -0
  97. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_events.py +0 -0
  98. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_invariants.py +0 -0
  99. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_maintenance.py +0 -0
  100. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_renew_release.py +0 -0
  101. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_tenancy.py +0 -0
  102. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_validation.py +0 -0
  103. {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_verify_fencing.py +0 -0
  104. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/conftest.py +0 -0
  105. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/helpers.py +0 -0
  106. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_periods.py +0 -0
  107. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_reservation_expiry_race.py +0 -0
  108. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_rls.py +0 -0
  109. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_sdk.py +0 -0
  110. {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_validation.py +0 -0
  111. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/__init__.py +0 -0
  112. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/conftest.py +0 -0
  113. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/helpers.py +0 -0
  114. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_emit.py +0 -0
  115. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_lost_cursor.py +0 -0
  116. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_maintenance.py +0 -0
  117. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
  118. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_tenancy.py +0 -0
  119. {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_validation.py +0 -0
  120. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/__init__.py +0 -0
  121. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/conftest.py +0 -0
  122. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/helpers.py +0 -0
  123. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_flap.py +0 -0
  124. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_heartbeat.py +0 -0
  125. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_invariants.py +0 -0
  126. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_maintenance.py +0 -0
  127. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_state_machine.py +0 -0
  128. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_tenancy.py +0 -0
  129. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_validation.py +0 -0
  130. {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_wall_clock.py +0 -0
  131. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/__init__.py +0 -0
  132. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/conftest.py +0 -0
  133. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/helpers.py +0 -0
  134. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_cron.py +0 -0
  135. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_dead_letters.py +0 -0
  136. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_e2e_demo.py +0 -0
  137. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_pull.py +0 -0
  138. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_purge.py +0 -0
  139. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_push.py +0 -0
  140. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_release_jobs.py +0 -0
  141. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_rls.py +0 -0
  142. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_schedules.py +0 -0
  143. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_stats.py +0 -0
  144. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tenancy.py +0 -0
  145. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tick.py +0 -0
  146. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tick_timeouts.py +0 -0
  147. {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_validation.py +0 -0
  148. {postkit-0.4.0 → postkit-0.5.0}/tests/test_error_codes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postkit
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues
5
5
  Project-URL: Homepage, https://github.com/varunchopra/postkit
6
6
  Project-URL: Repository, https://github.com/varunchopra/postkit
@@ -65,6 +65,14 @@ user_id = authn.create_user("alice@example.com", password_hash="argon2...")
65
65
  session_id = authn.create_session(user_id, token_hash="sha256...")
66
66
  ```
67
67
 
68
+ ## Tenant Context and Transactions
69
+
70
+ Constructing a client calls `{module}.set_tenant(namespace)` immediately. The setting is transaction-scoped, so inside an open transaction the constructor taints that transaction's context for the module until commit or rollback; an unrelated client built mid-transaction can therefore change which rows a later raw SQL statement sees.
71
+
72
+ Every SDK call needs a transaction for that context. On autocommit connections each call runs in its own transaction and commits immediately. On non-autocommit connections each call joins the connection's open transaction: nothing is durable until you commit, and a rollback takes it all with it, including a queue claim from `pull()`. That is the intended model for transactional consumers; `nack`/`fail` accept the pending job a rollback leaves behind.
73
+
74
+ In CI, call `client.assert_rls_active()` during setup. A suite connecting as a superuser or `BYPASSRLS` role (the docker default) bypasses every RLS policy and exercises none of the tenancy model.
75
+
68
76
  ## Requirements
69
77
 
70
78
  - PostgreSQL 14+
@@ -0,0 +1,47 @@
1
+ # postkit SDK
2
+
3
+ Python client for postkit.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install postkit
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import psycopg
15
+ from postkit.authz import AuthzClient
16
+ from postkit.authn import AuthnClient
17
+
18
+ conn = psycopg.connect("postgresql://...")
19
+ cursor = conn.cursor()
20
+
21
+ # Authorization
22
+ authz = AuthzClient(cursor, namespace="my-app")
23
+ authz.set_hierarchy("repo", "admin", "write", "read")
24
+ authz.grant("admin", resource=("repo", "api"), subject=("user", "alice"))
25
+ if authz.check(("user", "alice"), "read", ("repo", "api")):
26
+ print("Access granted")
27
+
28
+ # Authentication
29
+ authn = AuthnClient(cursor, namespace="my-app")
30
+ user_id = authn.create_user("alice@example.com", password_hash="argon2...")
31
+ session_id = authn.create_session(user_id, token_hash="sha256...")
32
+ ```
33
+
34
+ ## Tenant Context and Transactions
35
+
36
+ Constructing a client calls `{module}.set_tenant(namespace)` immediately. The setting is transaction-scoped, so inside an open transaction the constructor taints that transaction's context for the module until commit or rollback; an unrelated client built mid-transaction can therefore change which rows a later raw SQL statement sees.
37
+
38
+ Every SDK call needs a transaction for that context. On autocommit connections each call runs in its own transaction and commits immediately. On non-autocommit connections each call joins the connection's open transaction: nothing is durable until you commit, and a rollback takes it all with it, including a queue claim from `pull()`. That is the intended model for transactional consumers; `nack`/`fail` accept the pending job a rollback leaves behind.
39
+
40
+ In CI, call `client.assert_rls_active()` during setup. A suite connecting as a superuser or `BYPASSRLS` role (the docker default) bypasses every RLS policy and exercises none of the tenancy model.
41
+
42
+ ## Requirements
43
+
44
+ - PostgreSQL 14+
45
+ - The postkit SQL schema installed in your database
46
+
47
+ See the [main repository](https://github.com/varunchopra/postkit) for SQL installation instructions.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postkit"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -399,6 +399,29 @@ class BaseClient(ABC):
399
399
  self._on_behalf_of = None
400
400
  self._reason = None
401
401
 
402
+ def assert_rls_active(self) -> None:
403
+ """Raise unless row-level security applies to the connection's role.
404
+
405
+ Call from CI setup: a suite connecting as a superuser or BYPASSRLS
406
+ role bypasses every policy and exercises none of the tenancy model.
407
+
408
+ Raises:
409
+ The module's error class with error_code BIZ_RLS_NOT_ACTIVE
410
+ when the current role bypasses RLS.
411
+ """
412
+
413
+ def execute() -> None:
414
+ try:
415
+ self.cursor.execute(
416
+ sql.SQL("SELECT {}.assert_rls_active()").format(
417
+ sql.Identifier(self._schema)
418
+ )
419
+ )
420
+ except psycopg.Error as e:
421
+ self._handle_error(e)
422
+
423
+ self._with_context(execute)
424
+
402
425
  @staticmethod
403
426
  def _encode_cursor(event_time: datetime, event_id: int) -> str:
404
427
  """Encode pagination cursor as opaque string.
@@ -64,6 +64,7 @@ class AuthnErrorCode:
64
64
  # Business logic errors (BIZ_)
65
65
  BIZ_IMPERSONATE_SELF = "BIZ_IMPERSONATE_SELF"
66
66
  BIZ_IMPERSONATE_CHAIN = "BIZ_IMPERSONATE_CHAIN"
67
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
67
68
 
68
69
  # Credential state errors (CRED_)
69
70
  CRED_NO_MATERIAL = "CRED_NO_MATERIAL"
@@ -109,6 +110,7 @@ class AuthzErrorCode:
109
110
  BIZ_BULK_GROUP_MEMBERSHIP = "BIZ_BULK_GROUP_MEMBERSHIP"
110
111
  BIZ_BULK_PARENT_RELATION = "BIZ_BULK_PARENT_RELATION"
111
112
  BIZ_GRANT_NO_EXPIRATION = "BIZ_GRANT_NO_EXPIRATION"
113
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
112
114
 
113
115
  # Data state errors (DATA_)
114
116
  DATA_GRANT_NOT_FOUND = "DATA_GRANT_NOT_FOUND"
@@ -140,6 +142,7 @@ class ConfigErrorCode:
140
142
 
141
143
  # Business logic errors (BIZ_)
142
144
  BIZ_DELETE_ACTIVE_VERSION = "BIZ_DELETE_ACTIVE_VERSION"
145
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
143
146
 
144
147
 
145
148
  class LeaseErrorCode:
@@ -173,6 +176,9 @@ class LeaseErrorCode:
173
176
  # Internal errors (INT_)
174
177
  INT_COUNTER_MISSING = "INT_COUNTER_MISSING"
175
178
 
179
+ # Business logic errors (BIZ_)
180
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
181
+
176
182
 
177
183
  class MeterErrorCode:
178
184
  """Error codes for the meter module."""
@@ -200,6 +206,8 @@ class MeterErrorCode:
200
206
 
201
207
  # Business logic errors (BIZ_)
202
208
  BIZ_LEDGER_IMMUTABLE = "BIZ_LEDGER_IMMUTABLE"
209
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
210
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
203
211
 
204
212
  # Data state errors (DATA_)
205
213
  DATA_ACCOUNT_NOT_FOUND = "DATA_ACCOUNT_NOT_FOUND"
@@ -238,6 +246,8 @@ class OutboxErrorCode:
238
246
  BIZ_CONSUMER_UNKNOWN = "BIZ_CONSUMER_UNKNOWN"
239
247
  BIZ_CURSOR_LOST = "BIZ_CURSOR_LOST"
240
248
  BIZ_POSITION_BEYOND_HEAD = "BIZ_POSITION_BEYOND_HEAD"
249
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
250
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
241
251
 
242
252
 
243
253
  class PresenceErrorCode:
@@ -267,6 +277,8 @@ class PresenceErrorCode:
267
277
  # Business logic errors
268
278
  ENTITY_UNKNOWN = "ENTITY_UNKNOWN"
269
279
  HOOK_QUEUE_MISSING = "HOOK_QUEUE_MISSING"
280
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
281
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
270
282
 
271
283
 
272
284
  class QueueErrorCode:
@@ -299,10 +311,13 @@ class QueueErrorCode:
299
311
 
300
312
  # Business logic errors (BIZ_)
301
313
  BIZ_JOB_NOT_RUNNING = "BIZ_JOB_NOT_RUNNING"
314
+ BIZ_JOB_NOT_YOURS = "BIZ_JOB_NOT_YOURS"
302
315
  BIZ_SCHEDULE_DUPLICATE = "BIZ_SCHEDULE_DUPLICATE"
303
316
  BIZ_SCHEDULE_REQUIRES_SCHEDULE = "BIZ_SCHEDULE_REQUIRES_SCHEDULE"
304
317
  BIZ_SCHEDULE_CRON_AND_INTERVAL = "BIZ_SCHEDULE_CRON_AND_INTERVAL"
305
318
  BIZ_DEAD_LETTER_ALREADY_RETRIED = "BIZ_DEAD_LETTER_ALREADY_RETRIED"
319
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
320
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
306
321
 
307
322
  # Data state errors (DATA_)
308
323
  DATA_JOB_NOT_FOUND = "DATA_JOB_NOT_FOUND"
@@ -341,3 +341,16 @@ class OutboxClient(BaseClient):
341
341
  return self._fetch_all(
342
342
  "SELECT * FROM outbox.list_consumers(%s, %s)", (self.namespace, topic)
343
343
  )
344
+
345
+ def horizon_blockers(self) -> list[dict[str, Any]]:
346
+ """Backends whose open write transactions pin the visibility horizon.
347
+
348
+ Database-global, like the horizon itself. Seeing other sessions
349
+ requires pg_read_all_stats (or superuser).
350
+
351
+ Returns:
352
+ One dict per in-progress write transaction, oldest first:
353
+ pid, datname, xact_age, state, application_name, query,
354
+ is_horizon
355
+ """
356
+ return self._fetch_all("SELECT * FROM outbox.horizon_blockers()", ())
@@ -359,19 +359,25 @@ class QueueClient(BaseClient):
359
359
  *,
360
360
  error: str | None = None,
361
361
  backoff: timedelta | None = None,
362
+ worker_id: str | None = None,
362
363
  ) -> bool:
363
364
  """Return job to queue for retry (temporary failure).
364
365
 
366
+ Valid on running jobs and on pending jobs whose claim was rolled
367
+ back with the consumer's transaction.
368
+
365
369
  Args:
366
370
  job_id: Job ID
367
371
  error: Error message (stored for debugging)
368
372
  backoff: Custom backoff delay (default: exponential)
373
+ worker_id: If set, refuse jobs running under a different worker
369
374
 
370
375
  Returns:
371
376
  True if returned to queue, False if max attempts exceeded (moved to DLQ)
372
377
 
373
378
  Raises:
374
- QueueValidationError: If job exists but is not in running status
379
+ QueueValidationError: If job is settled (completed or dead), or
380
+ running under a different worker than worker_id
375
381
  QueueError: If job does not exist
376
382
  """
377
383
  result = self._fetch_val(
@@ -379,13 +385,15 @@ class QueueClient(BaseClient):
379
385
  p_namespace := %s,
380
386
  p_job_id := %s,
381
387
  p_error := %s,
382
- p_backoff := %s
388
+ p_backoff := %s,
389
+ p_worker_id := %s
383
390
  )""",
384
391
  (
385
392
  self.namespace,
386
393
  job_id,
387
394
  error,
388
395
  backoff,
396
+ worker_id,
389
397
  ),
390
398
  write=True,
391
399
  )
@@ -396,26 +404,34 @@ class QueueClient(BaseClient):
396
404
  job_id: int,
397
405
  *,
398
406
  error: str | None = None,
407
+ worker_id: str | None = None,
399
408
  ) -> bool:
400
409
  """Move job to dead letter queue (permanent failure).
401
410
 
411
+ Valid on running jobs and on pending jobs whose claim was rolled
412
+ back with the consumer's transaction.
413
+
402
414
  Args:
403
415
  job_id: Job ID
404
416
  error: Error message
417
+ worker_id: If set, refuse jobs running under a different worker
405
418
 
406
419
  Returns:
407
- True if moved to DLQ, False if job not found or not running
420
+ True if moved to DLQ, False if job settled, missing, or owned
421
+ by another worker
408
422
  """
409
423
  result = self._fetch_val(
410
424
  """SELECT queue.fail(
411
425
  p_namespace := %s,
412
426
  p_job_id := %s,
413
- p_error := %s
427
+ p_error := %s,
428
+ p_worker_id := %s
414
429
  )""",
415
430
  (
416
431
  self.namespace,
417
432
  job_id,
418
433
  error,
434
+ worker_id,
419
435
  ),
420
436
  write=True,
421
437
  )
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.authn import AuthnClient, AuthnError, AuthnErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = AuthnClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(AuthnError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == AuthnErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "authn")
21
+ conn = connect_as_rls_user(db_connection, "authn")
22
+ try:
23
+ AuthnClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.authz import AuthzClient, AuthzError, AuthzErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = AuthzClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(AuthzError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == AuthzErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "authz")
21
+ conn = connect_as_rls_user(db_connection, "authz")
22
+ try:
23
+ AuthzClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.config import ConfigClient, ConfigError, ConfigErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = ConfigClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(ConfigError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == ConfigErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "config")
21
+ conn = connect_as_rls_user(db_connection, "config")
22
+ try:
23
+ ConfigClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.lease import LeaseClient, LeaseError, LeaseErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = LeaseClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(LeaseError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == LeaseErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "lease")
21
+ conn = connect_as_rls_user(db_connection, "lease")
22
+ try:
23
+ LeaseClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,55 @@
1
+ """assert_rls_active and the all-namespaces sweep guard for meter."""
2
+
3
+ import psycopg
4
+ import pytest
5
+ from postkit.meter import MeterClient, MeterError, MeterErrorCode
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+ GUARDED_CALLS = [
9
+ "SELECT meter.release_expired_reservations(p_namespace := {ns})",
10
+ ]
11
+
12
+
13
+ class TestAssertRlsActive:
14
+ """Raises for bypass roles, passes when RLS applies."""
15
+
16
+ def test_raises_for_bypass_role(self, db_connection):
17
+ client = MeterClient(db_connection.cursor(), "rls_assert")
18
+
19
+ with pytest.raises(MeterError) as exc_info:
20
+ client.assert_rls_active()
21
+ assert exc_info.value.error_code == MeterErrorCode.BIZ_RLS_NOT_ACTIVE
22
+
23
+ def test_passes_for_rls_role(self, db_connection):
24
+ ensure_rls_role(db_connection, "meter")
25
+ conn = connect_as_rls_user(db_connection, "meter")
26
+ try:
27
+ MeterClient(conn.cursor(), "rls_assert").assert_rls_active()
28
+ conn.rollback()
29
+ finally:
30
+ conn.close()
31
+
32
+
33
+ class TestAllNamespacesGuard:
34
+ """NULL-namespace sweeps refuse roles subject to RLS."""
35
+
36
+ @pytest.fixture
37
+ def rls_conn(self, db_connection):
38
+ ensure_rls_role(db_connection, "meter")
39
+ conn = connect_as_rls_user(db_connection, "meter")
40
+ yield conn
41
+ conn.close()
42
+
43
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
44
+ def test_null_namespace_raises_without_bypass(self, rls_conn, call):
45
+ with pytest.raises(psycopg.errors.InsufficientPrivilege) as exc_info:
46
+ rls_conn.execute(call.format(ns="NULL"))
47
+ assert "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS" in exc_info.value.diag.message_hint
48
+ rls_conn.rollback()
49
+
50
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
51
+ def test_explicit_namespace_works_without_bypass(self, rls_conn, call):
52
+ MeterClient(rls_conn.cursor(), "rls_guard")
53
+
54
+ rls_conn.execute(call.format(ns="'rls_guard'"))
55
+ rls_conn.rollback()
@@ -206,3 +206,41 @@ class TestHorizonGate:
206
206
 
207
207
  test_helpers.wait_readable("orders", ids[-1])
208
208
  assert poll_ids(cur, ns, "orders", "billing") == ids
209
+
210
+
211
+ class TestHorizonBlockers:
212
+ """horizon_blockers surfaces the backends pinning the horizon."""
213
+
214
+ def test_open_write_transaction_appears_and_clears(self, outbox, connect):
215
+ """An uncommitted emit's backend is listed; after commit it is gone.
216
+
217
+ The horizon is database-global and other test workers may hold
218
+ older write transactions, so the is_horizon marker is asserted
219
+ only when this backend's xid actually is the horizon.
220
+ """
221
+ conn = connect()
222
+ cur = conn.cursor()
223
+ cur.execute("SELECT pg_backend_pid()")
224
+ blocker_pid = cur.fetchone()[0]
225
+ cur.execute(
226
+ "SELECT outbox.emit(%s, 'orders', 'test.event', '{}')",
227
+ (outbox.namespace,),
228
+ )
229
+ cur.execute("SELECT pg_current_xact_id()::text")
230
+ blocker_xid = int(cur.fetchone()[0])
231
+
232
+ rows = {r["pid"]: r for r in outbox.horizon_blockers()}
233
+ assert blocker_pid in rows
234
+ row = rows[blocker_pid]
235
+ assert row["xact_age"] is not None
236
+
237
+ cur.execute("SELECT mod(outbox._horizon()::text::numeric, 4294967296)")
238
+ horizon_low = int(cur.fetchone()[0])
239
+ if blocker_xid % 4294967296 == horizon_low:
240
+ assert row["is_horizon"] is True
241
+
242
+ conn.commit()
243
+ conn.close()
244
+
245
+ pids = [r["pid"] for r in outbox.horizon_blockers()]
246
+ assert blocker_pid not in pids
@@ -0,0 +1,63 @@
1
+ """assert_rls_active and the all-namespaces sweep guard for outbox."""
2
+
3
+ import psycopg
4
+ import pytest
5
+ from postkit.outbox import OutboxClient, OutboxError, OutboxErrorCode
6
+
7
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
8
+
9
+ GUARDED_CALLS = [
10
+ "SELECT * FROM outbox.trim(p_older_than := interval '30 days', p_namespace := {ns})",
11
+ ]
12
+
13
+
14
+ class TestAssertRlsActive:
15
+ """Raises for bypass roles, passes when RLS applies."""
16
+
17
+ def test_raises_for_bypass_role(self, db_connection):
18
+ client = OutboxClient(db_connection.cursor(), "rls_assert")
19
+
20
+ with pytest.raises(OutboxError) as exc_info:
21
+ client.assert_rls_active()
22
+ assert exc_info.value.error_code == OutboxErrorCode.BIZ_RLS_NOT_ACTIVE
23
+
24
+ def test_passes_for_rls_role(self, db_connection):
25
+ ensure_rls_role(db_connection, "outbox")
26
+ conn = connect_as_rls_user(db_connection, "outbox")
27
+ try:
28
+ OutboxClient(conn.cursor(), "rls_assert").assert_rls_active()
29
+ conn.rollback()
30
+ finally:
31
+ conn.close()
32
+
33
+
34
+ class TestAllNamespacesGuard:
35
+ """NULL-namespace sweeps refuse roles subject to RLS."""
36
+
37
+ @pytest.fixture
38
+ def rls_conn(self, db_connection):
39
+ ensure_rls_role(db_connection, "outbox")
40
+ conn = connect_as_rls_user(db_connection, "outbox")
41
+ yield conn
42
+ conn.close()
43
+
44
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
45
+ def test_null_namespace_raises_without_bypass(self, rls_conn, call):
46
+ with pytest.raises(psycopg.errors.InsufficientPrivilege) as exc_info:
47
+ rls_conn.execute(call.format(ns="NULL"))
48
+ assert "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS" in exc_info.value.diag.message_hint
49
+ rls_conn.rollback()
50
+
51
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
52
+ def test_explicit_namespace_works_without_bypass(self, rls_conn, call):
53
+ OutboxClient(rls_conn.cursor(), "rls_guard")
54
+
55
+ rls_conn.execute(call.format(ns="'rls_guard'"))
56
+ rls_conn.rollback()
57
+
58
+ def test_null_namespace_sweeps_for_bypass_role(self, db_connection):
59
+ """Superuser NULL trim runs unguarded (30-day cutoff deletes nothing)."""
60
+ db_connection.execute(
61
+ "SELECT * FROM outbox.trim(p_older_than := interval '30 days',"
62
+ " p_namespace := NULL)"
63
+ )
@@ -0,0 +1,57 @@
1
+ """assert_rls_active and the all-namespaces sweep guard for presence."""
2
+
3
+ import psycopg
4
+ import pytest
5
+ from postkit.presence import PresenceClient, PresenceError, PresenceErrorCode
6
+
7
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
8
+
9
+ GUARDED_CALLS = [
10
+ "SELECT * FROM presence.sweep(p_namespace := {ns})",
11
+ "SELECT * FROM presence.trim(p_older_than := interval '90 days', p_namespace := {ns})",
12
+ ]
13
+
14
+
15
+ class TestAssertRlsActive:
16
+ """Raises for bypass roles, passes when RLS applies."""
17
+
18
+ def test_raises_for_bypass_role(self, db_connection):
19
+ client = PresenceClient(db_connection.cursor(), "rls_assert")
20
+
21
+ with pytest.raises(PresenceError) as exc_info:
22
+ client.assert_rls_active()
23
+ assert exc_info.value.error_code == PresenceErrorCode.BIZ_RLS_NOT_ACTIVE
24
+
25
+ def test_passes_for_rls_role(self, db_connection):
26
+ ensure_rls_role(db_connection, "presence")
27
+ conn = connect_as_rls_user(db_connection, "presence")
28
+ try:
29
+ PresenceClient(conn.cursor(), "rls_assert").assert_rls_active()
30
+ conn.rollback()
31
+ finally:
32
+ conn.close()
33
+
34
+
35
+ class TestAllNamespacesGuard:
36
+ """NULL-namespace sweeps refuse roles subject to RLS."""
37
+
38
+ @pytest.fixture
39
+ def rls_conn(self, db_connection):
40
+ ensure_rls_role(db_connection, "presence")
41
+ conn = connect_as_rls_user(db_connection, "presence")
42
+ yield conn
43
+ conn.close()
44
+
45
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
46
+ def test_null_namespace_raises_without_bypass(self, rls_conn, call):
47
+ with pytest.raises(psycopg.errors.InsufficientPrivilege) as exc_info:
48
+ rls_conn.execute(call.format(ns="NULL"))
49
+ assert "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS" in exc_info.value.diag.message_hint
50
+ rls_conn.rollback()
51
+
52
+ @pytest.mark.parametrize("call", GUARDED_CALLS)
53
+ def test_explicit_namespace_works_without_bypass(self, rls_conn, call):
54
+ PresenceClient(rls_conn.cursor(), "rls_guard")
55
+
56
+ rls_conn.execute(call.format(ns="'rls_guard'"))
57
+ rls_conn.rollback()
@@ -224,15 +224,22 @@ class TestNack:
224
224
  stats = queue.get_stats()
225
225
  assert stats["dead"] == 1
226
226
 
227
- def test_nack_not_running_raises_error(self, queue):
228
- """nack raises error for non-running job."""
229
-
230
- job_id = queue.push("tasks", {"task": 1})
227
+ def test_nack_settled_job_raises_error(self, queue):
228
+ """nack raises for a job that is already settled (dead)."""
229
+ queue.push("tasks", {"task": 1}, max_attempts=1)
230
+ job = queue.pull("tasks")
231
+ queue.fail(job["id"], error="poison")
231
232
 
232
233
  with pytest.raises(QueueValidationError) as exc_info:
233
- queue.nack(job_id)
234
+ queue.nack(job["id"])
234
235
  assert exc_info.value.error_code == QueueErrorCode.BIZ_JOB_NOT_RUNNING
235
236
 
237
+ def test_nack_pending_job_reschedules(self, queue):
238
+ """nack on a pending job records the error and schedules a retry."""
239
+ job_id = queue.push("tasks", {"task": 1})
240
+
241
+ assert queue.nack(job_id, error="rolled back") is True
242
+
236
243
  def test_nack_not_found_raises_error(self, queue):
237
244
  """nack raises error for nonexistent job."""
238
245
 
@@ -262,12 +269,22 @@ class TestFail:
262
269
  stats = queue.get_stats()
263
270
  assert stats["dead"] == 1
264
271
 
265
- def test_fail_not_running_returns_false(self, queue):
266
- """fail returns False for non-running job."""
272
+ def test_fail_pending_job_dead_letters(self, queue):
273
+ """fail on a pending job moves it to the dead letter queue."""
267
274
  job_id = queue.push("tasks", {"task": 1})
268
275
 
269
- result = queue.fail(job_id)
270
- assert result is False
276
+ assert queue.fail(job_id) is True
277
+
278
+ stats = queue.get_stats()
279
+ assert stats["dead"] == 1
280
+
281
+ def test_fail_settled_job_returns_false(self, queue):
282
+ """fail returns False for a job that is already settled (dead)."""
283
+ queue.push("tasks", {"task": 1})
284
+ job = queue.pull("tasks")
285
+ queue.fail(job["id"])
286
+
287
+ assert queue.fail(job["id"]) is False
271
288
 
272
289
  def test_fail_stores_error_message(self, raw_cursor):
273
290
  """fail stores error message in dead_letters."""