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

@@ -33,7 +33,7 @@ from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.merchant_domain import MerchantDomain
from app.modules.tenancy.models.platform import Platform
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserType
from app.modules.tenancy.models.store import Role, Store, StoreUser
from app.modules.tenancy.models.store_domain import StoreDomain
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.models.user import User, UserRole
@@ -62,7 +62,6 @@ __all__ = [
# Store
"Store",
"StoreUser",
"StoreUserType",
"Role",
# Merchant configuration
"MerchantDomain",

View File

@@ -3,8 +3,8 @@
AdminPlatform junction table for many-to-many relationship between Admin Users and Platforms.
This enables platform-scoped admin access:
- Super Admins: Have is_super_admin=True on User model, bypass this table
- Platform Admins: Assigned to specific platforms via this junction table
- Super Admins: Have role='super_admin' on User model, bypass this table
- Platform Admins: Have role='platform_admin', assigned to specific platforms via this junction table
A platform admin CAN be assigned to multiple platforms (e.g., both OMS and Loyalty).
"""

View File

@@ -8,8 +8,6 @@ other models such as User (owner), Product, Customer, and Order.
Note: MarketplaceImportJob relationships are owned by the marketplace module.
"""
import enum
from sqlalchemy import (
JSON,
Boolean,
@@ -420,19 +418,14 @@ class Store(Base, TimestampMixin):
}
class StoreUserType(str, enum.Enum):
"""Types of store users."""
OWNER = "owner" # Store owner (full access to store area)
TEAM_MEMBER = "member" # Team member (role-based access to store area)
class StoreUser(Base, TimestampMixin):
"""
Represents a user's membership in a store.
Represents a user's team membership in a store.
- Owner: Created automatically when store is created
- Team Member: Invited by owner via email
Ownership is determined via User.is_owner_of(store_id) which checks
Merchant.owner_user_id, NOT a field on this table.
This table is for team members only (invited by owner).
"""
__tablename__ = "store_users"
@@ -446,10 +439,7 @@ class StoreUser(Base, TimestampMixin):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key linking to the associated User."""
# Distinguish between owner and team member
user_type = Column(String, nullable=False, default=StoreUserType.TEAM_MEMBER.value)
# Role for team members (NULL for owners - they have all permissions)
# Role for team members (determines granular permissions)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
"""Foreign key linking to the associated Role."""
@@ -480,22 +470,14 @@ class StoreUser(Base, TimestampMixin):
"""Relationship to the Role model, representing the role held by the store user."""
def __repr__(self) -> str:
"""Return a string representation of the StoreUser instance.
Returns:
str: A string that includes the store_id, the user_id and the user_type of the StoreUser instance.
"""
return f"<StoreUser(store_id={self.store_id}, user_id={self.user_id}, type={self.user_type})>"
"""Return a string representation of the StoreUser instance."""
role_name = self.role.name if self.role else "no-role"
return f"<StoreUser(store_id={self.store_id}, user_id={self.user_id}, role={role_name})>"
@property
def is_owner(self) -> bool:
"""Check if this is an owner membership."""
return self.user_type == StoreUserType.OWNER.value
@property
def is_team_member(self) -> bool:
"""Check if this is a team member (not owner)."""
return self.user_type == StoreUserType.TEAM_MEMBER.value
"""Check if this user is the owner of the store (via merchant ownership)."""
return self.user.is_owner_of(self.store_id)
@property
def is_invitation_pending(self) -> bool:
@@ -506,7 +488,7 @@ class StoreUser(Base, TimestampMixin):
"""
Check if user has a specific permission.
Owners always have all permissions.
Owners (merchant owners) always have all permissions.
Team members check their role's permissions.
"""
# Owners have all permissions
@@ -526,7 +508,6 @@ class StoreUser(Base, TimestampMixin):
def get_all_permissions(self) -> list:
"""Get all permissions this user has."""
if self.is_owner:
# Return all possible permissions from discovery service
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
@@ -571,4 +552,4 @@ class Role(Base, TimestampMixin):
return f"<Role(id={self.id}, name='{self.name}', store_id={self.store_id})>"
__all__ = ["Store", "StoreUser", "StoreUserType", "Role"]
__all__ = ["Store", "StoreUser", "Role"]

View File

@@ -2,13 +2,15 @@
"""
User model with authentication support.
ROLE CLARIFICATION:
- User.role should ONLY contain platform-level roles:
* "admin" - Platform administrator (full system access)
* "store" - Any user who owns or is part of a store team
ROLE SYSTEM (Phase 1 — Consolidated 4-value enum):
- User.role contains one of 4 values:
* "super_admin" Platform super administrator (all platforms, all settings)
* "platform_admin" — Platform admin scoped to assigned platforms
* "merchant_owner" — Owns merchant(s) and all their stores
* "store_member" — Team member on specific store(s)
- Store-specific roles (manager, staff, etc.) are stored in StoreUser.role
- Customers are NOT in the User table - they use the Customer model
- Store-specific granular permissions are in StoreUser.role_id -> Role.permissions
- Customers are NOT in the User table they use the Customer model
"""
import enum
@@ -23,12 +25,14 @@ from models.database.base import TimestampMixin
class UserRole(str, enum.Enum):
"""Platform-level user roles."""
ADMIN = "admin" # Platform administrator
STORE = "store" # Store owner or team member
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)
class User(Base, TimestampMixin):
"""Represents a platform user (admins and stores only)."""
"""Represents a platform user (admins, merchant owners, and store team)."""
__tablename__ = "users"
@@ -39,18 +43,13 @@ class User(Base, TimestampMixin):
last_name = Column(String)
hashed_password = Column(String, nullable=False)
# Platform-level role only (admin or store)
role = Column(String, nullable=False, default=UserRole.STORE.value)
# Consolidated role — one of: super_admin, platform_admin, merchant_owner, store_member
role = Column(String, nullable=False, default=UserRole.STORE_MEMBER.value)
is_active = Column(Boolean, default=True, nullable=False)
is_email_verified = Column(Boolean, default=False, nullable=False)
last_login = Column(DateTime, nullable=True)
# Super admin flag (only meaningful when role='admin')
# Super admins have access to ALL platforms and global settings
# Platform admins (is_super_admin=False) are assigned to specific platforms
is_super_admin = Column(Boolean, default=False, nullable=False)
# Language preference (NULL = use context default: store dashboard_language or system default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
@@ -92,15 +91,34 @@ class User(Base, TimestampMixin):
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_admin(self) -> bool:
"""Check if user is a platform admin."""
return self.role == UserRole.ADMIN.value
# =========================================================================
# Role-checking properties
# =========================================================================
@property
def is_store(self) -> bool:
"""Check if user is a store (owner or team member)."""
return self.role == UserRole.STORE.value
def is_super_admin(self) -> bool:
"""Check if user is a super admin."""
return self.role == UserRole.SUPER_ADMIN.value
@property
def is_admin(self) -> bool:
"""Check if user is any kind of platform admin (super or scoped)."""
return self.role in (UserRole.SUPER_ADMIN.value, UserRole.PLATFORM_ADMIN.value)
@property
def is_platform_admin(self) -> bool:
"""Check if user is a scoped platform admin (not super admin)."""
return self.role == UserRole.PLATFORM_ADMIN.value
@property
def is_merchant_owner(self) -> bool:
"""Check if user is a merchant owner."""
return self.role == UserRole.MERCHANT_OWNER.value
@property
def is_store_user(self) -> bool:
"""Check if user is a merchant owner or store member."""
return self.role in (UserRole.MERCHANT_OWNER.value, UserRole.STORE_MEMBER.value)
def is_owner_of(self, store_id: int) -> bool:
"""
@@ -155,16 +173,6 @@ class User(Base, TimestampMixin):
# Admin Platform Access Methods
# =========================================================================
@property
def is_super_admin_user(self) -> bool:
"""Check if user is a super admin (can access all platforms)."""
return self.role == UserRole.ADMIN.value and self.is_super_admin
@property
def is_platform_admin(self) -> bool:
"""Check if user is a platform admin (access to assigned platforms only)."""
return self.role == UserRole.ADMIN.value and not self.is_super_admin
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if admin can access a specific platform.