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.
@@ -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
+ )