[NOTICKET] revert: unwire F-2 service-secret gate — unarmable with a browser-only caller
Browse filesThe gate (#37) shipped inert 2026-07-23, meant to be armed once the caller sent
`X-Dataeyond-Service-Secret`. Verified in E2E-Frontend `src/services/agenticApi.ts`
that the sole caller of this service is the browser SPA, which sends only
`Content-Type` and which we do not own and cannot change. Arming the gate (setting
`dataeyond__service__secret`) would therefore 401 the entire app. A wired-but-unarmable
gate is a footgun: any operator who sets the env var takes prod down, with nothing in
the code preventing it.
Lead decision (Rifqi, 2026-07-27): unwire it and record the live surface as
unauthenticated by design until Go forwards a verified per-user identity (#43).
Changes — un-wiring only, nothing else touched:
- main.py: dropped `dependencies=_guard` from all six router mounts; commented the
`_guard` line, the `service_auth` import, and the ENFORCED/DISABLED boot-log block;
the boot log now states plainly that the surface is unauthenticated.
- src/middlewares/service_auth.py: kept in-tree (comment-out-don't-delete) with a
PARKED banner; the function is correct and unchanged, just unmounted.
- src/config/settings.py: `dataeyond_service_secret` field commented; `extra="allow"`
means a stale `.env` value is ignored, not an error.
- Docs: API_CONTRACT "Authentication" now says "None — do not expose beyond the demo"
with the history; REPO_STATUS security section updated; DEV_PLAN #37 -> paused with
the reasoning, #43 promoted to the sole auth path, decisions line added.
NOT touched: F-1 tenant predicates (#38) stay as defensive-in-depth; F-22 UUID
validation stays; CORS left at ["*"] deliberately (tightening needs the FE origin as
config, which we chose not to set now). Restoring the gate is a one-edit reversal,
documented beside the router mounts.
Verification: suite 456 passed / 0 failed / 7 skipped (unchanged — nothing asserted
the wiring; test_service_auth.py still passes against the parked function). Ruff on
touched paths identical to HEAD (7 E501 / 1 S104 / 1 I001, all pre-existing).
`import main` OK; all 10 live /api routes still mount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- API_CONTRACT_BE_PYTHON.md +5 -9
- DEV_PLAN.md +9 -2
- REPO_STATUS.md +8 -6
- main.py +30 -25
- src/config/settings.py +8 -9
- src/middlewares/service_auth.py +10 -0
|
@@ -36,19 +36,15 @@ The frontend uses this service during the analysis conversation flow:
|
|
| 36 |
| `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
|
| 37 |
| `GET` | `/api/v1/charts` | Retrieve chart(s) produced by `render_chart` for one assistant answer (added 2026-07-13). |
|
| 38 |
|
| 39 |
-
## Authentication
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
-
```
|
| 44 |
-
X-Dataeyond-Service-Secret: <secret>
|
| 45 |
-
```
|
| 46 |
-
|
| 47 |
-
A missing or incorrect value returns `401` with `{"detail": "Missing or invalid service credentials."}`. `GET /` and `GET /health` are exempt (deployment health probes).
|
| 48 |
|
| 49 |
-
**
|
| 50 |
|
| 51 |
-
**
|
| 52 |
|
| 53 |
## Common Concepts
|
| 54 |
|
|
|
|
| 36 |
| `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
|
| 37 |
| `GET` | `/api/v1/charts` | Retrieve chart(s) produced by `render_chart` for one assistant answer (added 2026-07-13). |
|
| 38 |
|
| 39 |
+
## Authentication
|
| 40 |
|
| 41 |
+
**None. The live surface is unauthenticated — do not expose it beyond the demo.**
|
| 42 |
|
| 43 |
+
The service accepts `user_id` and `analysis_id` as ordinary request fields with no verification, and there is no caller authentication in front of any endpoint.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
**History (2026-07-23 → 2026-07-27).** A shared service-secret gate (`X-Dataeyond-Service-Secret`, router-level, constant-time compare) shipped 2026-07-23, inert until `dataeyond__service__secret` was set. It was **unwired on 2026-07-27** (DEV_PLAN #37): the only caller of this service is the browser SPA, which cannot be changed to send the header, so the gate could never be armed without a 401 outage. The gate code is parked in `src/middlewares/service_auth.py` (not deleted) and restores in one edit if the caller situation changes.
|
| 46 |
|
| 47 |
+
**The real fix (DEV_PLAN #43).** Per-user authorization requires a verified identity forwarded by the Go service; once Go forwards a token, `user_id` comes from the verified claims rather than the request body. Until then the `user_id` predicates in the stores (F-1) are defensive-in-depth only, not an access control.
|
| 48 |
|
| 49 |
## Common Concepts
|
| 50 |
|
|
@@ -238,13 +238,13 @@ plus the live report bug on analysis `966224d4…`. Same status legend as §0.
|
|
| 238 |
| 34 | **Report floor extension** — a successful `retrieve_data` **that returned rows** clears the floor | Rifqi | ✅ | Same root cause; fixes a hard **409** for a session where every question is R2/R2b-shaped. Guardrail-adjacent, authorised 2026-07-23. NOT the "Floor Fixer" failure mode: the floor still asks "did we produce a real result" — empty retrievals, `check_*`-only and fully-failed runs all still fail it |
|
| 239 |
| 35 | **`GET …/records` `substantive` flag** repointed to the body predicate + contract §records updated | Rifqi | ✅ | The curation list was contradicting the artifact it curates. Behavioral, non-breaking: no field added/removed/retyped |
|
| 240 |
| 36 | **CK5b** — quality checkpoint covers analyze-free plans | Rifqi | ✅ | CK5 only inspected `analyze_*` tasks, so an all-null aggregate column on an R2/R2b plan reached the answer unflagged. `check_*` excluded (uncounted tables legitimately carry nulls) |
|
| 241 |
-
| 37 | **F-2 service-secret gate** — `X-Dataeyond-Service-Secret`, router-level dependency | Rifqi |
|
| 242 |
| 38 | **F-1 tenant scoping** — `user_id` predicate on the six analysis-keyed reads | Rifqi | ✅ | `CatalogStore.get_by_analysis` filtered on `analysis_id` alone where Go filters on both; the catalog payload carries the owner's `user_id`, so `DbExecutor`'s ownership check compared the victim's id against itself and passed → cross-tenant **query execution against a customer DB**. Defence-in-depth only until #37 is armed (`user_id` is caller-supplied, and `GET /traceability` leaks it) |
|
| 243 |
| 39 | **Stale tests resolved** — the two long-standing suite failures | Rifqi | ✅ | Both encoded the pre-2026-07-13 user-scope fallback that `reader.py` deliberately removed. Not product bugs. Suite is now **394 passed / 0 failed / 7 skipped** — fully green for the first time |
|
| 244 |
| 40 | ~~**F-3** — scope `GET /charts` + `GET /traceability` by `user_id`~~ | Rifqi | ⛔ | **RESOLVED 2026-07-23 — DECLINED by Rifqi. Do not re-open without his sign-off.** Both endpoints keep their existing lookup keys: `/traceability` by `(analysis_id, message_id)`, `/charts` by `message_id` alone (the 2026-07-13 lead decision). F-3 proposed adding a `user_id` parameter to both; an optional-param version was implemented on 2026-07-23 and then **reverted in full** (endpoints, charts-store predicates, and the contract notes) once the decision was restated — no FE change is required and none should be requested. **Accepted consequence:** both endpoints remain unauthenticated capability URLs over real customer data (`charts[].spec.plotly.data` is actual table values; the traceability payload carries 5-row previews, the executed SQL, and the owner's `user_id`). **The service-secret gate (#37) is therefore the only control protecting them** — which raises #37 from important to load-bearing. The stores' optional `user_id` parameters are kept as dead capability for a future Go-forwarded identity (#43); `PostgresChartStore` was returned to its message_id-only form. |
|
| 245 |
| 41 | **F-12 / F-13** — bound the planner catalog render and the tabular blob read | Rifqi | ✅ | Shipped 2026-07-23. **F-13:** neither storage backend could report an object size, so `object_size()` was added to both (S3 `head_object`→`ContentLength`, Azure `get_blob_properties().size`, each returning None rather than raising) and `TabularExecutor` now refuses >500 MB **before** downloading, with a post-download byte check as the fallback when the probe is unavailable. **F-12:** `render()` gained TWO ceilings — `_MAX_TABLES=150` and `_MAX_CATALOG_CHARS=250_000` — because a table-count cap alone leaves wide tables unbounded (20 tables × 300 cols is as fatal as 400 × 30). Measured after: 400×30 went from ~241k tokens to ~63k; 100×30 (~45k tokens) still renders **in full**, so no realistic catalog is touched. Truncation emits an explicit "N more tables not shown" line so the planner knows it saw a subset, and logs — that log is the signal to retune. 12 new tests |
|
| 246 |
| 42 | **F-20 observability** — `degraded_seam=<name>` on every never-throw / silent-drop path | Rifqi | 🔄 | The 2026-07-23 report bug was invisible by construction: the record was dropped with zero logging. **Partially shipped 2026-07-23** — the 10 seams where silent degradation is user-visible now emit a stable `degraded_seam` field (+ `repr(e)` instead of `str(e)`, so an empty-`str()` Fernet error is no longer a blank log): `input_guard_fail_open`, `analysis_catalog_read`, `report_floor_record_read`, `traceability_persist`, `traceability_flush`, `chart_persist` (×2), `report_input_persist` (×2), `analysis_state_ensure`. **Remaining:** the other ~76 `except Exception` sites, most of which are in unwired routers (`db_client`, `data_catalog`, `users`) or non-live paths — deliberately not swept, since a blanket edit across unwired code is exactly the drive-by §7A forbids. Control flow unchanged throughout (§5.4) |
|
| 247 |
-
| 43 | **Go identity contract** — what does Go forward, and when? | Rifqi ↔ Harry | ⬜
|
| 248 |
| 44 | **F-17 / F-18 / F-25 compiler-parity batch** | Rifqi | ✅ new | Shipped 2026-07-23. Three execution-verified review findings that had **no task row** — the tracker jumped from #39 to #40 and lost them. **F-17 (High):** the bare-select check was gated on `if ir.group_by`, so a mixed select with `group_by=[]` passed; Postgres then failed loudly but the pandas path silently DROPPED the column while `output_columns` still advertised it → a real-looking table with a fabricated all-null column. Fixed in `validator.py` (fires whenever any agg is present — no false positives possible) + a presence backstop in `TabularExecutor`. **F-18:** `astype(str)` ran before `na=False`, so `NULL LIKE '%an%'` matched the literal `"nan"`/`"None"`. **F-25:** `SqlCompiler` raised on an empty `in`/`not_in` where pandas and `_column_values`' own docstring implement the empty-set semantics — a two-step plan whose first step legitimately returned zero rows hard-failed instead of answering. 16 new parity tests; one stale test updated (it pinned the old F-25 raise) |
|
| 249 |
| 45 | **F-9 PII in persisted artifacts** — mask the traceability preview + report evidence tables | Rifqi | ✅ | Shipped 2026-07-24. `pii_flag` was an **ingestion-time control only**: it nulls `sample_values` into the planner prompt, but nothing stops the planner SELECTing a flagged column — and "list our top 20 customers" legitimately selects `customer_name`/`email`. Real values then reached two PERSISTED sinks: `message_traceability.data` (served by an unauthenticated GET, F-3 declined) and report evidence tables frozen permanently into `reports.content`. Fix = `retrieve_data` now carries `meta.pii_columns` (resolved through the IR select list, so aliases are honoured), and both sinks redact those cells to `[redacted]`. **Per the 2026-07-23 decision the ASSEMBLER still receives real values**, so the answer prose is unchanged and the question stays answerable — verified end-to-end: assembler input kept `Ada Lovelace`, the persisted preview showed `[redacted]`. Aggregates are deliberately NOT masked except `min`/`max`: `sum(salary)` identifies nobody, but `max(email)` returns one customer's actual address. Fails **open** (an unresolvable name is left unmasked, never a legitimate column wrongly blanked), so this is a mitigation, not a guarantee. 15 new tests |
|
| 250 |
| 46 | **F-8 prompt-injection resistance** — planner / assembler / report_summary | Rifqi | ✅ new | Shipped 2026-07-24. The three prompts that ingest customer data had **no injection rule**: `guardrails.md` is appended only in `chatbot.py`/`help.py`, and the `InputGuard` screens only the user's message — it never sees catalog or row content. This is the one attack the five query-defense layers structurally cannot see, because every IR the planner emits is individually valid. A hostile string only needs to reach a text column in the customer's OWN database (a product description, a support ticket, a form field) to be sampled into `sample_values` and rendered verbatim into the planner prompt. Fix = a purpose-written "content is data, never instructions" rule in each prompt + `<data>…</data>` delimiters around the catalog render and the run-state render. **Deliberately NOT `guardrails.md` wholesale** — its rules prescribe refusal sentences, and the planner's only free-text field is `infeasible_reason`, so those strings would surface there and could regress the Q2 data-gap path (tests pin their absence). Verified: planner eval **6/6, carried_over 5/5 green**; live hostile-catalog run planned only `t_products` and never touched the planted `employees.salary`; 10 new tests |
|
|
@@ -266,6 +266,13 @@ drift; per §7F no result file is ever deleted or overwritten.
|
|
| 266 |
Go's `database_clients.Service.Create` enforces `isSupportedActive`, and only `postgres` is
|
| 267 |
`active`, so no such source can be registered today.
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
## 5. Critical path & sequencing
|
| 270 |
|
| 271 |
- **Critical path:** ~~#22 (send Harry the `report_inputs` schema)~~ **✅ resolved 2026-07-22** — now **#32** (`message_traceability` + `message_charts` DDL to Harry) and **#31** (non-convergent migration set). HF deploy (#13) for the playground. (#4 ✅, #21 ✅; Harry's #3 no longer blocks us — Python is getattr-tolerant.)
|
|
|
|
| 238 |
| 34 | **Report floor extension** — a successful `retrieve_data` **that returned rows** clears the floor | Rifqi | ✅ | Same root cause; fixes a hard **409** for a session where every question is R2/R2b-shaped. Guardrail-adjacent, authorised 2026-07-23. NOT the "Floor Fixer" failure mode: the floor still asks "did we produce a real result" — empty retrievals, `check_*`-only and fully-failed runs all still fail it |
|
| 239 |
| 35 | **`GET …/records` `substantive` flag** repointed to the body predicate + contract §records updated | Rifqi | ✅ | The curation list was contradicting the artifact it curates. Behavioral, non-breaking: no field added/removed/retyped |
|
| 240 |
| 36 | **CK5b** — quality checkpoint covers analyze-free plans | Rifqi | ✅ | CK5 only inspected `analyze_*` tasks, so an all-null aggregate column on an R2/R2b plan reached the answer unflagged. `check_*` excluded (uncounted tables legitimately carry nulls) |
|
| 241 |
+
| 37 | **F-2 service-secret gate** — `X-Dataeyond-Service-Secret`, router-level dependency | Rifqi | ⏸️ | Shipped inert 2026-07-23. **UNWIRED 2026-07-27 (lead decision).** Verified in `E2E-Frontend-Data-Eyond/src/services/agenticApi.ts` that the **browser SPA is the only caller** of Python and we don't own it — it sends only `Content-Type`, and cannot be changed by us to send the header. So the gate could never be armed without a 401 outage: a wired-but-unarmable gate is a footgun (any operator setting `dataeyond__service__secret` breaks prod). Guard dependency removed from all six router mounts in `main.py`; `service_auth.py` kept in-tree but parked (comment-out-don't-delete); the `dataeyond_service_secret` setting commented. A prepared FE proxy (Node `server.js` holding the secret and injecting the header server-side) is the way to arm it later without exposing the secret to the browser — but that needs FE-repo access we don't have. **Net: the live surface is unauthenticated by design; real auth = #43. F-1 tenant predicates (#38) stay** as defensive-in-depth. |
|
| 242 |
| 38 | **F-1 tenant scoping** — `user_id` predicate on the six analysis-keyed reads | Rifqi | ✅ | `CatalogStore.get_by_analysis` filtered on `analysis_id` alone where Go filters on both; the catalog payload carries the owner's `user_id`, so `DbExecutor`'s ownership check compared the victim's id against itself and passed → cross-tenant **query execution against a customer DB**. Defence-in-depth only until #37 is armed (`user_id` is caller-supplied, and `GET /traceability` leaks it) |
|
| 243 |
| 39 | **Stale tests resolved** — the two long-standing suite failures | Rifqi | ✅ | Both encoded the pre-2026-07-13 user-scope fallback that `reader.py` deliberately removed. Not product bugs. Suite is now **394 passed / 0 failed / 7 skipped** — fully green for the first time |
|
| 244 |
| 40 | ~~**F-3** — scope `GET /charts` + `GET /traceability` by `user_id`~~ | Rifqi | ⛔ | **RESOLVED 2026-07-23 — DECLINED by Rifqi. Do not re-open without his sign-off.** Both endpoints keep their existing lookup keys: `/traceability` by `(analysis_id, message_id)`, `/charts` by `message_id` alone (the 2026-07-13 lead decision). F-3 proposed adding a `user_id` parameter to both; an optional-param version was implemented on 2026-07-23 and then **reverted in full** (endpoints, charts-store predicates, and the contract notes) once the decision was restated — no FE change is required and none should be requested. **Accepted consequence:** both endpoints remain unauthenticated capability URLs over real customer data (`charts[].spec.plotly.data` is actual table values; the traceability payload carries 5-row previews, the executed SQL, and the owner's `user_id`). **The service-secret gate (#37) is therefore the only control protecting them** — which raises #37 from important to load-bearing. The stores' optional `user_id` parameters are kept as dead capability for a future Go-forwarded identity (#43); `PostgresChartStore` was returned to its message_id-only form. |
|
| 245 |
| 41 | **F-12 / F-13** — bound the planner catalog render and the tabular blob read | Rifqi | ✅ | Shipped 2026-07-23. **F-13:** neither storage backend could report an object size, so `object_size()` was added to both (S3 `head_object`→`ContentLength`, Azure `get_blob_properties().size`, each returning None rather than raising) and `TabularExecutor` now refuses >500 MB **before** downloading, with a post-download byte check as the fallback when the probe is unavailable. **F-12:** `render()` gained TWO ceilings — `_MAX_TABLES=150` and `_MAX_CATALOG_CHARS=250_000` — because a table-count cap alone leaves wide tables unbounded (20 tables × 300 cols is as fatal as 400 × 30). Measured after: 400×30 went from ~241k tokens to ~63k; 100×30 (~45k tokens) still renders **in full**, so no realistic catalog is touched. Truncation emits an explicit "N more tables not shown" line so the planner knows it saw a subset, and logs — that log is the signal to retune. 12 new tests |
|
| 246 |
| 42 | **F-20 observability** — `degraded_seam=<name>` on every never-throw / silent-drop path | Rifqi | 🔄 | The 2026-07-23 report bug was invisible by construction: the record was dropped with zero logging. **Partially shipped 2026-07-23** — the 10 seams where silent degradation is user-visible now emit a stable `degraded_seam` field (+ `repr(e)` instead of `str(e)`, so an empty-`str()` Fernet error is no longer a blank log): `input_guard_fail_open`, `analysis_catalog_read`, `report_floor_record_read`, `traceability_persist`, `traceability_flush`, `chart_persist` (×2), `report_input_persist` (×2), `analysis_state_ensure`. **Remaining:** the other ~76 `except Exception` sites, most of which are in unwired routers (`db_client`, `data_catalog`, `users`) or non-live paths — deliberately not swept, since a blanket edit across unwired code is exactly the drive-by §7A forbids. Control flow unchanged throughout (§5.4) |
|
| 247 |
+
| 43 | **Go identity contract** — what does Go forward, and when? | Rifqi ↔ Harry | ⬜ | **Now the sole path to caller auth** after #37 was unwired 2026-07-27. Python cannot authenticate the caller alone; either Go forwards a verified per-user token, or the FE (once we can change it) forwards the Go bearer token it already holds (`orchestrationApi.ts` shows the FE has one). Until then the live surface is unauthenticated and the #38 predicates are defensive only. Options for arming interim protection when FE access returns: the `server.js` BFF proxy (secret server-side) or route agentic calls through Go |
|
| 248 |
| 44 | **F-17 / F-18 / F-25 compiler-parity batch** | Rifqi | ✅ new | Shipped 2026-07-23. Three execution-verified review findings that had **no task row** — the tracker jumped from #39 to #40 and lost them. **F-17 (High):** the bare-select check was gated on `if ir.group_by`, so a mixed select with `group_by=[]` passed; Postgres then failed loudly but the pandas path silently DROPPED the column while `output_columns` still advertised it → a real-looking table with a fabricated all-null column. Fixed in `validator.py` (fires whenever any agg is present — no false positives possible) + a presence backstop in `TabularExecutor`. **F-18:** `astype(str)` ran before `na=False`, so `NULL LIKE '%an%'` matched the literal `"nan"`/`"None"`. **F-25:** `SqlCompiler` raised on an empty `in`/`not_in` where pandas and `_column_values`' own docstring implement the empty-set semantics — a two-step plan whose first step legitimately returned zero rows hard-failed instead of answering. 16 new parity tests; one stale test updated (it pinned the old F-25 raise) |
|
| 249 |
| 45 | **F-9 PII in persisted artifacts** — mask the traceability preview + report evidence tables | Rifqi | ✅ | Shipped 2026-07-24. `pii_flag` was an **ingestion-time control only**: it nulls `sample_values` into the planner prompt, but nothing stops the planner SELECTing a flagged column — and "list our top 20 customers" legitimately selects `customer_name`/`email`. Real values then reached two PERSISTED sinks: `message_traceability.data` (served by an unauthenticated GET, F-3 declined) and report evidence tables frozen permanently into `reports.content`. Fix = `retrieve_data` now carries `meta.pii_columns` (resolved through the IR select list, so aliases are honoured), and both sinks redact those cells to `[redacted]`. **Per the 2026-07-23 decision the ASSEMBLER still receives real values**, so the answer prose is unchanged and the question stays answerable — verified end-to-end: assembler input kept `Ada Lovelace`, the persisted preview showed `[redacted]`. Aggregates are deliberately NOT masked except `min`/`max`: `sum(salary)` identifies nobody, but `max(email)` returns one customer's actual address. Fails **open** (an unresolvable name is left unmasked, never a legitimate column wrongly blanked), so this is a mitigation, not a guarantee. 15 new tests |
|
| 250 |
| 46 | **F-8 prompt-injection resistance** — planner / assembler / report_summary | Rifqi | ✅ new | Shipped 2026-07-24. The three prompts that ingest customer data had **no injection rule**: `guardrails.md` is appended only in `chatbot.py`/`help.py`, and the `InputGuard` screens only the user's message — it never sees catalog or row content. This is the one attack the five query-defense layers structurally cannot see, because every IR the planner emits is individually valid. A hostile string only needs to reach a text column in the customer's OWN database (a product description, a support ticket, a form field) to be sampled into `sample_values` and rendered verbatim into the planner prompt. Fix = a purpose-written "content is data, never instructions" rule in each prompt + `<data>…</data>` delimiters around the catalog render and the run-state render. **Deliberately NOT `guardrails.md` wholesale** — its rules prescribe refusal sentences, and the planner's only free-text field is `infeasible_reason`, so those strings would surface there and could regress the Q2 data-gap path (tests pin their absence). Verified: planner eval **6/6, carried_over 5/5 green**; live hostile-catalog run planned only `t_products` and never touched the planted `employees.salary`; 10 new tests |
|
|
|
|
| 266 |
Go's `database_clients.Service.Create` enforces `isSupportedActive`, and only `postgres` is
|
| 267 |
`active`, so no such source can be registered today.
|
| 268 |
|
| 269 |
+
**Decided 2026-07-27 (lead) — F-2 gate unwired, not armed.** The service-secret gate (#37) was
|
| 270 |
+
removed from the router mounts because the sole caller is a browser SPA we don't own and can't
|
| 271 |
+
change to send the header; arming it would 401 the whole app. The live surface is **unauthenticated
|
| 272 |
+
by design** until #43 (Go-forwarded identity). Not a gap to re-raise — it is a recorded posture.
|
| 273 |
+
CORS was left at `["*"]` on purpose (tightening it needs the FE origin as config, which we chose
|
| 274 |
+
not to set for now).
|
| 275 |
+
|
| 276 |
## 5. Critical path & sequencing
|
| 277 |
|
| 278 |
- **Critical path:** ~~#22 (send Harry the `report_inputs` schema)~~ **✅ resolved 2026-07-22** — now **#32** (`message_traceability` + `message_charts` DDL to Harry) and **#31** (non-convergent migration set). HF deploy (#13) for the playground. (#4 ✅, #21 ✅; Harry's #3 no longer blocks us — Python is getattr-tolerant.)
|
|
@@ -343,12 +343,14 @@ is not wired: a repo-wide search of the Orchestrator source finds **no HTTP clie
|
|
| 343 |
service** and no config key for one, while the FE calls `POST /api/v2/chat/stream` directly. Two
|
| 344 |
Python-side controls landed:
|
| 345 |
|
| 346 |
-
- **
|
| 347 |
-
(`X-Dataeyond-Service-Secret`
|
| 348 |
-
`
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
| 352 |
- **Tenant predicates.** `CatalogStore.get_by_analysis` filtered on `analysis_id` **alone** while
|
| 353 |
Go's equivalent always filters `analysis_id AND user_id` (`catalog_repo.go`). Because the
|
| 354 |
catalog payload also carries the *owner's* `user_id`, `DbExecutor`'s ownership check then
|
|
|
|
| 343 |
service** and no config key for one, while the FE calls `POST /api/v2/chat/stream` directly. Two
|
| 344 |
Python-side controls landed:
|
| 345 |
|
| 346 |
+
- **Service-secret gate — UNWIRED 2026-07-27 (DEV_PLAN #37).** A shared-secret header
|
| 347 |
+
(`X-Dataeyond-Service-Secret`) shipped 2026-07-23 as a router-level dependency, inert until
|
| 348 |
+
`dataeyond__service__secret` was set. It was **removed from the router mounts** on 2026-07-27:
|
| 349 |
+
the only caller is the browser SPA, which we don't own and can't change to send the header, so
|
| 350 |
+
arming the gate would 401 the whole app — a wired-but-unarmable gate is a footgun. The code is
|
| 351 |
+
parked in `src/middlewares/service_auth.py` (not deleted) and restores in one edit. **Net: the
|
| 352 |
+
live surface is unauthenticated by design**; the durable fix is a Go-forwarded per-user identity
|
| 353 |
+
(DEV_PLAN #43).
|
| 354 |
- **Tenant predicates.** `CatalogStore.get_by_analysis` filtered on `analysis_id` **alone** while
|
| 355 |
Go's equivalent always filters `analysis_id AND user_id` (`catalog_repo.go`). Because the
|
| 356 |
catalog payload also carries the *owner's* `user_id`, `DbExecutor`'s ownership check then
|
|
@@ -2,10 +2,15 @@
|
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
|
| 5 |
-
from fastapi import
|
| 6 |
from src.middlewares.logging import configure_logging, get_logger
|
| 7 |
from src.middlewares.cors import add_cors_middleware
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from src.middlewares.rate_limit import limiter, _rate_limit_exceeded_handler
|
| 10 |
from slowapi.errors import RateLimitExceeded
|
| 11 |
# --- pr/5 Phase 1: unwire non-AI routers (Go owns these now). ---
|
|
@@ -42,16 +47,13 @@ async def lifespan(app: FastAPI):
|
|
| 42 |
logger.info("Database initialized")
|
| 43 |
else:
|
| 44 |
logger.info("Skipping database initialization (SKIP_INIT_DB=true)")
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
"Service-secret gate DISABLED (dataeyond__service__secret unset) — "
|
| 53 |
-
"every endpoint is reachable without credentials"
|
| 54 |
-
)
|
| 55 |
yield
|
| 56 |
|
| 57 |
|
|
@@ -76,19 +78,22 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
| 76 |
# app.include_router(db_client_router) # unwired: Go registers DB client
|
| 77 |
# app.include_router(data_catalog_router) # unwired: Go handles the catalog
|
| 78 |
# app.include_router(chat_router) # unwired: v2 chat replaces it (drops v1 cache ops routes)
|
| 79 |
-
# F-2
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
-
#
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
app.include_router(
|
| 90 |
-
app.include_router(
|
| 91 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
@app.get("/")
|
|
|
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
from src.middlewares.logging import configure_logging, get_logger
|
| 7 |
from src.middlewares.cors import add_cors_middleware
|
| 8 |
+
# F-2 service-secret gate UNWIRED 2026-07-27 (lead decision, DEV_PLAN #37). The only
|
| 9 |
+
# caller of this service is the browser SPA, which we don't own and can't change to
|
| 10 |
+
# send the header, so the gate could never be armed without a 401 outage. Restore by
|
| 11 |
+
# re-adding these imports + the `_guard` dependency on each router mount below.
|
| 12 |
+
# from fastapi import Depends
|
| 13 |
+
# from src.middlewares.service_auth import is_enforced, require_service_secret
|
| 14 |
from src.middlewares.rate_limit import limiter, _rate_limit_exceeded_handler
|
| 15 |
from slowapi.errors import RateLimitExceeded
|
| 16 |
# --- pr/5 Phase 1: unwire non-AI routers (Go owns these now). ---
|
|
|
|
| 47 |
logger.info("Database initialized")
|
| 48 |
else:
|
| 49 |
logger.info("Skipping database initialization (SKIP_INIT_DB=true)")
|
| 50 |
+
# F-2 service-secret gate UNWIRED 2026-07-27 (DEV_PLAN #37): the live surface is
|
| 51 |
+
# unauthenticated by design — Python cannot authenticate a browser-only caller it
|
| 52 |
+
# doesn't front. The real fix is a verified per-user identity forwarded by Go
|
| 53 |
+
# (DEV_PLAN #43). Do not expose this service beyond the demo.
|
| 54 |
+
logger.warning(
|
| 55 |
+
"No caller authentication on the live surface (F-2 unwired — see DEV_PLAN #37)"
|
| 56 |
+
)
|
|
|
|
|
|
|
|
|
|
| 57 |
yield
|
| 58 |
|
| 59 |
|
|
|
|
| 78 |
# app.include_router(db_client_router) # unwired: Go registers DB client
|
| 79 |
# app.include_router(data_catalog_router) # unwired: Go handles the catalog
|
| 80 |
# app.include_router(chat_router) # unwired: v2 chat replaces it (drops v1 cache ops routes)
|
| 81 |
+
# F-2 service-secret gate UNWIRED 2026-07-27 (lead decision, DEV_PLAN #37). It shipped
|
| 82 |
+
# 2026-07-23 as a router-level dependency, inert until `dataeyond__service__secret` was
|
| 83 |
+
# set. But the sole caller is the browser SPA (verified in E2E-Frontend `agenticApi.ts`),
|
| 84 |
+
# which we don't own and can't change to send `X-Dataeyond-Service-Secret` — so the gate
|
| 85 |
+
# could never be armed without a 401 outage. A wired-but-unarmable gate is a footgun, so
|
| 86 |
+
# the dependency is removed here. `src/middlewares/service_auth.py` stays in-tree (parked,
|
| 87 |
+
# not deleted). To restore: re-add the imports above and
|
| 88 |
+
# _guard = [Depends(require_service_secret)]
|
| 89 |
+
# then pass `dependencies=_guard` to each mount below. Real auth = DEV_PLAN #43.
|
| 90 |
+
|
| 91 |
+
app.include_router(report_router)
|
| 92 |
+
app.include_router(tools_router)
|
| 93 |
+
app.include_router(help_router)
|
| 94 |
+
app.include_router(traceability_router) # KM-691: GET /api/v1/traceability
|
| 95 |
+
app.include_router(charts_router) # W2: GET /api/v1/charts (§4.5)
|
| 96 |
+
app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream
|
| 97 |
|
| 98 |
|
| 99 |
@app.get("/")
|
|
@@ -99,15 +99,14 @@ class Settings(BaseSettings):
|
|
| 99 |
alias="dataeyond__db__credential__key"
|
| 100 |
)
|
| 101 |
|
| 102 |
-
# Shared service secret
|
| 103 |
-
#
|
| 104 |
-
#
|
| 105 |
-
#
|
| 106 |
-
#
|
| 107 |
-
#
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
)
|
| 111 |
|
| 112 |
|
| 113 |
# Singleton instance
|
|
|
|
| 99 |
alias="dataeyond__db__credential__key"
|
| 100 |
)
|
| 101 |
|
| 102 |
+
# Shared service secret for the F-2 gate — UNWIRED 2026-07-27 (DEV_PLAN #37). The
|
| 103 |
+
# gate is no longer mounted (see main.py), so this value is read by nothing. Kept
|
| 104 |
+
# commented rather than deleted so the whole feature restores in one place. If a
|
| 105 |
+
# deployment still has `dataeyond__service__secret` in its .env it is simply
|
| 106 |
+
# ignored (extra="allow" on Settings). Restore alongside the main.py dependency.
|
| 107 |
+
# dataeyond_service_secret: str = Field(
|
| 108 |
+
# alias="dataeyond__service__secret", default=""
|
| 109 |
+
# )
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
# Singleton instance
|
|
@@ -1,5 +1,15 @@
|
|
| 1 |
"""Service-secret gate for the live Python surface (F-2, 2026-07-23).
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Python has no authentication of its own. The comments in `api/v1/traceability.py`
|
| 4 |
and `api/v1/charts.py` say "No auth — Go fronts Python", but Go does not: a
|
| 5 |
repo-wide search of the Orchestrator source finds no HTTP client pointed at this
|
|
|
|
| 1 |
"""Service-secret gate for the live Python surface (F-2, 2026-07-23).
|
| 2 |
|
| 3 |
+
⚠️ PARKED / UNWIRED 2026-07-27 (lead decision, DEV_PLAN #37). This module is no longer
|
| 4 |
+
mounted — `main.py` dropped the `Depends(require_service_secret)` dependency from every
|
| 5 |
+
router. It stays in-tree (comment-out-don't-delete) so it can be restored in one edit,
|
| 6 |
+
but it currently does NOTHING regardless of whether `dataeyond__service__secret` is set.
|
| 7 |
+
Reason: the sole caller is the browser SPA (E2E-Frontend), which we don't own and can't
|
| 8 |
+
change to send the header, so arming the gate would 401 the whole app. The durable fix
|
| 9 |
+
is a verified per-user identity forwarded by Go (DEV_PLAN #43). Restore instructions are
|
| 10 |
+
in `main.py` beside the router mounts.
|
| 11 |
+
|
| 12 |
+
|
| 13 |
Python has no authentication of its own. The comments in `api/v1/traceability.py`
|
| 14 |
and `api/v1/charts.py` say "No auth — Go fronts Python", but Go does not: a
|
| 15 |
repo-wide search of the Orchestrator source finds no HTTP client pointed at this
|