lorebase-store 0.2.3__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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,185 @@
1
+ """Signed checkpoints of the audit chains, kept outside the database.
2
+
3
+ A table owner can delete the newest entries of a chain, or recompute a whole chain, and the
4
+ chain still verifies. A checkpoint records the head (`seq`, `entry_hash`) of every chain of a
5
+ stream at a point in time; Scribe publishes one periodically to a sink the database cannot
6
+ reach (`scribe.audit_checkpoints`), and `audit verify --checkpoints` compares the log with
7
+ them: a checkpointed entry that is missing reveals a truncation, one whose hash differs a
8
+ rewrite. Entries newer than the latest checkpoint are not covered.
9
+
10
+ A checkpoint is a JSON document signed with Ed25519 over the RFC 8785 form of every member
11
+ but `signature`, so a verifier needs only the public key and a writer to the sink cannot forge
12
+ one::
13
+
14
+ {"format": "lorebase.audit.checkpoint/1", "stream": "actions",
15
+ "at": "2026-10-25T09:00:00.000000+00:00", "key_id": "main",
16
+ "chains": [{"domain_id": "acme", "seq": 42, "entry_hash": "<hex>"}, ...],
17
+ "signature": "<base64>"}
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import binascii
24
+ import json
25
+ from collections.abc import Iterable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import UTC, datetime
28
+ from typing import Any, Literal
29
+
30
+ from cryptography.exceptions import InvalidSignature
31
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
32
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key
33
+
34
+ from lorebase_core.canonical import jcs_bytes
35
+ from lorebase_store.auth.audit import AuditRepository, AuditStream, ChainHead
36
+
37
+ FORMAT = "lorebase.audit.checkpoint/1"
38
+ MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
39
+
40
+
41
+ class CheckpointError(ValueError):
42
+ """A document is not a valid checkpoint or its signature does not verify."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Checkpoint:
47
+ """The heads of every chain of a stream at `at`."""
48
+
49
+ stream: AuditStream
50
+ at: datetime
51
+ key_id: str
52
+ chains: tuple[ChainHead, ...]
53
+
54
+ def body(self) -> dict[str, Any]:
55
+ """Every member but the signature."""
56
+ return {
57
+ "format": FORMAT,
58
+ "stream": self.stream.value,
59
+ "at": self.at.astimezone(UTC).isoformat(timespec="microseconds"),
60
+ "key_id": self.key_id,
61
+ "chains": [
62
+ {"domain_id": h.domain_id, "seq": h.seq, "entry_hash": h.entry_hash.hex()}
63
+ for h in self.chains
64
+ ],
65
+ }
66
+
67
+ def name(self, prefix: str = "") -> str:
68
+ """Object name of the checkpoint under `prefix`: by stream, then sortable time."""
69
+ stamp = self.at.astimezone(UTC).strftime("%Y%m%dT%H%M%S%fZ")
70
+ return f"{prefix}{self.stream.value}/{stamp}.json"
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class CheckpointFinding:
75
+ """A checkpointed entry the log no longer holds as it was."""
76
+
77
+ checkpoint_at: datetime
78
+ domain_id: str | None
79
+ seq: int
80
+ problem: Literal["missing", "changed"]
81
+
82
+
83
+ def load_private_key(pem: str) -> Ed25519PrivateKey:
84
+ """An Ed25519 private key from PEM (PKCS #8, as `openssl genpkey -algorithm ed25519`)."""
85
+ try:
86
+ key = load_pem_private_key(pem.encode(), password=None)
87
+ except (ValueError, TypeError):
88
+ # The parser's message is left out: it never needs to describe secret material.
89
+ raise CheckpointError("not a PEM private key (unencrypted PKCS #8)") from None
90
+ if not isinstance(key, Ed25519PrivateKey):
91
+ raise CheckpointError("the checkpoint signing key must be an Ed25519 key")
92
+ return key
93
+
94
+
95
+ def load_public_key(pem: str) -> Ed25519PublicKey:
96
+ """An Ed25519 public key from PEM (`openssl pkey -pubout`)."""
97
+ try:
98
+ key = load_pem_public_key(pem.encode())
99
+ except (ValueError, TypeError) as error:
100
+ raise CheckpointError(f"not a PEM public key: {error}") from None
101
+ if not isinstance(key, Ed25519PublicKey):
102
+ raise CheckpointError("the checkpoint key must be an Ed25519 key")
103
+ return key
104
+
105
+
106
+ def signed_document(checkpoint: Checkpoint, key: Ed25519PrivateKey) -> bytes:
107
+ """The checkpoint as signed JSON."""
108
+ body = checkpoint.body()
109
+ signature = key.sign(jcs_bytes(body))
110
+ document = {**body, "signature": base64.b64encode(signature).decode("ascii")}
111
+ return json.dumps(document, separators=(",", ":"), sort_keys=True).encode()
112
+
113
+
114
+ def _head(item: Any) -> ChainHead:
115
+ if not isinstance(item, Mapping):
116
+ raise CheckpointError("a chain must be an object")
117
+ domain_id, seq, digest = item.get("domain_id"), item.get("seq"), item.get("entry_hash")
118
+ if domain_id is not None and not isinstance(domain_id, str):
119
+ raise CheckpointError("chain domain_id must be a string or null")
120
+ if not isinstance(seq, int) or isinstance(seq, bool) or seq < 1:
121
+ raise CheckpointError("chain seq must be a positive integer")
122
+ try:
123
+ entry_hash = bytes.fromhex(digest) if isinstance(digest, str) else b""
124
+ except ValueError:
125
+ entry_hash = b""
126
+ if len(entry_hash) != 32:
127
+ raise CheckpointError("chain entry_hash must be 64 hex digits")
128
+ return ChainHead(domain_id, seq, entry_hash)
129
+
130
+
131
+ def parse_document(raw: bytes, public_key: Ed25519PublicKey) -> Checkpoint:
132
+ """A checkpoint from signed JSON; `CheckpointError` unless well formed and signed by
133
+ `public_key`."""
134
+ if len(raw) > MAX_DOCUMENT_BYTES:
135
+ raise CheckpointError("checkpoint document too large")
136
+ try:
137
+ document = json.loads(raw)
138
+ except (ValueError, UnicodeDecodeError) as error:
139
+ raise CheckpointError(f"not JSON: {error}") from None
140
+ if not isinstance(document, dict) or document.get("format") != FORMAT:
141
+ raise CheckpointError(f"not a {FORMAT} document")
142
+ encoded = document.pop("signature", None)
143
+ if not isinstance(encoded, str):
144
+ raise CheckpointError("signature missing")
145
+ try:
146
+ signature = base64.b64decode(encoded, validate=True)
147
+ public_key.verify(signature, jcs_bytes(document))
148
+ except (binascii.Error, InvalidSignature):
149
+ raise CheckpointError("signature does not verify") from None
150
+ try:
151
+ stream = AuditStream(str(document.get("stream")))
152
+ at = datetime.fromisoformat(str(document.get("at")))
153
+ except ValueError as error:
154
+ raise CheckpointError(str(error)) from None
155
+ chains = document.get("chains")
156
+ if not isinstance(chains, list):
157
+ raise CheckpointError("chains must be a list")
158
+ if at.tzinfo is None:
159
+ raise CheckpointError("at must carry a time zone")
160
+ return Checkpoint(stream, at, str(document.get("key_id", "")), tuple(map(_head, chains)))
161
+
162
+
163
+ async def compare(
164
+ repository: AuditRepository,
165
+ checkpoints: Iterable[Checkpoint],
166
+ *,
167
+ domain_ids: Sequence[str | None] | None = None,
168
+ ) -> list[CheckpointFinding]:
169
+ """Checkpointed heads the log does not hold with the same hash, oldest checkpoint first.
170
+ `domain_ids` limits the comparison to some chains (None: every chain)."""
171
+ findings: list[CheckpointFinding] = []
172
+ for checkpoint in sorted(checkpoints, key=lambda c: c.at):
173
+ heads = [h for h in checkpoint.chains if domain_ids is None or h.domain_id in domain_ids]
174
+ stored = await repository.entry_hashes([(h.domain_id, h.seq) for h in heads])
175
+ for head in heads:
176
+ found = stored.get((head.domain_id, head.seq))
177
+ if found is None:
178
+ findings.append(
179
+ CheckpointFinding(checkpoint.at, head.domain_id, head.seq, "missing")
180
+ )
181
+ elif found != head.entry_hash:
182
+ findings.append(
183
+ CheckpointFinding(checkpoint.at, head.domain_id, head.seq, "changed")
184
+ )
185
+ return findings
@@ -0,0 +1,161 @@
1
+ """Role grants of principals and groups in a Domain (design 10.2).
2
+
3
+ Grants are Domain rows under row-level security: each lookup names its Domain, or the system
4
+ scope when it spans Domains.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Collection
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+
13
+ from sqlalchemy import delete, select, tuple_
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from lorebase_store.auth.scope import new_id, rls_scope
17
+ from lorebase_store.models import Domain, Grant
18
+ from lorebase_store.models.enums import GrantScope
19
+
20
+ ID_PREFIX = "grt"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class GrantRecord:
25
+ """A role of `subject` (principal or group id) in a Domain, optionally narrowed by scope."""
26
+
27
+ id: str
28
+ domain_id: str
29
+ subject: str
30
+ role: str
31
+ scope_type: GrantScope
32
+ scope_ref: str | None
33
+ created_by: str
34
+ created_at: datetime
35
+
36
+
37
+ def _record(row: Grant) -> GrantRecord:
38
+ return GrantRecord(
39
+ id=row.id,
40
+ domain_id=row.domain_id,
41
+ subject=row.subject,
42
+ role=row.role,
43
+ scope_type=row.scope_type,
44
+ scope_ref=row.scope_ref,
45
+ created_by=row.created_by,
46
+ created_at=row.created_at,
47
+ )
48
+
49
+
50
+ class GrantRepository:
51
+ """Grants by Domain and subject."""
52
+
53
+ def __init__(self, session: AsyncSession) -> None:
54
+ self._session = session
55
+
56
+ async def add(
57
+ self,
58
+ *,
59
+ domain_id: str,
60
+ subject: str,
61
+ role: str,
62
+ created_by: str,
63
+ scope_type: GrantScope = GrantScope.DOMAIN,
64
+ scope_ref: str | None = None,
65
+ ) -> GrantRecord:
66
+ """Insert a grant, or return the identical one that already exists.
67
+
68
+ `scope_ref` is required for every scope but `domain`, where it must be absent.
69
+ """
70
+ if (scope_type is GrantScope.DOMAIN) != (scope_ref is None):
71
+ raise ValueError("scope_ref is required for kind, operation and branch scopes only")
72
+ async with rls_scope(self._session, domain_ids=[domain_id]):
73
+ existing = (
74
+ await self._session.execute(
75
+ select(Grant).where(
76
+ Grant.domain_id == domain_id,
77
+ Grant.subject == subject,
78
+ Grant.role == role,
79
+ Grant.scope_type == scope_type,
80
+ Grant.scope_ref.is_not_distinct_from(scope_ref),
81
+ )
82
+ )
83
+ ).scalar_one_or_none()
84
+ if existing is not None:
85
+ return _record(existing)
86
+ row = Grant(
87
+ id=new_id(ID_PREFIX),
88
+ domain_id=domain_id,
89
+ subject=subject,
90
+ role=role,
91
+ scope_type=scope_type,
92
+ scope_ref=scope_ref,
93
+ created_by=created_by,
94
+ )
95
+ self._session.add(row)
96
+ await self._session.flush()
97
+ await self._session.refresh(row)
98
+ return _record(row)
99
+
100
+ async def get(self, domain_id: str, grant_id: str) -> GrantRecord | None:
101
+ """A grant of the Domain."""
102
+ async with rls_scope(self._session, domain_ids=[domain_id]):
103
+ row = (
104
+ await self._session.execute(
105
+ select(Grant).where(Grant.domain_id == domain_id, Grant.id == grant_id)
106
+ )
107
+ ).scalar_one_or_none()
108
+ return _record(row) if row else None
109
+
110
+ async def delete(self, domain_id: str, grant_id: str) -> bool:
111
+ """Revoke a grant; false when the Domain has no such grant."""
112
+ async with rls_scope(self._session, domain_ids=[domain_id]):
113
+ result = await self._session.execute(
114
+ delete(Grant).where(Grant.domain_id == domain_id, Grant.id == grant_id)
115
+ )
116
+ return bool(result.rowcount) # type: ignore[attr-defined]
117
+
118
+ async def for_subjects(self, domain_id: str, subjects: Collection[str]) -> list[GrantRecord]:
119
+ """Every grant in the Domain held by any of `subjects`."""
120
+ if not subjects:
121
+ return []
122
+ async with rls_scope(self._session, domain_ids=[domain_id]):
123
+ rows = await self._session.execute(
124
+ select(Grant)
125
+ .where(Grant.domain_id == domain_id, Grant.subject.in_(list(subjects)))
126
+ .order_by(Grant.created_at, Grant.id)
127
+ )
128
+ return [_record(row) for row in rows.scalars()]
129
+
130
+ async def domain_exists(self, domain_id: str) -> bool:
131
+ """Whether a Domain with this id exists (Domains are not under row-level security)."""
132
+ found = await self._session.execute(select(Domain.id).where(Domain.id == domain_id))
133
+ return found.scalar_one_or_none() is not None
134
+
135
+ async def for_subjects_anywhere(self, subjects: Collection[str]) -> list[GrantRecord]:
136
+ """Every grant held by any of `subjects`, across Domains (system scope)."""
137
+ if not subjects:
138
+ return []
139
+ async with rls_scope(self._session, bypass=True):
140
+ rows = await self._session.execute(
141
+ select(Grant)
142
+ .where(Grant.subject.in_(list(subjects)))
143
+ .order_by(Grant.domain_id, Grant.created_at, Grant.id)
144
+ )
145
+ return [_record(row) for row in rows.scalars()]
146
+
147
+ async def list_domain(
148
+ self, domain_id: str, *, limit: int, after: tuple[datetime, str] | None = None
149
+ ) -> list[GrantRecord]:
150
+ """Grants of a Domain in creation order; `after` is `(created_at, id)` of the last item."""
151
+ statement = (
152
+ select(Grant)
153
+ .where(Grant.domain_id == domain_id)
154
+ .order_by(Grant.created_at, Grant.id)
155
+ .limit(limit)
156
+ )
157
+ if after is not None:
158
+ statement = statement.where(tuple_(Grant.created_at, Grant.id) > after)
159
+ async with rls_scope(self._session, domain_ids=[domain_id]):
160
+ rows = await self._session.execute(statement)
161
+ return [_record(row) for row in rows.scalars()]
@@ -0,0 +1,158 @@
1
+ """Principals and groups (design 10.2, 13.2).
2
+
3
+ Users come from an identity provider and are provisioned on first sign-in; service principals
4
+ are created by administrators and own API keys. Ids are prefixed: `usr_`, `svc_`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Collection
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from sqlalchemy import CompoundSelect, Select, select, tuple_
15
+ from sqlalchemy.dialects.postgresql import insert
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from lorebase_store.auth.scope import new_id
19
+ from lorebase_store.models import Group, GroupMember, Principal
20
+ from lorebase_store.models.enums import PrincipalType
21
+
22
+ ID_PREFIXES = {
23
+ PrincipalType.USER: "usr",
24
+ PrincipalType.SERVICE: "svc",
25
+ PrincipalType.API_KEY: "key",
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PrincipalRecord:
31
+ """A stored principal."""
32
+
33
+ id: str
34
+ type: PrincipalType
35
+ display_name: str
36
+ external_subject: str | None
37
+ created_at: datetime
38
+
39
+
40
+ def _record(row: Principal) -> PrincipalRecord:
41
+ return PrincipalRecord(
42
+ id=row.id,
43
+ type=row.type,
44
+ display_name=row.display_name,
45
+ external_subject=row.external_subject,
46
+ created_at=row.created_at,
47
+ )
48
+
49
+
50
+ class PrincipalRepository:
51
+ """Principals of one installation (no row-level security: the table is installation-wide)."""
52
+
53
+ def __init__(self, session: AsyncSession) -> None:
54
+ self._session = session
55
+
56
+ async def get(self, principal_id: str) -> PrincipalRecord | None:
57
+ """The principal with this id, if any."""
58
+ row = await self._session.get(Principal, principal_id)
59
+ return _record(row) if row else None
60
+
61
+ async def get_by_subject(self, external_subject: str) -> PrincipalRecord | None:
62
+ """The principal linked to an identity provider subject, if any."""
63
+ row = (
64
+ await self._session.execute(
65
+ select(Principal).where(Principal.external_subject == external_subject)
66
+ )
67
+ ).scalar_one_or_none()
68
+ return _record(row) if row else None
69
+
70
+ async def add(
71
+ self,
72
+ type_: PrincipalType,
73
+ display_name: str,
74
+ *,
75
+ external_subject: str | None = None,
76
+ principal_id: str | None = None,
77
+ ) -> PrincipalRecord:
78
+ """Insert a principal. A taken `external_subject` raises `IntegrityError`."""
79
+ row = Principal(
80
+ id=principal_id or new_id(ID_PREFIXES[type_]),
81
+ type=type_,
82
+ display_name=display_name,
83
+ external_subject=external_subject,
84
+ )
85
+ self._session.add(row)
86
+ await self._session.flush()
87
+ await self._session.refresh(row)
88
+ return _record(row)
89
+
90
+ async def get_or_create_user(
91
+ self, external_subject: str, display_name: str
92
+ ) -> tuple[PrincipalRecord, bool]:
93
+ """The user of a provider subject, created on first use. Safe under concurrency.
94
+
95
+ Returns the principal and whether this call created it.
96
+ """
97
+ statement = (
98
+ insert(Principal)
99
+ .values(
100
+ id=new_id(ID_PREFIXES[PrincipalType.USER]),
101
+ type=PrincipalType.USER,
102
+ display_name=display_name,
103
+ external_subject=external_subject,
104
+ )
105
+ .on_conflict_do_nothing(
106
+ index_elements=[Principal.external_subject],
107
+ index_where=Principal.external_subject.is_not(None),
108
+ )
109
+ .returning(Principal.id)
110
+ )
111
+ created = (await self._session.execute(statement)).scalar_one_or_none() is not None
112
+ existing = await self.get_by_subject(external_subject)
113
+ if existing is None: # pragma: no cover - the subject row exists after the upsert
114
+ raise RuntimeError("principal vanished after upsert")
115
+ return existing, created
116
+
117
+ async def list(
118
+ self,
119
+ *,
120
+ type_: PrincipalType | None = None,
121
+ limit: int,
122
+ after: tuple[datetime, str] | None = None,
123
+ ) -> list[PrincipalRecord]:
124
+ """Principals in creation order; `after` is the `(created_at, id)` of the last item."""
125
+ statement = select(Principal).order_by(Principal.created_at, Principal.id).limit(limit)
126
+ if type_ is not None:
127
+ statement = statement.where(Principal.type == type_)
128
+ if after is not None:
129
+ statement = statement.where(tuple_(Principal.created_at, Principal.id) > after)
130
+ return [_record(row) for row in (await self._session.execute(statement)).scalars()]
131
+
132
+
133
+ class GroupRepository:
134
+ """Groups and memberships."""
135
+
136
+ def __init__(self, session: AsyncSession) -> None:
137
+ self._session = session
138
+
139
+ async def group_ids_for(
140
+ self, principal_id: str, external_groups: Collection[str] = ()
141
+ ) -> list[str]:
142
+ """Ids of the groups a principal belongs to.
143
+
144
+ Groups are found by stored membership and by the provider group names carried in the
145
+ principal's current token (`Group.external_group`).
146
+ """
147
+ by_membership = select(GroupMember.group_id).where(GroupMember.principal_id == principal_id)
148
+ statement: Select[Any] | CompoundSelect[Any] = by_membership
149
+ if external_groups:
150
+ statement = by_membership.union(
151
+ select(Group.id).where(Group.external_group.in_(list(external_groups)))
152
+ )
153
+ return sorted(str(item) for item in (await self._session.execute(statement)).scalars())
154
+
155
+ async def subject_exists(self, subject_id: str) -> bool:
156
+ """Whether `subject_id` names a principal or a group (grant subjects)."""
157
+ principal = await self._session.get(Principal, subject_id)
158
+ return principal is not None or await self._session.get(Group, subject_id) is not None
@@ -0,0 +1,70 @@
1
+ """Row-level security scope for auth lookups (see docs/store-schema-notes.md).
2
+
3
+ Postgres policies read two transaction-local settings: `lorebase.domain_ids` (comma-separated
4
+ Domain ids) and `lorebase.bypass_rls` (`on` for system paths). `rls_scope` sets them for the
5
+ body of the `with` block and puts the previous values back afterwards, so repositories can be
6
+ used inside a longer transaction without widening what later statements see.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from collections.abc import AsyncIterator, Sequence
13
+ from contextlib import asynccontextmanager
14
+
15
+ from sqlalchemy import text
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ DOMAIN_IDS_SETTING = "lorebase.domain_ids"
19
+ BYPASS_SETTING = "lorebase.bypass_rls"
20
+
21
+ _SWAP = text(
22
+ "WITH previous AS MATERIALIZED ("
23
+ "SELECT coalesce(current_setting(:domains_name, true), '') AS domains, "
24
+ "coalesce(current_setting(:bypass_name, true), '') AS bypass) "
25
+ "SELECT previous.domains, previous.bypass, "
26
+ "set_config(:domains_name, :domains, true), set_config(:bypass_name, :bypass, true) "
27
+ "FROM previous"
28
+ )
29
+ _RESTORE = text(
30
+ "SELECT set_config(:domains_name, :domains, true), set_config(:bypass_name, :bypass, true)"
31
+ )
32
+
33
+
34
+ def checked_domain_id(domain_id: str) -> str:
35
+ """`domain_id` when it fits the comma-separated `lorebase.domain_ids` setting.
36
+
37
+ Raises `ValueError` for an empty id or one holding a comma, either of which would change
38
+ which Domains the setting names.
39
+ """
40
+ if not domain_id:
41
+ raise ValueError("Domain ids cannot be empty")
42
+ if "," in domain_id:
43
+ raise ValueError("Domain ids cannot contain commas")
44
+ return domain_id
45
+
46
+
47
+ @asynccontextmanager
48
+ async def rls_scope(
49
+ session: AsyncSession, *, domain_ids: Sequence[str] = (), bypass: bool = False
50
+ ) -> AsyncIterator[None]:
51
+ """Make rows of `domain_ids` (or every row with `bypass`) visible inside the block.
52
+
53
+ The settings are transaction-local. When the block raises, they are left as they are:
54
+ the transaction is expected to roll back. An empty Domain id or one holding a comma raises
55
+ `ValueError` before anything is set.
56
+ """
57
+ domains = ",".join(checked_domain_id(domain_id) for domain_id in domain_ids)
58
+ names = {"domains_name": DOMAIN_IDS_SETTING, "bypass_name": BYPASS_SETTING}
59
+ row = (
60
+ await session.execute(
61
+ _SWAP, {**names, "domains": domains, "bypass": "on" if bypass else ""}
62
+ )
63
+ ).one()
64
+ yield
65
+ await session.execute(_RESTORE, {**names, "domains": row.domains, "bypass": row.bypass})
66
+
67
+
68
+ def new_id(prefix: str) -> str:
69
+ """Application-generated id with a type prefix, e.g. `usr_3f2a...`."""
70
+ return f"{prefix}_{uuid.uuid4().hex}"