feat(auth): enforce finance:read / finance:write scopes on all 22 API endpoints #12

Merged
john merged 1 commit from feat/add-scope-enforcement into dev 2026-07-30 03:54:29 +00:00
Owner

Why

require_scope was real, correct, and structurally unreachable through the MCP gateway.

smeos's gateway mints a short-lived forwarding JWT per proxied tool call (smeos/backend/app/stage_proxy.py:mint_forwarding_jwt) carrying gateway: True. Until smeos 5de5a7b that token carried no scopes claim, and haskos_kernel.auth resolved any validly-signed JWT to scopes=["*"]. So every scope check on every stage app passed regardless of which credential was actually calling.

Three halves were needed. Two had landed:

Half Status
Kernel 0.3.0_jwt_scopes() honours the scopes claim when gateway is truthy Published
smeos 5de5a7b — forwards auth.scopes when minting Live in prod (haskos-api at ab85c6d)
Stage app actually checking This PR

Money endpoints are where an over-broad credential matters most, so this errs toward gating rather than excluding.

Endpoints covered — 22 total

File Endpoints finance:read finance:write
app/api/invoices.py 9 2 7
app/api/billing_profiles.py 5 2 3
app/api/payment_accounts.py 4 2 2
app/api/generate.py 3 0 3
app/api/pnl.py 1 1 0
Total 22 7 15

Mapping applied exactly as specified: GETFINANCE_READ; POST / PATCH / DELETEFINANCE_WRITE. No deviations — there were no borderline "POST that is semantically a read" cases in this repo. Specifically, as directed:

  • app/api/generate.py — all three document-generation endpoints (/generate, /receipt, /statement) are WRITE. They read like renders but produce durable documents via haskydocs.
  • app/api/pnl.pyGET /pnl/summary is a report read, so READ.

Constants live in the new app/api/scopes.py. Gates are applied via the route decorator's dependencies=[...], not a new function parameter — the check's return value is unused, require_role already occupies the _actor parameter, and a decorator-level gate cannot be silently dropped by a later signature refactor.

Every existing require_role dependency is kept unchanged (verified: 22 Depends(require_role(...)) before and after). Role and scope are orthogonal — role is who the principal is, scope is what this credential may do.

Exclusions

Endpoint Reason
GET /api/v1/health (app/main.py) Unauthenticated liveness probe. Has no require_role, and both the Dockerfile HEALTHCHECK and the CI smoke test poll it before any credential exists. Gating it would break deploys.

That is the only exclusion. I grepped app/ for webhook, hmac, signature and public (case-insensitive): no matches. There are no HMAC-authenticated webhooks and no payment-provider callbacks in this repo. All 22 business endpoints already carried require_role, so none showed the "intentionally public" signal.

Kernel bump

Before After
pyproject.toml haskytech-haskos-kernel>=0.1.1 haskytech-haskos-kernel>=0.3.0
uv.lock version = "0.1.1" version = "0.3.0"

Lock regenerated with uv lock --upgrade-package haskytech-haskos-kernel (Updated haskytech-haskos-kernel v0.1.1 -> v0.3.0) and confirmed to record 0.3.0 — not just the specifier. Installed version verified at runtime via importlib.metadata0.3.0.

The lock diff is kernel-only (4 changed lines: specifier, version, sdist hash, wheel hash). No transitive dependency moved.

Verification

  • uv run ruff check .All checks passed!
  • uv run ruff format --check .28 files already formatted (my multi-line decorators needed no reformatting)
  • App imports and serves: OpenAPI resolves 23 operations = 22 gated + /health

Beyond a static import check, I exercised every gate with FastAPI dependency overrides — for all 22 endpoints:

  • unrelated scope (bugs:read) → 403 with exact detail Missing required scope: finance:{read,write}
  • the opposite finance scope → 403 (confirms read and write are genuinely distinct, not aliases)
  • correct scope → past the gate (422/500 from the stubbed DB, never 403)
  • wildcard * → past the gate
  • GET /health with no credential → 200

tests/ contains only __init__.py and CI runs lint + docker-build only, so no suite was invented; the sweep above was run as a one-off and is not committed.

Blocking Pre-Deploy Step

finance:read and finance:write must be granted to the four scoped operator API keys BEFORE this app is deployed.

These scope names are new. No prod API key currently holds either. The granted stage scopes today are only sales:read/write and pm:read/write (plus workstreams:*, orch:*, ci:*, events:*, env:*, deploy:*, db:seed, intake:*, bugs:*, and *).

Because smeos 5de5a7b is already live in production, enforcement begins the instant this app deploys — this is not a future concern gated on a later smeos release.

Keys that will 403 on every finance endpoint until granted:

Key Current stage scopes Action
dede-operator-v1 pm:*, sales:* Grant finance:read + finance:write
james-operator-v1 pm:*, sales:* Grant finance:read + finance:write
lionel-operator-v1 pm:*, sales:* Grant finance:read + finance:write
mathias-operator pm:*, sales:* Grant finance:read + finance:write

Keys that pass unaffected: system-key (*), prod-operator-v4 (*).

Three client keys — FlexiSCM, Jaberson Planner, SuperClean — carry only bugs:* and will also 403 on finance endpoints. This is the intended outcome and no grant is wanted for them: a bug-reporter credential has no business reading invoices or payment accounts. Listed only so the 403s are not mistaken for a regression.

Interactive logins and the operator dashboard are unaffected — those JWTs carry no gateway claim, so _jwt_scopes returns wildcard and every check passes.

⚠️ The grant is a full-list replace (existing.scopes = scopes), not an append. The runbook must resend each key's existing scopes plus the two new ones, or the key silently loses pm:* and sales:*.

This is a deliberate breaking change — the correct behaviour for a scope gate — and it was not softened to avoid the break. I have not performed the grant and have not touched prod.

Deploy Order

  1. Kernel package 0.3.0 publisheddone
  2. smeos 5de5a7b (forwards auth.scopes)done, live in prod
  3. Grant finance:read + finance:write to the four operator keys ← blocking, full-list replace
  4. Merge + deploy this app

Steps 3 and 4 must not be reordered. Deploying before the grant 403s every non-wildcard caller on all finance endpoints.

Spec Drift Callouts

  1. Kernel pin was 0.1.1, not 0.2.x. The brief warned not to push a lock "that still pins 0.2.x"; the lock actually pinned 0.1.1. Bumped to 0.3.0 as intended — the warning's intent is satisfied.
  2. len(app.routes) is not a route count on this dependency stack. The brief's suggested sanity check prints 6, which looks alarming. Installed FastAPI 0.137.1 / Starlette 1.3.1 keep include_router results as a lazy _IncludedRouter instead of flattening into app.routes. This is pre-existing on dev — the lock diff is kernel-only, so neither package moved in this PR. len(app.openapi()["paths"]) is the meaningful check and correctly reports all 23 operations. Nothing was changed on account of this.
  3. haskos_kernel.auth.ALL_SCOPES does not contain finance:* — consistent with the brief's note that it is not enforced anywhere. Left untouched; the kernel package was not edited.
  4. This repo has no CLAUDE.md. Read README.md (two lines) and the workspace ~/repos/product/haskyos/CLAUDE.md instead. Worth adding, given this is the fourth stage app and agents are dispatched into it.
  5. This repo has no .gitignore at all — nothing is ignored, so a routine git add -A after any local uv run would commit app/**/__pycache__/. I staged explicit paths to avoid this. Flagging rather than fixing, as it is outside this task's scope; the fix is a one-line .gitignore.
  6. Workspace CLAUDE.md still describes the kernel boundary as ADR-007, since superseded by ADR-009. Untouched — noting only.
## Why `require_scope` was real, correct, and **structurally unreachable** through the MCP gateway. smeos's gateway mints a short-lived forwarding JWT per proxied tool call (`smeos/backend/app/stage_proxy.py:mint_forwarding_jwt`) carrying `gateway: True`. Until smeos `5de5a7b` that token carried no `scopes` claim, and `haskos_kernel.auth` resolved *any* validly-signed JWT to `scopes=["*"]`. So every scope check on every stage app passed regardless of which credential was actually calling. Three halves were needed. Two had landed: | Half | Status | |---|---| | Kernel `0.3.0` — `_jwt_scopes()` honours the `scopes` claim when `gateway` is truthy | Published | | smeos `5de5a7b` — forwards `auth.scopes` when minting | **Live in prod** (`haskos-api` at `ab85c6d`) | | **Stage app actually checking** | **This PR** | Money endpoints are where an over-broad credential matters most, so this errs toward gating rather than excluding. ## Endpoints covered — 22 total | File | Endpoints | `finance:read` | `finance:write` | |---|---|---|---| | `app/api/invoices.py` | 9 | 2 | 7 | | `app/api/billing_profiles.py` | 5 | 2 | 3 | | `app/api/payment_accounts.py` | 4 | 2 | 2 | | `app/api/generate.py` | 3 | 0 | 3 | | `app/api/pnl.py` | 1 | 1 | 0 | | **Total** | **22** | **7** | **15** | Mapping applied exactly as specified: `GET` → `FINANCE_READ`; `POST` / `PATCH` / `DELETE` → `FINANCE_WRITE`. **No deviations** — there were no borderline "POST that is semantically a read" cases in this repo. Specifically, as directed: - `app/api/generate.py` — all three document-generation endpoints (`/generate`, `/receipt`, `/statement`) are `WRITE`. They read like renders but produce durable documents via haskydocs. - `app/api/pnl.py` — `GET /pnl/summary` is a report read, so `READ`. Constants live in the new `app/api/scopes.py`. Gates are applied via the route decorator's `dependencies=[...]`, not a new function parameter — the check's return value is unused, `require_role` already occupies the `_actor` parameter, and a decorator-level gate cannot be silently dropped by a later signature refactor. **Every existing `require_role` dependency is kept unchanged** (verified: 22 `Depends(require_role(...))` before and after). Role and scope are orthogonal — role is who the principal is, scope is what this credential may do. ## Exclusions | Endpoint | Reason | |---|---| | `GET /api/v1/health` (`app/main.py`) | Unauthenticated liveness probe. Has no `require_role`, and both the Dockerfile `HEALTHCHECK` and the CI smoke test poll it before any credential exists. Gating it would break deploys. | That is the **only** exclusion. I grepped `app/` for `webhook`, `hmac`, `signature` and `public` (case-insensitive): **no matches**. There are no HMAC-authenticated webhooks and no payment-provider callbacks in this repo. All 22 business endpoints already carried `require_role`, so none showed the "intentionally public" signal. ## Kernel bump | | Before | After | |---|---|---| | `pyproject.toml` | `haskytech-haskos-kernel>=0.1.1` | `haskytech-haskos-kernel>=0.3.0` | | `uv.lock` | `version = "0.1.1"` | `version = "0.3.0"` | Lock **regenerated** with `uv lock --upgrade-package haskytech-haskos-kernel` (`Updated haskytech-haskos-kernel v0.1.1 -> v0.3.0`) and confirmed to record `0.3.0` — not just the specifier. Installed version verified at runtime via `importlib.metadata` → `0.3.0`. The lock diff is **kernel-only** (4 changed lines: specifier, version, sdist hash, wheel hash). No transitive dependency moved. ## Verification - `uv run ruff check .` → `All checks passed!` - `uv run ruff format --check .` → `28 files already formatted` (my multi-line decorators needed no reformatting) - App imports and serves: OpenAPI resolves **23 operations** = 22 gated + `/health` Beyond a static import check, I exercised every gate with FastAPI dependency overrides — for all 22 endpoints: - unrelated scope (`bugs:read`) → **403** with exact detail `Missing required scope: finance:{read,write}` - the *opposite* finance scope → **403** (confirms read and write are genuinely distinct, not aliases) - correct scope → past the gate (422/500 from the stubbed DB, never 403) - wildcard `*` → past the gate - `GET /health` with **no** credential → **200** `tests/` contains only `__init__.py` and CI runs `lint` + `docker-build` only, so no suite was invented; the sweep above was run as a one-off and is not committed. ## ⛔ Blocking Pre-Deploy Step **`finance:read` and `finance:write` must be granted to the four scoped operator API keys BEFORE this app is deployed.** These scope names are **new**. No prod API key currently holds either. The granted stage scopes today are only `sales:read/write` and `pm:read/write` (plus `workstreams:*`, `orch:*`, `ci:*`, `events:*`, `env:*`, `deploy:*`, `db:seed`, `intake:*`, `bugs:*`, and `*`). Because smeos `5de5a7b` is **already live in production**, enforcement begins the *instant* this app deploys — this is not a future concern gated on a later smeos release. Keys that will **403 on every finance endpoint** until granted: | Key | Current stage scopes | Action | |---|---|---| | `dede-operator-v1` | `pm:*`, `sales:*` | **Grant `finance:read` + `finance:write`** | | `james-operator-v1` | `pm:*`, `sales:*` | **Grant `finance:read` + `finance:write`** | | `lionel-operator-v1` | `pm:*`, `sales:*` | **Grant `finance:read` + `finance:write`** | | `mathias-operator` | `pm:*`, `sales:*` | **Grant `finance:read` + `finance:write`** | Keys that pass unaffected: `system-key` (`*`), `prod-operator-v4` (`*`). Three client keys — `FlexiSCM`, `Jaberson Planner`, `SuperClean` — carry only `bugs:*` and will also 403 on finance endpoints. **This is the intended outcome and no grant is wanted for them**: a bug-reporter credential has no business reading invoices or payment accounts. Listed only so the 403s are not mistaken for a regression. Interactive logins and the operator dashboard are **unaffected** — those JWTs carry no `gateway` claim, so `_jwt_scopes` returns wildcard and every check passes. > ⚠️ **The grant is a full-list replace** (`existing.scopes = scopes`), not an append. The runbook must resend each key's *existing* scopes plus the two new ones, or the key silently loses `pm:*` and `sales:*`. This is a deliberate breaking change — the correct behaviour for a scope gate — and it was **not** softened to avoid the break. I have not performed the grant and have not touched prod. ## Deploy Order 1. ~~Kernel package `0.3.0` published~~ — **done** 2. ~~smeos `5de5a7b` (forwards `auth.scopes`)~~ — **done, live in prod** 3. **Grant `finance:read` + `finance:write` to the four operator keys** ← blocking, full-list replace 4. Merge + deploy this app Steps 3 and 4 must not be reordered. Deploying before the grant 403s every non-wildcard caller on all finance endpoints. ## Spec Drift Callouts 1. **Kernel pin was `0.1.1`, not `0.2.x`.** The brief warned not to push a lock "that still pins 0.2.x"; the lock actually pinned `0.1.1`. Bumped to `0.3.0` as intended — the warning's intent is satisfied. 2. **`len(app.routes)` is not a route count on this dependency stack.** The brief's suggested sanity check prints `6`, which looks alarming. Installed FastAPI `0.137.1` / Starlette `1.3.1` keep `include_router` results as a lazy `_IncludedRouter` instead of flattening into `app.routes`. This is **pre-existing on `dev`** — the lock diff is kernel-only, so neither package moved in this PR. `len(app.openapi()["paths"])` is the meaningful check and correctly reports all 23 operations. Nothing was changed on account of this. 3. **`haskos_kernel.auth.ALL_SCOPES` does not contain `finance:*`** — consistent with the brief's note that it is not enforced anywhere. Left untouched; the kernel package was not edited. 4. **This repo has no `CLAUDE.md`.** Read `README.md` (two lines) and the workspace `~/repos/product/haskyos/CLAUDE.md` instead. Worth adding, given this is the fourth stage app and agents are dispatched into it. 5. **This repo has no `.gitignore` at all** — nothing is ignored, so a routine `git add -A` after any local `uv run` would commit `app/**/__pycache__/`. I staged explicit paths to avoid this. Flagging rather than fixing, as it is outside this task's scope; the fix is a one-line `.gitignore`. 6. Workspace `CLAUDE.md` still describes the kernel boundary as **ADR-007**, since superseded by **ADR-009**. Untouched — noting only.
feat(auth): enforce finance:read / finance:write on all 22 API endpoints
Some checks failed
CI / Deploy (pull_request) Has been skipped
CI / Backend (pull_request) Has been cancelled
CI / Docker Build (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
9ab6dc03dc
Stage apps never checked scopes, which made require_scope unreachable in
practice. smeos's MCP gateway mints a short-lived forwarding JWT per proxied
tool call; until smeos 5de5a7b that token carried no `scopes` claim, and
haskos_kernel.auth resolved any validly-signed JWT to scopes=["*"]. Every
scope check on every stage app therefore passed regardless of the calling
credential — the checks were real, correct, and structurally unreachable.

Kernel 0.3.0 added _jwt_scopes(): a token with a truthy `gateway` claim AND a
`scopes` claim now resolves to exactly those scopes (an absent claim still
means wildcard, deliberately, to tolerate version skew). smeos 5de5a7b
forwards auth.scopes when minting. This commit supplies the missing third
half — the stage app actually checking.

- app/api/scopes.py: FINANCE_READ / FINANCE_WRITE constants
- 22 endpoints gated across billing_profiles, generate, invoices,
  payment_accounts and pnl
- GET -> finance:read (7), POST/PATCH/DELETE -> finance:write (15)
- kernel pin >=0.1.1 -> >=0.3.0, uv.lock regenerated (now records 0.3.0)

Applied via the route decorator's dependencies=[...] rather than a new
function parameter: the check's return value is unused, require_role already
occupies the _actor parameter, and a decorator-level gate cannot be silently
dropped by a later signature refactor.

Role and scope checks are both kept on every endpoint. They are orthogonal —
role is who the principal is, scope is what this credential may do. Dropping
either would be a regression.

Excluded: GET /api/v1/health, an unauthenticated liveness probe that the CI
smoke test and Dockerfile HEALTHCHECK both depend on. No HMAC-authenticated
webhooks or public provider callbacks exist in this repo; every one of the 22
business endpoints already carried require_role.

Verified: all 22 endpoints return 403 "Missing required scope: <scope>" for an
unrelated scope AND for the opposite finance scope, and pass the gate for the
correct scope and for wildcard. /health still answers 200 with no credential.

BLOCKING: finance:read + finance:write must be granted to the four scoped
operator API keys BEFORE this app is deployed. smeos 5de5a7b is already live
in production, so enforcement begins the instant this ships.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFrACc19eGQYck4jd2NJWc
john merged commit d0f0127f99 into dev 2026-07-30 03:54:29 +00:00
john deleted branch feat/add-scope-enforcement 2026-07-30 03:54:29 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
haskytech/haskos-finance!12
No description provided.