point-topic-access 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: point-topic-access
3
+ Version: 0.1.0
4
+ Summary: Shared permission -> ClickHouse access-control logic for Point Topic apps (claims, JWT verification, geo filter composer, per-org provisioning)
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: clickhouse-connect>=0.8.0
8
+ Requires-Dist: httpx>=0.28.0
9
+ Requires-Dist: pymongo>=4.10.0
10
+ Requires-Dist: python-jose>=3.3.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: cryptography>=44.0.0; extra == 'dev'
13
+ Requires-Dist: pyright>=1.1.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pt-access — shared permission → ClickHouse access-control logic
20
+
21
+ One place for the permission pipeline every Point Topic data app needs. Ported from the
22
+ production-proven `point-topic-mcp` implementation (issues #100/#104/#105) so the MCP
23
+ server, the ontology web app (`onto-app-new`) and future consumers cannot drift.
24
+
25
+ > Full design context: [`HANDOFF.md`](HANDOFF.md). Tracked by
26
+ > [onto-app-new#25](https://github.com/Point-Topic/onto-app-new/issues/25)
27
+ > (Auth0 login + per-org dataset/geo permissions).
28
+
29
+ ## The pipeline
30
+
31
+ ```
32
+ JWT claims ──► policy spec ──► SQL predicate ──► ClickHouse DDL
33
+ (what you (normalised (composer: (provisioning:
34
+ have) dataset + escaping, role, grants,
35
+ filter rows) containment, row policies,
36
+ OR-grouping) settings profile)
37
+ ```
38
+
39
+ Identity comes from the shared sub-site Auth0 tenant (`point-topic.eu.auth0.com`). The
40
+ Post-Login Action stamps `pt_org_id` and `products[]` (`[{name, permissions}]`, scalar
41
+ values only). **Geo filters are never in the token** — list values would bloat tokens
42
+ past proxy header limits (documented decision in the Action's own code) — they are read
43
+ **fresh from sub-site MongoDB** (`organisations.productPermissions.<ds> = {field: string[]}`)
44
+ on every login/session, then converted to ClickHouse row policies.
45
+
46
+ ## Modules
47
+
48
+ | Module | What | Ported from |
49
+ |---|---|---|
50
+ | `claims.py` | `get_org_id`, `get_held_products`, `is_pt_admin`, `has_data_source_access`, `get_product_role` | MCP `auth/middleware.py` (pure parts) |
51
+ | `jwt.py` | `Auth0TokenVerifier` — JWKS cache, RS256, issuer/audience/exp checks | MCP `auth/auth0_helpers.py` (FastMCP wrapper stays in the MCP) |
52
+ | `composer.py` | `compose_sql_filter` / `compose_geo_predicate` — sub-site permission values → per-dataset SQL disjunction | MCP `core/sql_filter_composer.py` (verbatim, live-verified SQL shapes) |
53
+ | `provisioning.py` | `fetch_org_datasets` (sub-site Mongo read) + `resolve_org_config` + `provision_org_role` / `provision_org_on_login` (ClickHouse DDL, serialisation lock) | MCP `core/org_provisioning.py` (verbatim) |
54
+ | `contract.py` | `load_contract` / `validate_contract` — fixture loader + code↔contract consistency check | MCP `core/ontology-permission-contract.json` + `test_permission_contract.py` |
55
+
56
+ Instance-specific config (CH host/port/creds, `grant_to` service user, measurement
57
+ tables, PREWHERE profile) stays in the consuming app's environment — this package takes
58
+ it as parameters, never hardcodes it. `provision_org_role()` already takes `grant_to`
59
+ per call; the convenience wrapper uses `GRANT_TO_USER` (env `MCP_CLICKHOUSE_GRANT_TO_USER`,
60
+ default `mcp_service`) — rename/re-purpose per instance when wiring a new consumer.
61
+
62
+ ## Usage
63
+
64
+ ```python
65
+ from pt_access.jwt import Auth0TokenVerifier
66
+ from pt_access.claims import get_org_id, get_held_products, is_pt_admin
67
+ from pt_access.provisioning import provision_org_on_login
68
+
69
+ # 1. Verify the bearer token (FastAPI dependency, etc.)
70
+ claims = await Auth0TokenVerifier(
71
+ auth0_domain="point-topic.eu.auth0.com",
72
+ audience="<your_client_id>", # ID token audience = client_id
73
+ ).verify_token(bearer)
74
+
75
+ # 2. Provision the org's ClickHouse role + row policy (on login / session start)
76
+ if not is_pt_admin(claims):
77
+ summary = provision_org_on_login(get_org_id(claims), get_held_products(claims))
78
+ # summary: {"role": "org_<id>", "granted_to", "using", "tables", "warnings"}
79
+
80
+ # 3. Per-query scoping on the app's READ client (never SET ROLE on a shared
81
+ # connection — thread-local clients are shared across users):
82
+ # client.query(sql, settings={"role": "org_<id>"})
83
+ ```
84
+
85
+ `provision_org_on_login` is a no-op when `CLICKHOUSE_PROVISIONING_USER`/`_PASSWORD` are
86
+ unset; without a provisioning credential orgs fail closed on the engine (role never
87
+ granted). Requires `SUB_SITE_MONGODB_URI` (read-only sub-site Mongo user) for the fresh
88
+ per-org read.
89
+
90
+ ## Behaviour contract (do not "fix" — each rule exists because of a live incident)
91
+
92
+ - **Fail closed**: no held data-source products → deny (`REVOKE` role + row policy
93
+ `USING 0`); unknown/empty filter fields → that dataset excluded, ALL excluded →
94
+ deny; a held product with no stored values → bare `(DATA_SOURCE='<ds>')` (omit-empty
95
+ contract = full access to that product's data).
96
+ - **Per-dataset disjunction**: `(DATA_SOURCE='upc' AND <geo>) OR (DATA_SOURCE='gbs')` —
97
+ never a bare `DATA_SOURCE IN (...) AND <geo>` blob.
98
+ - **OR-grouping**: multiple predicates must be wrapped `(A OR B)` *inside* the
99
+ `DATA_SOURCE` guard — SQL precedence otherwise leaks other datasets' rows at the
100
+ postcode (found live on prod 2026-08-05).
101
+ - **PREWHERE**: row policies on non-sorting-key columns return 0 rows under PREWHERE
102
+ (ClickHouse GH #85222). Provisioning creates a settings profile
103
+ (`optimize_move_to_prewhere = 0`) and attaches it to the **service user** — per-query
104
+ `SETTINGS` is blocked under `readonly=1` (error 164) and role-attached profiles don't
105
+ apply. Trade-off: applies to every query through that user.
106
+ - **Convergence**: full re-provision on every login (no drift check, ~320ms), serialised
107
+ by a global lock (row-policy `DROP+CREATE` races → `ACCESS_ENTITY_ALREADY_EXISTS`,
108
+ code 493). Deny orgs are always re-provisioned too (REVOKE converges).
109
+ - **Escaping**: every literal is single-quote-doubled (`King's Lynn`); the row policy is
110
+ the enforcement boundary, so a quote in an admin-entered value must never escape the
111
+ literal.
112
+
113
+ ## The contract fixture — the single evolvable artifact
114
+
115
+ `src/pt_access/ontology-permission-contract.json` mirrors
116
+ `sub-site/apps/ui/src/organisations/config/ontology-permission-contract.json`
117
+ (sub-site UI renders exactly what this declares). Adding a **new data source** = a
118
+ `dataSources` entry; a **new filter field** = a `fields` entry (+ `fieldProducts` /
119
+ `compatibility` where applicable). Both are fixture changes, never composer code
120
+ changes. The package CI drift-guard workflow fetches the sub-site copy via `gh api` and
121
+ fails on mismatch; `tests/test_contract.py` (via `contract.validate_contract()`) checks
122
+ the code constants against the fixture.
123
+
124
+ ## Consumers
125
+
126
+ | App | Status | Notes |
127
+ |---|---|---|
128
+ | `point-topic-mcp` | to migrate | swap local modules for this package; keep FastMCP middleware + tool→product map |
129
+ | `onto-app-new` | to integrate | issue #25; adds `ontology_app` product (role admin/viewer), JWT auth on all routes, per-query role settings |
130
+ | `upc_query_agent` | follow-up | already reads `pt_org_id`/`products[]` in `api/auth_handler.py` — adopt `claims.py` |
131
+ | `local-pricing-dashboard`, `european-fttp-forecasts` | don't break | read `products[].permissions.role` — the claim shape is pinned by tests |
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ uv sync # or use any venv with the deps
137
+ uv run pytest -q # 93 tests, no network/DB needed (all mocked)
138
+ uv run ruff check src tests
139
+ uv run pyright # requires pyright install; config in pyproject
140
+ ```
141
+
142
+ Distribution: private GitHub tag, pinned by consumers
143
+ (`pip install git+https://github.com/Point-Topic/point-topic-access@v0.1.0`).
@@ -0,0 +1,10 @@
1
+ pt_access/__init__.py,sha256=w7fm3sh4-t4L-9vh7GqVuENnBnUu6eR8RzypXlDK6Q4,618
2
+ pt_access/claims.py,sha256=ZZhA-GhiX_pZDZ5_VXOZj2qpANviRaga0LQmHN0DJDE,3616
3
+ pt_access/composer.py,sha256=vYRLLOAO_X8UZBWHuNyzjtP63B4mSDqsG3KNdAh12rA,11222
4
+ pt_access/contract.py,sha256=D47efpBtI7iGExQ5_mZw2a3t7J2TgYr_1NJvss_IKr8,2659
5
+ pt_access/jwt.py,sha256=viYBdNXLobAJoVDGDoYdh3U_rkpk_2bHWex4g9Amkj4,5810
6
+ pt_access/ontology-permission-contract.json,sha256=HD3I34xtbWidMv2n7Z97EvCwhN2p2welSd5vI1DsGlM,504
7
+ pt_access/provisioning.py,sha256=G56JehsVSXxhoLlb3y7RkoavlfiaTqkwIGbJDlyZB8g,16085
8
+ point_topic_access-0.1.0.dist-info/METADATA,sha256=TSBrVyXo9meGltuL7XqK8dysKkKxxIG6Jy-eeUBTvjI,7884
9
+ point_topic_access-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ point_topic_access-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
pt_access/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """pt-access: shared permission → ClickHouse access-control logic.
2
+
3
+ Centralises the permission pipeline used by Point Topic's data apps (MCP
4
+ server, ontology web app, query agent):
5
+
6
+ JWT claims ──► policy spec ──► SQL predicate ──► ClickHouse DDL
7
+ (what you (normalised (composer: (provisioning:
8
+ have) dataset + escaping, role, grants,
9
+ filter rows) containment, row policies,
10
+ OR-grouping) settings profile)
11
+
12
+ See README.md for the architecture and HANDOFF.md for the full context.
13
+ """
14
+
15
+ __version__ = "0.1.0"
pt_access/claims.py ADDED
@@ -0,0 +1,97 @@
1
+ """JWT claim helpers — the identity/entitlement surface every app shares.
2
+
3
+ Ported from point-topic-mcp auth/middleware.py (the FastMCP middleware itself
4
+ stays in the MCP; these pure functions are transport-agnostic and take claims
5
+ as an argument — no request context).
6
+
7
+ JWT shape (Auth0 post-login Action, live-verified): `pt_org_id`,
8
+ `pt_org_name`, `products[]` as `[{name, permissions}]` — or legacy flat
9
+ strings, handled defensively.
10
+
11
+ Claim contract (FREEZE — additive-only changes):
12
+ - `pt_org_id`, `pt_org_name` (NOT `org_id`/`org_name`: Auth0 reserved names)
13
+ - `products[]` entries: `{"name": "<product_id>", "permissions": {<scalar fields>}}`
14
+ — `permissions` holds SCALAR values only (e.g. `role`); list-valued geo
15
+ filters are NEVER stamped into tokens (token size / proxy header limits),
16
+ they are read fresh from sub-site MongoDB. Client apps
17
+ (local-pricing-dashboard, european-fttp-forecasts) read
18
+ `products[].permissions.role` — do not change this shape.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from pt_access.provisioning import ALL_DATA_SOURCES, POINT_TOPIC_ORG_ID
26
+
27
+
28
+ def get_org_id(claims: dict | None) -> str | None:
29
+ """Org id from claims (`pt_org_id`)."""
30
+ return (claims or {}).get("pt_org_id")
31
+
32
+
33
+ def get_held_products(claims: dict | None) -> list[str]:
34
+ """Product names from the JWT `products[]` claim.
35
+
36
+ Handles both the current shape (`[{name, permissions}]`) and legacy flat
37
+ strings (`["ukplus", "gbs"]`).
38
+ """
39
+ held: list[str] = []
40
+ for entry in (claims or {}).get("products") or []:
41
+ if isinstance(entry, dict):
42
+ name = entry.get("name")
43
+ if isinstance(name, str):
44
+ held.append(name)
45
+ elif isinstance(entry, str):
46
+ held.append(entry)
47
+ return held
48
+
49
+
50
+ def is_pt_admin(claims: dict | None) -> bool:
51
+ """True when the request is from the Point Topic org (internal admin)."""
52
+ return get_org_id(claims) == POINT_TOPIC_ORG_ID
53
+
54
+
55
+ def has_data_source_access(claims: dict | None) -> bool:
56
+ """True when the org holds any data-source product (or legacy `ontology`).
57
+
58
+ The ontology tool family is unlocked by ANY data-source product (sub-site
59
+ PR #110: one product per DATA_SOURCE). The retired `ontology` product name
60
+ is still accepted during the transition window so pre-cutover tokens keep
61
+ working.
62
+ """
63
+ held = set(get_held_products(claims))
64
+ return bool(held & set(ALL_DATA_SOURCES)) or "ontology" in held
65
+
66
+
67
+ def get_product_permission(claims: dict | None, product: str, field: str) -> Any:
68
+ """A scalar permission value for one product, e.g. the app role.
69
+
70
+ ``products[].permissions`` holds scalar values only (the Action stamps
71
+ non-object values; list-valued geo filters never reach the token).
72
+ Returns None when the product or field is absent.
73
+ """
74
+ for entry in (claims or {}).get("products") or []:
75
+ if isinstance(entry, dict) and entry.get("name") == product:
76
+ permissions = entry.get("permissions") or {}
77
+ if isinstance(permissions, dict):
78
+ return permissions.get(field)
79
+ return None
80
+ return None
81
+
82
+
83
+ def get_product_role(claims: dict | None, product: str) -> str | None:
84
+ """Convenience: the `role` permission of one product (e.g. admin/viewer)."""
85
+ role = get_product_permission(claims, product, "role")
86
+ return role if isinstance(role, str) else None
87
+
88
+
89
+ __all__ = [
90
+ "get_org_id",
91
+ "get_held_products",
92
+ "is_pt_admin",
93
+ "has_data_source_access",
94
+ "get_product_permission",
95
+ "get_product_role",
96
+ "POINT_TOPIC_ORG_ID",
97
+ ]
pt_access/composer.py ADDED
@@ -0,0 +1,258 @@
1
+ """Compose per-org geography filters (sql_filter) for ClickHouse row policies.
2
+
3
+ Ported from point-topic-mcp core/sql_filter_composer.py (issue #105, now
4
+ issue #25 of Point-Topic/onto-app-new). Turns the sub-site's structured
5
+ ontology permission config —
6
+ ``productPermissions.ontology.datasets = { <ds>: { filters: [{field, values[]}] } }``
7
+ — into the per-dataset disjunction consumed by provisioning.
8
+
9
+ The disjunction is deliberately per-dataset, each disjunct guarded by its own
10
+ DATA_SOURCE literal::
11
+
12
+ (DATA_SOURCE='upc' AND <geo>) OR (DATA_SOURCE='upc_takeup' AND <geo>)
13
+ OR (DATA_SOURCE='global_tariffs')
14
+
15
+ Never emit a bare ``DATA_SOURCE IN (...) AND (<geo>)`` blob — the geo
16
+ predicate would double-wrap and silently intersect datasets.
17
+
18
+ Within a dataset, multiple filter rows are **OR**-combined (union): each row
19
+ grants an additional allowed area. This matches the "add another filter"
20
+ grant model in the sub-site UI. Consequence: a broad row subsumes narrower
21
+ ones (a ``country``/``home_nation`` row plus LA/postcode rows → the whole
22
+ country matches), so the composer warns on that combination.
23
+
24
+ This module only builds SQL text. It never executes anything; the row policy
25
+ is the enforcement boundary (see infrastructure/provision_org_role.py).
26
+
27
+ Verified SQL shapes (live ClickHouse, 2026-08-03):
28
+ Country: COUNTRY IN ('United Kingdom')
29
+ LA: LOCATION_NAME IN (SELECT ANCESTOR FROM ontology.cto_containment_assertion
30
+ WHERE ANCESTOR_TYPE='postcode' AND DESCENDANT_TYPE='la_name'
31
+ AND DESCENDANT IN (...) AND VALID_TO IS NULL)
32
+ Home nation: LOCATION_NAME IN (SELECT ANCESTOR FROM ontology.cto_containment_assertion
33
+ WHERE ANCESTOR_TYPE='postcode' AND DESCENDANT_TYPE='home_nation'
34
+ AND DESCENDANT IN ('England') AND VALID_TO IS NULL)
35
+ Postcode: LOCATION_NAME IN ('AB1 2CD', 'DD1 4HN')
36
+ """
37
+
38
+ import re
39
+
40
+ # v1 geography dimensions (registry-driven in the sub-site config). Adding a
41
+ # dimension = add the constant, a compose branch, and a config entry.
42
+ FIELD_COUNTRY = "country"
43
+ FIELD_LA = "la"
44
+ FIELD_POSTCODE = "postcode"
45
+ FIELD_HOME_NATION = "home_nation"
46
+
47
+ GEO_FIELDS = (FIELD_COUNTRY, FIELD_LA, FIELD_POSTCODE, FIELD_HOME_NATION)
48
+
49
+ # ONS LA codes: one letter (E/W/S/N) + 8 digits, e.g. E09000001.
50
+ LA_CODE_RE = re.compile(r"^[EWNS][0-9]{8}$")
51
+
52
+ # The containment table that resolves postcode → LA (name or code). Fully
53
+ # qualified, matching the live-verified policy shape.
54
+ CONTAINMENT_TABLE = "ontology.cto_containment_assertion"
55
+
56
+ # Dataset ↔ dimension compatibility, warn level. UK-geography fields (country,
57
+ # home_nation, la, postcode) on these datasets return 0 rows or are
58
+ # misleading: pcd_checker / edgap_ekg are European (no UK postcode
59
+ # containment), ebm is NUTS3-scoped, gbs and global_tariffs are mostly
60
+ # non-UK. Any dataset not listed is compatible with all geography fields.
61
+ # Only "warn" exists today; kept as a table so stricter levels can be added
62
+ # without code change.
63
+ DATASET_FIELD_COMPATIBILITY: dict[str, dict[str, str]] = {
64
+ "pcd_checker": {
65
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
66
+ },
67
+ "edgap_ekg": {
68
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
69
+ },
70
+ "ebm": {
71
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
72
+ },
73
+ "gbs": {
74
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
75
+ },
76
+ "global_tariffs": {
77
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
78
+ },
79
+ }
80
+
81
+
82
+ def escape_sql_string(value: str) -> str:
83
+ """Escape a value as a ClickHouse single-quoted string literal.
84
+
85
+ Every literal ``'`` becomes ``''``. Required for correctness (King's Lynn)
86
+ and security: the row policy is the enforcement boundary, so a quote in an
87
+ admin-entered value must never break out of the literal (self-escalation).
88
+ """
89
+ return "'" + value.replace("'", "''") + "'"
90
+
91
+
92
+ def _clean_values(values: list[str | None] | None) -> list[str]:
93
+ """Strip whitespace, drop empties and duplicates, preserve first-seen order.
94
+
95
+ Non-string entries (e.g. None from a pasted list) are skipped deliberately.
96
+ """
97
+ cleaned: list[str] = []
98
+ seen: set[str] = set()
99
+ for value in values or []:
100
+ if not isinstance(value, str):
101
+ continue
102
+ value = value.strip()
103
+ if not value or value in seen:
104
+ continue
105
+ seen.add(value)
106
+ cleaned.append(value)
107
+ return cleaned
108
+
109
+
110
+ def _in_list(values: list[str]) -> str:
111
+ """``('a', 'b')`` from a value list, each literal escaped."""
112
+ return ", ".join(escape_sql_string(v) for v in values)
113
+
114
+
115
+ def compose_geo_predicate(field: str, values: list[str | None] | None) -> tuple[str, list[str]]:
116
+ """Build the SQL predicate for one filter row.
117
+
118
+ Args:
119
+ field: One of the GEO_FIELDS.
120
+ values: Raw admin-entered values (list/CSV pasted). Cleaned internally.
121
+
122
+ Returns:
123
+ (predicate, warnings). Empty predicate with a warning when the row has
124
+ no usable values (filter skipped).
125
+
126
+ Raises:
127
+ ValueError: Unknown field. Callers with config-driven input should
128
+ pre-check against GEO_FIELDS (compose_sql_filter does).
129
+ """
130
+ cleaned = _clean_values(values)
131
+ if not cleaned:
132
+ return "", [f"Filter field '{field}' has no usable values — skipped"]
133
+
134
+ if field == FIELD_COUNTRY:
135
+ return f"COUNTRY IN ({_in_list(cleaned)})", []
136
+
137
+ if field == FIELD_POSTCODE:
138
+ return f"LOCATION_NAME IN ({_in_list(cleaned)})", []
139
+
140
+ if field == FIELD_LA:
141
+ codes = [v for v in cleaned if LA_CODE_RE.match(v)]
142
+ names = [v for v in cleaned if not LA_CODE_RE.match(v)]
143
+ preds: list[str] = []
144
+ for dtype, subset in (("la_code", codes), ("la_name", names)):
145
+ if subset:
146
+ preds.append(
147
+ f"LOCATION_NAME IN (SELECT ANCESTOR FROM {CONTAINMENT_TABLE} "
148
+ f"WHERE ANCESTOR_TYPE='postcode' AND DESCENDANT_TYPE='{dtype}' "
149
+ f"AND DESCENDANT IN ({_in_list(subset)}) AND VALID_TO IS NULL)"
150
+ )
151
+ if len(preds) == 1:
152
+ return preds[0], []
153
+ # Mixed codes + names in one paste: a postcode is inside an LA that is
154
+ # listed by code OR by name — combine with OR.
155
+ return "(" + " OR ".join(preds) + ")", []
156
+
157
+ if field == FIELD_HOME_NATION:
158
+ # UK nations (England/Wales/Scotland/NI) are a containment level, NOT
159
+ # the COUNTRY column — COUNTRY is 'United Kingdom' for every UK row.
160
+ return (
161
+ f"LOCATION_NAME IN (SELECT ANCESTOR FROM {CONTAINMENT_TABLE} "
162
+ f"WHERE ANCESTOR_TYPE='postcode' AND DESCENDANT_TYPE='home_nation' "
163
+ f"AND DESCENDANT IN ({_in_list(cleaned)}) AND VALID_TO IS NULL)",
164
+ [],
165
+ )
166
+
167
+ raise ValueError(f"Unknown geography field: {field}")
168
+
169
+
170
+ def compose_sql_filter(datasets: dict[str, dict] | None) -> tuple[str, list[str]]:
171
+ """Compose the per-dataset disjunction for an org's row policy.
172
+
173
+ Args:
174
+ datasets: Sub-site config, ``{ <data_source>: {filters: [{field, values[]}] } }``.
175
+ A dataset with **no filter rows** yields a bare ``(DATA_SOURCE='<ds>')``
176
+ disjunct — checked-but-unscoped, the legitimate "whole dataset" case.
177
+ A dataset whose filter rows produce no usable geography predicate
178
+ (empty values / unknown field) is **excluded** (fail closed): a
179
+ slipped empty filter must not silently open the whole dataset.
180
+
181
+ Returns:
182
+ (sql, warnings). Empty sql (no warnings) when no datasets are
183
+ configured — the caller treats that as "no policy" (deny).
184
+ """
185
+ disjuncts: list[str] = []
186
+ warnings: list[str] = []
187
+
188
+ for ds, config in (datasets or {}).items():
189
+ if not isinstance(ds, str) or not ds.strip():
190
+ warnings.append("Ignored dataset entry with an empty name")
191
+ continue
192
+
193
+ filters = (config or {}).get("filters") or []
194
+ had_filter_rows = bool(filters)
195
+ preds: list[str] = []
196
+ for f in filters:
197
+ field = (f or {}).get("field")
198
+ if field not in GEO_FIELDS:
199
+ warnings.append(f"Dataset '{ds}': unknown filter field '{field}' — ignored")
200
+ continue
201
+ compat = DATASET_FIELD_COMPATIBILITY.get(ds, {}).get(field)
202
+ if compat == "warn":
203
+ warnings.append(
204
+ f"Dataset '{ds}': '{field}' filter may not match this dataset's "
205
+ f"rows (UK geography on '{ds}' can return 0 rows or is misleading)"
206
+ )
207
+ pred, pred_warnings = compose_geo_predicate(field, (f or {}).get("values"))
208
+ warnings.extend(pred_warnings)
209
+ if pred:
210
+ preds.append(pred)
211
+
212
+ if preds:
213
+ # A broad row (whole country / home nation) subsumes narrower LA
214
+ # and postcode rows when OR-combined — warn, don't drop silently.
215
+ filter_fields = {
216
+ (f or {}).get("field") for f in filters if (f or {}).get("field") in GEO_FIELDS
217
+ }
218
+ if len(preds) > 1 and {FIELD_COUNTRY, FIELD_HOME_NATION} & filter_fields:
219
+ warnings.append(
220
+ f"Dataset '{ds}': a 'country'/'home_nation' filter OR-combined with "
221
+ f"other filters matches every row of that country — the LA/postcode "
222
+ f"rows are subsumed"
223
+ )
224
+ joined = " OR ".join(f"({p})" for p in preds)
225
+ # Multiple predicates MUST be grouped: without the wrapping parens,
226
+ # SQL precedence turns `(DATA_SOURCE='upc' AND (la) OR (postcode))`
227
+ # into `((DATA_SOURCE='upc' AND (la)) OR (postcode))` — the
228
+ # postcode predicate escapes the DATA_SOURCE guard and leaks rows
229
+ # of other datasets at that postcode (found live on prod, 2026-08-05).
230
+ geo = f"({joined})" if len(preds) > 1 else joined
231
+ disjuncts.append(f"(DATA_SOURCE={escape_sql_string(ds)} AND {geo})")
232
+ elif had_filter_rows:
233
+ # The admin engaged the filter UI for this dataset but no usable
234
+ # geography predicate came out of it. Excluding the dataset is the
235
+ # fail-closed choice — a slipped empty filter must not open the
236
+ # whole dataset ("no filter" is expressed by having NO filter rows).
237
+ warnings.append(
238
+ f"Dataset '{ds}': filter rows present but none produced a usable "
239
+ f"geography predicate — dataset excluded (fail closed)"
240
+ )
241
+ else:
242
+ # Checked with no filter rows → the whole dataset, no geography.
243
+ disjuncts.append(f"(DATA_SOURCE={escape_sql_string(ds)})")
244
+
245
+ return " OR ".join(disjuncts), warnings
246
+
247
+
248
+ __all__ = [
249
+ "compose_sql_filter",
250
+ "compose_geo_predicate",
251
+ "escape_sql_string",
252
+ "GEO_FIELDS",
253
+ "FIELD_COUNTRY",
254
+ "FIELD_LA",
255
+ "FIELD_POSTCODE",
256
+ "FIELD_HOME_NATION",
257
+ "DATASET_FIELD_COMPATIBILITY",
258
+ ]
pt_access/contract.py ADDED
@@ -0,0 +1,70 @@
1
+ """Contract fixture loading + validation — the single evolvable artifact.
2
+
3
+ The fixture (`src/pt_access/ontology-permission-contract.json`) mirrors the
4
+ sub-site's copy (`apps/ui/src/organisations/config/
5
+ ontology-permission-contract.json`) — the sub-site UI renders exactly the
6
+ fields/products declared here, and the package's composer/provisioning
7
+ interpret them. If the two drift, the sub-site UI can save permission values
8
+ the composer doesn't understand — and an org silently loses access on its
9
+ next login (the composer fail-closes on unknown fields).
10
+
11
+ The package CI runs a drift-guard workflow that fetches the sub-site copy via
12
+ `gh api` and fails on mismatch. When changing either side, update BOTH
13
+ fixtures and both test suites.
14
+
15
+ Contract evolution rule: a new data source = a `dataSources` entry; a new
16
+ filter field = a `fields` entry (+ `fieldProducts` + `compatibility` entries
17
+ if applicable) — both are DATA changes, never code changes (the declarative
18
+ refactor of the composer consumes this fixture directly).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from importlib import resources
25
+
26
+ from pt_access import composer, provisioning
27
+
28
+
29
+ def load_contract() -> dict:
30
+ """Load the packaged contract fixture."""
31
+ fixture = resources.files("pt_access").joinpath("ontology-permission-contract.json")
32
+ return json.loads(fixture.read_text(encoding="utf-8"))
33
+
34
+
35
+ def validate_contract() -> list[str]:
36
+ """Check the code's constants against the fixture.
37
+
38
+ Returns a list of mismatch descriptions; empty list = consistent. Used by
39
+ the drift-guard tests and CI.
40
+ """
41
+ mismatches: list[str] = []
42
+ contract = load_contract()
43
+
44
+ code_ds = set(provisioning.ALL_DATA_SOURCES)
45
+ contract_ds = set(contract.get("dataSources", []))
46
+ if code_ds != contract_ds:
47
+ mismatches.append(
48
+ f"dataSources mismatch: code={sorted(code_ds)} contract={sorted(contract_ds)}"
49
+ )
50
+
51
+ code_fields = set(composer.GEO_FIELDS)
52
+ contract_fields = set(contract.get("fields", []))
53
+ if code_fields != contract_fields:
54
+ mismatches.append(
55
+ f"fields mismatch: code={sorted(code_fields)} contract={sorted(contract_fields)}"
56
+ )
57
+
58
+ for ds, fields in (contract.get("fieldProducts") or {}).items():
59
+ if ds not in contract_ds:
60
+ mismatches.append(f"fieldProducts references unknown data source '{ds}'")
61
+ unknown = set(fields) - contract_fields
62
+ if unknown:
63
+ mismatches.append(
64
+ f"fieldProducts['{ds}'] references unknown fields {sorted(unknown)}"
65
+ )
66
+
67
+ return mismatches
68
+
69
+
70
+ __all__ = ["load_contract", "validate_contract"]
pt_access/jwt.py ADDED
@@ -0,0 +1,153 @@
1
+ """Auth0 JWT verification via JWKS — shared by every Point Topic app backend.
2
+
3
+ Ported from point-topic-mcp auth/auth0_helpers.py (the FastMCP
4
+ TokenVerifier adapter stays in the MCP; this is the pure verifier).
5
+
6
+ Usage (FastAPI dependency or any async path)::
7
+
8
+ verifier = Auth0TokenVerifier(auth0_domain="point-topic.eu.auth0.com",
9
+ audience="<client_id>")
10
+ claims = await verifier.verify_token(bearer_token) # None when invalid
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import time
17
+
18
+ import httpx
19
+ from jose import JWTError, jwk, jwt
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Auth0TokenVerifier:
25
+ """JWT token verifier using the Auth0 JWKS endpoint.
26
+
27
+ Validates tokens issued by the shared Auth0 tenant: signature (RS256),
28
+ issuer, expiry and audience. The JWKS is cached with a TTL so key
29
+ rotation doesn't lock users out until restart.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ auth0_domain: str,
35
+ audience: str | list[str],
36
+ jwks_url: str | None = None,
37
+ ):
38
+ """Args:
39
+ auth0_domain: Auth0 tenant domain (e.g. 'point-topic.eu.auth0.com').
40
+ audience: Accepted audience(s). May be a single string, a
41
+ comma-separated string, or a list. Sub-site tokens carry
42
+ 'http://ss-api'; tokens minted for the MCP resource (RFC 9728)
43
+ carry the resource URL (e.g. 'https://mcp.point-topic.com/mcp');
44
+ ID tokens from express-openid-connect carry the app's
45
+ client_id.
46
+ jwks_url: Optional JWKS URL. Defaults to
47
+ https://{domain}/.well-known/jwks.json
48
+ """
49
+ self.auth0_domain = auth0_domain
50
+ if isinstance(audience, str):
51
+ self.audiences = [a.strip() for a in audience.split(",") if a.strip()]
52
+ else:
53
+ self.audiences = list(audience or [])
54
+ self.jwks_url = jwks_url or f"https://{auth0_domain}/.well-known/jwks.json"
55
+ self._jwks: dict | None = None
56
+ self._jwks_fetched_at: float | None = None
57
+ # Refresh the cached JWKS after this long (seconds) so Auth0 key
58
+ # rotation doesn't lock users out until the process restarts.
59
+ self._jwks_ttl = 3600
60
+
61
+ async def _get_jwks(self) -> dict | None:
62
+ """Fetch JWKS from Auth0, cached with a TTL for key rotation."""
63
+ now = time.time()
64
+ if self._jwks is None or self._jwks_fetched_at is None or now - self._jwks_fetched_at > self._jwks_ttl:
65
+ async with httpx.AsyncClient() as client:
66
+ response = await client.get(self.jwks_url)
67
+ response.raise_for_status()
68
+ self._jwks = response.json()
69
+ self._jwks_fetched_at = now
70
+ return self._jwks
71
+
72
+ async def verify_token(self, token: str) -> dict | None:
73
+ """Verify and decode a JWT token.
74
+
75
+ Returns:
76
+ Decoded token claims dict if valid, None otherwise.
77
+ """
78
+ try:
79
+ jwks = await self._get_jwks()
80
+ if jwks is None:
81
+ logger.warning("Failed to fetch JWKS")
82
+ return None
83
+ signing_key = self._get_signing_key(token, jwks)
84
+
85
+ if not signing_key:
86
+ logger.warning("No signing key found for token")
87
+ return None
88
+
89
+ # Construct a proper key object from the JWK dict
90
+ try:
91
+ key = jwk.construct(signing_key)
92
+ except Exception as e:
93
+ logger.warning(f"Failed to construct signing key: {e}")
94
+ return None
95
+
96
+ # Decode and verify the token. Verify signature/issuer/exp via
97
+ # python-jose but defer audience to the manual check below so any
98
+ # of the accepted audiences passes (python-jose takes a single
99
+ # audience string only).
100
+ payload = jwt.decode(
101
+ token,
102
+ key,
103
+ issuer=f"https://{self.auth0_domain}/",
104
+ options={"verify_aud": False},
105
+ )
106
+
107
+ # Audience must be one of the accepted values (aud may be a
108
+ # single string or a list in the token).
109
+ aud_claims = payload.get("aud")
110
+ if isinstance(aud_claims, str):
111
+ aud_claims = [aud_claims]
112
+ if not aud_claims or not any(a in self.audiences for a in aud_claims):
113
+ logger.warning(
114
+ f"Token audience {payload.get('aud')} not accepted "
115
+ f"(expected one of {self.audiences})"
116
+ )
117
+ return None
118
+
119
+ return payload
120
+
121
+ except jwt.ExpiredSignatureError: # type: ignore
122
+ logger.warning("Token has expired")
123
+ except jwt.JWTClaimsError as e: # type: ignore
124
+ logger.warning(f"Token claims error: {e}")
125
+ except JWTError as e:
126
+ logger.warning(f"JWT validation error: {e}")
127
+ except Exception as e:
128
+ logger.error(f"Unexpected error verifying token: {e}")
129
+
130
+ return None
131
+
132
+ def _get_signing_key(self, token: str, jwks: dict) -> dict | None:
133
+ """Extract the signing key from JWKS matching the token's kid."""
134
+ try:
135
+ headers = jwt.get_unverified_header(token)
136
+ kid = headers.get("kid")
137
+ if not kid:
138
+ return None
139
+
140
+ for key in jwks.get("keys", []):
141
+ if key.get("kid") == kid:
142
+ return key
143
+ except JWTError as e:
144
+ logger.warning(f"Failed to extract token header: {e}")
145
+
146
+ return None
147
+
148
+ async def get_token_claims(self, token: str) -> dict | None:
149
+ """Get claims from a token (wrapper around verify_token)."""
150
+ return await self.verify_token(token)
151
+
152
+
153
+ __all__ = ["Auth0TokenVerifier"]
@@ -0,0 +1,23 @@
1
+ {
2
+ "dataSources": [
3
+ "upc",
4
+ "upc_takeup",
5
+ "upc_forecasts",
6
+ "global_tariffs",
7
+ "gbs",
8
+ "bddi",
9
+ "arpu",
10
+ "ebm",
11
+ "pcd_checker",
12
+ "roadworks",
13
+ "ofcom_tar",
14
+ "edgap_ekg"
15
+ ],
16
+ "fields": ["country", "home_nation", "la", "postcode"],
17
+ "fieldProducts": {
18
+ "upc": ["home_nation", "la", "postcode"],
19
+ "upc_takeup": ["home_nation", "la", "postcode"],
20
+ "upc_forecasts": ["home_nation", "la", "postcode"],
21
+ "roadworks": ["home_nation", "la", "postcode"]
22
+ }
23
+ }
@@ -0,0 +1,381 @@
1
+ """Per-org ClickHouse provisioning on login.
2
+
3
+ Ported from point-topic-mcp core/org_provisioning.py (issue #104/#105, now
4
+ issue #25 of Point-Topic/onto-app-new). Every successful login from an org
5
+ unconditionally re-provisions that org's ClickHouse role + RESTRICTIVE row
6
+ policy from the sub-site's *fresh* config — no drift check, no stored state
7
+ (full re-provision measures ~320ms on prod). Converges to whatever the
8
+ sub-site currently says: a UI change is applied on the org's next login, a
9
+ revoked org is narrowed on its next login, and manual misconfiguration
10
+ self-heals.
11
+
12
+ Mechanism (verified on prod 2026-08-03):
13
+ CREATE ROLE IF NOT EXISTS org_<id>
14
+ GRANT SELECT ON ontology.* TO org_<id>
15
+ GRANT org_<id> TO mcp_service (org with config)
16
+ REVOKE org_<id> FROM mcp_service (org with no config → deny)
17
+ DROP ROW POLICY IF EXISTS + CREATE ROW POLICY ... AS RESTRICTIVE TO org_<id>
18
+ on every table in MEASUREMENT_TABLES
19
+ CREATE SETTINGS PROFILE IF NOT EXISTS mcp_scoped_org_prewhere
20
+ SETTINGS optimize_move_to_prewhere = 0
21
+ ALTER USER mcp_service ADD PROFILES 'mcp_scoped_org_prewhere'
22
+ (attached to the service user, not org roles — see PREWHERE note)
23
+
24
+ The row policy condition is the composer's per-dataset disjunction (see
25
+ core/sql_filter_composer.py). An org with no datasets config gets a deny-all
26
+ policy (USING 0) and its role is revoked from the service user — fail closed.
27
+
28
+ PREWHERE fix (verified live 2026-08-05, ClickHouse GH #85222): a row policy
29
+ whose condition references non-sorting-key columns (LOCATION_NAME via the
30
+ containment subquery, COUNTRY) evaluates to 0 rows because ClickHouse applies
31
+ policies inside PREWHERE, where only sorting-key columns are read — the
32
+ missing columns default to empty and every row is filtered. Disabling
33
+ PREWHERE (optimize_move_to_prewhere=0) applies the policy where all columns
34
+ are available.
35
+
36
+ Where the setting lives (live-tested): per-query SETTINGS is blocked for
37
+ mcp_service (readonly=1 → error 164) and a profile attached to an org role is
38
+ NOT applied when the role is activated under readonly. The one working lever
39
+ is a settings profile attached to the SERVICE USER: it applies at connection
40
+ time, before the readonly check. Trade-off: it applies to every query through
41
+ mcp_service, so the Point Topic org's full-scan queries also run with PREWHERE
42
+ disabled (correctness for all orgs wins over that optimization; the sorting
43
+ key still prunes via the primary index).
44
+
45
+ Credentials: ONE dedicated provisioning credential, never shared with
46
+ `mcp_service` (which stays readonly=1 / allow_ddl=0):
47
+ CLICKHOUSE_PROVISIONING_USER / CLICKHOUSE_PROVISIONING_PASSWORD
48
+ Host/port/database default from the CLICKHOUSE_* vars (same endpoint,
49
+ different user). The provisioning user needs ACCESS MANAGEMENT to create
50
+ roles/policies/grants; it can bypass row policies by design — it is stored in
51
+ secrets and never exposed.
52
+
53
+ The org's geography values are read from the sub-site MongoDB (fresh per
54
+ login): flat per-product permissions —
55
+ ``organisations.productPermissions.<data_source> = {field: string[]}`` — one
56
+ entry per data-source product the org holds (sub-site PR #110 contract, see
57
+ ontology-permission-contract.json). The set of data-source products the org
58
+ holds comes from the JWT products[] (the same list the tool gate uses);
59
+ absent permission values for a held product mean full access to that
60
+ product's data (omit-empty contract). Requires SUB_SITE_MONGODB_URI (read-only
61
+ user).
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import logging
67
+ import os
68
+ import threading
69
+ from typing import Any
70
+ from urllib.parse import urlparse
71
+
72
+ from bson import ObjectId
73
+ from pymongo import MongoClient
74
+
75
+ from pt_access.composer import (
76
+ compose_sql_filter,
77
+ escape_sql_string,
78
+ )
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+ # Serialize the DDL across concurrent logins: two initializes for the same
83
+ # org race on DROP ROW POLICY IF EXISTS + CREATE ROW POLICY (no CREATE OR
84
+ # REPLACE exists), and the loser fails with ACCESS_ENTITY_ALREADY_EXISTS
85
+ # (code 493, seen live on prod 2026-08-06). A global lock is plenty —
86
+ # provisioning is ~320ms and logins are rare; concurrent logins just queue.
87
+ _PROVISION_LOCK = threading.Lock()
88
+
89
+
90
+ # Measurement-bearing tables needing row policies (extend via get_onto_schema
91
+ # if more appear). Reference tables stay open via the role's ontology.* grant.
92
+ MEASUREMENT_TABLES = [
93
+ "ontology.cto_measurement_record",
94
+ "ontology.cto_measurement_record_staging",
95
+ ]
96
+
97
+ # The 12 live DATA_SOURCEs (verified on prod 2026-08-03). The Point Topic
98
+ # org's full-access role covers exactly these — add new sources here so the
99
+ # admin role keeps seeing them.
100
+ ALL_DATA_SOURCES = [
101
+ "upc",
102
+ "upc_takeup",
103
+ "upc_forecasts",
104
+ "global_tariffs",
105
+ "gbs",
106
+ "bddi",
107
+ "arpu",
108
+ "ebm",
109
+ "pcd_checker",
110
+ "roadworks",
111
+ "ofcom_tar",
112
+ "edgap_ekg",
113
+ ]
114
+
115
+ # Point Topic org — internal admin, full access (env-overridable; mirrors
116
+ # POINT_TOPIC_ORG_ID in auth/middleware.py).
117
+ POINT_TOPIC_ORG_ID = os.getenv("POINT_TOPIC_ORG_ID", "65dc868c4f33e6b7d256fab4")
118
+
119
+ # The least-privilege service user that activates org roles per request.
120
+ GRANT_TO_USER = os.getenv("MCP_CLICKHOUSE_GRANT_TO_USER", "mcp_service")
121
+
122
+ # Deny-all row policy condition: matches no rows (documented ClickHouse
123
+ # pattern). Used when an org has no datasets config — fail closed until an
124
+ # admin configures geography in the sub-site.
125
+ DENY_USING = "0"
126
+
127
+ # Settings profile that disables PREWHERE for scoped orgs (see the module
128
+ # docstring: row policies on non-sorting-key columns return 0 rows under
129
+ # PREWHERE, GH #85222). Attached to the SERVICE USER (GRANT_TO_USER) — a
130
+ # role-attached profile is not applied under readonly=1, but a user-attached
131
+ # one applies at connection time. Applies to all queries through the service
132
+ # user (including the Point Topic org).
133
+ SCOPED_ORG_PREWHERE_PROFILE = "mcp_scoped_org_prewhere"
134
+
135
+ # Short timeouts on the sub-site read so a DB outage never stalls a login.
136
+ # Values are Any so Pyright resolves the **kwargs against MongoClient's
137
+ # overloads (typed ints mis-resolve against the TypeRegistry parameter).
138
+ _SUB_SITE_CLIENT_OPTS: dict[str, Any] = {
139
+ "connectTimeoutMS": 3000,
140
+ "serverSelectionTimeoutMS": 5000,
141
+ }
142
+
143
+
144
+ def provisioning_configured() -> bool:
145
+ """Whether the dedicated provisioning credential is present in env."""
146
+ return bool(os.getenv("CLICKHOUSE_PROVISIONING_USER") and os.getenv("CLICKHOUSE_PROVISIONING_PASSWORD"))
147
+
148
+
149
+ def provisioning_client():
150
+ """ClickHouse client bound to the dedicated provisioning credential.
151
+
152
+ Returns None when the credential is not configured — provisioning is
153
+ disabled and orgs fail closed on the engine (their roles are never
154
+ granted to the service user).
155
+ """
156
+ if not provisioning_configured():
157
+ return None
158
+ import clickhouse_connect
159
+
160
+ port = int(os.environ.get("CLICKHOUSE_PORT", "443"))
161
+ return clickhouse_connect.get_client(
162
+ host=os.environ["CLICKHOUSE_HOST"],
163
+ port=port,
164
+ username=os.environ["CLICKHOUSE_PROVISIONING_USER"],
165
+ password=os.environ["CLICKHOUSE_PROVISIONING_PASSWORD"],
166
+ database=os.environ.get("CLICKHOUSE_DATABASE", "ontology"),
167
+ secure=port == 443,
168
+ )
169
+
170
+
171
+ def fetch_org_datasets(org_id: str) -> dict[str, dict[str, list[str]]]:
172
+ """Read the org's flat per-product permission values from the sub-site.
173
+
174
+ Returns ``{ <data_source>: {field: [values]} }`` for every data-source
175
+ product present in ``productPermissions`` (omit-empty contract: a held
176
+ product with no stored values is absent — the caller treats absence as
177
+ full access to that product's data). A missing org document is treated the
178
+ same as "no stored values": the JWT products[] is the access gate and is a
179
+ ≤24h snapshot, so a deleted org's tokens keep their last-stamped access
180
+ until expiry.
181
+
182
+ Raises RuntimeError when SUB_SITE_MONGODB_URI is not configured.
183
+ """
184
+ uri = os.getenv("SUB_SITE_MONGODB_URI", "")
185
+ if not uri:
186
+ raise RuntimeError("SUB_SITE_MONGODB_URI is not set")
187
+ client = MongoClient(uri, **_SUB_SITE_CLIENT_OPTS)
188
+ try:
189
+ db_name = urlparse(uri).path.lstrip("/") or "sub-site"
190
+ org = client[db_name].organisations.find_one(
191
+ {"_id": ObjectId(org_id)}, {"productPermissions": 1}
192
+ )
193
+ finally:
194
+ client.close()
195
+ if not org:
196
+ logger.warning("Org %s not found in sub-site MongoDB", org_id)
197
+ return {}
198
+ # Annotated so Pyright resolves the value shape; productPermissions is
199
+ # free-form in Mongo (e.g. query_agent holds a number), and only
200
+ # data-source entries are selected below. Subscript (not .get) for the
201
+ # value so the guard above guarantees presence; a malformed truthy value
202
+ # still flows through and fails closed downstream in _filters_from_flat.
203
+ perms: dict[str, Any] = org.get("productPermissions") or {}
204
+ return {ds: perms[ds] for ds in ALL_DATA_SOURCES if perms.get(ds)}
205
+
206
+
207
+ def _filters_from_flat(flat: dict[str, list[str]] | None) -> list[dict]:
208
+ """Flat ``{field: [values]}`` → composer filter rows (omit-empty).
209
+
210
+ Only fields with at least one value become rows; anything else is absent
211
+ and means "no predicate" for that field (the omit-empty contract).
212
+
213
+ Unknown fields are deliberately NOT dropped here: they flow through to
214
+ the composer, which warns and excludes the dataset (fail closed).
215
+ Dropping them here would silently open the whole dataset (found live
216
+ 2026-08-06 — a field the MCP doesn't know yet must never mean full
217
+ access).
218
+ """
219
+ return [
220
+ {"field": field, "values": values}
221
+ for field, values in (flat or {}).items()
222
+ if values
223
+ ]
224
+
225
+
226
+ def resolve_org_config(org_id: str, held_products: list[str] | None = None) -> tuple[list[str], str, list[str]]:
227
+ """Resolve (data_sources, sql_filter, warnings) for an org's row policy.
228
+
229
+ Point Topic org → full access (all data sources, no geography filter).
230
+ Other orgs → the data-source products the org holds (JWT products[]),
231
+ each composed from its sub-site permission values. A held product with no
232
+ stored values becomes a bare ``(DATA_SOURCE='<ds>')`` disjunct — full
233
+ access to that product, per the omit-empty contract. No held data-source
234
+ products → deny (empty data_sources → REVOKE + USING 0).
235
+ """
236
+ if org_id == POINT_TOPIC_ORG_ID:
237
+ return ALL_DATA_SOURCES, "", []
238
+ held = set(held_products or [])
239
+ data_sources = [ds for ds in ALL_DATA_SOURCES if ds in held]
240
+ if not data_sources:
241
+ return [], "", []
242
+ flat = fetch_org_datasets(org_id)
243
+ composer_input = {
244
+ ds: {"filters": _filters_from_flat(flat.get(ds))} if flat.get(ds) else {}
245
+ for ds in data_sources
246
+ }
247
+ sql, warnings = compose_sql_filter(composer_input)
248
+ if not sql and warnings:
249
+ # The composer excluded EVERY held dataset (unknown/invalid filter
250
+ # fields). Deny — an empty composed filter must never fall back to
251
+ # the full data-source list (provisioning's "no geography filter"
252
+ # signal). Fail closed, loudly: the caller logs each warning.
253
+ logger.warning(
254
+ "Org %s: all held data-source products excluded by filter config — denying",
255
+ org_id,
256
+ )
257
+ return [], "", warnings
258
+ return data_sources, sql, warnings
259
+
260
+
261
+ def _sources_in_list(data_sources: list[str]) -> str:
262
+ return "DATA_SOURCE IN (" + ", ".join(escape_sql_string(s) for s in data_sources) + ")"
263
+
264
+
265
+ def provision_org_role(client, org_id: str, data_sources: list[str], sql_filter: str, grant_to: str) -> dict:
266
+ """Idempotent DDL: org role + grants + RESTRICTIVE policy on both tables.
267
+
268
+ Args:
269
+ client: ClickHouse client with the provisioning credential (injected
270
+ for testability).
271
+ org_id: Sub-site organisation id (Mongo ObjectId hex).
272
+ data_sources: DATA_SOURCE allowlist; empty means deny.
273
+ sql_filter: Composed per-dataset disjunction ("" = no geography
274
+ scoping, used for the full-access role).
275
+ grant_to: Service user the org role is granted to (revoked on deny).
276
+
277
+ Returns:
278
+ Summary dict (role, granted_to, using, tables).
279
+ """
280
+ role = f"org_{org_id}"
281
+ client.command(f"CREATE ROLE IF NOT EXISTS {role}")
282
+ client.command(f"GRANT SELECT ON ontology.* TO {role}")
283
+
284
+ # Global, idempotent, self-healing (like CREATE ROLE IF NOT EXISTS).
285
+ client.command(
286
+ f"CREATE SETTINGS PROFILE IF NOT EXISTS {SCOPED_ORG_PREWHERE_PROFILE} "
287
+ "SETTINGS optimize_move_to_prewhere = 0"
288
+ )
289
+ # Attach the profile to the SERVICE USER, not the org role: mcp_service
290
+ # is readonly=1, which blocks per-query SETTINGS (error 164) and does not
291
+ # apply role-attached profiles when the role is activated. A user-attached
292
+ # profile applies at connection time. ALTER USER ... ADD PROFILES is
293
+ # idempotent, so this converges on every login.
294
+ client.command(
295
+ f"ALTER USER {grant_to} ADD PROFILES '{SCOPED_ORG_PREWHERE_PROFILE}'"
296
+ )
297
+
298
+ if data_sources:
299
+ using = sql_filter if sql_filter else _sources_in_list(data_sources)
300
+ # REVOKE is safe unconditionally (ClickHouse ignores missing grants),
301
+ # so deny -> revoke converges even if a previous login granted.
302
+ client.command(f"GRANT {role} TO {grant_to}")
303
+ granted_to = grant_to
304
+ else:
305
+ using = DENY_USING
306
+ client.command(f"REVOKE {role} FROM {grant_to}")
307
+ granted_to = None
308
+
309
+ for table in MEASUREMENT_TABLES:
310
+ # No CREATE OR REPLACE for row policies; drop + create is idempotent.
311
+ client.command(f"DROP ROW POLICY IF EXISTS {role}_policy ON {table}")
312
+ client.command(
313
+ f"CREATE ROW POLICY {role}_policy ON {table} "
314
+ f"FOR SELECT USING {using} AS RESTRICTIVE TO {role}"
315
+ )
316
+
317
+ return {
318
+ "role": role,
319
+ "granted_to": granted_to,
320
+ "using": using,
321
+ "tables": list(MEASUREMENT_TABLES),
322
+ "prewhere_profile": SCOPED_ORG_PREWHERE_PROFILE,
323
+ }
324
+
325
+
326
+ def provision_org_on_login(org_id: str, held_products: list[str] | None = None) -> dict:
327
+ """Fetch fresh config, compose, and re-provision an org. Returns summary.
328
+
329
+ held_products: the org's JWT products[] (used to decide which
330
+ data-source products the org holds).
331
+ """
332
+ data_sources, sql_filter, warnings = resolve_org_config(org_id, held_products)
333
+ client = provisioning_client()
334
+ if client is None:
335
+ raise RuntimeError(
336
+ "Provisioning disabled: CLICKHOUSE_PROVISIONING_USER/PASSWORD not set"
337
+ )
338
+ try:
339
+ with _PROVISION_LOCK:
340
+ summary = provision_org_role(client, org_id, data_sources, sql_filter, GRANT_TO_USER)
341
+ finally:
342
+ client.close()
343
+ summary["warnings"] = warnings
344
+ # Loud fail-closed: unknown/invalid filter fields are a dev-time config
345
+ # error (MCP + sub-site shipped out of lockstep). Log every warning with
346
+ # the org id and full text, and an ERROR when the org is denied because
347
+ # of them — never swallow this into a quiet "0 rows".
348
+ for w in warnings:
349
+ logger.warning("Org %s filter warning: %s", org_id, w)
350
+ if not data_sources and warnings:
351
+ logger.error(
352
+ "Org %s DENIED on login: all held data-source products excluded "
353
+ "by filter config (unknown/invalid fields) — %s",
354
+ org_id,
355
+ warnings,
356
+ )
357
+ logger.info(
358
+ "Provisioned %s: %d data source(s), geo=%s, granted_to=%s, warnings=%d",
359
+ summary["role"],
360
+ len(data_sources),
361
+ "yes" if sql_filter else "no",
362
+ summary["granted_to"],
363
+ len(warnings),
364
+ )
365
+ return summary
366
+
367
+
368
+ __all__ = [
369
+ "MEASUREMENT_TABLES",
370
+ "ALL_DATA_SOURCES",
371
+ "POINT_TOPIC_ORG_ID",
372
+ "GRANT_TO_USER",
373
+ "DENY_USING",
374
+ "SCOPED_ORG_PREWHERE_PROFILE",
375
+ "provisioning_configured",
376
+ "provisioning_client",
377
+ "fetch_org_datasets",
378
+ "resolve_org_config",
379
+ "provision_org_role",
380
+ "provision_org_on_login",
381
+ ]