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. Ontology app integration:docs/ONTOLOGY_APP_HOOKUP.md. Tracked by onto-app-new#25.
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) — including the additive boolean permissions.data_source for products
flagged dataSource: true. 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.
Which products are data sources is itself read fresh from the sub-site registry
(products.find({dataSource: true})) on the same Mongo connection as the org perms.
There is no static data-source allowlist in this package: adding or retiring a
source is a sub-site product change only — no pt-access PR, no release, no consumer
rebuild. The Point Topic org maps to the whole registry; every other org to the
registry ∩ its JWT products.
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_data_source_registry + fetch_org_datasets (one sub-site Mongo read) + resolve_org_config + provision_org_role / provision_org_on_login (ClickHouse DDL, serialisation lock) |
MCP core/org_provisioning.py |
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). - Registry is the single source of truth: the data-source set is read fresh from
the sub-site (
products.find({dataSource: true})) at login; a read failure raises so the previous policy stays. There is no static list in the policy path. - Gate transition (remove in 0.4.0):
has_data_source_accessis flag-based (products[].permissions.data_source is True). Tokens minted before the Auth0 Action deploy carry no flag, so the gate also recognises the old static product list and the legacy"ontology"name — gate visibility only; access still comes from the registry. - 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 — composer field knowledge
src/pt_access/ontology-permission-contract.json mirrors the composer's field
knowledge in sub-site/apps/ui/src/organisations/config/ ontology-permission-contract.json (the sub-site UI renders exactly those
filter fields). A new filter field = a fields entry (+ fieldProducts /
compatibility where applicable) — a fixture change, never composer code. A
new data source is no longer declared here: since v0.3.0 it is a sub-site
product flag (dataSource: true) read at login (see the pipeline above). The
sub-site has a vitest drift test against the fixture; the package-side CI
drift-guard workflow (fetch the sub-site copy via gh api and fail on mismatch)
is planned but not yet created (HANDOFF item 3) — the package-side check
today is tests/test_contract.py (via contract.validate_contract()), which
checks the composer's field constants against the fixture.
Distribution & consumers
Published to PyPI as point-topic-access (current: 0.3.0) — pin it like any dependency.
The GitHub repo + tags are the source home. (A private git-tag dependency was
evaluated first and rejected: a repo's GITHUB_TOKEN cannot read other private
repos, so the production box's uv sync couldn't clone it. Public PyPI matches how
point-topic-mcp itself is already distributed.)
| App | Status | Notes |
|---|---|---|
point-topic-mcp |
✅ live (deployed 2026-08-07, E2E-verified) | dependency point-topic-access>=0.1.0; local modules deleted |
onto-app-new |
next | issue #30 (supersedes #25); needs v0.2.0 — provision_onto_app_role / provision_onto_public_role (§3.4 contract); docs/ONTOLOGY_APP_HOOKUP.md is the reference |
upc_query_agent |
follow-up | already reads pt_org_id/products[] in api/auth_handler.py — adopt claims.py |
pt-dashboards |
✅ live (dashboards hub) | jwt-only consumer — Auth0TokenVerifier(auth0_domain=…, audience=[<client_id>, "http://ss-api"]) with explicit args; imports only pt_access.jwt, so it uses none of the package's env keys (the CLICKHOUSE_* / SUB_SITE_MONGODB_URI set) → no secret injection needed; API env is public config only, no .env on the box since 2026-09-05, deploys via GH Actions |
local-pricing-dashboard, european-fttp-forecasts |
don't break | read products[].permissions.role — the claim shape is pinned by tests |
Secrets injection for consumers — no .env
pt_access reads its credentials from env vars only; it never touches AWS itself. Consumers
must not persist those values in a .env file — inject them at process startup with
tools/exec-env (installed once per box, e.g. /opt/bin/exec-env):
exec-env pt-access-credentials -- <app launch command>
This fetches the pt-access-credentials JSON secret from AWS Secrets Manager and exports its
keys (CLICKHOUSE_*, CLICKHOUSE_PROVISIONING_*, SUB_SITE_MONGODB_URI, …) into the process
env — never written to disk. Full design, the box's Roles Anywhere identity, and the staged
per-consumer rollout: docs/secrets-injection-plan.md.
Behaviour tests: bash tests/test_exec_env.sh.
Exception: jwt-only consumers (e.g. pt-dashboards — imports pt_access.jwt only) need
no injection — they use none of the package's env keys (deep dive: Point-Topic/pt-dashboards#2).
Development
uv sync # or use any venv with the deps
uv run pytest -q # 111 tests, no network/DB needed (all mocked)
uv run ruff check src tests
Distribution: uv build && uv publish (credentials via ~/.pypirc, fetched from AWS
Secrets Manager pypirc).
Metadata
Release files for point-topic-access 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| point_topic_access-0.3.1.tar.gz | 94.1 kB | Without provenance | Details for point_topic_access-0.3.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| point_topic_access-0.3.1-py3-none-any.whl | Python 3 | none | any | Details for point_topic_access-0.3.1-py3-none-any.whl |
Total release size: 119.5 kB
Release files / point_topic_access-0.3.1.tar.gz
| Download URL | point_topic_access-0.3.1.tar.gz |
|---|---|
| Size | 94.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
48dc052d8d643708e6fcc862355be2de8c29b65675d687e4829f4c4f4fcb8b80
|
|
BLAKE2b-256 checksum How to use checksums |
a49c16f11468f90f720eac22c86df6fa21c1f17b021253ccc2b0c633f36487e9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|
Release files / point_topic_access-0.3.1-py3-none-any.whl
| Download URL | point_topic_access-0.3.1-py3-none-any.whl |
|---|---|
| Size | 25.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
13fc914f2e618daf0a15f60a512d402f99897b93111e816956fd1f80fa465481
|
|
BLAKE2b-256 checksum How to use checksums |
adcb82febc5636e460f293e117493a112653b9a021ea86776a58b30c7f5aacd3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|
Security for point-topic-access 0.3.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.