WS-29 H2: central tenant binding + routes/projects converted to tenant_session() - #416
Merged
Merged
Conversation
…d to tenant_session The runbook's 'do this first' step and its largest package slice: - _with_resolved_access binds bind_tenant(organization_id) from the app_user row (never a header, R11); gateway TenantScopeMiddleware opens a fresh scope per request and releases it after the response. Pinned by tests/unit/test_tenant_request_binding.py (6 tests incl. service identity binds nothing, no leak across sequential same-task requests). - routes/projects: all 84 request-handler get_db() sites are now 'async with _tenant_session() as db:' — the per-package alias IS acb_common.db.tenant_session (identity asserted). Explicit mid-flow commits removed: the wrapper commits on clean exit, and a mid-block commit would end the transaction and drop the GUC. agent_dispatch.py (2 sites) is an event consumer and stays on get_db() by name until H4 threads an explicit tenant through the event payload. - Ratchets in test_db_engine_seam.py: routes/projects pinned at zero; the 494 sites elsewhere are a frozen baseline that only ratchets down, with a bank-your-progress guard. -⚠️ Live run found tenant_session() itself broken: the literal SET LOCAL app.tenant_id = :tenant is a Postgres syntax error through the extended protocol (SET cannot bind a parameter) — green under every hermetic fake, refused by real Postgres on the first converted handler. Fixed to SELECT set_config('app.tenant_id', :tenant, true) (identical transaction-local semantics), test_tenant_session.py re-pinned to the working form, and proven by a scratch-Postgres smoke: unbound raises TenantUnbound, converted handlers write under the GUC, rows land with the bound organization_id. Both tenancy specs annotated. - Test seams updated: _projects_fakes.bind_db patches _tenant_session (commit-on-clean-exit so one-transaction contracts stay observable); committed==0 write-nothing proxies replaced with statement/row assertions. Verification: full unit suite 5797 passed; ruff gate clean; live smoke green on scratch Postgres 16 with migrations through 166 applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Starts H2 — the long pole of the multi-tenancy workstream (
saas_multitenancy_handover.md) — with the runbook's "do this first" step and its largest single package slice. Includes today'smainmerged in (D28–D30 harness + #409 tenancy alignment); no conflicts.The central binding (the prerequisite for every conversion)
_with_resolved_access(acb_auth/deps.py) now callsbind_tenant(organization_id)the moment identity resolves — from theapp_userrow, never a header/query/body (R11). One place, so the 500+ call sites need no tenant argument.TenantScopeMiddlewarein the gateway (pure ASGI): opens a fresh empty tenant scope per HTTP request, releases it after the response — a binding can never leak between requests on any server task model.clear_tenant()added toacb_common.dbfor it.tests/unit/test_tenant_request_binding.py(new, 6 tests): binds on resolution, tenant comes from identity not caller-controlled headers, unresolved identity binds nothing,system:internalbinds nothing (jobs bind explicitly — H4), no leak across sequential same-task requests, non-http passthrough.The Projects slice (84 of the ~580 sites)
routes/projects(21 files) is nowasync with _tenant_session() as db:. The per-package alias is asserted to beacb_common.db.tenant_session(seam-identity test extended), so there's exactly one GUC discipline.db.commit()calls removed — the wrapper commits on clean exit, and a mid-block commit would end the transaction and silently drop theSET LOCALscope for everything after it. (Recon first proved no handler queries after its final commit, so the rewrite is behavior-preserving.)agent_dispatch.py(2 sites) is an event consumer, and the runbook forbids jobs inheriting an ambient tenant — it stays onget_db()with a loud H4 marker until H4 threads an explicit tenant through the event payload.routes/projectspinned at zero unconverted sites; the 494 sites elsewhere frozen as a baseline that only ratchets down, with a bank-your-progress guard so headroom can't become new-debt budget.tenant_session()itself broken (shipped in #404)The seam issued
SET LOCAL app.tenant_id = :tenant— a Postgres syntax error through the extended protocol (SETcannot take a bind parameter). Every hermetic test was green because fakes don't parse SQL;test_tenant_session.pyeven pinned the broken spelling. The first converted handler against real Postgres refused it — had this survived to H3, every request would have 500'd at its first query.Fixed to
SELECT set_config('app.tenant_id', :tenant, true)— identical transaction-local semantics (is_local = trueISSET LOCAL), parameterizable. The pin test now enforces the working form and refuses both the literal and anis_local = falsevariant. Both tenancy specs annotated at their illustrative snippets; the handover's new H2 box tells the remaining packages' converters to run at least one live smoke each.Verification
uv run python -m pytest tests/unit/→ 5798 passed (49 skipped are the documented needs-live-Postgres checks); ruff CI gate clean.npx vitest run→ 1278 passed;tsc --noEmitclean.test_tenant_session.pygreen against the real engine, plus an end-to-end smoke — unboundtenant_session()raisesTenantUnbound; real converted handlers (create_node,create_task,list_tasks) write and read under the bound GUC;current_setting('app.tenant_id')observed inside the wrapper's transaction; handler-written rows stamped with the boundorganization_id._projects_fakes.bind_dbpatches the package's_tenant_sessionwith a commit-on-clean-exit context manager (one-transaction contracts stay observable ascommitted == 1); read-path tests that usedcommitted == 0as a "writes nothing" proxy now assert on statements/rows, since the wrapper commits read transactions too — exactly as real Postgres does.Not in this PR
get_db()sites (email, whatsapp, crm, tasks, notes, workflows…) — next H2 slices, package-by-package per the handover's new conversion notes.agent_dispatch's explicit-tenant conversion — H4.🤖 Generated with Claude Code
https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Generated by Claude Code