feat(auth): enforce finance:read / finance:write scopes on all 22 API endpoints #12
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/add-scope-enforcement"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
require_scopewas 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) carryinggateway: True. Until smeos5de5a7bthat token carried noscopesclaim, andhaskos_kernel.authresolved any validly-signed JWT toscopes=["*"]. So every scope check on every stage app passed regardless of which credential was actually calling.Three halves were needed. Two had landed:
0.3.0—_jwt_scopes()honours thescopesclaim whengatewayis truthy5de5a7b— forwardsauth.scopeswhen mintinghaskos-apiatab85c6d)Money endpoints are where an over-broad credential matters most, so this errs toward gating rather than excluding.
Endpoints covered — 22 total
finance:readfinance:writeapp/api/invoices.pyapp/api/billing_profiles.pyapp/api/payment_accounts.pyapp/api/generate.pyapp/api/pnl.pyMapping 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) areWRITE. They read like renders but produce durable documents via haskydocs.app/api/pnl.py—GET /pnl/summaryis a report read, soREAD.Constants live in the new
app/api/scopes.py. Gates are applied via the route decorator'sdependencies=[...], not a new function parameter — the check's return value is unused,require_rolealready occupies the_actorparameter, and a decorator-level gate cannot be silently dropped by a later signature refactor.Every existing
require_roledependency is kept unchanged (verified: 22Depends(require_role(...))before and after). Role and scope are orthogonal — role is who the principal is, scope is what this credential may do.Exclusions
GET /api/v1/health(app/main.py)require_role, and both the DockerfileHEALTHCHECKand the CI smoke test poll it before any credential exists. Gating it would break deploys.That is the only exclusion. I grepped
app/forwebhook,hmac,signatureandpublic(case-insensitive): no matches. There are no HMAC-authenticated webhooks and no payment-provider callbacks in this repo. All 22 business endpoints already carriedrequire_role, so none showed the "intentionally public" signal.Kernel bump
pyproject.tomlhaskytech-haskos-kernel>=0.1.1haskytech-haskos-kernel>=0.3.0uv.lockversion = "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 record0.3.0— not just the specifier. Installed version verified at runtime viaimportlib.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)/healthBeyond a static import check, I exercised every gate with FastAPI dependency overrides — for all 22 endpoints:
bugs:read) → 403 with exact detailMissing required scope: finance:{read,write}*→ past the gateGET /healthwith no credential → 200tests/contains only__init__.pyand CI runslint+docker-buildonly, so no suite was invented; the sweep above was run as a one-off and is not committed.⛔ Blocking Pre-Deploy Step
finance:readandfinance:writemust 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/writeandpm:read/write(plusworkstreams:*,orch:*,ci:*,events:*,env:*,deploy:*,db:seed,intake:*,bugs:*, and*).Because smeos
5de5a7bis 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:
dede-operator-v1pm:*,sales:*finance:read+finance:writejames-operator-v1pm:*,sales:*finance:read+finance:writelionel-operator-v1pm:*,sales:*finance:read+finance:writemathias-operatorpm:*,sales:*finance:read+finance:writeKeys that pass unaffected:
system-key(*),prod-operator-v4(*).Three client keys —
FlexiSCM,Jaberson Planner,SuperClean— carry onlybugs:*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
gatewayclaim, so_jwt_scopesreturns wildcard and every check passes.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
Kernel package— done0.3.0publishedsmeos— done, live in prod5de5a7b(forwardsauth.scopes)finance:read+finance:writeto the four operator keys ← blocking, full-list replaceSteps 3 and 4 must not be reordered. Deploying before the grant 403s every non-wildcard caller on all finance endpoints.
Spec Drift Callouts
0.1.1, not0.2.x. The brief warned not to push a lock "that still pins 0.2.x"; the lock actually pinned0.1.1. Bumped to0.3.0as intended — the warning's intent is satisfied.len(app.routes)is not a route count on this dependency stack. The brief's suggested sanity check prints6, which looks alarming. Installed FastAPI0.137.1/ Starlette1.3.1keepinclude_routerresults as a lazy_IncludedRouterinstead of flattening intoapp.routes. This is pre-existing ondev— 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.haskos_kernel.auth.ALL_SCOPESdoes not containfinance:*— consistent with the brief's note that it is not enforced anywhere. Left untouched; the kernel package was not edited.CLAUDE.md. ReadREADME.md(two lines) and the workspace~/repos/product/haskyos/CLAUDE.mdinstead. Worth adding, given this is the fourth stage app and agents are dispatched into it..gitignoreat all — nothing is ignored, so a routinegit add -Aafter any localuv runwould commitapp/**/__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.CLAUDE.mdstill describes the kernel boundary as ADR-007, since superseded by ADR-009. Untouched — noting only.