Nobody has started this yet — be first.
Business impact
owner is the only role that can manage billing or close the tenant. If a tenant ends up with zero owners, nobody -- not support, not the remaining admins, nobody without direct database access -- can ever manage that tenant's billing or close it through the product again. This happens more easily than it sounds: an owner demoting themselves "to hand off to the new admin later," or an admin with manage_roles demoting the sole owner by mistake while cleaning up roles. Once it happens, there is no self-service recovery path -- this becomes a support ticket that can only be resolved by someone with raw database access running a manual UPDATE.
Problem
change_member_role changes a membership's role to anything in ROLES with no check on whether doing so would leave the tenant with zero owner memberships.
Current behavior
Demoting the tenant's only owner membership to admin or member succeeds exactly like any other role change -- change_member_role never counts how many other owner memberships exist before applying the change.
Expected behavior
A role change that would leave the tenant with zero owner memberships is rejected (409, with a clear error message), whether the caller is demoting themselves or someone else. Role changes that leave at least one owner in place (a tenant with 2+ owners, or demoting a non-owner) are unaffected.
Steps to reproduce
Why this matters
This is a missing business-invariant check, not a missing permission check -- the caller is fully authorized to change roles; the problem is the specific change requested would violate an invariant the rest of the system depends on, without the schema or RLS layer ever being able to express "at least one row in this table has role='owner'" on their own, since that is a statement about a set of rows, not a single row a foreign key or CHECK constraint could enforce. It has to be enforced in application logic, at the exact moment of the mutation that could break it.
Suggested approach
In change_member_role, before committing the new role, determine: is this membership currently an owner, is the new role something other than owner, and if both are true, would this tenant have any owner memberships left afterward? That last question means counting (or checking existence of) other owner memberships for this tenant, excluding the one being changed.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_09_last_owner_demotion.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/last-owner-demotionFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/last-owner-demotionSubmit 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 inacmeglobexmake 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.