postkit 0.3.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.3.0 → postkit-0.5.0}/PKG-INFO +9 -1
- postkit-0.5.0/README.md +47 -0
- {postkit-0.3.0 → postkit-0.5.0}/pyproject.toml +1 -1
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/base.py +23 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/errors.py +44 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/outbox/client.py +13 -0
- postkit-0.5.0/src/postkit/presence/__init__.py +15 -0
- postkit-0.5.0/src/postkit/presence/client.py +274 -0
- {postkit-0.3.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/integration/test_presence_hooks.py +189 -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.3.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/__init__.py +0 -0
- postkit-0.5.0/tests/presence/conftest.py +81 -0
- postkit-0.5.0/tests/presence/helpers.py +88 -0
- postkit-0.5.0/tests/presence/test_flap.py +146 -0
- postkit-0.5.0/tests/presence/test_heartbeat.py +102 -0
- postkit-0.5.0/tests/presence/test_invariants.py +189 -0
- postkit-0.5.0/tests/presence/test_maintenance.py +98 -0
- postkit-0.5.0/tests/presence/test_rls_active.py +57 -0
- postkit-0.5.0/tests/presence/test_state_machine.py +177 -0
- postkit-0.5.0/tests/presence/test_tenancy.py +108 -0
- postkit-0.5.0/tests/presence/test_validation.py +100 -0
- postkit-0.5.0/tests/presence/test_wall_clock.py +56 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_ack.py +26 -9
- {postkit-0.3.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.3.0 → postkit-0.5.0}/tests/test_error_codes.py +2 -0
- {postkit-0.3.0 → postkit-0.5.0}/uv.lock +1 -1
- postkit-0.3.0/README.md +0 -39
- {postkit-0.3.0 → postkit-0.5.0}/.gitignore +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authn/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authn/client.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authz/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authz/client.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/config/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/config/client.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/lease/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/lease/client.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/meter/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/meter/client.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/outbox/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/py.typed +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/src/postkit/queue/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_base.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_credentials.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_disabled_user.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_impersonation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_lockout.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_operator_impersonation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_refresh_tokens.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_sessions.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_tokens.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_users.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_access_patterns.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_concurrency.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_consistency.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_expiration.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_groups.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_hierarchy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_listing.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_namespaces.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_nested_teams.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_resource_hierarchy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_sdk.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_stress.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_transactions.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_entries.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_schemas.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/integration/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/integration/test_api_keys.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_acquire.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_events.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_invariants.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_renew_release.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_verify_fencing.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_periods.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_reservation_expiry_race.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_sdk.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_emit.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_lost_cursor.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_cron.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_dead_letters.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_pull.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_purge.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_push.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_release_jobs.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_schedules.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_stats.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tick.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tick_timeouts.py +0 -0
- {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_validation.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,39 @@ 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"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class PresenceErrorCode:
|
|
254
|
+
"""Error codes for the presence module."""
|
|
255
|
+
|
|
256
|
+
# Validation errors (VAL_)
|
|
257
|
+
VAL_NAMESPACE_NULL = "VAL_NAMESPACE_NULL"
|
|
258
|
+
VAL_NAMESPACE_EMPTY = "VAL_NAMESPACE_EMPTY"
|
|
259
|
+
VAL_NAMESPACE_TOO_LONG = "VAL_NAMESPACE_TOO_LONG"
|
|
260
|
+
VAL_NAMESPACE_INVALID_CHARS = "VAL_NAMESPACE_INVALID_CHARS"
|
|
261
|
+
VAL_NAMESPACE_WHITESPACE = "VAL_NAMESPACE_WHITESPACE"
|
|
262
|
+
VAL_ENTITY_NULL = "VAL_ENTITY_NULL"
|
|
263
|
+
VAL_ENTITY_EMPTY = "VAL_ENTITY_EMPTY"
|
|
264
|
+
VAL_ENTITY_TOO_LONG = "VAL_ENTITY_TOO_LONG"
|
|
265
|
+
VAL_ENTITY_INVALID_CHARS = "VAL_ENTITY_INVALID_CHARS"
|
|
266
|
+
VAL_ENTITY_WHITESPACE = "VAL_ENTITY_WHITESPACE"
|
|
267
|
+
VAL_ENTITIES_NULL = "VAL_ENTITIES_NULL"
|
|
268
|
+
VAL_KIND_NULL = "VAL_KIND_NULL"
|
|
269
|
+
VAL_KIND_EMPTY = "VAL_KIND_EMPTY"
|
|
270
|
+
VAL_KIND_TOO_LONG = "VAL_KIND_TOO_LONG"
|
|
271
|
+
VAL_KIND_FORMAT = "VAL_KIND_FORMAT"
|
|
272
|
+
VAL_TIMEOUT_NOT_POSITIVE = "VAL_TIMEOUT_NOT_POSITIVE"
|
|
273
|
+
VAL_STATUS_INVALID = "VAL_STATUS_INVALID"
|
|
274
|
+
VAL_TRIM_INTERVAL_NOT_POSITIVE = "VAL_TRIM_INTERVAL_NOT_POSITIVE"
|
|
275
|
+
VAL_NOT_POSITIVE = "VAL_NOT_POSITIVE"
|
|
276
|
+
|
|
277
|
+
# Business logic errors
|
|
278
|
+
ENTITY_UNKNOWN = "ENTITY_UNKNOWN"
|
|
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"
|
|
241
282
|
|
|
242
283
|
|
|
243
284
|
class QueueErrorCode:
|
|
@@ -270,10 +311,13 @@ class QueueErrorCode:
|
|
|
270
311
|
|
|
271
312
|
# Business logic errors (BIZ_)
|
|
272
313
|
BIZ_JOB_NOT_RUNNING = "BIZ_JOB_NOT_RUNNING"
|
|
314
|
+
BIZ_JOB_NOT_YOURS = "BIZ_JOB_NOT_YOURS"
|
|
273
315
|
BIZ_SCHEDULE_DUPLICATE = "BIZ_SCHEDULE_DUPLICATE"
|
|
274
316
|
BIZ_SCHEDULE_REQUIRES_SCHEDULE = "BIZ_SCHEDULE_REQUIRES_SCHEDULE"
|
|
275
317
|
BIZ_SCHEDULE_CRON_AND_INTERVAL = "BIZ_SCHEDULE_CRON_AND_INTERVAL"
|
|
276
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"
|
|
277
321
|
|
|
278
322
|
# Data state errors (DATA_)
|
|
279
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()", ())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Postkit Presence SDK - heartbeat liveness with transition edge detection."""
|
|
2
|
+
|
|
3
|
+
from postkit.errors import PresenceErrorCode
|
|
4
|
+
from postkit.presence.client import (
|
|
5
|
+
PresenceClient,
|
|
6
|
+
PresenceError,
|
|
7
|
+
PresenceValidationError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PresenceClient",
|
|
12
|
+
"PresenceError",
|
|
13
|
+
"PresenceErrorCode",
|
|
14
|
+
"PresenceValidationError",
|
|
15
|
+
]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Postkit Presence SDK - heartbeat liveness with transition edge detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from postkit.base import BaseClient, PostkitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PresenceError(PostkitError):
|
|
13
|
+
"""Exception for presence operations."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PresenceValidationError(PresenceError):
|
|
17
|
+
"""Raised when input validation fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PresenceClient(BaseClient):
|
|
21
|
+
"""Client for Postkit presence module.
|
|
22
|
+
|
|
23
|
+
Heartbeat liveness for a fleet of entities. Register once, heartbeat
|
|
24
|
+
periodically, and run sweep() on a timer; the module emits an
|
|
25
|
+
append-only transition stream (alive -> dead -> alive) and can enqueue
|
|
26
|
+
alert jobs atomically with each edge when the queue module is
|
|
27
|
+
installed.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
presence = PresenceClient(cursor, namespace="acme")
|
|
31
|
+
|
|
32
|
+
presence.register("worker-7") # once, idempotent
|
|
33
|
+
presence.heartbeat("worker-7") # every 10-60s
|
|
34
|
+
|
|
35
|
+
# From a cron, anywhere:
|
|
36
|
+
for death in presence.sweep():
|
|
37
|
+
page(death["entity_id"])
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_schema = "presence"
|
|
41
|
+
_error_class = PresenceError
|
|
42
|
+
_module_sqlstate_map = {
|
|
43
|
+
"22023": PresenceValidationError, # invalid_parameter_value
|
|
44
|
+
"22004": PresenceValidationError, # null_value_not_allowed
|
|
45
|
+
"22001": PresenceValidationError, # string_data_right_truncation
|
|
46
|
+
"22026": PresenceValidationError, # string_data_length_mismatch
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def _apply_actor_context(self) -> None:
|
|
50
|
+
"""Apply actor context via presence.set_actor()."""
|
|
51
|
+
self.cursor.execute(
|
|
52
|
+
"""SELECT presence.set_actor(
|
|
53
|
+
p_actor_id := %s,
|
|
54
|
+
p_request_id := %s,
|
|
55
|
+
p_on_behalf_of := %s,
|
|
56
|
+
p_reason := %s
|
|
57
|
+
)""",
|
|
58
|
+
(self._actor_id, self._request_id, self._on_behalf_of, self._reason),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def register(
|
|
62
|
+
self,
|
|
63
|
+
entity: str,
|
|
64
|
+
*,
|
|
65
|
+
kind: str | None = None,
|
|
66
|
+
timeout: timedelta | None = None,
|
|
67
|
+
metadata: dict[str, Any] | None = None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Register an entity for liveness tracking, or update its attributes.
|
|
70
|
+
|
|
71
|
+
Idempotent: None arguments preserve what is stored, so deploys can
|
|
72
|
+
re-run register safely. Re-registering is an attribute update, not
|
|
73
|
+
a heartbeat - liveness only changes through heartbeat() and
|
|
74
|
+
sweep(). A new entity starts at 'unknown' and cannot die before its
|
|
75
|
+
first heartbeat.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
entity: Entity id (e.g. 'worker-7', 'sensor:eu:42')
|
|
79
|
+
kind: Entity kind, keys the config row. None means 'default'
|
|
80
|
+
for a new entity and keeps the current kind on re-register
|
|
81
|
+
timeout: Per-entity liveness window replacing the kind's
|
|
82
|
+
dead_after. None keeps the current override
|
|
83
|
+
metadata: Metadata stored on the entity; None keeps the
|
|
84
|
+
existing metadata on re-register (new entities start empty)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The entity row dict
|
|
88
|
+
"""
|
|
89
|
+
result = self._fetch_one(
|
|
90
|
+
"""SELECT * FROM presence.register(
|
|
91
|
+
p_namespace := %s,
|
|
92
|
+
p_entity := %s,
|
|
93
|
+
p_kind := %s,
|
|
94
|
+
p_timeout := %s,
|
|
95
|
+
p_metadata := %s::jsonb
|
|
96
|
+
)""",
|
|
97
|
+
(
|
|
98
|
+
self.namespace,
|
|
99
|
+
entity,
|
|
100
|
+
kind,
|
|
101
|
+
timeout,
|
|
102
|
+
json.dumps(metadata) if metadata is not None else None,
|
|
103
|
+
),
|
|
104
|
+
write=True,
|
|
105
|
+
)
|
|
106
|
+
if result is None:
|
|
107
|
+
raise PresenceError("presence.register returned no row")
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def heartbeat(self, entity: str) -> str:
|
|
111
|
+
"""Report an entity alive.
|
|
112
|
+
|
|
113
|
+
A dead or never-seen entity revives here and now - the revival
|
|
114
|
+
transition is emitted by this call, never deferred to a sweep. An
|
|
115
|
+
unregistered entity raises (registration is explicit;
|
|
116
|
+
heartbeat_many reports 'unknown' instead of raising).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
entity: Entity id (must be registered)
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The resulting status (always 'alive')
|
|
123
|
+
"""
|
|
124
|
+
result = self._fetch_val(
|
|
125
|
+
"SELECT presence.heartbeat(%s, %s)",
|
|
126
|
+
(self.namespace, entity),
|
|
127
|
+
write=True,
|
|
128
|
+
)
|
|
129
|
+
if result is None:
|
|
130
|
+
raise PresenceError("presence.heartbeat returned no value")
|
|
131
|
+
return str(result)
|
|
132
|
+
|
|
133
|
+
def heartbeat_many(self, entities: list[str]) -> list[dict[str, Any]]:
|
|
134
|
+
"""Report a batch of entities alive in one round trip.
|
|
135
|
+
|
|
136
|
+
Per-entity semantics match heartbeat(), including revivals.
|
|
137
|
+
Unregistered entities come back with status 'unknown' instead of
|
|
138
|
+
raising - one typo must not abort a fleet batch.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
entities: Entity ids
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
One dict per distinct entity: entity_id and its resulting status
|
|
145
|
+
"""
|
|
146
|
+
return self._fetch_all(
|
|
147
|
+
"SELECT * FROM presence.heartbeat_many(%s, %s)",
|
|
148
|
+
(self.namespace, entities),
|
|
149
|
+
write=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def sweep(self, *, limit: int = 1000) -> list[dict[str, Any]]:
|
|
153
|
+
"""Mark overdue entities dead and deliver deferred death alerts.
|
|
154
|
+
|
|
155
|
+
Call from a cron or timer; nothing runs on its own, and death is
|
|
156
|
+
detected no faster than the sweep cadence. Deaths emit transitions
|
|
157
|
+
and fire the configured queue hooks in the same transaction.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
limit: Maximum entities to process per call
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The death transitions emitted by this call
|
|
164
|
+
"""
|
|
165
|
+
return self._fetch_all(
|
|
166
|
+
"SELECT * FROM presence.sweep(%s, %s)",
|
|
167
|
+
(self.namespace, limit),
|
|
168
|
+
write=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def deregister(self, entity: str) -> bool:
|
|
172
|
+
"""Remove an entity deliberately, emitting a departed transition.
|
|
173
|
+
|
|
174
|
+
Intentional exit is not death: no death hooks fire and nobody gets
|
|
175
|
+
paged for a planned shutdown. Idempotent - removing an absent
|
|
176
|
+
entity returns False, never raises.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
entity: Entity id
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if the entity existed and was removed
|
|
183
|
+
"""
|
|
184
|
+
result = self._fetch_val(
|
|
185
|
+
"SELECT presence.deregister(%s, %s)",
|
|
186
|
+
(self.namespace, entity),
|
|
187
|
+
write=True,
|
|
188
|
+
)
|
|
189
|
+
return bool(result)
|
|
190
|
+
|
|
191
|
+
def status(self, entity: str) -> dict[str, Any] | None:
|
|
192
|
+
"""Inspect one entity, with the wall-clock truth alongside the cache.
|
|
193
|
+
|
|
194
|
+
The stored status lags until the next sweep; the returned overdue
|
|
195
|
+
flag is true when the entity is nominally alive but already past
|
|
196
|
+
its liveness window.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
entity: Entity id
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Dict with the liveness fields plus overdue, or None when the
|
|
203
|
+
entity is not registered
|
|
204
|
+
"""
|
|
205
|
+
return self._fetch_one(
|
|
206
|
+
"SELECT * FROM presence.status(%s, %s)", (self.namespace, entity)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def list_entities(
|
|
210
|
+
self, *, kind: str | None = None, status: str | None = None
|
|
211
|
+
) -> list[dict[str, Any]]:
|
|
212
|
+
"""List entities in the namespace.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
kind: Kind filter (None = all kinds)
|
|
216
|
+
status: Status filter: 'unknown', 'alive', or 'dead'
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of entity row dicts
|
|
220
|
+
"""
|
|
221
|
+
return self._fetch_all(
|
|
222
|
+
"SELECT * FROM presence.list(%s, %s, %s)",
|
|
223
|
+
(self.namespace, kind, status),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def get_transitions(
|
|
227
|
+
self, entity: str | None = None, *, limit: int = 100
|
|
228
|
+
) -> list[dict[str, Any]]:
|
|
229
|
+
"""Read the transition history, newest first.
|
|
230
|
+
|
|
231
|
+
History, not a feed: delivery is the queue hooks and NOTIFY; do
|
|
232
|
+
not poll this by id.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
entity: Entity filter (None = all entities)
|
|
236
|
+
limit: Maximum transitions to return
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of transition dicts with actor context
|
|
240
|
+
"""
|
|
241
|
+
return self._fetch_all(
|
|
242
|
+
"SELECT * FROM presence.get_transitions(%s, %s, %s)",
|
|
243
|
+
(self.namespace, entity, limit),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def get_stats(self) -> dict[str, Any]:
|
|
247
|
+
"""Get namespace-wide presence statistics.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Dict with total_entities, alive, dead, unknown, overdue, and
|
|
251
|
+
total_transitions counts
|
|
252
|
+
"""
|
|
253
|
+
result = self._fetch_one(
|
|
254
|
+
"SELECT * FROM presence.get_stats(%s)", (self.namespace,)
|
|
255
|
+
)
|
|
256
|
+
return result or {}
|
|
257
|
+
|
|
258
|
+
def trim(
|
|
259
|
+
self, older_than: timedelta, *, limit: int = 10000
|
|
260
|
+
) -> list[dict[str, Any]]:
|
|
261
|
+
"""Delete old transitions. Retention has no default; pass it explicitly.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
older_than: Delete transitions older than this (required, positive)
|
|
265
|
+
limit: Maximum transitions to delete per call
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
One dict per namespace touched, with the deleted count
|
|
269
|
+
"""
|
|
270
|
+
return self._fetch_all(
|
|
271
|
+
"SELECT * FROM presence.trim(%s, %s, %s)",
|
|
272
|
+
(older_than, self.namespace, limit),
|
|
273
|
+
write=True,
|
|
274
|
+
)
|
|
@@ -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()
|