Nobody has started this yet — be first.
Business impact
As acme's admin, sending a tenant_id field in a POST /api/tenant/members body that points at globex's id currently blows up the request with a raw, unhandled 500 -- Postgres's own Row-Level Security rejects the write ("new row violates row-level security policy for table memberships") because the database session is still bound to acme, no matter what tenant_id the application handed it. RLS is genuinely catching this one, exactly as the README describes it as "the last line of defense." What is not good news is that the application relies on that last line to cover for a mistake in its own code, or that the failure a client sees is a bare 500 with a database error message rather than a clean rejection or, matching every other mutation in this file, simply having no effect at all.
Problem
invite_member reads tenant_id from the request body when present, instead of always using the resolved subdomain's tenant like every other mutation in this codebase does.
Current behavior
target_tenant_id = payload.get("tenant_id") or g.tenant_uuid lets a client-supplied tenant_id win over g.tenant_uuid; the resulting Membership(tenant_id=target_tenant_id, ...) insert then fails Postgres's WITH CHECK clause on the memberships INSERT policy when the value disagrees with the session's actual RLS binding, surfacing as an uncaught / that Flask turns into a bare 500.
Expected behavior
The tenant a new membership is created under is always g.tenant_uuid -- the tenant resolved from the subdomain/header for this request -- regardless of anything in the request body. A tenant_id field in the payload, if present, has no effect at all: the request either succeeds exactly as if that field were absent, or is rejected cleanly with a 400. A raw database exception surfacing as a 500 is not acceptable.
Steps to reproduce
Why this matters
This is the same lesson the README's "RLS-bypass path" section makes about migrations and the admin connection, from the other direction: RLS is designed to be the isolation boundary that holds even when application code gets the tenant wrong -- and here, it does exactly that. But "the last line of defense caught it" is a signal that the earlier lines failed, not a reason to leave them failed. The fix belongs in application code, in the one place that is supposed to be the single source of truth for "which tenant is this request for."
Suggested approach
Compare this route to change_member_role and remove_member immediately below it in the same file -- neither reads anything resembling tenant_id from payload. Look at where target_tenant_id is computed and used in invite_member.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_08_tenant_id_spoofing_invite.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/tenant-id-spoofing-inviteFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/tenant-id-spoofing-inviteSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign insqlalchemy.exc.ProgrammingErrorpsycopg2.errors.InsufficientPrivilegeacmeglobexmake seed
*.localhost subdomains resolve to 127.0.0.1 out of the box in curl and every modern browser -- no /etc/hosts editing needed:
curl -s -X POST http://acme.localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
--cookie-jar /tmp/acme.cookies --cookie /tmp/acme.cookies \
-d '{"email": "owner@acme.test", "password": "owner-acme"}'
curl -s http://acme.localhost:5000/api/tenant/settings --cookie /tmp/acme.cookies
For tooling that cannot fake subdomains (some HTTP clients, CI), send X-Tenant-Slug instead -- honored only when TRUST_TENANT_HEADER=true (the Docker dev setup enables it; production does not):
curl -s http://localhost:5000/api/tenant/settings -H "X-Tenant-Slug: acme"
You do not strictly need Docker to work the tickets, but -- unlike a project that can fake its backing services -- this one genuinely needs a real Postgres and Redis, because the whole point is proving isolation against the real database, not a mock. Without Docker: cp .env.example .env, point DATABASE_URL/ADMIN_DATABASE_URL/TEST_DATABASE_URL/TEST_ADMIN_DATABASE_URL/REDIS_URL at a local Postgres/Redis (docker/init-db.sql documents the exact roles/databases the app expects -- notably the saas_admin/BYPASSRLS role migrations need and the plain saas role every request uses), then:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/flask db upgrade
.venv/bin/pytest practice_tickets/tests/test_ticket_07_webhook_signature_raw_body.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
./practice_tickets/run_tickets.sh 07 12 13 # just a few, by number
All 15 tests fail at the start; as you fix each ticket in app/ (or, for TICKET-12, in migrations/versions/0002_row_level_security.py, plus re-applying it), its test -- and only its test -- should flip to passing. make test runs the project's own main suite; several of its tests (RBAC, tenant-spoofing, and billing-webhook tests among them) fail at the start for the same underlying reasons as specific tickets -- fixing those tickets fixes those failures too, not a separate task.
Level 1
Fix a bug
Read existing behaviour, correct it.