Nobody has started this yet — be first.
Business impact
The last-owner-demotion and last-owner-removal tickets fix the bugs around a tenant ending up with zero owners by blocking demotions/removals that would cause it. But "blocked" is not the same as "solved" -- a real business needs a supported way to hand ownership to someone else (a founder leaving, an account being reassigned to a new admin) without ever passing through a zero-owner state or requiring two separate manual role-change calls that could be interrupted halfway. Right now the endpoint is a stub: it returns 501 Not Implemented, and worse, it is guarded by a permission every single role holds (view_members) -- meaning if it did do something, any regular member could currently call it.
Problem
POST /api/tenant/members/<id>/transfer-ownership always responds 501 Not Implemented regardless of input, and is gated by PERM_VIEW_MEMBERS (held by every role) instead of being owner-only.
Current behavior
The handler's entire body is return jsonify(error="not_implemented", ...), 501 -- it never reads membership_id, never touches the database, and its @require_permission decorator checks a permission every role has, so authorization does not actually restrict who can reach it.
Expected behavior
POST /api/tenant/members/<id>/transfer-ownership is owner-only (via the currently-unused require_role("owner") decorator). It atomically makes the target membership an owner and demotes the caller's own membership to admin, in one transaction, so the tenant has exactly the same total number of owners before and after. It records one audit entry describing the transfer. It rejects transferring to a membership that is already an owner, and rejects an id that does not exist or belongs to a different tenant with a 404.
Steps to reproduce
Why this matters
This is the one ticket in this set about wiring up an architectural extension point that already exists but was never used -- require_role -- rather than adding new plumbing. It is the right tool here specifically because this operation's authorization rule genuinely is "you must hold the owner role," not "you must hold some permission that happens to be assigned to owners today." The atomicity requirement (never passing through a zero-owner instant) is exactly why this is one endpoint rather than a demote-then-promote pair of calls, which the last-owner-demotion ticket would otherwise correctly block for a single-owner tenant.
Suggested approach
Look at change_member_role for the general shape: look up the target membership scoped by tenant, 404 if missing, call record_audit, single db.session.commit(). You will also need the caller's own membership in this tenant -- app.rbac.decorators already loads it into as part of /'s own checks; check whether that is usable directly, or whether you need to query it again scoped by .
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_15_transfer_ownership_endpoint.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 feat/transfer-ownership-endpointFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/transfer-ownership-endpointSubmit 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 ing.current_membershiprequire_rolerequire_permissiong.current_user.idacmeglobexmake 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 2
Implement a feature
Extend the system within its own patterns.