- Add /admin/merchant-users/{id}/edit page route and template
- Replace toggle-status button with edit button on merchant-users list
- Editable fields: username, email, first name, last name
- Quick actions: toggle status, delete (with double confirm)
- Move RBAC two-phase plan to docs/proposals/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
16 KiB
RBAC Cleanup: Two-Phase Plan
Phase 1 Status: COMPLETED (2026-02-19) — All 13 steps implemented, migration
tenancy_003applied, 1081 tests passing.Phase 2 Status: PLANNED — Pending future sprint.
Context
The current role/permission system is fragmented across 4 mechanisms and 5 tables, creating confusion and bugs:
| Mechanism | Where | What it determines |
|---|---|---|
User.role |
users.role (String: "admin"/"store") |
Admin vs Store user |
User.is_super_admin |
users.is_super_admin (Boolean) |
Super admin privileges |
StoreUser.user_type |
store_users.user_type ("owner"/"member") |
STALE — ownership moved to Merchant.owner_user_id |
StoreUser.role_id -> Role.permissions |
store_users + roles |
Per-store granular permissions |
Key issues:
- Stale "store owner" concept — ownership lives at
Merchant.owner_user_id, butStoreUser(user_type="owner")entries are still created as redundant mirrors (~20 files) - Bug:
merchant_service.py:72setsrole="user"on new merchant owners — not a validUserRolevalue, breaks store portal login - Naming chaos — UI shows "Type" for admins / "Role" for merchants, but DB field names are the opposite
is_super_adminboolean bolted onto a 2-value enum instead of being a proper role level- 147 references to
is_super_adminacross 29 files; 30 auth dependency functions indeps.py
This plan covers two phases — neither to be executed before the client demo:
- Phase 1 (post-demo): Pragmatic cleanup, ~50 files, fixes real bugs
- Phase 2 (later sprint): Full unified role assignment system, ~80-100 files
Phase 1 — Pragmatic Cleanup (~50 files) [COMPLETED]
Goal: Consolidate User.role + User.is_super_admin into a single 4-value enum. Remove stale StoreUser.user_type. Fix the role="user" bug. Keep StoreUser.role_id -> Role.permissions unchanged (it works fine for per-store granular perms).
Step 1.1 — Expand UserRole enum
File: app/modules/tenancy/models/user.py
# BEFORE
class UserRole(str, Enum):
ADMIN = "admin"
STORE = "store"
# AFTER
class UserRole(str, Enum):
SUPER_ADMIN = "super_admin" # Platform super administrator
PLATFORM_ADMIN = "platform_admin" # Platform admin (scoped to assigned platforms)
MERCHANT_OWNER = "merchant_owner" # Owns merchant(s) and all their stores
STORE_MEMBER = "store_member" # Team member on specific store(s)
Mapping from old to new:
| Old state | New UserRole |
|---|---|
role="admin" + is_super_admin=True |
SUPER_ADMIN |
role="admin" + is_super_admin=False |
PLATFORM_ADMIN |
role="store" + owns merchant(s) |
MERCHANT_OWNER |
role="store" + no merchant ownership |
STORE_MEMBER |
role="user" (bug) |
MERCHANT_OWNER (fix) |
Step 1.2 — Migration: data migration + drop is_super_admin + drop user_type
File: app/modules/tenancy/migrations/versions/tenancy_00X_consolidate_user_role.py (NEW)
- Widen
users.rolecolumn to accept new values - Data migration:
UPDATE users SET role = 'super_admin' WHERE role = 'admin' AND is_super_admin = true; UPDATE users SET role = 'platform_admin' WHERE role = 'admin' AND is_super_admin = false; UPDATE users SET role = 'merchant_owner' WHERE role = 'store' AND id IN (SELECT owner_user_id FROM merchants); UPDATE users SET role = 'merchant_owner' WHERE role = 'user'; -- fix bug UPDATE users SET role = 'store_member' WHERE role = 'store' AND id NOT IN (SELECT owner_user_id FROM merchants); - Drop
users.is_super_admincolumn - Drop
store_users.user_typecolumn
Step 1.3 — Update User model
File: app/modules/tenancy/models/user.py
- Remove
is_super_admincolumn definition - Add backward-compat properties:
@property def is_super_admin(self): return self.role == UserRole.SUPER_ADMIN.value @property def is_admin(self): return self.role in (UserRole.SUPER_ADMIN.value, UserRole.PLATFORM_ADMIN.value) @property def is_merchant_owner(self): return self.role == UserRole.MERCHANT_OWNER.value
File: app/modules/tenancy/models/store.py
- Remove
StoreUserTypeenum - Remove
StoreUser.user_typecolumn - Update
StoreUser.is_ownerproperty to check viaUser.is_owner_of(store_id)instead ofuser_type
Step 1.4 — Update auth dependencies
File: app/api/deps.py (~30 functions)
| Old check | New check |
|---|---|
role == "admin" |
role in ("super_admin", "platform_admin") |
role == "admin" and is_super_admin |
role == "super_admin" |
role == "store" |
role in ("merchant_owner", "store_member") |
Key functions:
get_current_admin_api()-> checkis_adminpropertyget_current_super_admin_api()-> checkrole == "super_admin"get_current_store_api()-> check role in(merchant_owner, store_member)get_current_merchant_api()-> checkrole == "merchant_owner"require_store_owner()-> already usesUser.is_owner_of()(no change needed)require_store_permission()-> already usesUser.has_store_permission()(no change needed)
Step 1.5 — Update JWT token creation + backward-compat shim
File: app/middleware/auth.py
create_access_token(): Removeis_super_adminfrom payload —rolefield now carries all infoverify_token(): Add backward-compat shim — if token has oldrole="admin"+is_super_admin=True, map to"super_admin". Remove shim after 1 release cycle.
Step 1.6 — Update auth service
File: app/modules/core/services/auth_service.py
login_user()/login_merchant()— removeis_super_adminfrom token dataget_user_store_role()— derive fromUser.roleinstead of checkingStoreUser.user_type
Step 1.7 — Fix merchant creation bug
File: app/modules/tenancy/services/merchant_service.py
- Line 72: Change
role="user"->role=UserRole.MERCHANT_OWNER.value
File: app/modules/marketplace/services/platform_signup_service.py
- Remove
StoreUser(user_type=StoreUserType.OWNER.value)creation — ownership is viaMerchant.owner_user_id - Set
role=UserRole.MERCHANT_OWNER.valueon user creation
Step 1.8 — Update store team service
File: app/modules/tenancy/services/store_team_service.py
remove_team_member(): Replacestore_user.is_ownercheck ->user.is_owner_of(store_id)update_member_role(): Same replacementget_team_members(): Deriveis_ownerfromUser.is_owner_of()instead ofStoreUser.user_typeadd_team_member(): Removeuser_typeassignment, ensure new members getrole=STORE_MEMBER
Step 1.9 — Update seed data
File: scripts/seed/seed_demo.py
- Remove
StoreUser(user_type="owner")creation for demo merchants - Use new
UserRolevalues for all seeded users
File: scripts/seed/init_production.py
- Update super admin creation:
role="super_admin"instead ofrole="admin", is_super_admin=True
Step 1.10 — Update admin API routes + responses
Files:
app/modules/tenancy/routes/api/admin_users.py—AdminUserResponse: removeis_super_admin, therolefield is sufficientapp/modules/tenancy/routes/api/admin_platform_users.py— merchant user listing: derive owner status fromUser.role == "merchant_owner"notowned_merchants_count
Step 1.11 — Update admin UI templates + JS
Files:
app/modules/tenancy/templates/tenancy/admin/admin-users.html— Change "Type" header -> "Role", display role value (super_admin / platform_admin badge)app/modules/tenancy/templates/tenancy/admin/merchant-users.html— Display role value (merchant_owner / store_member badge) instead of deriving fromowned_merchants_countapp/modules/tenancy/static/admin/js/admin-users.js— Update role badge renderingapp/modules/tenancy/static/admin/js/merchant-users.js— Update role badge rendering
Step 1.12 — Bulk update remaining is_super_admin references (~29 files, 147 occurrences)
Search-and-replace across:
- All route files checking
is_super_admin-> useuser.is_super_adminproperty (backed by enum check, so these work as-is via the compat property) - All template files displaying
is_super_admin-> displayrolevalue - All JS files referencing
is_super_admin-> userolefield - All test files -> update assertions
Key files by reference count:
app/api/deps.py— 15+ referencesapp/middleware/auth.py— 8+ referencesapp/modules/tenancy/routes/api/admin_users.py— 5+ references- Various admin JS files — 3-5 references each
Step 1.13 — Update RBAC documentation
Files:
docs/api/rbac.md— Update role hierarchy, remove is_super_admin referencesdocs/api/rbac-visual-guide.md— Update diagramsdocs/backend/rbac-quick-reference.md— Update quick reference tablesdocs/backend/store-rbac.md— Update store-level sectiondocs/architecture/auth-rbac.md— Update architecture overview
Phase 1 File Summary
| Category | Files | Change |
|---|---|---|
| Models | 2 | Modify User, StoreUser |
| Migration | 1 | New data migration |
| Auth (deps + middleware + service) | 3 | Modify |
| Services | 3 | Modify (merchant, signup, team) |
| API routes | 4-6 | Modify response schemas + checks |
| Admin templates (HTML) | 2-4 | Modify badges/labels |
| Admin JS | 4-6 | Modify role rendering |
| Seed scripts | 2-3 | Modify |
| Tests | 10-15 | Update assertions |
| Documentation | 5-6 | Update |
| Total | ~50 |
Phase 1 Verification
alembic upgrade head— migration applies, data migrated correctlySELECT role, count(*) FROM users GROUP BY role— only 4 new valuesSELECT * FROM users WHERE role IN ('admin', 'store', 'user')— empty (all migrated)is_super_admincolumn gone from users tableuser_typecolumn gone from store_users table- Admin login still works (JWT backward compat shim)
- Store login still works
- Merchant creation sets
role="merchant_owner" - Super admin can access all admin routes
- Platform admin scoped correctly
- Store team member permissions unchanged
pytest— all tests pass
Phase 2 — Target State (Later Sprint, ~80-100 files)
Goal: Replace all current role mechanisms with a unified, context-aware role assignment system. One roles table, one permissions table, one user_role_assignments junction table that handles all contexts (platform, merchant, store).
Target Schema
users user_role_assignments roles
+--------------+ +-------------------------+ +--------------+
| id |----< | user_id (FK) |>--| id |
| email | | role_id (FK) | | name |
| password_hash| | context_type (enum) | | context_type |
| first_name | | "platform" | | is_system |
| last_name | | "merchant" | | created_at |
| ... | | "store" | +------+-------+
| (NO role col)| | context_id (nullable) | |
| (NO is_super)| | NULL = global | role_permissions
+ + | platform.id | +--------------+
| merchant.id | | role_id (FK) |
| store.id | | permission |
| granted_by (FK->users) | | (string) |
| created_at | +--------------+
+-------------------------+
Target Role Definitions
System roles (seeded, immutable):
| Role | context_type | Description |
|---|---|---|
super_admin |
platform | Full platform access, all permissions |
platform_admin |
platform | Scoped to assigned platform(s) |
merchant_owner |
merchant | Owns merchant, manages all stores |
store_manager |
store | Full store management |
store_staff |
store | Day-to-day operations |
store_support |
store | Customer support only |
store_viewer |
store | Read-only access |
store_marketing |
store | Marketing and content |
Custom roles: Merchants can create custom roles at store context level with specific permission sets.
How Assignments Work
# Super admin — global platform access
user_role_assignments(user_id=1, role=super_admin, context_type="platform", context_id=NULL)
# Platform admin — scoped to platform 3
user_role_assignments(user_id=2, role=platform_admin, context_type="platform", context_id=3)
# Merchant owner — owns merchant 5
user_role_assignments(user_id=3, role=merchant_owner, context_type="merchant", context_id=5)
# Store member — staff role on store 12
user_role_assignments(user_id=4, role=store_staff, context_type="store", context_id=12)
# Same user, different stores, different roles
user_role_assignments(user_id=4, role=store_manager, context_type="store", context_id=15)
Phase 2 Steps (High-Level)
2.1 — New models
Rolemodel (replaces current per-storerolestable) — now context-awareRolePermissionmodel — maps role -> permission stringsUserRoleAssignmentmodel — the unified junction table
2.2 — Migration
- Create new tables
- Migrate data from:
users.role->user_role_assignments(platform/merchant context)store_users+store_users.role_id->user_role_assignments(store context)admin_platforms->user_role_assignments(platform context)- Current
roles+role.permissions-> newroles+role_permissions
- Drop old tables:
store_users,admin_platforms, oldroles - Drop
users.rolecolumn
2.3 — Auth service rewrite
get_user_roles(user_id, context_type?, context_id?)— query assignmentshas_permission(user_id, permission, context_type, context_id)— check via role -> permissions- Permission inheritance: super_admin > platform_admin > merchant_owner > store roles
2.4 — Auth dependencies rewrite
- Replace all 30 dependency functions with a permission-based system:
# Instead of: Depends(get_current_admin_api) # Use: Depends(require_permission("stores.manage")) - Role hierarchy handles escalation automatically
2.5 — JWT simplification
- Token payload:
{user_id, active_context: {type, id}, permissions: [...]} - No more role strings in token — permissions resolved at login
2.6 — Admin UI
- Role management page (CRUD roles + permissions)
- User role assignment UI (assign roles with context)
- Remove separate admin-users / merchant-users pages -> unified users page with role filter
2.7 — Drop legacy
- Remove
store_userstable - Remove
admin_platformstable - Remove old
rolestable - Remove
users.rolecolumn - Remove all backward-compat properties
Phase 2 Benefits
- Single source of truth — all role info in
user_role_assignments - Context-aware — same user can have different roles in different stores/merchants
- Extensible — add new contexts (e.g., "warehouse") without schema changes
- Auditable —
granted_bytracks who assigned roles - Custom roles — merchants define their own roles with specific permissions
- No derived logic — no checking
owned_merchants_countorStoreUser.user_type
Execution Order
- Phase 1 first (post-demo) — safe, incremental, fixes real bugs
- Phase 2 later — larger refactor, needs dedicated sprint, ideally with test coverage from Phase 1
Phase 1 is designed so Phase 2 builds cleanly on top of it. The 4-value enum from Phase 1 maps 1:1 to the system roles in Phase 2.