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,154 @@
|
|
|
1
|
+
"""Document blobs, extraction items and extraction job bookkeeping (design 7.3, 7.4, 9.2).
|
|
2
|
+
|
|
3
|
+
- `document_blobs` is the default blob store backend: document bytes in Postgres, addressed
|
|
4
|
+
per Domain by their SHA-256 (`documents.blob_ref` is `postgres:<hex digest>`). Domain-scoped
|
|
5
|
+
with row-level security like every table of 0001.
|
|
6
|
+
- `documents (domain_id, sha256)` becomes unique: uploading the same bytes again returns the
|
|
7
|
+
stored document.
|
|
8
|
+
- `proposal_items` gains what an extraction item needs besides its decision: item type,
|
|
9
|
+
subject (proposed Record, value or Link), match details, tier, model, job, reviewer edits
|
|
10
|
+
and a stable order.
|
|
11
|
+
- `extraction_jobs` gains its author, timing, statistics, error and branch.
|
|
12
|
+
|
|
13
|
+
Revision ID: 0007
|
|
14
|
+
Revises: 0006
|
|
15
|
+
Create Date: 2026-10-21 18:00:00.000000
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
import sqlalchemy as sa
|
|
23
|
+
from alembic import op
|
|
24
|
+
from sqlalchemy.dialects import postgresql
|
|
25
|
+
|
|
26
|
+
revision: str = "0007"
|
|
27
|
+
down_revision: str | Sequence[str] | None = "0006"
|
|
28
|
+
branch_labels: str | Sequence[str] | None = None
|
|
29
|
+
depends_on: str | Sequence[str] | None = None
|
|
30
|
+
|
|
31
|
+
DOMAIN_VISIBLE = (
|
|
32
|
+
"current_setting('lorebase.bypass_rls', true) = 'on' "
|
|
33
|
+
"OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
|
|
34
|
+
)
|
|
35
|
+
_JSON = postgresql.JSONB(astext_type=sa.Text())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def upgrade() -> None:
|
|
39
|
+
op.create_table(
|
|
40
|
+
"document_blobs",
|
|
41
|
+
sa.Column("domain_id", sa.Text(), nullable=False),
|
|
42
|
+
sa.Column("sha256", sa.LargeBinary(), nullable=False),
|
|
43
|
+
sa.Column("content", sa.LargeBinary(), nullable=False),
|
|
44
|
+
sa.Column("size", sa.BigInteger(), nullable=False),
|
|
45
|
+
sa.Column(
|
|
46
|
+
"created_at",
|
|
47
|
+
sa.DateTime(timezone=True),
|
|
48
|
+
server_default=sa.text("now()"),
|
|
49
|
+
nullable=False,
|
|
50
|
+
),
|
|
51
|
+
sa.CheckConstraint("octet_length(sha256) = 32", name=op.f("ck_document_blobs_sha256_h32")),
|
|
52
|
+
sa.ForeignKeyConstraint(
|
|
53
|
+
["domain_id"],
|
|
54
|
+
["domains.id"],
|
|
55
|
+
name=op.f("fk_document_blobs_domain_id_domains"),
|
|
56
|
+
ondelete="CASCADE",
|
|
57
|
+
),
|
|
58
|
+
sa.PrimaryKeyConstraint("domain_id", "sha256", name=op.f("pk_document_blobs")),
|
|
59
|
+
)
|
|
60
|
+
op.execute("ALTER TABLE document_blobs ENABLE ROW LEVEL SECURITY")
|
|
61
|
+
op.execute("ALTER TABLE document_blobs FORCE ROW LEVEL SECURITY")
|
|
62
|
+
op.execute(
|
|
63
|
+
"CREATE POLICY domain_isolation ON document_blobs "
|
|
64
|
+
f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
op.drop_index(op.f("ix_documents_domain_id_sha256"), table_name="documents")
|
|
68
|
+
op.create_index(
|
|
69
|
+
op.f("ix_documents_domain_id_sha256"), "documents", ["domain_id", "sha256"], unique=True
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
op.add_column(
|
|
73
|
+
"proposal_items",
|
|
74
|
+
sa.Column("item_type", sa.Text(), server_default=sa.text("'record'"), nullable=False),
|
|
75
|
+
)
|
|
76
|
+
op.add_column(
|
|
77
|
+
"proposal_items",
|
|
78
|
+
sa.Column("seq", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
|
79
|
+
)
|
|
80
|
+
op.add_column("proposal_items", sa.Column("job_id", sa.Text(), nullable=True))
|
|
81
|
+
op.add_column(
|
|
82
|
+
"proposal_items",
|
|
83
|
+
sa.Column("subject", _JSON, server_default=sa.text("'{}'::jsonb"), nullable=False),
|
|
84
|
+
)
|
|
85
|
+
op.add_column("proposal_items", sa.Column("match", _JSON, nullable=True))
|
|
86
|
+
op.add_column("proposal_items", sa.Column("tier", sa.SmallInteger(), nullable=True))
|
|
87
|
+
op.add_column("proposal_items", sa.Column("model", sa.Text(), nullable=True))
|
|
88
|
+
op.add_column("proposal_items", sa.Column("edits", _JSON, nullable=True))
|
|
89
|
+
op.add_column(
|
|
90
|
+
"proposal_items",
|
|
91
|
+
sa.Column(
|
|
92
|
+
"created_at",
|
|
93
|
+
sa.DateTime(timezone=True),
|
|
94
|
+
server_default=sa.text("now()"),
|
|
95
|
+
nullable=False,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
op.create_check_constraint(
|
|
99
|
+
op.f("ck_proposal_items_item_type"),
|
|
100
|
+
"proposal_items",
|
|
101
|
+
"item_type IN ('record', 'value', 'link')",
|
|
102
|
+
)
|
|
103
|
+
op.create_index(
|
|
104
|
+
op.f("ix_proposal_items_domain_id_proposal_id_seq"),
|
|
105
|
+
"proposal_items",
|
|
106
|
+
["domain_id", "proposal_id", "seq"],
|
|
107
|
+
unique=False,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
op.add_column("extraction_jobs", sa.Column("created_by", sa.Text(), nullable=True))
|
|
111
|
+
op.add_column("extraction_jobs", sa.Column("branch", sa.Text(), nullable=True))
|
|
112
|
+
op.add_column(
|
|
113
|
+
"extraction_jobs", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True)
|
|
114
|
+
)
|
|
115
|
+
op.add_column(
|
|
116
|
+
"extraction_jobs", sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True)
|
|
117
|
+
)
|
|
118
|
+
op.add_column(
|
|
119
|
+
"extraction_jobs",
|
|
120
|
+
sa.Column("stats", _JSON, server_default=sa.text("'{}'::jsonb"), nullable=False),
|
|
121
|
+
)
|
|
122
|
+
op.add_column("extraction_jobs", sa.Column("error", _JSON, nullable=True))
|
|
123
|
+
op.create_index(
|
|
124
|
+
op.f("ix_extraction_jobs_documents"),
|
|
125
|
+
"extraction_jobs",
|
|
126
|
+
["documents"],
|
|
127
|
+
unique=False,
|
|
128
|
+
postgresql_using="gin",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def downgrade() -> None:
|
|
133
|
+
op.drop_index(op.f("ix_extraction_jobs_documents"), table_name="extraction_jobs")
|
|
134
|
+
for column in ("error", "stats", "finished_at", "started_at", "branch", "created_by"):
|
|
135
|
+
op.drop_column("extraction_jobs", column)
|
|
136
|
+
op.drop_index(op.f("ix_proposal_items_domain_id_proposal_id_seq"), table_name="proposal_items")
|
|
137
|
+
op.drop_constraint(op.f("ck_proposal_items_item_type"), "proposal_items", type_="check")
|
|
138
|
+
for column in (
|
|
139
|
+
"created_at",
|
|
140
|
+
"edits",
|
|
141
|
+
"model",
|
|
142
|
+
"tier",
|
|
143
|
+
"match",
|
|
144
|
+
"subject",
|
|
145
|
+
"job_id",
|
|
146
|
+
"seq",
|
|
147
|
+
"item_type",
|
|
148
|
+
):
|
|
149
|
+
op.drop_column("proposal_items", column)
|
|
150
|
+
op.drop_index(op.f("ix_documents_domain_id_sha256"), table_name="documents")
|
|
151
|
+
op.create_index(
|
|
152
|
+
op.f("ix_documents_domain_id_sha256"), "documents", ["domain_id", "sha256"], unique=False
|
|
153
|
+
)
|
|
154
|
+
op.drop_table("document_blobs")
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Argument, scope and search_path checks in the security definer read functions.
|
|
2
|
+
|
|
3
|
+
Revision 0005 trusted its callers: a NULL count made `LIMIT NULL` (no limit), and the Domain
|
|
4
|
+
argument was not compared with the transaction scope. The four functions are replaced, with
|
|
5
|
+
the same signatures, bodies, owner and grants, by versions that first check:
|
|
6
|
+
|
|
7
|
+
- every count (`p_limit`, `p_candidates`, `p_depth`, `p_fanout`, `p_max_steps`, `p_max_nodes`)
|
|
8
|
+
is between 1 and its ceiling in `store.reads.definer_ceilings`, read when this revision runs
|
|
9
|
+
(SQLSTATE 22023 otherwise);
|
|
10
|
+
- `p_domain` is given (22023) and visible under the condition of the `domain_isolation`
|
|
11
|
+
policies of revision 0001: listed in `lorebase.domain_ids` or `lorebase.bypass_rls` on
|
|
12
|
+
(42501 otherwise);
|
|
13
|
+
- `search_path` is still the one fixed here, so `ALTER FUNCTION ... SET/RESET search_path`
|
|
14
|
+
cannot change how the body resolves names (55000 otherwise).
|
|
15
|
+
|
|
16
|
+
The upgrade refuses to run while `database.application_role` may create objects in a schema
|
|
17
|
+
of that path (the schema of the tables, or of pg_trgm): such a role could shadow an operator
|
|
18
|
+
and run code as the function owner.
|
|
19
|
+
|
|
20
|
+
Revision ID: 0008
|
|
21
|
+
Revises: 0007
|
|
22
|
+
Create Date: 2026-10-21 18:00:00.000000
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import importlib.util
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from types import ModuleType
|
|
32
|
+
|
|
33
|
+
import sqlalchemy as sa
|
|
34
|
+
from alembic import op
|
|
35
|
+
|
|
36
|
+
revision: str = "0008"
|
|
37
|
+
down_revision: str | Sequence[str] | None = "0007"
|
|
38
|
+
branch_labels: str | Sequence[str] | None = None
|
|
39
|
+
depends_on: str | Sequence[str] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _revision_module(filename: str) -> ModuleType:
|
|
43
|
+
"""An earlier revision file of this directory, loaded for its SQL templates."""
|
|
44
|
+
path = Path(__file__).with_name(filename)
|
|
45
|
+
spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
|
|
46
|
+
if spec is None or spec.loader is None:
|
|
47
|
+
raise RuntimeError(f"cannot load migration {path}")
|
|
48
|
+
module = importlib.util.module_from_spec(spec)
|
|
49
|
+
spec.loader.exec_module(module)
|
|
50
|
+
return module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_initial = _revision_module("0001_initial_schema.py")
|
|
54
|
+
_reads = _revision_module("0005_security_definer_reads.py")
|
|
55
|
+
|
|
56
|
+
COUNTS = {
|
|
57
|
+
_reads.LB_SEARCH: ("p_candidates", "p_limit"),
|
|
58
|
+
_reads.LB_LINKS_IN: ("p_limit",),
|
|
59
|
+
_reads.LB_QUERY_BY_ATTRIBUTE: ("p_limit",),
|
|
60
|
+
_reads.LB_NEIGHBORHOOD: ("p_depth", "p_fanout", "p_max_steps", "p_max_nodes"),
|
|
61
|
+
}
|
|
62
|
+
"""The 0005 template of each function and its count arguments, named as the fields of
|
|
63
|
+
`store.reads.definer_ceilings` with a `p_` prefix."""
|
|
64
|
+
|
|
65
|
+
_DOMAIN_VISIBLE, _replaced = re.subn(r"\bdomain_id\b", "p_domain", _initial.DOMAIN_VISIBLE)
|
|
66
|
+
if _replaced != 1:
|
|
67
|
+
raise RuntimeError("revision 0001 changed its domain_isolation condition")
|
|
68
|
+
|
|
69
|
+
_SEARCH_PATH_CHECK = """
|
|
70
|
+
IF pg_catalog.current_setting('search_path') OPERATOR(pg_catalog.<>) {path} THEN
|
|
71
|
+
RAISE EXCEPTION 'the search_path of this function was changed from %', {path}
|
|
72
|
+
USING ERRCODE = '55000';
|
|
73
|
+
END IF;
|
|
74
|
+
"""
|
|
75
|
+
"""First, fully qualified: the rest of the body resolves names through the checked path."""
|
|
76
|
+
|
|
77
|
+
_DOMAIN_CHECK = f"""
|
|
78
|
+
IF p_domain IS NULL OR p_domain = '' THEN
|
|
79
|
+
RAISE EXCEPTION 'a Domain is required' USING ERRCODE = '22023';
|
|
80
|
+
END IF;
|
|
81
|
+
IF ({_DOMAIN_VISIBLE}) IS NOT TRUE THEN
|
|
82
|
+
RAISE EXCEPTION 'Domain % is not in the transaction scope', p_domain
|
|
83
|
+
USING ERRCODE = '42501';
|
|
84
|
+
END IF;
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_COUNT_CHECK = """
|
|
88
|
+
IF {name} IS NULL OR {name} NOT BETWEEN 1 AND {ceiling} THEN
|
|
89
|
+
RAISE EXCEPTION '{name} must be between 1 and {ceiling}' USING ERRCODE = '22023';
|
|
90
|
+
END IF;
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _literal(value: str) -> str:
|
|
95
|
+
return "'" + value.replace("'", "''") + "'"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _create_or_replace(template: str, guards: str = "") -> str:
|
|
99
|
+
"""A 0005 template as `CREATE OR REPLACE`, `guards` first in its body; the result still
|
|
100
|
+
takes 0005's `options` and `record_columns`."""
|
|
101
|
+
statement = template.replace("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION", 1)
|
|
102
|
+
if guards:
|
|
103
|
+
escaped = guards.replace("{", "{{").replace("}", "}}")
|
|
104
|
+
statement = statement.replace("\nBEGIN\n", "\nBEGIN" + escaped, 1)
|
|
105
|
+
return statement
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _guards(search_path: str, counts: Sequence[str]) -> str:
|
|
109
|
+
ceilings = op.get_context().config.attributes["store_settings"].reads.definer_ceilings
|
|
110
|
+
checks = [_SEARCH_PATH_CHECK.format(path=_literal(search_path)), _DOMAIN_CHECK]
|
|
111
|
+
for name in counts:
|
|
112
|
+
ceiling = getattr(ceilings, name.removeprefix("p_"))
|
|
113
|
+
checks.append(_COUNT_CHECK.format(name=name, ceiling=int(ceiling)))
|
|
114
|
+
return "".join(checks)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _refuse_writable_schemas(role: str) -> None:
|
|
118
|
+
"""Refuse when `role` may CREATE in the schema of the tables or of pg_trgm."""
|
|
119
|
+
writable = (
|
|
120
|
+
op.get_bind()
|
|
121
|
+
.execute(
|
|
122
|
+
sa.text(
|
|
123
|
+
"SELECT n.nspname FROM pg_namespace n "
|
|
124
|
+
"WHERE (n.nspname = current_schema() OR n.oid = (SELECT extnamespace "
|
|
125
|
+
"FROM pg_extension WHERE extname = 'pg_trgm')) "
|
|
126
|
+
"AND has_schema_privilege(:role, n.oid, 'CREATE') ORDER BY 1"
|
|
127
|
+
),
|
|
128
|
+
{"role": role},
|
|
129
|
+
)
|
|
130
|
+
.scalars()
|
|
131
|
+
.all()
|
|
132
|
+
)
|
|
133
|
+
if writable:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
f"database.application_role {role!r} may create objects in schema(s) "
|
|
136
|
+
f"{', '.join(writable)}, which the security definer read functions resolve names "
|
|
137
|
+
"in; revoke CREATE on them from the role (and from PUBLIC) and migrate again"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _replace(*, checked: bool) -> None:
|
|
142
|
+
search_path = _reads._search_path()
|
|
143
|
+
options = _reads._OPTIONS.format(search_path=search_path).strip()
|
|
144
|
+
for template, counts in COUNTS.items():
|
|
145
|
+
guards = _guards(search_path, counts) if checked else ""
|
|
146
|
+
statement = _create_or_replace(template, guards)
|
|
147
|
+
op.execute(statement.format(options=options, record_columns=_reads._RECORD_COLUMNS))
|
|
148
|
+
# CREATE OR REPLACE keeps the owner and the grants; restate them all the same.
|
|
149
|
+
for signature in _reads.SIGNATURES:
|
|
150
|
+
op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
|
|
151
|
+
role = _reads._application_role()
|
|
152
|
+
if role is not None:
|
|
153
|
+
quoted = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar()
|
|
154
|
+
for signature in _reads.SIGNATURES:
|
|
155
|
+
op.execute(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def upgrade() -> None:
|
|
159
|
+
role = _reads._application_role()
|
|
160
|
+
if role is not None:
|
|
161
|
+
exists = (
|
|
162
|
+
op.get_bind()
|
|
163
|
+
.execute(
|
|
164
|
+
sa.text("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :r)"), {"r": role}
|
|
165
|
+
)
|
|
166
|
+
.scalar()
|
|
167
|
+
)
|
|
168
|
+
if not exists:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"database.application_role names {role!r}, which does not exist; create the "
|
|
171
|
+
"role first or leave the setting empty"
|
|
172
|
+
)
|
|
173
|
+
_refuse_writable_schemas(role)
|
|
174
|
+
_replace(checked=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def downgrade() -> None:
|
|
178
|
+
_replace(checked=False)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Client networks an API key may be used from (design 13.2).
|
|
2
|
+
|
|
3
|
+
`api_keys.allowed_networks` lists CIDR networks; Keeper refuses the key from any other client
|
|
4
|
+
address. Null, the value of every existing key, keeps a key usable from anywhere.
|
|
5
|
+
|
|
6
|
+
Revision ID: 0009
|
|
7
|
+
Revises: 0008
|
|
8
|
+
Create Date: 2026-10-21 18:00:00.000000
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
from alembic import op
|
|
17
|
+
from sqlalchemy.dialects import postgresql
|
|
18
|
+
|
|
19
|
+
revision: str = "0009"
|
|
20
|
+
down_revision: str | Sequence[str] | None = "0008"
|
|
21
|
+
branch_labels: str | Sequence[str] | None = None
|
|
22
|
+
depends_on: str | Sequence[str] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def upgrade() -> None:
|
|
26
|
+
op.add_column(
|
|
27
|
+
"api_keys",
|
|
28
|
+
sa.Column("allowed_networks", postgresql.ARRAY(postgresql.CIDR()), nullable=True),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def downgrade() -> None:
|
|
33
|
+
op.drop_column("api_keys", "allowed_networks")
|