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.
- point_topic_access-0.1.0.dist-info/METADATA +143 -0
- point_topic_access-0.1.0.dist-info/RECORD +10 -0
- point_topic_access-0.1.0.dist-info/WHEEL +4 -0
- pt_access/__init__.py +15 -0
- pt_access/claims.py +97 -0
- pt_access/composer.py +258 -0
- pt_access/contract.py +70 -0
- pt_access/jwt.py +153 -0
- pt_access/ontology-permission-contract.json +23 -0
- pt_access/provisioning.py +381 -0
|
@@ -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,,
|
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
|
+
]
|