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

@@ -25,7 +25,6 @@ class UserResponse(BaseModel):
username: str
role: str
is_active: bool
is_super_admin: bool = False
preferred_language: str | None = None
last_login: datetime | None = None
created_at: datetime
@@ -64,7 +63,7 @@ class StoreMembershipSummary(BaseModel):
store_id: int
store_code: str
store_name: str
user_type: str
role: str
is_active: bool
@@ -88,7 +87,7 @@ class UserUpdate(BaseModel):
email: EmailStr | None = None
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str | None = Field(None, pattern="^(admin|store)$")
role: str | None = Field(None, pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
is_active: bool | None = None
is_email_verified: bool | None = None
preferred_language: str | None = Field(
@@ -113,7 +112,7 @@ class UserCreate(BaseModel):
password: str = Field(..., min_length=6, description="Password")
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str = Field(default="store", pattern="^(admin|store)$")
role: str = Field(default="store_member", pattern="^(super_admin|platform_admin|merchant_owner|store_member)$")
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@@ -222,11 +221,10 @@ class UserContext(BaseModel):
id: int
email: str
username: str
role: str # "admin" or "store"
role: str # super_admin, platform_admin, merchant_owner, or store_member
is_active: bool = True
# Admin-specific fields
is_super_admin: bool = False
accessible_platform_ids: list[int] | None = None # None = all platforms (super admin)
# Admin platform context (from JWT token after platform selection)
@@ -253,14 +251,19 @@ class UserContext(BaseModel):
return self.username
@property
def is_admin(self) -> bool:
"""Check if user is a platform admin."""
return self.role == "admin"
def is_super_admin(self) -> bool:
"""Check if user is a super admin."""
return self.role == "super_admin"
@property
def is_store(self) -> bool:
"""Check if user is a store."""
return self.role == "store"
def is_admin(self) -> bool:
"""Check if user is an admin (super_admin or platform_admin)."""
return self.role in ("super_admin", "platform_admin")
@property
def is_store_user(self) -> bool:
"""Check if user is a store user (merchant_owner or store_member)."""
return self.role in ("merchant_owner", "store_member")
def can_access_platform(self, platform_id: int) -> bool:
"""
@@ -302,15 +305,14 @@ class UserContext(BaseModel):
"username": user.username,
"role": user.role,
"is_active": user.is_active,
"is_super_admin": getattr(user, "is_super_admin", False),
"first_name": getattr(user, "first_name", None),
"last_name": getattr(user, "last_name", None),
"preferred_language": getattr(user, "preferred_language", None),
}
# Add admin platform access info
if user.role == "admin":
if getattr(user, "is_super_admin", False):
if user.is_admin:
if user.is_super_admin:
data["accessible_platform_ids"] = None # All platforms
else:
# Get platform IDs from admin_platforms relationship