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.
- {postkit-0.4.0 → postkit-0.5.0}/PKG-INFO +9 -1
- postkit-0.5.0/README.md +47 -0
- {postkit-0.4.0 → postkit-0.5.0}/pyproject.toml +1 -1
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/base.py +23 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/errors.py +15 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/outbox/client.py +13 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/queue/client.py +20 -4
- postkit-0.5.0/tests/authn/test_rls_active.py +26 -0
- postkit-0.5.0/tests/authz/test_rls_active.py +26 -0
- postkit-0.5.0/tests/config/test_rls_active.py +26 -0
- postkit-0.5.0/tests/lease/test_rls_active.py +26 -0
- postkit-0.5.0/tests/meter/test_rls_active.py +55 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_horizon.py +38 -0
- postkit-0.5.0/tests/outbox/test_rls_active.py +63 -0
- postkit-0.5.0/tests/presence/test_rls_active.py +57 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_ack.py +26 -9
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_nack_ack_race.py +7 -4
- postkit-0.5.0/tests/queue/test_rls_active.py +57 -0
- postkit-0.5.0/tests/queue/test_rollback_recovery.py +162 -0
- {postkit-0.4.0 → postkit-0.5.0}/uv.lock +1 -1
- postkit-0.4.0/README.md +0 -39
- {postkit-0.4.0 → postkit-0.5.0}/.gitignore +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authn/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authn/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authz/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/authz/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/config/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/config/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/lease/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/lease/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/meter/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/meter/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/outbox/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/presence/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/presence/client.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/py.typed +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/src/postkit/queue/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_audit.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_base.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_credentials.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_disabled_user.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_e2e_demo.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_impersonation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_lockout.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_maintenance.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_operator_impersonation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_refresh_tokens.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_rls.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_sessions.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_tokens.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_users.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authn/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_access_patterns.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_audit.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_concurrency.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_consistency.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_e2e_demo.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_expiration.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_groups.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_hierarchy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_listing.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_maintenance.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_namespaces.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_nested_teams.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_resource_hierarchy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_rls.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_sdk.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_stress.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_transactions.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/authz/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_audit.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_entries.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_rls.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_schemas.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_tenancy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/config/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/integration/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/integration/test_api_keys.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/integration/test_presence_hooks.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_acquire.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_events.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_invariants.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_maintenance.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_renew_release.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_tenancy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/lease/test_verify_fencing.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_periods.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_reservation_expiry_race.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_rls.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_sdk.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/meter/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_emit.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_lost_cursor.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_maintenance.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_tenancy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/outbox/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_flap.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_heartbeat.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_invariants.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_maintenance.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_state_machine.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_tenancy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_validation.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/presence/test_wall_clock.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/__init__.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/conftest.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/helpers.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_cron.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_dead_letters.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_e2e_demo.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_pull.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_purge.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_push.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_release_jobs.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_rls.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_schedules.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_stats.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tenancy.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tick.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_tick_timeouts.py +0 -0
- {postkit-0.4.0 → postkit-0.5.0}/tests/queue/test_validation.py +0 -0
- {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.
|
|
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+
|
postkit-0.5.0/README.md
ADDED
|
@@ -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.
|
|
@@ -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
|
|
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
|
|
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
|
|
228
|
-
"""nack raises
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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
|
|
266
|
-
"""fail
|
|
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
|
-
|
|
270
|
-
|
|
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."""
|