point-topic-access 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/PKG-INFO +21 -1
  2. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/README.md +20 -0
  3. point_topic_access-0.2.1/docs/secrets-injection-plan.md +190 -0
  4. point_topic_access-0.2.1/docs/tickets/point-topic-mcp-lock-bump-open-data.md +39 -0
  5. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/pyproject.toml +1 -1
  6. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/composer.py +10 -31
  7. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/jwt.py +10 -14
  8. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/ontology-permission-contract.json +1 -1
  9. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/provisioning.py +5 -4
  10. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_claims.py +4 -22
  11. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_composer.py +4 -14
  12. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_contract.py +4 -4
  13. point_topic_access-0.2.1/tests/test_exec_env.sh +85 -0
  14. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_jwt.py +1 -3
  15. point_topic_access-0.2.1/tools/exec-env +92 -0
  16. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/uv.lock +1 -1
  17. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/.gitignore +0 -0
  18. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/HANDOFF.md +0 -0
  19. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/docs/ONTOLOGY_APP_HOOKUP.md +0 -0
  20. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/__init__.py +0 -0
  21. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/claims.py +0 -0
  22. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/contract.py +0 -0
  23. {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_provisioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: point-topic-access
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Shared permission -> ClickHouse access-control logic for Point Topic apps (claims, JWT verification, geo filter composer, per-org provisioning)
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -137,8 +137,28 @@ repos, so the production box's `uv sync` couldn't clone it. Public PyPI matches
137
137
  | `point-topic-mcp` | ✅ **live** (deployed 2026-08-07, E2E-verified) | dependency `point-topic-access>=0.1.0`; local modules deleted |
138
138
  | `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 |
139
139
  | `upc_query_agent` | follow-up | already reads `pt_org_id`/`products[]` in `api/auth_handler.py` — adopt `claims.py` |
140
+ | `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 |
140
141
  | `local-pricing-dashboard`, `european-fttp-forecasts` | don't break | read `products[].permissions.role` — the claim shape is pinned by tests |
141
142
 
143
+ ## Secrets injection for consumers — no `.env`
144
+
145
+ `pt_access` reads its credentials from env vars only; it never touches AWS itself. Consumers
146
+ must **not** persist those values in a `.env` file — inject them at process startup with
147
+ `tools/exec-env` (installed once per box, e.g. `/opt/bin/exec-env`):
148
+
149
+ ```bash
150
+ exec-env pt-access-credentials -- <app launch command>
151
+ ```
152
+
153
+ This fetches the `pt-access-credentials` JSON secret from AWS Secrets Manager and exports its
154
+ keys (`CLICKHOUSE_*`, `CLICKHOUSE_PROVISIONING_*`, `SUB_SITE_MONGODB_URI`, …) into the process
155
+ env — never written to disk. Full design, the box's Roles Anywhere identity, and the staged
156
+ per-consumer rollout: [`docs/secrets-injection-plan.md`](docs/secrets-injection-plan.md).
157
+ Behaviour tests: `bash tests/test_exec_env.sh`.
158
+
159
+ Exception: **jwt-only consumers** (e.g. `pt-dashboards` — imports `pt_access.jwt` only) need
160
+ no injection — they use none of the package's env keys (deep dive: `Point-Topic/pt-dashboards#2`).
161
+
142
162
  ## Development
143
163
 
144
164
  ```bash
@@ -119,8 +119,28 @@ repos, so the production box's `uv sync` couldn't clone it. Public PyPI matches
119
119
  | `point-topic-mcp` | ✅ **live** (deployed 2026-08-07, E2E-verified) | dependency `point-topic-access>=0.1.0`; local modules deleted |
120
120
  | `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 |
121
121
  | `upc_query_agent` | follow-up | already reads `pt_org_id`/`products[]` in `api/auth_handler.py` — adopt `claims.py` |
122
+ | `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 |
122
123
  | `local-pricing-dashboard`, `european-fttp-forecasts` | don't break | read `products[].permissions.role` — the claim shape is pinned by tests |
123
124
 
125
+ ## Secrets injection for consumers — no `.env`
126
+
127
+ `pt_access` reads its credentials from env vars only; it never touches AWS itself. Consumers
128
+ must **not** persist those values in a `.env` file — inject them at process startup with
129
+ `tools/exec-env` (installed once per box, e.g. `/opt/bin/exec-env`):
130
+
131
+ ```bash
132
+ exec-env pt-access-credentials -- <app launch command>
133
+ ```
134
+
135
+ This fetches the `pt-access-credentials` JSON secret from AWS Secrets Manager and exports its
136
+ keys (`CLICKHOUSE_*`, `CLICKHOUSE_PROVISIONING_*`, `SUB_SITE_MONGODB_URI`, …) into the process
137
+ env — never written to disk. Full design, the box's Roles Anywhere identity, and the staged
138
+ per-consumer rollout: [`docs/secrets-injection-plan.md`](docs/secrets-injection-plan.md).
139
+ Behaviour tests: `bash tests/test_exec_env.sh`.
140
+
141
+ Exception: **jwt-only consumers** (e.g. `pt-dashboards` — imports `pt_access.jwt` only) need
142
+ no injection — they use none of the package's env keys (deep dive: `Point-Topic/pt-dashboards#2`).
143
+
124
144
  ## Development
125
145
 
126
146
  ```bash
@@ -0,0 +1,190 @@
1
+ # Plan — `pt_access` env vars from Secrets Manager (point-topic-access#2)
2
+
3
+ **Status:** plan agreed; this repo's part **implemented** (`tools/exec-env`, tested). Consumer
4
+ rollout happens per-repo **after** this is verified working, as its own ticket per app.
5
+
6
+ ## Goal
7
+
8
+ `pt_access` keeps reading its credentials from env vars. We stop the consumer-side
9
+ **persistent plaintext `.env`** (written today by each app's `deploy.sh`) and instead inject
10
+ the values at process startup, in memory only, via **one shared tool**. Adding a future app
11
+ then becomes a one-line change, not a new bespoke mechanism.
12
+
13
+ The package itself needs **no code change** — injection happens *outside* the app, before it
14
+ starts. That removes any need for boto3 in the package, per-app secrets code, or per-app AWS
15
+ roles.
16
+
17
+ ## Design in one picture
18
+
19
+ ```
20
+ AWS Secrets Manager
21
+ pt-access-credentials
22
+ ▲
23
+ │ GetSecretValue (box role)
24
+ ┌─────────────────────┴───────────────┐
25
+ │ one box identity │
26
+ │ Roles Anywhere role/profile/cert │
27
+ │ (IP-restricted; reads this box's │
28
+ │ secrets only) │
29
+ └─────────────────────┬───────────────┘
30
+ │ ambient creds
31
+ ┌─────────────────────┴───────────────┐
32
+ │ one injector: exec-env │
33
+ │ fetch → export → exec (never disk)│
34
+ └───────────────┬─────────┬──────────┘
35
+ │ │
36
+ exec-env <secret> -- app exec-env <secret> -- docker compose up
37
+ │ │
38
+ ▼ ▼
39
+ each app reads os.environ → pt_access → ClickHouse / sub-site Mongo
40
+ ```
41
+
42
+ **Trust model:** the **box is the trust boundary** for *internal* apps — one identity and one
43
+ injector per box. Be honest about the trade: per-app roles protect against **individual app
44
+ compromise** (a web-exploitable app can reach only *its* secrets), while a shared box role lets
45
+ an exploited app reach **all the box's secrets**. The shared role widens the blast radius
46
+ **per-app-compromise**, not per-box-compromise. For internal, trusted apps that is a deliberate,
47
+ reasonable trade that costs far less to maintain.
48
+
49
+ **Co-existence rule — do NOT rip out what's built:** keep the **per-app roles for MCP and
50
+ pt-mail** (isolation-correct, and MCP is public-facing with a real data boundary) and for any
51
+ client-facing app. Use the **box-shared model for new internal apps** (onto-app, pt-dashboards,
52
+ upc_query_agent, etc.). Both models coexist on the same box.
53
+
54
+ ## The one new secret
55
+
56
+ `pt-access-credentials` in Secrets Manager, holding exactly the keys this package reads:
57
+
58
+ ```
59
+ CLICKHOUSE_HOST, CLICKHOUSE_PORT, CLICKHOUSE_DATABASE,
60
+ CLICKHOUSE_PROVISIONING_USER, CLICKHOUSE_PROVISIONING_PASSWORD,
61
+ SUB_SITE_MONGODB_URI, POINT_TOPIC_ORG_ID, MCP_CLICKHOUSE_GRANT_TO_USER
62
+ ```
63
+
64
+ - Least privilege: a consumer reads **this** secret, not the whole `ptserver-secret` mega-bundle.
65
+ - **Decision (weighed):** a dedicated bundle chosen for the cleanest, least-privileged IAM. The
66
+ acknowledged cost is that the shared CH/Mongo values are duplicated from wherever they are
67
+ canonical, so **rotation must update all copies** — add it to the secrets catalogue. The
68
+ alternative (point consumers at the canonical `ptserver-secret` keys, avoiding the mirror)
69
+ was considered and rejected because it regrants them read access to the whole box bundle.
70
+ - The `POINT_TOPIC_ORG_ID` / `MCP_CLICKHOUSE_GRANT_TO_USER` defaults (`provisioning.py:117,120`)
71
+ stop being baked via env absence — they're explicit in the secret so an app can't silently
72
+ diverge.
73
+
74
+ ## The one injector: `exec-env`
75
+
76
+ A small, language-agnostic tool installed once per box (`/opt/bin/exec-env`). sops `exec-env`
77
+ style: fetch the secret, export its keys into the child process's env, run it — **nothing written
78
+ to disk**. **Implemented at `tools/exec-env`** (bash 3.2+; behaviour-tested via
79
+ tests/test_exec_env.sh with the `aws` CLI stubbed — no AWS needed to run the tests):
80
+
81
+ ```bash
82
+ # install once per box
83
+ install -m 0755 tools/exec-env /opt/bin/exec-env
84
+
85
+ # usage — prefix any app's launch command
86
+ /opt/bin/exec-env <secret-id> [--region <region>] -- <command [args...]>
87
+ /opt/bin/exec-env pt-access-credentials -- uvicorn backend.main:app
88
+ ```
89
+
90
+ - Uses the **ambient credential chain** (`aws` CLI → Roles Anywhere profile) — no AWS code in any app.
91
+ - Depends on the `aws` CLI + `python3` (JSON parsing; python3 is present on every consumer box).
92
+ - Exports **all** keys in the secret (the secret *is* the allowlist, so this stays simple and safe).
93
+ - **Fail closed & never echo:** on any fetch/parse failure it exits non-zero and **never prints a
94
+ secret value** — not in an error message, not in the raw response; `aws`/`python3` stderr is
95
+ suppressed and replaced with a generic message (secret id only). Keys are validated
96
+ (`[A-Za-z0-9_]+`); values must be single-line strings. Memory-only + don't-echo is what makes
97
+ this safe; keep it that way.
98
+
99
+ ## The one box identity (Roles Anywhere)
100
+
101
+ One shared identity for **internal apps**; coexists with (and does not replace) the existing
102
+ per-app MCP/pt-mail roles. Per box, not per app:
103
+
104
+ 1. **Reuse the shared trust anchor**
105
+ `arn:aws:rolesanywhere:eu-west-1:543072339765:trust-anchor/3efa399c-648d-482e-a4ea-29d20eadc604`.
106
+ Never create a new CA.
107
+ 2. **One role** (e.g. `<box>-roles-anywhere`): trust policy restricted by `aws:SourceArn` = the
108
+ trust anchor **and** `aws:PrincipalTag/x509Subject/CN` = the box's cert CN. Identity policy:
109
+ `secretsmanager:GetSecretValue` on **the secrets that box's apps need** (e.g.
110
+ `pt-access-credentials`), IP-restricted to the box's egress IP. Nothing else.
111
+ 3. **One profile** — remember it is **created disabled; call `enable_profile`**.
112
+ 4. **One cert** (CN = the box) — signed by the shared CA (CA key at
113
+ `~/.aws/roles-anywhere-ca/ca-key.pem` / Secret `pt-roles-anywhere-ca`). Key on box `chmod 600`;
114
+ chown the cert dir to the user that runs the apps.
115
+ 5. **Wire** `aws_signing_helper` + `credential_process` + `region = eu-west-1` in `~/.aws/config`.
116
+ Set `AWS_PROFILE=<box>` (or default) for the `aws` CLI used by `exec-env`.
117
+
118
+ ## Wire each app — one line
119
+
120
+ Every app's launch command gets prefixed with `exec-env <secret> --`:
121
+
122
+ | App | Before | After |
123
+ |---|---|---|
124
+ | onto-app (compose) | `deploy.sh`: write `.env` → `docker compose up -d` | `exec-env pt-access-credentials -- docker compose up -d` (compose interpolates `${CLICKHOUSE_PASSWORD}` from the exported env — `docker-compose.yml:11,41` keep working, zero compose change) |
125
+ | MCP backend (compose `command`) | `command: python -m point_topic_mcp.server_http` | `exec-env pt-access-credentials -- python -m point_topic_mcp.server_http` |
126
+ | pt-dashboards / other | its existing start command | `exec-env <its-secret> -- <start command>` |
127
+
128
+ Notes:
129
+ - onto-app's ClickHouse container needs `CLICKHOUSE_PASSWORD` at **compose-parse time**; wrapping
130
+ the `docker compose up` call with `exec-env` puts the value in compose's shell env, which is
131
+ exactly when it's interpolated. (If `exec-env` isn't desired for compose, do the equivalent in
132
+ `deploy.sh`: `export` from AWS, then `docker compose up`.)
133
+ - MCP: also drop the `env_file: ${MCP_ENV_FILE:-../.env}` entries (`docker-compose.yml:37,75`) —
134
+ compose fails if a referenced file is gone. Use the `exec-env`-exported env instead.
135
+ - Ordering safety: this injects before the app starts, so `pt_access`'s module-level env reads
136
+ (`POINT_TOPIC_ORG_ID`/`GRANT_TO_USER`, `provisioning.py:117,120`) always see the injected values.
137
+
138
+ ## Remove the `.env`
139
+
140
+ For each consumer: delete the `.env` write from `deploy.sh` / the deploy workflow, delete the
141
+ file on the box, and any compose `env_file:` references to it. No static key on the box.
142
+
143
+ ## Security posture (still secure)
144
+
145
+ - **Never on disk:** values live in the child process env only; nothing persisted, nothing logged.
146
+ - **Box-least-privilege:** the box role reads only the secrets *its* apps need, IP-restricted.
147
+ - **No fallback:** `exec-env` is the deliberate single source — the app never "reads env, else
148
+ fetch." (Avoids the MCP's existing `_resolve_credentials` env-else-fetch fallback pattern.)
149
+ - **Memory-only env is the intended safe holder:** dies with the process; just never log values,
150
+ and don't echo secrets in fail-closed errors.
151
+ - **Reuses, never recreates** the shared trust anchor and CA.
152
+
153
+ ## Adding a future app (runbook)
154
+
155
+ 1. If it needs a secret that the box role can't already read: create the secret and add its ARN
156
+ to the box role's `GetSecretValue` (IP-restricted to the box).
157
+ 2. Prefix its start command with `exec-env <secret> -- …`.
158
+ 3. Delete its old `.env` (if any).
159
+
160
+ That's it. No new role, cert, code, or per-app mechanism.
161
+
162
+ ## Consumer rollout order
163
+
164
+ 1. **This repo (DONE):** this plan + `tools/exec-env` (implemented, tested). Each consumer gets
165
+ its own ticket **after** this is verified working; every app's rollout needs its own deep dive.
166
+ 2. **point-topic-mcp:** `exec-env` prefix + drop `../.env` `env_file` entries + remove `.env`
167
+ write from `deploy-http-prod.yml`. (Note: MCP's `.env` holds its whole secret set, sourced
168
+ from GH Actions — treat as a per-app secret of its own; separate, larger ticket.)
169
+ 3. **onto-app-new:** `exec-env pt-access-credentials -- docker compose up -d` in `deploy.sh`;
170
+ remove `.env` write/read.
171
+ 4. **pt-dashboards:** `exec-env` + its own secret(s); add to the package's consumer table.
172
+ 5. **upc_query_agent:** `exec-env` in a follow-up. (Adopting `pt_access.claims` in its auth path
173
+ is a separate, unrelated change — not part of this secrets plan.)
174
+
175
+ Each rollout is staged: create secret → grant box role → install + test `exec-env` → flip the
176
+ launch line at a normal deploy → verify → delete `.env`. No app should experience downtime
177
+ beyond its normal deploy restart window.
178
+
179
+ ## Files in this repo
180
+
181
+ - `docs/secrets-injection-plan.md` — this plan (design + rollout).
182
+ - `tools/exec-env` — the injector (implemented, tested); install to `/opt/bin/exec-env` per box.
183
+ - `tests/test_exec_env.sh` — behaviour tests for the injector (stubbed `aws`, no network).
184
+ - `README.md` — “Secrets injection” section pointing consumers at this plan.
185
+
186
+ ## Open for confirmation
187
+
188
+ - ~~Location of `exec-env` source~~ → **landed in this repo** (`tools/exec-env`) per the plan.
189
+ - ~~Shared vs per-app box role~~ → **resolved by review:** keep per-app roles for MCP/pt-mail and
190
+ any client-facing app; shared `<box>-roles-anywhere` role for new internal apps.
@@ -0,0 +1,39 @@
1
+ # point-topic-mcp: bump point-topic-access to 0.2.1 (open_data data source)
2
+
3
+ **Repo:** Point-Topic/point-topic-mcp
4
+ **Type:** task
5
+ **Blocked by:** point-topic-access 0.2.1 published to PyPI
6
+
7
+ ## Why
8
+
9
+ The MCP image builds with `uv sync --frozen` against the committed `uv.lock`,
10
+ which currently resolves `point-topic-access` 0.1.1. pt-access 0.2.1 swaps the
11
+ `edgap_ekg` data source for `open_data` (`ALL_DATA_SOURCES` + contract fixture).
12
+
13
+ Without this bump, a rebuild/redeploy keeps 0.1.1 and org row policies never
14
+ include `(DATA_SOURCE='open_data')` — agents on the MCP stay blind to the new
15
+ source.
16
+
17
+ ## Change
18
+
19
+ ```bash
20
+ uv lock --upgrade-package point-topic-access # 0.1.1 -> 0.2.1
21
+ uv sync # local check
22
+ uv run pytest -q # MCP suite
23
+ ```
24
+
25
+ Commit the updated `uv.lock`. `pyproject.toml` currently pins
26
+ `point-topic-access>=0.1.0`, which already covers 0.2.1 — optional to raise the
27
+ floor to `>=0.2.1` for clarity.
28
+
29
+ ## Deploy & verify
30
+
31
+ - Rebuild + redeploy via `infrastructure/docker-deploy.sh` on the box.
32
+ - Fresh login for a test org; confirm the org's row policy includes
33
+ `(DATA_SOURCE='open_data')` and an open_data query returns rows.
34
+
35
+ ## Context
36
+
37
+ - pt-access change: Point-Topic/point-topic-access (0.2.1 — swap on `main`).
38
+ - Sub-site: the `open_data` product must exist and sit on the org's licence for
39
+ the JWT `products[]` to carry it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "point-topic-access"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Shared permission -> ClickHouse access-control logic for Point Topic apps (claims, JWT verification, geo filter composer, per-org provisioning)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -55,41 +55,26 @@ CONTAINMENT_TABLE = "ontology.cto_containment_assertion"
55
55
 
56
56
  # Dataset ↔ dimension compatibility, warn level. UK-geography fields (country,
57
57
  # home_nation, la, postcode) on these datasets return 0 rows or are
58
- # misleading: pcd_checker / edgap_ekg are European (no UK postcode
58
+ # misleading: pcd_checker / open_data are European (no UK postcode
59
59
  # containment), ebm is NUTS3-scoped, gbs and global_tariffs are mostly
60
60
  # non-UK. Any dataset not listed is compatible with all geography fields.
61
61
  # Only "warn" exists today; kept as a table so stricter levels can be added
62
62
  # without code change.
63
63
  DATASET_FIELD_COMPATIBILITY: dict[str, dict[str, str]] = {
64
64
  "pcd_checker": {
65
- FIELD_COUNTRY: "warn",
66
- FIELD_HOME_NATION: "warn",
67
- FIELD_LA: "warn",
68
- FIELD_POSTCODE: "warn",
65
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
69
66
  },
70
- "edgap_ekg": {
71
- FIELD_COUNTRY: "warn",
72
- FIELD_HOME_NATION: "warn",
73
- FIELD_LA: "warn",
74
- FIELD_POSTCODE: "warn",
67
+ "open_data": {
68
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
75
69
  },
76
70
  "ebm": {
77
- FIELD_COUNTRY: "warn",
78
- FIELD_HOME_NATION: "warn",
79
- FIELD_LA: "warn",
80
- FIELD_POSTCODE: "warn",
71
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
81
72
  },
82
73
  "gbs": {
83
- FIELD_COUNTRY: "warn",
84
- FIELD_HOME_NATION: "warn",
85
- FIELD_LA: "warn",
86
- FIELD_POSTCODE: "warn",
74
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
87
75
  },
88
76
  "global_tariffs": {
89
- FIELD_COUNTRY: "warn",
90
- FIELD_HOME_NATION: "warn",
91
- FIELD_LA: "warn",
92
- FIELD_POSTCODE: "warn",
77
+ FIELD_COUNTRY: "warn", FIELD_HOME_NATION: "warn", FIELD_LA: "warn", FIELD_POSTCODE: "warn"
93
78
  },
94
79
  }
95
80
 
@@ -127,9 +112,7 @@ def _in_list(values: list[str]) -> str:
127
112
  return ", ".join(escape_sql_string(v) for v in values)
128
113
 
129
114
 
130
- def compose_geo_predicate(
131
- field: str, values: list[str | None] | None
132
- ) -> tuple[str, list[str]]:
115
+ def compose_geo_predicate(field: str, values: list[str | None] | None) -> tuple[str, list[str]]:
133
116
  """Build the SQL predicate for one filter row.
134
117
 
135
118
  Args:
@@ -213,9 +196,7 @@ def compose_sql_filter(datasets: dict[str, dict] | None) -> tuple[str, list[str]
213
196
  for f in filters:
214
197
  field = (f or {}).get("field")
215
198
  if field not in GEO_FIELDS:
216
- warnings.append(
217
- f"Dataset '{ds}': unknown filter field '{field}' — ignored"
218
- )
199
+ warnings.append(f"Dataset '{ds}': unknown filter field '{field}' — ignored")
219
200
  continue
220
201
  compat = DATASET_FIELD_COMPATIBILITY.get(ds, {}).get(field)
221
202
  if compat == "warn":
@@ -232,9 +213,7 @@ def compose_sql_filter(datasets: dict[str, dict] | None) -> tuple[str, list[str]
232
213
  # A broad row (whole country / home nation) subsumes narrower LA
233
214
  # and postcode rows when OR-combined — warn, don't drop silently.
234
215
  filter_fields = {
235
- (f or {}).get("field")
236
- for f in filters
237
- if (f or {}).get("field") in GEO_FIELDS
216
+ (f or {}).get("field") for f in filters if (f or {}).get("field") in GEO_FIELDS
238
217
  }
239
218
  if len(preds) > 1 and {FIELD_COUNTRY, FIELD_HOME_NATION} & filter_fields:
240
219
  warnings.append(
@@ -36,15 +36,15 @@ class Auth0TokenVerifier:
36
36
  jwks_url: str | None = None,
37
37
  ):
38
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
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
48
  """
49
49
  self.auth0_domain = auth0_domain
50
50
  if isinstance(audience, str):
@@ -61,11 +61,7 @@ class Auth0TokenVerifier:
61
61
  async def _get_jwks(self) -> dict | None:
62
62
  """Fetch JWKS from Auth0, cached with a TTL for key rotation."""
63
63
  now = time.time()
64
- if (
65
- self._jwks is None
66
- or self._jwks_fetched_at is None
67
- or now - self._jwks_fetched_at > self._jwks_ttl
68
- ):
64
+ if self._jwks is None or self._jwks_fetched_at is None or now - self._jwks_fetched_at > self._jwks_ttl:
69
65
  async with httpx.AsyncClient() as client:
70
66
  response = await client.get(self.jwks_url)
71
67
  response.raise_for_status()
@@ -11,7 +11,7 @@
11
11
  "pcd_checker",
12
12
  "roadworks",
13
13
  "ofcom_tar",
14
- "edgap_ekg"
14
+ "open_data"
15
15
  ],
16
16
  "fields": ["country", "home_nation", "la", "postcode"],
17
17
  "fieldProducts": {
@@ -94,9 +94,10 @@ MEASUREMENT_TABLES = [
94
94
  "ontology.cto_measurement_record_staging",
95
95
  ]
96
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.
97
+ # The 12 live DATA_SOURCEs (verified on prod 2026-08-03; open_data replaced
98
+ # edgap_ekg as the EDGAP data source, 2026-10). The Point Topic org's
99
+ # full-access role covers exactly these — add new sources here so the admin
100
+ # role keeps seeing them.
100
101
  ALL_DATA_SOURCES = [
101
102
  "upc",
102
103
  "upc_takeup",
@@ -109,7 +110,7 @@ ALL_DATA_SOURCES = [
109
110
  "pcd_checker",
110
111
  "roadworks",
111
112
  "ofcom_tar",
112
- "edgap_ekg",
113
+ "open_data",
113
114
  ]
114
115
 
115
116
  # Point Topic org — internal admin, full access (env-overridable; mirrors
@@ -4,6 +4,7 @@ Ported from point-topic-mcp tests/test_middleware.py — but only the pure
4
4
  functions; the FastMCP middleware itself stays in the MCP.
5
5
  """
6
6
 
7
+
7
8
  from pt_access import claims
8
9
  from pt_access.provisioning import ALL_DATA_SOURCES, POINT_TOPIC_ORG_ID
9
10
 
@@ -16,7 +17,6 @@ def _claims(**overrides) -> dict:
16
17
 
17
18
  # ── get_org_id / is_pt_admin ───────────────────────────────────────────────────
18
19
 
19
-
20
20
  def test_get_org_id():
21
21
  assert claims.get_org_id(_claims()) == "abc123"
22
22
  assert claims.get_org_id({}) is None
@@ -31,14 +31,8 @@ def test_is_pt_admin():
31
31
 
32
32
  # ── get_held_products (both token shapes) ──────────────────────────────────────
33
33
 
34
-
35
34
  def test_held_products_current_shape():
36
- c = _claims(
37
- products=[
38
- {"name": "upc", "permissions": {}},
39
- {"name": "gbs", "permissions": {}},
40
- ]
41
- )
35
+ c = _claims(products=[{"name": "upc", "permissions": {}}, {"name": "gbs", "permissions": {}}])
42
36
  assert claims.get_held_products(c) == ["upc", "gbs"]
43
37
 
44
38
 
@@ -55,24 +49,13 @@ def test_held_products_missing_or_malformed():
55
49
 
56
50
  # ── has_data_source_access ─────────────────────────────────────────────────────
57
51
 
58
-
59
52
  def test_any_data_source_product_unlocks_ontology_family():
60
53
  for ds in ALL_DATA_SOURCES:
61
- assert (
62
- claims.has_data_source_access(
63
- _claims(products=[{"name": ds, "permissions": {}}])
64
- )
65
- is True
66
- )
54
+ assert claims.has_data_source_access(_claims(products=[{"name": ds, "permissions": {}}])) is True
67
55
 
68
56
 
69
57
  def test_legacy_ontology_product_still_accepted():
70
- assert (
71
- claims.has_data_source_access(
72
- _claims(products=[{"name": "ontology", "permissions": {}}])
73
- )
74
- is True
75
- )
58
+ assert claims.has_data_source_access(_claims(products=[{"name": "ontology", "permissions": {}}])) is True
76
59
 
77
60
 
78
61
  def test_non_data_source_products_do_not_unlock():
@@ -86,7 +69,6 @@ def test_no_products_denies():
86
69
 
87
70
  # ── get_product_permission / get_product_role ─────────────────────────────────
88
71
 
89
-
90
72
  def test_product_permission_reads_scalar_value():
91
73
  c = _claims(products=[{"name": "ontology_app", "permissions": {"role": "admin"}}])
92
74
  assert claims.get_product_permission(c, "ontology_app", "role") == "admin"
@@ -18,7 +18,6 @@ from pt_access.composer import (
18
18
 
19
19
  # ── escaping ───────────────────────────────────────────────────────────────────
20
20
 
21
-
22
21
  def test_escape_sql_string_doubles_quotes():
23
22
  assert escape_sql_string("King's Lynn") == "'King''s Lynn'"
24
23
 
@@ -40,7 +39,6 @@ def test_injection_in_dataset_name_escaped():
40
39
 
41
40
  # ── country ────────────────────────────────────────────────────────────────────
42
41
 
43
-
44
42
  def test_country_shape():
45
43
  sql, warnings = compose_sql_filter(
46
44
  {"upc": {"filters": [{"field": "country", "values": ["United Kingdom"]}]}}
@@ -56,7 +54,6 @@ def test_country_multiple_values():
56
54
 
57
55
  # ── postcode ───────────────────────────────────────────────────────────────────
58
56
 
59
-
60
57
  def test_postcode_shape():
61
58
  sql, warnings = compose_sql_filter(
62
59
  {"upc": {"filters": [{"field": "postcode", "values": ["AB1 2CD", "DD1 4HN"]}]}}
@@ -67,7 +64,6 @@ def test_postcode_shape():
67
64
 
68
65
  # ── LA branch ──────────────────────────────────────────────────────────────────
69
66
 
70
-
71
67
  def test_la_names_shape():
72
68
  """la_name branch — live-verified containment subquery shape."""
73
69
  sql, warnings = compose_sql_filter(
@@ -119,7 +115,6 @@ def test_unknown_field_raises():
119
115
 
120
116
  # ── home_nation branch ─────────────────────────────────────────────────────────
121
117
 
122
-
123
118
  def test_home_nation_shape():
124
119
  """home_nation containment branch — same shape as la, level='home_nation'.
125
120
 
@@ -197,15 +192,12 @@ def test_home_nation_alone_no_subsume_warning():
197
192
 
198
193
  # ── per-dataset disjunction ────────────────────────────────────────────────────
199
194
 
200
-
201
195
  def test_disjunction_guards_each_dataset():
202
196
  """Each disjunct is guarded by its own DATA_SOURCE literal (no IN blob)."""
203
197
  sql, warnings = compose_sql_filter(
204
198
  {
205
199
  "upc": {"filters": [{"field": "country", "values": ["United Kingdom"]}]},
206
- "upc_takeup": {
207
- "filters": [{"field": "country", "values": ["United Kingdom"]}]
208
- },
200
+ "upc_takeup": {"filters": [{"field": "country", "values": ["United Kingdom"]}]},
209
201
  "global_tariffs": {},
210
202
  }
211
203
  )
@@ -311,7 +303,6 @@ def test_none_datasets_yield_no_sql():
311
303
 
312
304
  # ── value cleaning ─────────────────────────────────────────────────────────────
313
305
 
314
-
315
306
  def test_values_are_stripped_deduped_and_empties_dropped():
316
307
  sql, _ = compose_geo_predicate(
317
308
  "postcode", [" AB1 2CD ", "", "DD1 4HN", " ", "AB1 2CD", None]
@@ -347,7 +338,6 @@ def test_empty_filter_row_plus_other_datasets_excludes_only_that_dataset():
347
338
 
348
339
  # ── compatibility warnings ─────────────────────────────────────────────────────
349
340
 
350
-
351
341
  def test_compatible_dataset_no_warning():
352
342
  _, warnings = compose_sql_filter(
353
343
  {"upc": {"filters": [{"field": "country", "values": ["United Kingdom"]}]}}
@@ -356,7 +346,7 @@ def test_compatible_dataset_no_warning():
356
346
 
357
347
 
358
348
  @pytest.mark.parametrize(
359
- "ds", ["pcd_checker", "edgap_ekg", "ebm", "gbs", "global_tariffs"]
349
+ "ds", ["pcd_checker", "open_data", "ebm", "gbs", "global_tariffs"]
360
350
  )
361
351
  def test_incompatible_dataset_warns(ds):
362
352
  """UK-geography fields on these datasets warn but still compose."""
@@ -368,7 +358,7 @@ def test_incompatible_dataset_warns(ds):
368
358
 
369
359
 
370
360
  @pytest.mark.parametrize(
371
- "ds", ["pcd_checker", "edgap_ekg", "ebm", "gbs", "global_tariffs"]
361
+ "ds", ["pcd_checker", "open_data", "ebm", "gbs", "global_tariffs"]
372
362
  )
373
363
  def test_home_nation_warns_on_incompatible_datasets(ds):
374
364
  """home_nation is UK geography — same warn-on-non-UK-datasets rule."""
@@ -380,7 +370,7 @@ def test_home_nation_warns_on_incompatible_datasets(ds):
380
370
 
381
371
 
382
372
  def test_compatibility_table_covers_all_warned_datasets():
383
- for ds in ("pcd_checker", "edgap_ekg", "ebm", "gbs", "global_tariffs"):
373
+ for ds in ("pcd_checker", "open_data", "ebm", "gbs", "global_tariffs"):
384
374
  assert DATASET_FIELD_COMPATIBILITY[ds] == {
385
375
  "country": "warn",
386
376
  "home_nation": "warn",
@@ -20,15 +20,15 @@ from pt_access.composer import GEO_FIELDS
20
20
 
21
21
 
22
22
  def _contract() -> dict:
23
- fixture = resources.files("pt_access").joinpath("ontology-permission-contract.json")
23
+ fixture = resources.files("pt_access").joinpath(
24
+ "ontology-permission-contract.json"
25
+ )
24
26
  return json.loads(fixture.read_text(encoding="utf-8"))
25
27
 
26
28
 
27
29
  def test_data_sources_match_contract():
28
30
  contract = _contract()
29
- assert len(op.ALL_DATA_SOURCES) == len(contract["dataSources"]), (
30
- "duplicate data sources?"
31
- )
31
+ assert len(op.ALL_DATA_SOURCES) == len(contract["dataSources"]), "duplicate data sources?"
32
32
  assert sorted(op.ALL_DATA_SOURCES) == sorted(contract["dataSources"])
33
33
 
34
34
 
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Behaviour tests for tools/exec-env — no AWS needed, the aws CLI is stubbed.
3
+ # Run: bash tests/test_exec_env.sh
4
+ set -u
5
+
6
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
7
+ EXEC_ENV="$HERE/tools/exec-env"
8
+ TMP="$(mktemp -d)"
9
+ trap 'rm -rf "$TMP"' EXIT
10
+
11
+ fail() { echo "FAIL: $1" >&2; exit 1; }
12
+ pass() { echo "ok: $1"; }
13
+
14
+ cat > "$TMP/aws" <<'STUB'
15
+ #!/usr/bin/env bash
16
+ # Stub `aws secretsmanager get-secret-value`: canned output per --secret-id.
17
+ id=""
18
+ while [[ $# -gt 0 ]]; do
19
+ case "$1" in
20
+ --secret-id) id="$2"; shift 2 ;;
21
+ *) shift ;;
22
+ esac
23
+ done
24
+ case "$id" in
25
+ good)
26
+ printf '%s' '{"CLICKHOUSE_HOST":"db.example.com","CLICKHOUSE_PORT":"443","CLICKHOUSE_PROVISIONING_USER":"provisioner","CLICKHOUSE_PROVISIONING_PASSWORD":"p@ss word$=x","SUB_SITE_MONGODB_URI":"mongodb://user:pw@host:27017/db","MCP_CLICKHOUSE_GRANT_TO_USER":"mcp_service","POINT_TOPIC_ORG_ID":"65dc868c4f33e6b7d256fab4"}'
27
+ ;;
28
+ fails) echo '{"CLICKHOUSE_PASSWORD":"SUPERSECRET"}'; exit 1 ;;
29
+ badjson) printf '%s' '{not json' ;;
30
+ badkey) printf '%s' '{"BAD KEY":"v","OK_KEY":"fine"}' ;;
31
+ nonstring) printf '%s' '{"CLICKHOUSE_PORT":443}' ;;
32
+ multiline) printf '%s' '{"CLICKHOUSE_PASSWORD":"line1\nline2"}' ;;
33
+ empty) printf '%s' '{}' ;;
34
+ esac
35
+ exit 0
36
+ STUB
37
+ chmod +x "$TMP/aws"
38
+
39
+ run() { PATH="$TMP:$PATH" "$EXEC_ENV" "$@"; }
40
+
41
+ # 1. Happy path: exports every key verbatim and execs the command.
42
+ out="$(run good -- sh -c 'printf "%s|%s|%s|%s" "$CLICKHOUSE_HOST" "$CLICKHOUSE_PROVISIONING_PASSWORD" "$SUB_SITE_MONGODB_URI" "$POINT_TOPIC_ORG_ID"')"
43
+ [ "$out" = 'db.example.com|p@ss word$=x|mongodb://user:pw@host:27017/db|65dc868c4f33e6b7d256fab4' ] \
44
+ || fail "happy path (got: $out)"
45
+ pass "happy path: keys exported verbatim, command exec'd"
46
+
47
+ # 2. exec semantics: child's exit code propagates.
48
+ run good -- sh -c 'exit 7' 2>/dev/null
49
+ [ $? -eq 7 ] || fail "exit code passthrough"
50
+ pass "exec semantics: child exit code propagates"
51
+
52
+ # 3. --region is accepted.
53
+ out="$(run good --region eu-west-1 -- sh -c 'printf "%s" "$CLICKHOUSE_HOST"')"
54
+ [ "$out" = "db.example.com" ] || fail "--region parsing (got: $out)"
55
+ pass "--region accepted"
56
+
57
+ # 4. Fetch failure: exits non-zero, generic message, NEVER echoes the secret.
58
+ out="$(run fails -- true 2>&1)"; code=$?
59
+ [ $code -ne 0 ] || fail "fetch failure should exit non-zero"
60
+ case "$out" in
61
+ *SUPERSECRET*) fail "fetch failure echoed the secret: $out" ;;
62
+ esac
63
+ case "$out" in
64
+ *"failed to fetch secret 'fails'"*) : ;;
65
+ *) fail "fetch failure missing generic message: $out" ;;
66
+ esac
67
+ pass "fetch failure: fail closed, secret never echoed"
68
+
69
+ # 5. Malformed payloads all fail closed with no value echo.
70
+ for id in badjson badkey nonstring multiline empty; do
71
+ out="$(run "$id" -- true 2>&1)"; code=$?
72
+ [ $code -ne 0 ] || fail "$id should exit non-zero"
73
+ case "$out" in
74
+ *line1*) fail "$id echoed a value" ;;
75
+ esac
76
+ done
77
+ pass "malformed payloads (badjson/badkey/nonstring/multiline/empty) fail closed"
78
+
79
+ # 6. Usage errors exit 2.
80
+ run 2>/dev/null; [ $? -eq 2 ] || fail "no args should exit 2"
81
+ run good true 2>/dev/null; [ $? -eq 2 ] || fail "missing '--' should exit 2"
82
+ run good -- 2>/dev/null; [ $? -eq 2 ] || fail "no command after '--' should exit 2"
83
+ pass "usage errors exit 2"
84
+
85
+ echo "all exec-env tests passed"
@@ -91,9 +91,7 @@ def jwks_fixture():
91
91
 
92
92
 
93
93
  def _patch_jwks(jwks):
94
- return patch(
95
- "httpx.AsyncClient.get", new=AsyncMock(return_value=_FakeResponse(jwks))
96
- )
94
+ return patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_FakeResponse(jwks)))
97
95
 
98
96
 
99
97
  async def test_verify_valid_token_returns_claims(verifier, jwks_fixture):
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # exec-env — fetch a JSON secret from AWS Secrets Manager and exec a command
3
+ # with every key exported into its environment (sops exec-env style).
4
+ #
5
+ # exec-env <secret-id> [--region <region>] -- <command [args...]>
6
+ #
7
+ # Design rules (docs/secrets-injection-plan.md):
8
+ # - Uses the ambient AWS credential chain (Roles Anywhere / profile);
9
+ # the wrapped app itself never needs AWS access.
10
+ # - Never writes anything to disk — values live only in the child env.
11
+ # - Fail closed: exits non-zero on any fetch/parse failure and never
12
+ # prints a secret value (aws/python3 stderr is suppressed).
13
+ # - Env keys must match [A-Za-z0-9_]+; values must be single-line strings.
14
+ # - bash 3.2+ (macOS default) and any box Linux bash.
15
+
16
+ set -euo pipefail
17
+
18
+ REGION="${AWS_DEFAULT_REGION:-eu-west-1}"
19
+
20
+ usage() {
21
+ echo "usage: exec-env <secret-id> [--region <region>] -- <command [args...]>" >&2
22
+ }
23
+
24
+ [[ $# -gt 0 ]] || { usage; exit 2; }
25
+ case "$1" in
26
+ -h|--help) usage; exit 0 ;;
27
+ esac
28
+ SECRET_ID="$1"
29
+ shift
30
+
31
+ while [[ $# -gt 0 && "$1" != "--" ]]; do
32
+ case "$1" in
33
+ --region)
34
+ [[ $# -ge 2 ]] || { echo "exec-env: --region needs a value" >&2; exit 2; }
35
+ REGION="$2"
36
+ shift 2
37
+ ;;
38
+ *)
39
+ echo "exec-env: unknown option '$1' — expected '--' before the command" >&2
40
+ exit 2
41
+ ;;
42
+ esac
43
+ done
44
+ [[ ${1:-} == "--" ]] || { echo "exec-env: missing '--' before the command" >&2; usage; exit 2; }
45
+ shift
46
+ [[ $# -gt 0 ]] || { echo "exec-env: no command given after '--'" >&2; exit 2; }
47
+
48
+ # Fetch. The secret never leaves this process and is never printed.
49
+ json="$(aws secretsmanager get-secret-value \
50
+ --secret-id "$SECRET_ID" \
51
+ --region "$REGION" \
52
+ --query SecretString \
53
+ --output text 2>/dev/null)" || {
54
+ echo "exec-env: failed to fetch secret '$SECRET_ID' (region $REGION) — check the role's GetSecretValue grant and the ambient AWS profile" >&2
55
+ exit 1
56
+ }
57
+
58
+ # Parse into key=value lines. Values must be single-line strings (no NUL,
59
+ # no newline); keys are re-validated below before export.
60
+ pairs="$(printf '%s' "$json" | python3 -c "
61
+ import json, sys
62
+ data = json.load(sys.stdin)
63
+ if not isinstance(data, dict):
64
+ sys.exit('payload is not a JSON object')
65
+ for key, value in data.items():
66
+ if not key or not all('a' <= c <= 'z' or 'A' <= c <= 'Z' or '0' <= c <= '9' or c == '_' for c in key):
67
+ sys.exit('invalid env key')
68
+ if not isinstance(value, str) or chr(0) in value or chr(10) in value or chr(13) in value:
69
+ sys.exit('unsupported value for key ' + key)
70
+ sys.stdout.write(key + '=' + value + chr(10))
71
+ " 2>/dev/null)" || {
72
+ echo "exec-env: failed to parse secret '$SECRET_ID' (expected a flat JSON object of single-line strings)" >&2
73
+ exit 1
74
+ }
75
+ unset json
76
+
77
+ exported=0
78
+ while IFS='=' read -r key value; do
79
+ case "$key" in
80
+ ""|*[!A-Za-z0-9_]*)
81
+ echo "exec-env: secret '$SECRET_ID' contains an invalid env key — refusing to export" >&2
82
+ exit 1
83
+ ;;
84
+ esac
85
+ export "$key=$value"
86
+ exported=$((exported + 1))
87
+ done <<< "$pairs"
88
+ unset pairs
89
+
90
+ [[ "$exported" -gt 0 ]] || { echo "exec-env: secret '$SECRET_ID' has no keys to export" >&2; exit 1; }
91
+
92
+ exec "$@"
@@ -415,7 +415,7 @@ wheels = [
415
415
 
416
416
  [[package]]
417
417
  name = "point-topic-access"
418
- version = "0.2.0"
418
+ version = "0.2.1"
419
419
  source = { editable = "." }
420
420
  dependencies = [
421
421
  { name = "clickhouse-connect" },