feat: RBAC Phase 1 — consolidate user roles into 4-value enum
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean)
into a single 4-value UserRole enum: super_admin, platform_admin,
merchant_owner, store_member. Drop stale StoreUser.user_type column.
Fix role="user" bug in merchant creation.

Key changes:
- Expand UserRole enum from 2 to 4 values with computed properties
  (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user)
- Add Alembic migration (tenancy_003) for data migration + column drops
- Remove is_super_admin from JWT token payload
- Update all auth dependencies, services, routes, templates, JS, and tests
- Update all RBAC documentation

66 files changed, 1219 unit tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 22:44:29 +01:00
parent ef21d47533
commit 1dcb0e6c33
67 changed files with 874 additions and 616 deletions

View File

@@ -5,16 +5,32 @@
```
┌─────────────────────────────────────────────────────────────────┐
│ PLATFORM LEVEL │
│ User.role (4-value enum) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Admin Users │ │ Store Users │ │
│ │ role="admin" │ │ role="store" │ │
│ │ Super Admin │ │ Platform Admin │ │
│ │ role= │ │ role= │ │
│ │ "super_admin" │ │ "platform_admin"│ │
│ │ │ │ │ │
│ │ • Full platform │ │ • Can own/join │ │
│ │ access │ │ stores │ │
│ │ • Full platform │ │ • Scoped to │ │
│ │ access │ │ assigned │ │
│ │ • All platforms │ │ platforms │ │
│ │ • Cannot access │ │ • Cannot access │ │
│ │ store portal │ │ admin portal │ │
│ │ store portal │ │ store portal │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Merchant Owner │ │ Store Member │ │
│ │ role= │ │ role= │ │
│ │ "merchant_owner"│ │ "store_member" │ │
│ │ │ │ │ │
│ │ • Owns stores │ │ • Invited to │ │
│ │ • All perms in │ │ stores │ │
│ │ own stores │ │ • Role-based │ │
│ │ • Cannot access │ │ permissions │ │
│ │ admin portal │ │ • Cannot access │ │
│ └──────────────────┘ │ admin portal │ │
│ └──────────────────┘ │
│ │ │
└──────────────────────────────────────────────┼──────────────────┘
@@ -27,17 +43,18 @@
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Owner │ │ Team Members │ │ │
│ │ │ user_type= │ │ user_type= │ │ │
│ │ │ "owner" │ │ "member" │ │ │
│ │ │ │ │ │ │ │
│ │ │ • All perms │ │ • Role-based perms │ │ │
│ │ │ • Can invite │ │ • Manager/Staff/etc │ │ │
│ │ │ • Can remove │ │ • Can be invited │ │ │
│ │ │ • Cannot be │ │ • Can be removed │ │ │
│ │ │ role= │ │ role= │ │ │
│ │ │ "merchant_ │ │ "store_member" │ │ │
│ │ │ owner" │ │ │ │ │
│ │ │ │ │ • Role-based perms │ │ │
│ │ │ • All perms │ │ • Manager/Staff/etc │ │ │
│ │ │ • Can invite │ │ • Can be invited │ │ │
│ │ │ • Can remove │ │ • Can be removed │ │ │
│ │ │ • Cannot be │ │ │ │ │
│ │ │ removed │ │ │ │ │
│ │ └──────────────┘ └──────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Ownership via Merchant.owner_user_id ▼ │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Roles │ │ │
│ │ │ │ │ │
@@ -180,8 +197,12 @@
│ id (PK) │◄────┐
│ email │ │
│ role │ │
│ ('admin' or │ │
'store') │ │
│ ('super_admin', │ │
'platform_ │ │
│ admin', │ │
│ 'merchant_ │ │
│ owner', │ │
│ 'store_member') │ │
└──────────────────┘ │
│ │
│ owner_user_id │
@@ -204,12 +225,12 @@
│ store_id (FK) │ │ store_id (FK) │
│ user_id (FK) │ │ name │
│ role_id (FK) ───┼────►│ permissions │
user_type │ │ (JSON) │
('owner' or │ └──────────────────┘
│ 'member') │
│ invitation_* │
│ is_active │
invitation_* │ │ (JSON) │
is_active │ └──────────────────┘
└──────────────────┘
(no user_type column;
ownership via
Merchant.owner_user_id)
Separate hierarchy:
@@ -302,12 +323,14 @@ Examples:
## Security Boundaries
```
❌ BLOCKED ✅ ALLOWED
Admin → Store Portal Admin → Admin Portal
Store → Admin Portal Store → Store Portal
Customer → Admin Portal Customer → Shop Catalog
Customer → Store Portal Customer → Own Account
❌ BLOCKED ✅ ALLOWED
super_admin → Store Portal super_admin → Admin Portal
platform_admin → Store Portal platform_admin → Admin Portal
merchant_owner → Admin Portal merchant_owner → Store Portal
store_member → Admin Portal store_member → Store Portal
Customer → Admin Portal Customer → Shop Catalog
Customer → Store Portal Customer → Own Account
Cookie Isolation:
admin_token (path=/admin) ← Only sent to /admin/*

View File

@@ -1,7 +1,7 @@
# Role-Based Access Control (RBAC) Developer Guide
**Version:** 1.0
**Last Updated:** November 2025
**Version:** 2.0
**Last Updated:** February 2026
**Audience:** Development Team
---
@@ -48,47 +48,44 @@ The RBAC system ensures that:
## RBAC Overview
### Three-Tier Permission Model
### Two-Tier Permission Model
```
┌─────────────────────────────────────────────────────────┐
│ PLATFORM LEVEL │
│ User.role
│ │
│ ┌──────────┐ ┌──────────┐
│ │ Admin │ Store │
│ │ (admin) │ (store) │
│ └──────────┘ └──────────┘
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────
PLATFORM LEVEL
User.role (4-value enum)
│ ┌──────────────┐ ┌────────────────┐
│ │ Super Admin │ │ Platform Admin │ Platform admins
│ │(super_admin) │ │(platform_admin)│ (is_admin = True)
│ └──────────────┘ └────────────────┘
│ │
│ ┌────────────────┐ ┌──────────────┐ │
│ │ Merchant Owner │ │ Store Member │ Store users │
│ │(merchant_owner)│ │(store_member)│ (is_store_user=True) │
│ └────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ STORE LEVEL
StoreUser.user_type
│ │
┌──────────┐ ┌──────────────┐
│ Owner │ │ Team Member
│ (owner) │ │ (member)
└──────────┘ └──────────────┘
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ PERMISSION LEVEL │
│ Role.permissions │
│ │
│ Manager, Staff, Support, Viewer, Marketing, Custom │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────
│ STORE PERMISSION LEVEL │
Role.permissions
Merchant owners bypass permission checks (all permissions)
Store members get permissions from assigned Role
Manager, Staff, Support, Viewer, Marketing, Custom
└─────────────────────────────────────────────────────────────
```
### Context Separation
The application operates in three isolated contexts:
| Context | Routes | Authentication | User Type |
|---------|--------|----------------|-----------|
| **Admin** | `/admin/*` | `admin_token` cookie | Platform Admins |
| **Store** | `/store/*` | `store_token` cookie | Store Owners & Teams |
| Context | Routes | Authentication | User Roles |
|---------|--------|----------------|------------|
| **Admin** | `/admin/*` | `admin_token` cookie | `super_admin`, `platform_admin` |
| **Store** | `/store/*` | `store_token` cookie | `merchant_owner`, `store_member` |
| **Shop** | `/shop/account/*` | `customer_token` cookie | Customers |
**Important:** These contexts are security boundaries. Admin users cannot access store routes, store users cannot access admin routes, and customers are entirely separate.
@@ -165,18 +162,19 @@ The application operates in three isolated contexts:
## User Types & Contexts
### Platform Admins
### Super Admins
**Characteristics:**
- `User.role = "admin"`
- `User.role = "super_admin"`
- Full access to `/admin/*` routes
- Manage all stores and users
- Manage all platforms, stores, and users
- Cannot access store or customer portals
- `User.is_super_admin` property returns `True` (computed from `role == "super_admin"`)
**Use Cases:**
- Platform configuration
- Store approval/verification
- User management
- Full platform configuration
- Multi-platform management
- Super-level user management
- System monitoring
**Authentication:**
@@ -193,14 +191,31 @@ GET /admin/stores
POST /admin/users/{user_id}/suspend
```
### Store Owners
### Platform Admins
**Characteristics:**
- `User.role = "store"`
- `StoreUser.user_type = "owner"`
- Automatic full permissions within their store
- `User.role = "platform_admin"`
- Access to `/admin/*` routes scoped to assigned platforms
- `User.is_admin` property returns `True` (shared with super_admin)
- `User.is_platform_admin` property returns `True`
- Cannot access store or customer portals
**Use Cases:**
- Platform-scoped configuration
- Store approval/verification
- User management within assigned platforms
- System monitoring
### Merchant Owners
**Characteristics:**
- `User.role = "merchant_owner"`
- Automatic full permissions within their stores
- Ownership determined via `Merchant.owner_user_id` (not a column on StoreUser)
- Can invite and manage team members
- Cannot be removed from their store
- `User.is_merchant_owner` property returns `True`
- `User.is_store_user` property returns `True`
**Use Cases:**
- Complete store management
@@ -210,7 +225,7 @@ POST /admin/users/{user_id}/suspend
**Special Privileges:**
```python
# Automatic permissions
# Automatic permissions - owners bypass permission checks
def has_permission(self, permission: str) -> bool:
if self.is_owner:
return True # Owners bypass permission checks
@@ -219,11 +234,11 @@ def has_permission(self, permission: str) -> bool:
### Store Team Members
**Characteristics:**
- `User.role = "store"`
- `StoreUser.user_type = "member"`
- Permissions defined by `Role.permissions`
- Invited by store owner via email
- `User.role = "store_member"`
- Permissions defined by `Role.permissions` via `StoreUser.role_id`
- Invited by merchant owner via email
- Can be assigned different roles (Manager, Staff, etc.)
- `User.is_store_user` property returns `True`
**Use Cases:**
- Day-to-day operations based on role
@@ -284,8 +299,12 @@ permissions = [
│ email │ │
│ username │ │
│ role │ │ owner_user_id
│ ('admin' | │ │
'store') │ │
│ ('super_admin'| │ │
'platform_ │ │
│ admin' | │ │
│ 'merchant_ │ │
│ owner' | │ │
│ 'store_member') │ │
│ is_active │ │
│ is_email_ │ │
│ verified │ │
@@ -301,9 +320,6 @@ permissions = [
│ store_id (FK) ─┼───┐ │
│ user_id (FK) ───┼─┐ │ │
│ role_id (FK) │ │ │ │
│ user_type │ │ │ │
│ ('owner' | │ │ │ │
│ 'member') │ │ │ │
│ invitation_ │ │ │ │
│ token │ │ │ │
│ invitation_ │ │ │ │
@@ -355,11 +371,13 @@ permissions = [
└──────────────────┘
```
**Note:** The `store_users` table no longer has a `user_type` column. Ownership is determined by `Merchant.owner_user_id` and `User.role == "merchant_owner"`, not by a field on StoreUser.
### Key Tables
#### users
Primary platform user table for admins and stores.
Primary platform user table for all user types.
```python
class User(Base):
@@ -369,15 +387,25 @@ class User(Base):
email = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False) # 'admin' or 'store'
role = Column(String, nullable=False)
# 'super_admin', 'platform_admin', 'merchant_owner', or 'store_member'
is_active = Column(Boolean, default=True)
is_email_verified = Column(Boolean, default=False)
# Computed properties (not database columns):
# is_super_admin: bool -> role == "super_admin"
# is_admin: bool -> role in ("super_admin", "platform_admin")
# is_platform_admin: bool -> role == "platform_admin"
# is_merchant_owner: bool -> role == "merchant_owner"
# is_store_user: bool -> role in ("merchant_owner", "store_member")
```
**Important Fields:**
- `role`: Only contains `"admin"` or `"store"` (platform-level role)
- `role`: Contains one of 4 values: `"super_admin"`, `"platform_admin"`, `"merchant_owner"`, or `"store_member"`
- `is_email_verified`: Required for team member invitations
**Note:** The `is_super_admin` column was removed. It is now a computed property: `self.role == "super_admin"`. Similarly, `is_admin` checks `role in ("super_admin", "platform_admin")`.
#### stores
Store entities representing businesses on the platform.
@@ -411,7 +439,6 @@ class StoreUser(Base):
id = Column(Integer, primary_key=True)
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_type = Column(String, nullable=False) # 'owner' or 'member'
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
invited_by = Column(Integer, ForeignKey("users.id"))
invitation_token = Column(String, nullable=True)
@@ -421,11 +448,12 @@ class StoreUser(Base):
```
**Important Fields:**
- `user_type`: Distinguishes owners (`"owner"`) from team members (`"member"`)
- `role_id`: NULL for owners (they have all permissions), set for team members
- `role_id`: NULL for merchant owners (they have all permissions), set for store members
- `invitation_*`: Fields for tracking invitation workflow
- `is_active`: FALSE until invitation accepted (for team members)
**Note:** The `user_type` column was removed from StoreUser. Ownership is now determined by `User.role == "merchant_owner"` and `Merchant.owner_user_id`, not by a field on this table. Use `User.is_owner_of(store_id)` to check ownership.
#### roles
Store-specific role definitions with permissions.
@@ -685,7 +713,9 @@ role = Role(
│ Admin Auth Endpoint │
│ │
│ 1. Validate credentials │
│ 2. Check role == "admin"
│ 2. Check is_admin
│ (super_admin or │
│ platform_admin) │
│ 3. Generate JWT │
└──────┬──────────────────────┘
@@ -719,9 +749,11 @@ role = Role(
│ Store Auth Endpoint │
│ │
│ 1. Validate credentials │
│ 2. Block if admin
│ 2. Block if is_admin │
│ 3. Find store membership │
│ 4. Get role (owner/member)
│ 4. Determine ownership
│ via Merchant. │
│ owner_user_id │
│ 5. Generate JWT │
└──────┬──────────────────────┘
@@ -880,7 +912,7 @@ def get_current_admin_from_cookie_or_header(
Checks:
1. admin_token cookie (path=/admin)
2. Authorization: Bearer <token> header
3. Validates role == "admin"
3. Validates is_admin (role in: super_admin, platform_admin)
Use for: Admin HTML pages
"""
@@ -899,13 +931,13 @@ def get_current_store_from_cookie_or_header(
Checks:
1. store_token cookie (path=/store)
2. Authorization: Bearer <token> header
3. Blocks admin users
4. Validates store membership
3. Blocks admin users (super_admin, platform_admin)
4. Validates store membership (merchant_owner or store_member)
Use for: Store HTML pages
"""
# Implementation checks cookie first, then header
# Returns User object if authenticated as store
# Returns User object if authenticated as store user
# Raises InsufficientPermissionsException if admin
# API-only authentication (header required)
@@ -1051,14 +1083,29 @@ class User(Base):
# ... fields ...
@property
def is_admin(self) -> bool:
"""Check if user is platform admin."""
return self.role == "admin"
def is_super_admin(self) -> bool:
"""Check if user is super admin (computed from role)."""
return self.role == "super_admin"
@property
def is_store(self) -> bool:
"""Check if user is store."""
return self.role == "store"
def is_admin(self) -> bool:
"""Check if user is any type of platform admin."""
return self.role in ("super_admin", "platform_admin")
@property
def is_platform_admin(self) -> bool:
"""Check if user is a (non-super) platform admin."""
return self.role == "platform_admin"
@property
def is_merchant_owner(self) -> bool:
"""Check if user is a merchant owner."""
return self.role == "merchant_owner"
@property
def is_store_user(self) -> bool:
"""Check if user is a store-level user (owner or member)."""
return self.role in ("merchant_owner", "store_member")
def is_owner_of(self, store_id: int) -> bool:
"""Check if user owns a specific store."""
@@ -1109,13 +1156,16 @@ class StoreUser(Base):
@property
def is_owner(self) -> bool:
"""Check if this is an owner membership."""
return self.user_type == "owner"
"""Check if this user is the store owner.
Determined by User.role == 'merchant_owner' and Merchant.owner_user_id,
NOT by a column on StoreUser (user_type was removed).
"""
return self.user.is_merchant_owner and self.user.is_owner_of(self.store_id)
@property
def is_team_member(self) -> bool:
"""Check if this is a team member (not owner)."""
return self.user_type == "member"
return not self.is_owner
@property
def is_invitation_pending(self) -> bool:
@@ -1176,14 +1226,13 @@ The system uses email-based invitations for team member onboarding.
├─> User record (if doesn't exist)
│ - email: from invitation
│ - username: auto-generated
│ - role: "store"
│ - role: "store_member"
│ - is_active: FALSE
│ - is_email_verified: FALSE
└─> StoreUser record
- store_id: current store
- user_id: from User
- user_type: "member"
- role_id: from role selection
- invitation_token: secure random string
- invitation_sent_at: now()
@@ -1414,17 +1463,17 @@ def _generate_invitation_token(self) -> str:
#### Admin Blocking
Admins are blocked from store routes:
Platform admins are blocked from store routes:
```python
# In store auth endpoint
if user.role == "admin":
if current_user.is_admin: # role in ("super_admin", "platform_admin")
raise InvalidCredentialsException(
"Admins cannot access store portal"
)
# In store dependencies
if current_user.role == "admin":
if current_user.is_admin:
raise InsufficientPermissionsException(
"Store access only"
)
@@ -1719,13 +1768,12 @@ Test permission logic in isolation.
# tests/unit/test_permissions.py
def test_owner_has_all_permissions():
"""Owners have all permissions automatically."""
user = create_user()
"""Merchant owners have all permissions automatically."""
user = create_user(role="merchant_owner")
store = create_store(owner=user)
store_user = create_store_user(
user=user,
store=store,
user_type="owner"
store=store
)
assert store_user.has_permission("products.create")
@@ -1734,8 +1782,8 @@ def test_owner_has_all_permissions():
# All permissions should return True
def test_team_member_respects_role():
"""Team members have only their role's permissions."""
user = create_user()
"""Store members have only their role's permissions."""
user = create_user(role="store_member")
store = create_store()
role = create_role(
store=store,
@@ -1745,7 +1793,6 @@ def test_team_member_respects_role():
store_user = create_store_user(
user=user,
store=store,
user_type="member",
role=role
)
@@ -1959,6 +2006,6 @@ See [RBAC Quick Reference](../backend/rbac-quick-reference.md) for a condensed c
---
**Document Version:** 1.0
**Last Updated:** November 2025
**Document Version:** 2.0
**Last Updated:** February 2026
**Maintained By:** Backend Team