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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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}"
|