# RBAC Cleanup: Two-Phase Plan

> **Phase 1 Status: COMPLETED** (2026-02-19) — All 13 steps implemented, migration `tenancy_003` applied, 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:**
1. **Stale "store owner" concept** — ownership lives at `Merchant.owner_user_id`, but `StoreUser(user_type="owner")` entries are still created as redundant mirrors (~20 files)
2. **Bug:** `merchant_service.py:72` sets `role="user"` on new merchant owners — not a valid `UserRole` value, breaks store portal login
3. **Naming chaos** — UI shows "Type" for admins / "Role" for merchants, but DB field names are the opposite
4. **`is_super_admin` boolean** bolted onto a 2-value enum instead of being a proper role level
5. **147 references** to `is_super_admin` across 29 files; 30 auth dependency functions in `deps.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`

```python
# 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)

1. Widen `users.role` column to accept new values
2. Data migration:
   ```sql
   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);
   ```
3. Drop `users.is_super_admin` column
4. Drop `store_users.user_type` column

### Step 1.3 — Update User model

**File:** `app/modules/tenancy/models/user.py`

- Remove `is_super_admin` column definition
- Add backward-compat properties:
  ```python
  @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 `StoreUserType` enum
- Remove `StoreUser.user_type` column
- Update `StoreUser.is_owner` property to check via `User.is_owner_of(store_id)` instead of `user_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()` -> check `is_admin` property
- `get_current_super_admin_api()` -> check `role == "super_admin"`
- `get_current_store_api()` -> check role in `(merchant_owner, store_member)`
- `get_current_merchant_api()` -> check `role == "merchant_owner"`
- `require_store_owner()` -> already uses `User.is_owner_of()` (no change needed)
- `require_store_permission()` -> already uses `User.has_store_permission()` (no change needed)

### Step 1.5 — Update JWT token creation + backward-compat shim

**File:** `app/middleware/auth.py`

- `create_access_token()`: Remove `is_super_admin` from payload — `role` field now carries all info
- `verify_token()`: Add backward-compat shim — if token has old `role="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()` — remove `is_super_admin` from token data
- `get_user_store_role()` — derive from `User.role` instead of checking `StoreUser.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 via `Merchant.owner_user_id`
- Set `role=UserRole.MERCHANT_OWNER.value` on user creation

### Step 1.8 — Update store team service

**File:** `app/modules/tenancy/services/store_team_service.py`

- `remove_team_member()`: Replace `store_user.is_owner` check -> `user.is_owner_of(store_id)`
- `update_member_role()`: Same replacement
- `get_team_members()`: Derive `is_owner` from `User.is_owner_of()` instead of `StoreUser.user_type`
- `add_team_member()`: Remove `user_type` assignment, ensure new members get `role=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 `UserRole` values for all seeded users

**File:** `scripts/seed/init_production.py`
- Update super admin creation: `role="super_admin"` instead of `role="admin", is_super_admin=True`

### Step 1.10 — Update admin API routes + responses

**Files:**
- `app/modules/tenancy/routes/api/admin_users.py` — `AdminUserResponse`: remove `is_super_admin`, the `role` field is sufficient
- `app/modules/tenancy/routes/api/admin_platform_users.py` — merchant user listing: derive owner status from `User.role == "merchant_owner"` not `owned_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 from `owned_merchants_count`
- `app/modules/tenancy/static/admin/js/admin-users.js` — Update role badge rendering
- `app/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` -> use `user.is_super_admin` property (backed by enum check, so these work as-is via the compat property)
- All template files displaying `is_super_admin` -> display `role` value
- All JS files referencing `is_super_admin` -> use `role` field
- All test files -> update assertions

**Key files by reference count:**
- `app/api/deps.py` — 15+ references
- `app/middleware/auth.py` — 8+ references
- `app/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 references
- `docs/api/rbac-visual-guide.md` — Update diagrams
- `docs/backend/rbac-quick-reference.md` — Update quick reference tables
- `docs/backend/store-rbac.md` — Update store-level section
- `docs/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

1. `alembic upgrade head` — migration applies, data migrated correctly
2. `SELECT role, count(*) FROM users GROUP BY role` — only 4 new values
3. `SELECT * FROM users WHERE role IN ('admin', 'store', 'user')` — empty (all migrated)
4. `is_super_admin` column gone from users table
5. `user_type` column gone from store_users table
6. Admin login still works (JWT backward compat shim)
7. Store login still works
8. Merchant creation sets `role="merchant_owner"`
9. Super admin can access all admin routes
10. Platform admin scoped correctly
11. Store team member permissions unchanged
12. `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**
- `Role` model (replaces current per-store `roles` table) — now context-aware
- `RolePermission` model — maps role -> permission strings
- `UserRoleAssignment` model — 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` -> new `roles` + `role_permissions`
- Drop old tables: `store_users`, `admin_platforms`, old `roles`
- Drop `users.role` column

**2.3 — Auth service rewrite**
- `get_user_roles(user_id, context_type?, context_id?)` — query assignments
- `has_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:
  ```python
  # 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_users` table
- Remove `admin_platforms` table
- Remove old `roles` table
- Remove `users.role` column
- Remove all backward-compat properties

### Phase 2 Benefits

1. **Single source of truth** — all role info in `user_role_assignments`
2. **Context-aware** — same user can have different roles in different stores/merchants
3. **Extensible** — add new contexts (e.g., "warehouse") without schema changes
4. **Auditable** — `granted_by` tracks who assigned roles
5. **Custom roles** — merchants define their own roles with specific permissions
6. **No derived logic** — no checking `owned_merchants_count` or `StoreUser.user_type`

---

## Execution Order

1. **Phase 1 first** (post-demo) — safe, incremental, fixes real bugs
2. **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.
