pt-access — shared permission → ClickHouse access-control logic
One place for the permission pipeline every Point Topic data app needs. Ported from the
production-proven point-topic-mcp implementation (issues #100/#104/#105) so the MCP
server, the ontology web app (onto-app-new) and future consumers cannot drift.
Full design context:
HANDOFF.md. Tracked by onto-app-new#25 (Auth0 login + per-org dataset/geo permissions).
The pipeline
JWT claims ──► policy spec ──► SQL predicate ──► ClickHouse DDL
(what you (normalised (composer: (provisioning:
have) dataset + escaping, role, grants,
filter rows) containment, row policies,
OR-grouping) settings profile)
Identity comes from the shared sub-site Auth0 tenant (point-topic.eu.auth0.com). The
Post-Login Action stamps pt_org_id and products[] ([{name, permissions}], scalar
values only). Geo filters are never in the token — list values would bloat tokens
past proxy header limits (documented decision in the Action's own code) — they are read
fresh from sub-site MongoDB (organisations.productPermissions.<ds> = {field: string[]})
on every login/session, then converted to ClickHouse row policies.
Modules
| Module | What | Ported from |
|---|---|---|
claims.py |
get_org_id, get_held_products, is_pt_admin, has_data_source_access, get_product_role |
MCP auth/middleware.py (pure parts) |
jwt.py |
Auth0TokenVerifier — JWKS cache, RS256, issuer/audience/exp checks |
MCP auth/auth0_helpers.py (FastMCP wrapper stays in the MCP) |
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) |
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) |
contract.py |
load_contract / validate_contract — fixture loader + code↔contract consistency check |
MCP core/ontology-permission-contract.json + test_permission_contract.py |
Instance-specific config (CH host/port/creds, grant_to service user, measurement
tables, PREWHERE profile) stays in the consuming app's environment — this package takes
it as parameters, never hardcodes it. provision_org_role() already takes grant_to
per call; the convenience wrapper uses GRANT_TO_USER (env MCP_CLICKHOUSE_GRANT_TO_USER,
default mcp_service) — rename/re-purpose per instance when wiring a new consumer.
Usage
from pt_access.jwt import Auth0TokenVerifier
from pt_access.claims import get_org_id, get_held_products, is_pt_admin
from pt_access.provisioning import provision_org_on_login
# 1. Verify the bearer token (FastAPI dependency, etc.)
claims = await Auth0TokenVerifier(
auth0_domain="point-topic.eu.auth0.com",
audience="<your_client_id>", # ID token audience = client_id
).verify_token(bearer)
# 2. Provision the org's ClickHouse role + row policy (on login / session start)
if not is_pt_admin(claims):
summary = provision_org_on_login(get_org_id(claims), get_held_products(claims))
# summary: {"role": "org_<id>", "granted_to", "using", "tables", "warnings"}
# 3. Per-query scoping on the app's READ client (never SET ROLE on a shared
# connection — thread-local clients are shared across users):
# client.query(sql, settings={"role": "org_<id>"})
provision_org_on_login is a no-op when CLICKHOUSE_PROVISIONING_USER/_PASSWORD are
unset; without a provisioning credential orgs fail closed on the engine (role never
granted). Requires SUB_SITE_MONGODB_URI (read-only sub-site Mongo user) for the fresh
per-org read.
Behaviour contract (do not "fix" — each rule exists because of a live incident)
- Fail closed: no held data-source products → deny (
REVOKErole + row policyUSING 0); unknown/empty filter fields → that dataset excluded, ALL excluded → deny; a held product with no stored values → bare(DATA_SOURCE='<ds>')(omit-empty contract = full access to that product's data). - Per-dataset disjunction:
(DATA_SOURCE='upc' AND <geo>) OR (DATA_SOURCE='gbs')— never a bareDATA_SOURCE IN (...) AND <geo>blob. - OR-grouping: multiple predicates must be wrapped
(A OR B)inside theDATA_SOURCEguard — SQL precedence otherwise leaks other datasets' rows at the postcode (found live on prod 2026-08-05). - PREWHERE: row policies on non-sorting-key columns return 0 rows under PREWHERE
(ClickHouse GH #85222). Provisioning creates a settings profile
(
optimize_move_to_prewhere = 0) and attaches it to the service user — per-querySETTINGSis blocked underreadonly=1(error 164) and role-attached profiles don't apply. Trade-off: applies to every query through that user. - Convergence: full re-provision on every login (no drift check, ~320ms), serialised
by a global lock (row-policy
DROP+CREATEraces →ACCESS_ENTITY_ALREADY_EXISTS, code 493). Deny orgs are always re-provisioned too (REVOKE converges). - Escaping: every literal is single-quote-doubled (
King's Lynn); the row policy is the enforcement boundary, so a quote in an admin-entered value must never escape the literal.
The contract fixture — the single evolvable artifact
src/pt_access/ontology-permission-contract.json mirrors
sub-site/apps/ui/src/organisations/config/ontology-permission-contract.json
(sub-site UI renders exactly what this declares). Adding a new data source = a
dataSources entry; a new filter field = a fields entry (+ fieldProducts /
compatibility where applicable). Both are fixture changes, never composer code
changes. The package CI drift-guard workflow fetches the sub-site copy via gh api and
fails on mismatch; tests/test_contract.py (via contract.validate_contract()) checks
the code constants against the fixture.
Consumers
| App | Status | Notes |
|---|---|---|
point-topic-mcp |
to migrate | swap local modules for this package; keep FastMCP middleware + tool→product map |
onto-app-new |
to integrate | issue #25; adds ontology_app product (role admin/viewer), JWT auth on all routes, per-query role settings |
upc_query_agent |
follow-up | already reads pt_org_id/products[] in api/auth_handler.py — adopt claims.py |
local-pricing-dashboard, european-fttp-forecasts |
don't break | read products[].permissions.role — the claim shape is pinned by tests |
Development
uv sync # or use any venv with the deps
uv run pytest -q # 93 tests, no network/DB needed (all mocked)
uv run ruff check src tests
uv run pyright # requires pyright install; config in pyproject
Distribution: private GitHub tag, pinned by consumers
(pip install git+https://github.com/Point-Topic/point-topic-access@v0.1.0).
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file point_topic_access-0.1.0.tar.gz.
File metadata
- Download URL: point_topic_access-0.1.0.tar.gz
- Upload date:
- Size: 29.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
91cc37463a0de3999e0c15eaf75ee6322ed240712fd7e9d1e54237585fac928d
|
|
| MD5 |
8d7e5edcd27157d7e1fd1130b7cf5d98
|
|
| BLAKE2b-256 |
a1182ffdfdac623ab218d2febf62d0a3612c1db16844eadd93f353f8f20ba1a0
|
File details
Details for the file point_topic_access-0.1.0-py3-none-any.whl.
File metadata
- Download URL: point_topic_access-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73417d48e61013e7ad6fe4461fbe8ffeb9570c2e173e4e0bcec68a1b1f4ed831
|
|
| MD5 |
09b2416c5b8c8d2a494ec76599154568
|
|
| BLAKE2b-256 |
155325dc8048120e594c6e56d7eab7e6db7eec5cc44daa7fbdfb071361a2280e
|