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,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")