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.
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/PKG-INFO +21 -1
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/README.md +20 -0
- point_topic_access-0.2.1/docs/secrets-injection-plan.md +190 -0
- point_topic_access-0.2.1/docs/tickets/point-topic-mcp-lock-bump-open-data.md +39 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/pyproject.toml +1 -1
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/composer.py +10 -31
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/jwt.py +10 -14
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/ontology-permission-contract.json +1 -1
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/provisioning.py +5 -4
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_claims.py +4 -22
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_composer.py +4 -14
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_contract.py +4 -4
- point_topic_access-0.2.1/tests/test_exec_env.sh +85 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/tests/test_jwt.py +1 -3
- point_topic_access-0.2.1/tools/exec-env +92 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/uv.lock +1 -1
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/.gitignore +0 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/HANDOFF.md +0 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/docs/ONTOLOGY_APP_HOOKUP.md +0 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/__init__.py +0 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/claims.py +0 -0
- {point_topic_access-0.2.0 → point_topic_access-0.2.1}/src/pt_access/contract.py +0 -0
- {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.
|
|
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.
|
|
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 /
|
|
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
|
-
"
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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()
|
|
@@ -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
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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
|
-
"
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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(
|
|
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 "$@"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|