postkit 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- postkit/authn/__init__.py +13 -0
- postkit/authn/client.py +567 -0
- postkit/authz/__init__.py +17 -0
- postkit/authz/client.py +913 -0
- postkit-0.1.0.dist-info/METADATA +66 -0
- postkit-0.1.0.dist-info/RECORD +8 -0
- postkit-0.1.0.dist-info/WHEEL +4 -0
postkit/authz/client.py
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
postkit.authz - Authorization client for PostgreSQL-native ReBAC.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- AuthzClient: SDK-style interface for authorization operations
|
|
6
|
+
- Exception classes: AuthzError, AuthzValidationError, AuthzCycleError
|
|
7
|
+
- Type aliases: Entity tuple type
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuthzClient",
|
|
19
|
+
"AuthzError",
|
|
20
|
+
"AuthzValidationError",
|
|
21
|
+
"AuthzCycleError",
|
|
22
|
+
"Entity",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Type alias for resource/subject tuples
|
|
27
|
+
Entity = tuple[str, str] # (type, id) e.g., ("repo", "payments-api")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# EXCEPTIONS
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthzError(Exception):
|
|
36
|
+
"""Base exception for authz operations."""
|
|
37
|
+
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthzValidationError(AuthzError):
|
|
42
|
+
"""Raised when input validation fails."""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthzCycleError(AuthzError):
|
|
48
|
+
"""Raised when a hierarchy cycle is detected."""
|
|
49
|
+
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# CLIENT
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthzClient:
|
|
59
|
+
"""
|
|
60
|
+
SDK-style client for postkit/authz.
|
|
61
|
+
|
|
62
|
+
This is the interface customers would use. It wraps the SQL functions
|
|
63
|
+
with a Pythonic API using named parameters and tuple-based entities.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
authz = AuthzClient(cursor, namespace="production")
|
|
67
|
+
|
|
68
|
+
# Set actor context for audit logging
|
|
69
|
+
authz.set_actor("admin@acme.com", "req-123", "Quarterly review")
|
|
70
|
+
|
|
71
|
+
# Grant permission (actor context automatically included in audit)
|
|
72
|
+
authz.grant("admin", resource=("repo", "api"), subject=("team", "eng"))
|
|
73
|
+
|
|
74
|
+
# Check permission
|
|
75
|
+
if authz.check("alice", "read", ("repo", "api")):
|
|
76
|
+
allow_access()
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, cursor, namespace: str):
|
|
80
|
+
self.cursor = cursor
|
|
81
|
+
self.namespace = namespace
|
|
82
|
+
# Set tenant context for RLS
|
|
83
|
+
self.cursor.execute("SELECT authz.set_tenant(%s)", (namespace,))
|
|
84
|
+
# Actor context stored as instance state (applied per-operation in _write_scalar)
|
|
85
|
+
self._actor_id: str | None = None
|
|
86
|
+
self._request_id: str | None = None
|
|
87
|
+
self._reason: str | None = None
|
|
88
|
+
|
|
89
|
+
def _handle_error(self, e: psycopg.Error) -> None:
|
|
90
|
+
"""Convert psycopg errors to SDK exceptions."""
|
|
91
|
+
raise AuthzError(str(e)) from e
|
|
92
|
+
|
|
93
|
+
def _scalar(self, sql: str, params: tuple):
|
|
94
|
+
"""Execute SQL and return single scalar value."""
|
|
95
|
+
try:
|
|
96
|
+
self.cursor.execute(sql, params)
|
|
97
|
+
result = self.cursor.fetchone()
|
|
98
|
+
return result[0] if result else None
|
|
99
|
+
except psycopg.Error as e:
|
|
100
|
+
self._handle_error(e)
|
|
101
|
+
|
|
102
|
+
def _write_scalar(self, sql: str, params: tuple):
|
|
103
|
+
"""Execute a write operation with actor context for audit logging.
|
|
104
|
+
|
|
105
|
+
Actor context uses PostgreSQL's transaction-local settings (set_config with
|
|
106
|
+
is_local=true). This means the actor info only persists within a transaction.
|
|
107
|
+
|
|
108
|
+
When actor context is set:
|
|
109
|
+
- In autocommit mode: Each statement is its own transaction, so we must:
|
|
110
|
+
1. Begin an explicit transaction
|
|
111
|
+
2. Set actor context
|
|
112
|
+
3. Execute the write (triggers capture actor from settings)
|
|
113
|
+
4. Commit
|
|
114
|
+
- In manual transaction mode: The caller controls the transaction, so we just
|
|
115
|
+
set the actor context and let them commit when ready.
|
|
116
|
+
|
|
117
|
+
Note: This method assumes single-threaded access to the cursor.
|
|
118
|
+
psycopg cursors are not thread-safe; do not share AuthzClient across threads.
|
|
119
|
+
"""
|
|
120
|
+
if self._actor_id is None:
|
|
121
|
+
return self._scalar(sql, params)
|
|
122
|
+
|
|
123
|
+
# Check if already in a transaction (psycopg transaction_status: 0 = idle)
|
|
124
|
+
in_transaction = self.cursor.connection.info.transaction_status != 0
|
|
125
|
+
|
|
126
|
+
if in_transaction:
|
|
127
|
+
# Caller manages transaction - just set actor context
|
|
128
|
+
self.cursor.execute(
|
|
129
|
+
"SELECT authz.set_actor(%s, %s, %s)",
|
|
130
|
+
(self._actor_id, self._request_id, self._reason),
|
|
131
|
+
)
|
|
132
|
+
return self._scalar(sql, params)
|
|
133
|
+
|
|
134
|
+
# Autocommit mode - wrap in transaction so actor context persists
|
|
135
|
+
try:
|
|
136
|
+
self.cursor.execute("BEGIN")
|
|
137
|
+
self.cursor.execute(
|
|
138
|
+
"SELECT authz.set_actor(%s, %s, %s)",
|
|
139
|
+
(self._actor_id, self._request_id, self._reason),
|
|
140
|
+
)
|
|
141
|
+
result = self._scalar(sql, params)
|
|
142
|
+
self.cursor.execute("COMMIT")
|
|
143
|
+
return result
|
|
144
|
+
except Exception:
|
|
145
|
+
self.cursor.execute("ROLLBACK")
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
def _fetchall(self, sql: str, params: tuple) -> list:
|
|
149
|
+
"""Execute SQL and return all rows."""
|
|
150
|
+
self.cursor.execute(sql, params)
|
|
151
|
+
return self.cursor.fetchall()
|
|
152
|
+
|
|
153
|
+
# =========================================================================
|
|
154
|
+
# Core operations
|
|
155
|
+
# =========================================================================
|
|
156
|
+
|
|
157
|
+
def grant(
|
|
158
|
+
self,
|
|
159
|
+
permission: str,
|
|
160
|
+
*,
|
|
161
|
+
resource: Entity,
|
|
162
|
+
subject: Entity,
|
|
163
|
+
subject_relation: str | None = None,
|
|
164
|
+
expires_at: datetime | None = None,
|
|
165
|
+
) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Grant a permission on a resource to a subject.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
permission: The permission to grant (e.g., "admin", "read")
|
|
171
|
+
resource: The resource as (type, id) tuple (e.g., ("repo", "api"))
|
|
172
|
+
subject: The subject as (type, id) tuple (e.g., ("team", "eng"))
|
|
173
|
+
subject_relation: Optional relation on the subject (e.g., "admin" for team#admin)
|
|
174
|
+
expires_at: Optional expiration time for time-bound permissions
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The tuple ID
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
authz.grant("admin", resource=("repo", "api"), subject=("team", "eng"))
|
|
181
|
+
authz.grant("read", resource=("repo", "api"), subject=("user", "alice"))
|
|
182
|
+
# Grant only to team admins:
|
|
183
|
+
authz.grant("write", resource=("repo", "api"), subject=("team", "eng"), subject_relation="admin")
|
|
184
|
+
# Grant with expiration:
|
|
185
|
+
authz.grant("read", resource=("doc", "1"), subject=("user", "bob"),
|
|
186
|
+
expires_at=datetime.now(timezone.utc) + timedelta(days=30))
|
|
187
|
+
"""
|
|
188
|
+
resource_type, resource_id = resource
|
|
189
|
+
subject_type, subject_id = subject
|
|
190
|
+
|
|
191
|
+
if subject_relation is not None:
|
|
192
|
+
return self._write_scalar(
|
|
193
|
+
"SELECT authz.write_tuple(%s, %s, %s, %s, %s, %s, %s, %s)",
|
|
194
|
+
(
|
|
195
|
+
resource_type,
|
|
196
|
+
resource_id,
|
|
197
|
+
permission,
|
|
198
|
+
subject_type,
|
|
199
|
+
subject_id,
|
|
200
|
+
subject_relation,
|
|
201
|
+
self.namespace,
|
|
202
|
+
expires_at,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
return self._write_scalar(
|
|
207
|
+
"SELECT authz.write(%s, %s, %s, %s, %s, %s, %s)",
|
|
208
|
+
(
|
|
209
|
+
resource_type,
|
|
210
|
+
resource_id,
|
|
211
|
+
permission,
|
|
212
|
+
subject_type,
|
|
213
|
+
subject_id,
|
|
214
|
+
self.namespace,
|
|
215
|
+
expires_at,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def revoke(
|
|
220
|
+
self,
|
|
221
|
+
permission: str,
|
|
222
|
+
*,
|
|
223
|
+
resource: Entity,
|
|
224
|
+
subject: Entity,
|
|
225
|
+
subject_relation: str | None = None,
|
|
226
|
+
) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Revoke a permission on a resource from a subject.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
permission: The permission to revoke
|
|
232
|
+
resource: The resource as (type, id) tuple
|
|
233
|
+
subject: The subject as (type, id) tuple
|
|
234
|
+
subject_relation: Optional relation on the subject (e.g., "admin" for team#admin)
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
True if a tuple was deleted
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
authz.revoke("read", resource=("repo", "api"), subject=("user", "alice"))
|
|
241
|
+
# Revoke from team admins only:
|
|
242
|
+
authz.revoke("write", resource=("repo", "api"), subject=("team", "eng"), subject_relation="admin")
|
|
243
|
+
"""
|
|
244
|
+
resource_type, resource_id = resource
|
|
245
|
+
subject_type, subject_id = subject
|
|
246
|
+
|
|
247
|
+
if subject_relation is not None:
|
|
248
|
+
result = self._write_scalar(
|
|
249
|
+
"SELECT authz.delete_tuple(%s, %s, %s, %s, %s, %s, %s)",
|
|
250
|
+
(
|
|
251
|
+
resource_type,
|
|
252
|
+
resource_id,
|
|
253
|
+
permission,
|
|
254
|
+
subject_type,
|
|
255
|
+
subject_id,
|
|
256
|
+
subject_relation,
|
|
257
|
+
self.namespace,
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
result = self._write_scalar(
|
|
262
|
+
"SELECT authz.delete(%s, %s, %s, %s, %s, %s)",
|
|
263
|
+
(
|
|
264
|
+
resource_type,
|
|
265
|
+
resource_id,
|
|
266
|
+
permission,
|
|
267
|
+
subject_type,
|
|
268
|
+
subject_id,
|
|
269
|
+
self.namespace,
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
return bool(result)
|
|
273
|
+
|
|
274
|
+
def check(self, user_id: str, permission: str, resource: Entity) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
Check if a user has a permission on a resource.
|
|
277
|
+
|
|
278
|
+
This is the core authorization check - the question every service asks.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
user_id: The user ID
|
|
282
|
+
permission: The permission to check (e.g., "read", "write")
|
|
283
|
+
resource: The resource as (type, id) tuple
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if the user has the permission
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
if authz.check("alice", "read", ("repo", "api")):
|
|
290
|
+
return repo_contents
|
|
291
|
+
"""
|
|
292
|
+
resource_type, resource_id = resource
|
|
293
|
+
return self._scalar(
|
|
294
|
+
"SELECT authz.check(%s, %s, %s, %s, %s)",
|
|
295
|
+
(user_id, permission, resource_type, resource_id, self.namespace),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def check_any(self, user_id: str, permissions: list[str], resource: Entity) -> bool:
|
|
299
|
+
"""
|
|
300
|
+
Check if a user has any of the specified permissions.
|
|
301
|
+
|
|
302
|
+
Useful for "can edit OR admin" style checks. More efficient than
|
|
303
|
+
multiple check() calls.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
user_id: The user ID
|
|
307
|
+
permissions: List of permissions (user needs at least one)
|
|
308
|
+
resource: The resource as (type, id) tuple
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
True if the user has at least one of the permissions
|
|
312
|
+
"""
|
|
313
|
+
resource_type, resource_id = resource
|
|
314
|
+
return self._scalar(
|
|
315
|
+
"SELECT authz.check_any(%s, %s, %s, %s, %s)",
|
|
316
|
+
(user_id, permissions, resource_type, resource_id, self.namespace),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def check_all(self, user_id: str, permissions: list[str], resource: Entity) -> bool:
|
|
320
|
+
"""
|
|
321
|
+
Check if a user has all of the specified permissions.
|
|
322
|
+
|
|
323
|
+
Useful for operations requiring multiple permissions.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
user_id: The user ID
|
|
327
|
+
permissions: List of permissions (user needs all of them)
|
|
328
|
+
resource: The resource as (type, id) tuple
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
True if the user has all of the permissions
|
|
332
|
+
"""
|
|
333
|
+
resource_type, resource_id = resource
|
|
334
|
+
return self._scalar(
|
|
335
|
+
"SELECT authz.check_all(%s, %s, %s, %s, %s)",
|
|
336
|
+
(user_id, permissions, resource_type, resource_id, self.namespace),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# =========================================================================
|
|
340
|
+
# Audit and listing
|
|
341
|
+
# =========================================================================
|
|
342
|
+
|
|
343
|
+
def explain(self, user_id: str, permission: str, resource: Entity) -> list[str]:
|
|
344
|
+
"""
|
|
345
|
+
Explain why a user has a permission.
|
|
346
|
+
|
|
347
|
+
Returns the permission paths - useful for debugging and auditing.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
user_id: The user ID
|
|
351
|
+
permission: The permission to explain
|
|
352
|
+
resource: The resource as (type, id) tuple
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of human-readable explanation strings
|
|
356
|
+
|
|
357
|
+
Example:
|
|
358
|
+
paths = authz.explain("alice", "read", ("repo", "api"))
|
|
359
|
+
# ["HIERARCHY: alice is member of team:eng which has admin (admin -> read)"]
|
|
360
|
+
"""
|
|
361
|
+
resource_type, resource_id = resource
|
|
362
|
+
rows = self._fetchall(
|
|
363
|
+
"SELECT * FROM authz.explain_text(%s, %s, %s, %s, %s)",
|
|
364
|
+
(user_id, permission, resource_type, resource_id, self.namespace),
|
|
365
|
+
)
|
|
366
|
+
return [row[0] for row in rows]
|
|
367
|
+
|
|
368
|
+
def list_users(
|
|
369
|
+
self,
|
|
370
|
+
permission: str,
|
|
371
|
+
resource: Entity,
|
|
372
|
+
*,
|
|
373
|
+
limit: int | None = None,
|
|
374
|
+
cursor: str | None = None,
|
|
375
|
+
) -> list[str]:
|
|
376
|
+
"""
|
|
377
|
+
List users who have a permission on a resource.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
permission: The permission to check
|
|
381
|
+
resource: The resource as (type, id) tuple
|
|
382
|
+
limit: Maximum number of results (optional)
|
|
383
|
+
cursor: Pagination cursor (optional)
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
List of user IDs
|
|
387
|
+
|
|
388
|
+
Example:
|
|
389
|
+
users = authz.list_users("read", ("repo", "api"))
|
|
390
|
+
# ["alice", "bob", "charlie"]
|
|
391
|
+
"""
|
|
392
|
+
resource_type, resource_id = resource
|
|
393
|
+
if limit is not None:
|
|
394
|
+
rows = self._fetchall(
|
|
395
|
+
"SELECT * FROM authz.list_users(%s, %s, %s, %s, %s, %s)",
|
|
396
|
+
(resource_type, resource_id, permission, self.namespace, limit, cursor),
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
rows = self._fetchall(
|
|
400
|
+
"SELECT * FROM authz.list_users(%s, %s, %s, %s)",
|
|
401
|
+
(resource_type, resource_id, permission, self.namespace),
|
|
402
|
+
)
|
|
403
|
+
return [row[0] for row in rows]
|
|
404
|
+
|
|
405
|
+
def list_resources(
|
|
406
|
+
self,
|
|
407
|
+
user_id: str,
|
|
408
|
+
resource_type: str,
|
|
409
|
+
permission: str,
|
|
410
|
+
*,
|
|
411
|
+
limit: int | None = None,
|
|
412
|
+
cursor: str | None = None,
|
|
413
|
+
) -> list[str]:
|
|
414
|
+
"""
|
|
415
|
+
List resources a user has a permission on.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
user_id: The user ID
|
|
419
|
+
resource_type: The resource type to list
|
|
420
|
+
permission: The permission to check
|
|
421
|
+
limit: Maximum number of results (optional)
|
|
422
|
+
cursor: Pagination cursor (optional)
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
List of resource IDs
|
|
426
|
+
|
|
427
|
+
Example:
|
|
428
|
+
repos = authz.list_resources("alice", "repo", "read")
|
|
429
|
+
# ["api", "frontend", "docs"]
|
|
430
|
+
"""
|
|
431
|
+
if limit is not None:
|
|
432
|
+
rows = self._fetchall(
|
|
433
|
+
"SELECT * FROM authz.list_resources(%s, %s, %s, %s, %s, %s)",
|
|
434
|
+
(user_id, resource_type, permission, self.namespace, limit, cursor),
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
rows = self._fetchall(
|
|
438
|
+
"SELECT * FROM authz.list_resources(%s, %s, %s, %s)",
|
|
439
|
+
(user_id, resource_type, permission, self.namespace),
|
|
440
|
+
)
|
|
441
|
+
return [row[0] for row in rows]
|
|
442
|
+
|
|
443
|
+
def filter_authorized(
|
|
444
|
+
self, user_id: str, resource_type: str, permission: str, resource_ids: list[str]
|
|
445
|
+
) -> list[str]:
|
|
446
|
+
"""Filter resource IDs to only those the user can access."""
|
|
447
|
+
result = self._scalar(
|
|
448
|
+
"SELECT authz.filter_authorized(%s, %s, %s, %s, %s)",
|
|
449
|
+
(user_id, resource_type, permission, resource_ids, self.namespace),
|
|
450
|
+
)
|
|
451
|
+
return result if result else []
|
|
452
|
+
|
|
453
|
+
# =========================================================================
|
|
454
|
+
# Setup helpers
|
|
455
|
+
# =========================================================================
|
|
456
|
+
|
|
457
|
+
def set_hierarchy(self, resource_type: str, *permissions: str):
|
|
458
|
+
"""
|
|
459
|
+
Define permission hierarchy for a resource type.
|
|
460
|
+
|
|
461
|
+
Each permission implies the next in the chain.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
resource_type: The resource type (e.g., "repo")
|
|
465
|
+
*permissions: Permissions in order of power (e.g., "admin", "write", "read")
|
|
466
|
+
|
|
467
|
+
Example:
|
|
468
|
+
authz.set_hierarchy("repo", "admin", "write", "read")
|
|
469
|
+
# Now admin implies write, write implies read
|
|
470
|
+
"""
|
|
471
|
+
for i in range(len(permissions) - 1):
|
|
472
|
+
self.add_hierarchy_rule(resource_type, permissions[i], permissions[i + 1])
|
|
473
|
+
|
|
474
|
+
def add_hierarchy_rule(self, resource_type: str, permission: str, implies: str):
|
|
475
|
+
"""
|
|
476
|
+
Add a single hierarchy rule (for complex/branching hierarchies).
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
resource_type: The resource type
|
|
480
|
+
permission: The higher permission
|
|
481
|
+
implies: The permission it implies
|
|
482
|
+
|
|
483
|
+
Example:
|
|
484
|
+
authz.add_hierarchy_rule("doc", "admin", "read")
|
|
485
|
+
authz.add_hierarchy_rule("doc", "admin", "share")
|
|
486
|
+
"""
|
|
487
|
+
self._write_scalar(
|
|
488
|
+
"SELECT authz.add_hierarchy(%s, %s, %s, %s)",
|
|
489
|
+
(resource_type, permission, implies, self.namespace),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def remove_hierarchy_rule(self, resource_type: str, permission: str, implies: str):
|
|
493
|
+
"""Remove a single hierarchy rule."""
|
|
494
|
+
self._write_scalar(
|
|
495
|
+
"SELECT authz.remove_hierarchy(%s, %s, %s, %s)",
|
|
496
|
+
(resource_type, permission, implies, self.namespace),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def clear_hierarchy(self, resource_type: str) -> int:
|
|
500
|
+
"""Clear all hierarchy rules for a resource type."""
|
|
501
|
+
return self._write_scalar(
|
|
502
|
+
"SELECT authz.clear_hierarchy(%s, %s)",
|
|
503
|
+
(resource_type, self.namespace),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# =========================================================================
|
|
507
|
+
# Audit logging
|
|
508
|
+
# =========================================================================
|
|
509
|
+
|
|
510
|
+
def set_actor(
|
|
511
|
+
self,
|
|
512
|
+
actor_id: str,
|
|
513
|
+
request_id: str | None = None,
|
|
514
|
+
reason: str | None = None,
|
|
515
|
+
) -> None:
|
|
516
|
+
"""
|
|
517
|
+
Set actor context for audit logging.
|
|
518
|
+
|
|
519
|
+
Call this before performing operations to record who made changes.
|
|
520
|
+
Context persists until clear_actor() is called or client is discarded.
|
|
521
|
+
|
|
522
|
+
When actor context is set, write operations (grant, revoke, etc.) are
|
|
523
|
+
automatically wrapped in a transaction to ensure the audit trigger
|
|
524
|
+
captures the actor information.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
actor_id: The actor making changes (e.g., user ID, service name)
|
|
528
|
+
request_id: Optional request/correlation ID for tracing
|
|
529
|
+
reason: Optional reason for the changes
|
|
530
|
+
|
|
531
|
+
Example:
|
|
532
|
+
authz.set_actor("admin@acme.com", "req-123", "Quarterly review")
|
|
533
|
+
authz.grant("admin", resource=("repo", "api"), subject=("team", "eng"))
|
|
534
|
+
authz.clear_actor() # optional, clears context
|
|
535
|
+
"""
|
|
536
|
+
self._actor_id = actor_id
|
|
537
|
+
self._request_id = request_id
|
|
538
|
+
self._reason = reason
|
|
539
|
+
|
|
540
|
+
def clear_actor(self) -> None:
|
|
541
|
+
"""Clear actor context."""
|
|
542
|
+
self._actor_id = None
|
|
543
|
+
self._request_id = None
|
|
544
|
+
self._reason = None
|
|
545
|
+
|
|
546
|
+
def get_audit_events(
|
|
547
|
+
self,
|
|
548
|
+
*,
|
|
549
|
+
limit: int = 100,
|
|
550
|
+
event_type: str | None = None,
|
|
551
|
+
actor_id: str | None = None,
|
|
552
|
+
resource: Entity | None = None,
|
|
553
|
+
subject: Entity | None = None,
|
|
554
|
+
) -> list[dict]:
|
|
555
|
+
"""
|
|
556
|
+
Query audit events with optional filters.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
limit: Maximum number of events to return (default 100)
|
|
560
|
+
event_type: Filter by event type (e.g., 'tuple_created')
|
|
561
|
+
actor_id: Filter by actor ID
|
|
562
|
+
resource: Filter by resource as (type, id) tuple
|
|
563
|
+
subject: Filter by subject as (type, id) tuple
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
List of audit event dictionaries
|
|
567
|
+
|
|
568
|
+
Example:
|
|
569
|
+
events = authz.get_audit_events(actor_id="admin@acme.com", limit=50)
|
|
570
|
+
for event in events:
|
|
571
|
+
print(f"{event['event_type']}: {event['resource']}")
|
|
572
|
+
"""
|
|
573
|
+
conditions = ["namespace = %s"]
|
|
574
|
+
params: list = [self.namespace]
|
|
575
|
+
|
|
576
|
+
if event_type is not None:
|
|
577
|
+
conditions.append("event_type = %s")
|
|
578
|
+
params.append(event_type)
|
|
579
|
+
|
|
580
|
+
if actor_id is not None:
|
|
581
|
+
conditions.append("actor_id = %s")
|
|
582
|
+
params.append(actor_id)
|
|
583
|
+
|
|
584
|
+
if resource is not None:
|
|
585
|
+
conditions.append("resource_type = %s")
|
|
586
|
+
conditions.append("resource_id = %s")
|
|
587
|
+
params.extend(resource)
|
|
588
|
+
|
|
589
|
+
if subject is not None:
|
|
590
|
+
conditions.append("subject_type = %s")
|
|
591
|
+
conditions.append("subject_id = %s")
|
|
592
|
+
params.extend(subject)
|
|
593
|
+
|
|
594
|
+
params.append(limit)
|
|
595
|
+
|
|
596
|
+
sql = f"""
|
|
597
|
+
SELECT
|
|
598
|
+
event_id, event_type, event_time,
|
|
599
|
+
actor_id, request_id, reason,
|
|
600
|
+
session_user_name, current_user_name, client_addr, application_name,
|
|
601
|
+
resource_type, resource_id, relation,
|
|
602
|
+
subject_type, subject_id, subject_relation,
|
|
603
|
+
tuple_id, expires_at
|
|
604
|
+
FROM authz.audit_events
|
|
605
|
+
WHERE {' AND '.join(conditions)}
|
|
606
|
+
ORDER BY event_time DESC, id DESC
|
|
607
|
+
LIMIT %s
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
self.cursor.execute(sql, tuple(params))
|
|
611
|
+
rows = self.cursor.fetchall()
|
|
612
|
+
|
|
613
|
+
return [
|
|
614
|
+
{
|
|
615
|
+
"event_id": str(row[0]),
|
|
616
|
+
"event_type": row[1],
|
|
617
|
+
"event_time": row[2],
|
|
618
|
+
"actor_id": row[3],
|
|
619
|
+
"request_id": row[4],
|
|
620
|
+
"reason": row[5],
|
|
621
|
+
"session_user": row[6],
|
|
622
|
+
"current_user": row[7],
|
|
623
|
+
"client_addr": str(row[8]) if row[8] else None,
|
|
624
|
+
"application_name": row[9],
|
|
625
|
+
"resource": (row[10], row[11]),
|
|
626
|
+
"relation": row[12],
|
|
627
|
+
"subject": (row[13], row[14]),
|
|
628
|
+
"subject_relation": row[15],
|
|
629
|
+
"tuple_id": row[16],
|
|
630
|
+
"expires_at": row[17],
|
|
631
|
+
}
|
|
632
|
+
for row in rows
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
# =========================================================================
|
|
636
|
+
# Admin/maintenance operations
|
|
637
|
+
# =========================================================================
|
|
638
|
+
|
|
639
|
+
def verify(self) -> list[dict]:
|
|
640
|
+
"""
|
|
641
|
+
Check for data integrity issues (e.g., group membership cycles).
|
|
642
|
+
|
|
643
|
+
Returns list of issues (empty if healthy).
|
|
644
|
+
|
|
645
|
+
Example:
|
|
646
|
+
issues = authz.verify()
|
|
647
|
+
for issue in issues:
|
|
648
|
+
print(f"{issue['status']}: {issue['details']}")
|
|
649
|
+
"""
|
|
650
|
+
rows = self._fetchall(
|
|
651
|
+
"SELECT resource_type, resource_id, status, details FROM authz.verify_integrity(%s)",
|
|
652
|
+
(self.namespace,),
|
|
653
|
+
)
|
|
654
|
+
return [
|
|
655
|
+
{
|
|
656
|
+
"resource_type": r[0],
|
|
657
|
+
"resource_id": r[1],
|
|
658
|
+
"status": r[2],
|
|
659
|
+
"details": r[3],
|
|
660
|
+
}
|
|
661
|
+
for r in rows
|
|
662
|
+
]
|
|
663
|
+
|
|
664
|
+
def stats(self) -> dict:
|
|
665
|
+
"""
|
|
666
|
+
Get namespace statistics for monitoring.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Dictionary with:
|
|
670
|
+
- tuple_count: Number of relationship tuples
|
|
671
|
+
- hierarchy_rule_count: Number of hierarchy rules
|
|
672
|
+
- unique_users: Distinct users with permissions
|
|
673
|
+
- unique_resources: Distinct resources with permissions
|
|
674
|
+
|
|
675
|
+
Example:
|
|
676
|
+
stats = authz.stats()
|
|
677
|
+
print(f"Tuples: {stats['tuple_count']}, Users: {stats['unique_users']}")
|
|
678
|
+
"""
|
|
679
|
+
self.cursor.execute("SELECT * FROM authz.get_stats(%s)", (self.namespace,))
|
|
680
|
+
row = self.cursor.fetchone()
|
|
681
|
+
if row:
|
|
682
|
+
return {
|
|
683
|
+
"tuple_count": row[0],
|
|
684
|
+
"hierarchy_rule_count": row[1],
|
|
685
|
+
"unique_users": row[2],
|
|
686
|
+
"unique_resources": row[3],
|
|
687
|
+
}
|
|
688
|
+
return {}
|
|
689
|
+
|
|
690
|
+
def bulk_grant(
|
|
691
|
+
self, permission: str, *, resource: Entity, subject_ids: list[str]
|
|
692
|
+
) -> int:
|
|
693
|
+
"""
|
|
694
|
+
Grant permission to many users at once (single statement).
|
|
695
|
+
|
|
696
|
+
Returns count of tuples inserted.
|
|
697
|
+
|
|
698
|
+
Example:
|
|
699
|
+
authz.bulk_grant("read", resource=("doc", "1"), subject_ids=["alice", "bob", "carol"])
|
|
700
|
+
"""
|
|
701
|
+
resource_type, resource_id = resource
|
|
702
|
+
return self._write_scalar(
|
|
703
|
+
"SELECT authz.write_tuples_bulk(%s, %s, %s, 'user', %s, %s)",
|
|
704
|
+
(resource_type, resource_id, permission, subject_ids, self.namespace),
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def bulk_grant_resources(
|
|
708
|
+
self,
|
|
709
|
+
permission: str,
|
|
710
|
+
*,
|
|
711
|
+
resource_type: str,
|
|
712
|
+
resource_ids: list[str],
|
|
713
|
+
subject: Entity,
|
|
714
|
+
subject_relation: str | None = None,
|
|
715
|
+
) -> int:
|
|
716
|
+
"""
|
|
717
|
+
Grant permission to a subject on many resources at once.
|
|
718
|
+
|
|
719
|
+
Optimized for bulk operations: uses single recompute instead of
|
|
720
|
+
per-resource triggers.
|
|
721
|
+
|
|
722
|
+
Returns count of tuples inserted.
|
|
723
|
+
|
|
724
|
+
Example:
|
|
725
|
+
authz.bulk_grant_resources(
|
|
726
|
+
"read",
|
|
727
|
+
resource_type="doc",
|
|
728
|
+
resource_ids=["doc-1", "doc-2", "doc-3"],
|
|
729
|
+
subject=("team", "engineering"),
|
|
730
|
+
)
|
|
731
|
+
"""
|
|
732
|
+
subject_type, subject_id = subject
|
|
733
|
+
return self._write_scalar(
|
|
734
|
+
"SELECT authz.grant_to_resources_bulk(%s, %s, %s, %s, %s, %s, %s)",
|
|
735
|
+
(
|
|
736
|
+
resource_type,
|
|
737
|
+
resource_ids,
|
|
738
|
+
permission,
|
|
739
|
+
subject_type,
|
|
740
|
+
subject_id,
|
|
741
|
+
subject_relation,
|
|
742
|
+
self.namespace,
|
|
743
|
+
),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# =========================================================================
|
|
747
|
+
# Expiration management
|
|
748
|
+
# =========================================================================
|
|
749
|
+
|
|
750
|
+
def list_expiring(self, within: timedelta = timedelta(days=7)) -> list[dict]:
|
|
751
|
+
"""
|
|
752
|
+
List grants expiring within the given timeframe.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
within: Time window to check (default 7 days).
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
List of grants with their expiration times
|
|
759
|
+
|
|
760
|
+
Example:
|
|
761
|
+
expiring = authz.list_expiring(within=timedelta(days=30))
|
|
762
|
+
for grant in expiring:
|
|
763
|
+
print(f"{grant['subject']} access to {grant['resource']} expires {grant['expires_at']}")
|
|
764
|
+
"""
|
|
765
|
+
rows = self._fetchall(
|
|
766
|
+
"SELECT * FROM authz.list_expiring(%s, %s)",
|
|
767
|
+
(within, self.namespace),
|
|
768
|
+
)
|
|
769
|
+
return [
|
|
770
|
+
{
|
|
771
|
+
"resource": (row[0], row[1]),
|
|
772
|
+
"relation": row[2],
|
|
773
|
+
"subject": (row[3], row[4]),
|
|
774
|
+
"subject_relation": row[5],
|
|
775
|
+
"expires_at": row[6],
|
|
776
|
+
}
|
|
777
|
+
for row in rows
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
def cleanup_expired(self) -> dict:
|
|
781
|
+
"""
|
|
782
|
+
Remove expired grants.
|
|
783
|
+
|
|
784
|
+
This is optional for storage management - expired entries are
|
|
785
|
+
automatically filtered at query time.
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
Dictionary with count of deleted tuples
|
|
789
|
+
|
|
790
|
+
Example:
|
|
791
|
+
result = authz.cleanup_expired()
|
|
792
|
+
print(f"Removed {result['tuples_deleted']} expired grants")
|
|
793
|
+
"""
|
|
794
|
+
self.cursor.execute(
|
|
795
|
+
"SELECT * FROM authz.cleanup_expired(%s)",
|
|
796
|
+
(self.namespace,),
|
|
797
|
+
)
|
|
798
|
+
row = self.cursor.fetchone()
|
|
799
|
+
return {"tuples_deleted": row[0]}
|
|
800
|
+
|
|
801
|
+
def set_expiration(
|
|
802
|
+
self,
|
|
803
|
+
permission: str,
|
|
804
|
+
*,
|
|
805
|
+
resource: Entity,
|
|
806
|
+
subject: Entity,
|
|
807
|
+
expires_at: datetime | None,
|
|
808
|
+
) -> bool:
|
|
809
|
+
"""
|
|
810
|
+
Set or update expiration on an existing grant.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
permission: The permission/relation
|
|
814
|
+
resource: The resource as (type, id) tuple
|
|
815
|
+
subject: The subject as (type, id) tuple
|
|
816
|
+
expires_at: New expiration time (None to make permanent)
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
True if grant was found and updated
|
|
820
|
+
|
|
821
|
+
Example:
|
|
822
|
+
authz.set_expiration("read", resource=("doc", "1"), subject=("user", "alice"),
|
|
823
|
+
expires_at=datetime.now(timezone.utc) + timedelta(days=30))
|
|
824
|
+
"""
|
|
825
|
+
resource_type, resource_id = resource
|
|
826
|
+
subject_type, subject_id = subject
|
|
827
|
+
return self._write_scalar(
|
|
828
|
+
"SELECT authz.set_expiration(%s, %s, %s, %s, %s, %s, %s)",
|
|
829
|
+
(
|
|
830
|
+
resource_type,
|
|
831
|
+
resource_id,
|
|
832
|
+
permission,
|
|
833
|
+
subject_type,
|
|
834
|
+
subject_id,
|
|
835
|
+
expires_at,
|
|
836
|
+
self.namespace,
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def clear_expiration(
|
|
841
|
+
self,
|
|
842
|
+
permission: str,
|
|
843
|
+
*,
|
|
844
|
+
resource: Entity,
|
|
845
|
+
subject: Entity,
|
|
846
|
+
) -> bool:
|
|
847
|
+
"""
|
|
848
|
+
Remove expiration from a grant (make it permanent).
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
permission: The permission/relation
|
|
852
|
+
resource: The resource as (type, id) tuple
|
|
853
|
+
subject: The subject as (type, id) tuple
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
True if grant was found and updated
|
|
857
|
+
|
|
858
|
+
Example:
|
|
859
|
+
authz.clear_expiration("read", resource=("doc", "1"), subject=("user", "alice"))
|
|
860
|
+
"""
|
|
861
|
+
resource_type, resource_id = resource
|
|
862
|
+
subject_type, subject_id = subject
|
|
863
|
+
return self._write_scalar(
|
|
864
|
+
"SELECT authz.clear_expiration(%s, %s, %s, %s, %s, %s)",
|
|
865
|
+
(
|
|
866
|
+
resource_type,
|
|
867
|
+
resource_id,
|
|
868
|
+
permission,
|
|
869
|
+
subject_type,
|
|
870
|
+
subject_id,
|
|
871
|
+
self.namespace,
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def extend_expiration(
|
|
876
|
+
self,
|
|
877
|
+
permission: str,
|
|
878
|
+
*,
|
|
879
|
+
resource: Entity,
|
|
880
|
+
subject: Entity,
|
|
881
|
+
extension: timedelta,
|
|
882
|
+
) -> datetime:
|
|
883
|
+
"""
|
|
884
|
+
Extend an existing expiration by a given interval.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
permission: The permission/relation
|
|
888
|
+
resource: The resource as (type, id) tuple
|
|
889
|
+
subject: The subject as (type, id) tuple
|
|
890
|
+
extension: Time to add to current expiration
|
|
891
|
+
|
|
892
|
+
Returns:
|
|
893
|
+
The new expiration time
|
|
894
|
+
|
|
895
|
+
Example:
|
|
896
|
+
new_expires = authz.extend_expiration("read", resource=("doc", "1"),
|
|
897
|
+
subject=("user", "alice"),
|
|
898
|
+
extension=timedelta(days=30))
|
|
899
|
+
"""
|
|
900
|
+
resource_type, resource_id = resource
|
|
901
|
+
subject_type, subject_id = subject
|
|
902
|
+
return self._write_scalar(
|
|
903
|
+
"SELECT authz.extend_expiration(%s, %s, %s, %s, %s, %s, %s)",
|
|
904
|
+
(
|
|
905
|
+
resource_type,
|
|
906
|
+
resource_id,
|
|
907
|
+
permission,
|
|
908
|
+
subject_type,
|
|
909
|
+
subject_id,
|
|
910
|
+
extension,
|
|
911
|
+
self.namespace,
|
|
912
|
+
),
|
|
913
|
+
)
|