feat: RBAC Phase 1 — consolidate user roles into 4-value enum
Some checks failed
Some checks failed
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:
@@ -200,7 +200,7 @@ def get_current_admin_from_cookie_or_header(
|
||||
user = _validate_user_token(token, db)
|
||||
|
||||
# Verify user is admin
|
||||
if user.role != "admin":
|
||||
if not user.is_admin:
|
||||
logger.warning(
|
||||
f"Non-admin user {user.username} attempted admin route: {request.url.path}"
|
||||
)
|
||||
@@ -235,7 +235,7 @@ def get_current_admin_api(
|
||||
|
||||
user = _validate_user_token(credentials.credentials, db)
|
||||
|
||||
if user.role != "admin":
|
||||
if not user.is_admin:
|
||||
logger.warning(f"Non-admin user {user.username} attempted admin API")
|
||||
raise AdminRequiredException("Admin privileges required")
|
||||
|
||||
@@ -399,7 +399,7 @@ def get_admin_with_platform_context(
|
||||
|
||||
user = _validate_user_token(token, db)
|
||||
|
||||
if user.role != "admin":
|
||||
if not user.is_admin:
|
||||
raise AdminRequiredException("Admin privileges required")
|
||||
|
||||
# Super admins bypass platform context
|
||||
@@ -702,7 +702,7 @@ def get_current_store_from_cookie_or_header(
|
||||
user = _validate_user_token(token, db)
|
||||
|
||||
# CRITICAL: Block admins from store routes
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
logger.warning(
|
||||
f"Admin user {user.username} attempted store route: {request.url.path}"
|
||||
)
|
||||
@@ -710,8 +710,8 @@ def get_current_store_from_cookie_or_header(
|
||||
"Store access only - admins cannot use store portal"
|
||||
)
|
||||
|
||||
# Verify user is store
|
||||
if user.role != "store":
|
||||
# Verify user is store user (merchant_owner or store_member)
|
||||
if not user.is_store_user:
|
||||
logger.warning(
|
||||
f"Non-store user {user.username} attempted store route: {request.url.path}"
|
||||
)
|
||||
@@ -749,11 +749,11 @@ def get_current_store_api(
|
||||
user = _validate_user_token(credentials.credentials, db)
|
||||
|
||||
# Block admins from store API
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
logger.warning(f"Admin user {user.username} attempted store API")
|
||||
raise InsufficientPermissionsException("Store access only")
|
||||
|
||||
if user.role != "store":
|
||||
if not user.is_store_user:
|
||||
logger.warning(f"Non-store user {user.username} attempted store API")
|
||||
raise InsufficientPermissionsException("Store privileges required")
|
||||
|
||||
@@ -1570,7 +1570,7 @@ def get_current_admin_optional(
|
||||
user = _validate_user_token(token, db)
|
||||
|
||||
# Verify user is admin
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
return UserContext.from_user(user, include_store_context=False)
|
||||
except Exception:
|
||||
# Invalid token or other error
|
||||
@@ -1616,8 +1616,8 @@ def get_current_store_optional(
|
||||
# Validate token and get user
|
||||
user = _validate_user_token(token, db)
|
||||
|
||||
# Verify user is store
|
||||
if user.role == "store":
|
||||
# Verify user is a store user
|
||||
if user.is_store_user:
|
||||
return UserContext.from_user(user)
|
||||
except Exception:
|
||||
# Invalid token or other error
|
||||
|
||||
@@ -420,7 +420,7 @@ class StatsService:
|
||||
total_users = db.query(User).count()
|
||||
active_users = db.query(User).filter(User.is_active == True).count()
|
||||
inactive_users = total_users - active_users
|
||||
admin_users = db.query(User).filter(User.role == "admin").count()
|
||||
admin_users = db.query(User).filter(User.role.in_(["super_admin", "platform_admin"])).count()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
|
||||
@@ -80,7 +80,7 @@ def rt_merchant(db, rt_platform):
|
||||
email=f"merchant_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"merchant_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
|
||||
@@ -43,7 +43,7 @@ def merch_owner(db):
|
||||
email=f"merchowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"merchowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("merchpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -160,7 +160,7 @@ def merch_auth_headers(merch_owner, merch_merchant):
|
||||
id=merch_owner.id,
|
||||
email=merch_owner.email,
|
||||
username=merch_owner.username,
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.modules.billing.models import (
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser, StoreUserType
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
# ============================================================================
|
||||
@@ -65,7 +65,7 @@ def store_full_setup(db, store_platform):
|
||||
email=f"storeowner_{uid}@test.com",
|
||||
username=f"storeowner_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -98,11 +98,10 @@ def store_full_setup(db, store_platform):
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
# 4. Create StoreUser (owner association)
|
||||
# 4. Create StoreUser (ownership determined via Merchant.owner_user_id)
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=owner.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
|
||||
@@ -175,7 +175,7 @@ function data() {
|
||||
adminProfile: getAdminProfileFromStorage(),
|
||||
|
||||
get isSuperAdmin() {
|
||||
return this.adminProfile?.is_super_admin === true;
|
||||
return this.adminProfile?.role === 'super_admin';
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -398,4 +398,4 @@ const PlatformSettings = {
|
||||
};
|
||||
|
||||
// Export to window
|
||||
window.PlatformSettings = PlatformSettings;
|
||||
window.PlatformSettings = PlatformSettings;
|
||||
|
||||
@@ -147,7 +147,7 @@ function adminLogin() {
|
||||
throw new Error('Invalid response from server - no token');
|
||||
}
|
||||
|
||||
if (response.user && response.user.role !== 'admin') {
|
||||
if (response.user && !['super_admin', 'platform_admin'].includes(response.user.role)) {
|
||||
loginLog.error('Authorization failed: User is not admin', {
|
||||
actualRole: response.user.role
|
||||
});
|
||||
@@ -166,8 +166,7 @@ function adminLogin() {
|
||||
loginLog.debug('User data stored:', {
|
||||
username: response.user.username,
|
||||
role: response.user.role,
|
||||
id: response.user.id,
|
||||
is_super_admin: response.user.is_super_admin
|
||||
id: response.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -243,4 +242,4 @@ function adminLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
loginLog.info('Login module loaded');
|
||||
loginLog.info('Login module loaded');
|
||||
|
||||
@@ -87,7 +87,7 @@ class MarketplaceImportJobService:
|
||||
raise ImportJobNotFoundException(job_id)
|
||||
|
||||
# Users can only see their own jobs, admins can see all
|
||||
if user.role != "admin" and job.user_id != user.id:
|
||||
if not user.is_admin and job.user_id != user.id:
|
||||
raise ImportJobNotOwnedException(job_id, user.id)
|
||||
|
||||
return job
|
||||
@@ -160,7 +160,7 @@ class MarketplaceImportJobService:
|
||||
)
|
||||
|
||||
# Users can only see their own jobs, admins can see all store jobs
|
||||
if user.role != "admin":
|
||||
if not user.is_admin:
|
||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||
|
||||
# Apply marketplace filter
|
||||
|
||||
@@ -38,8 +38,6 @@ from app.modules.tenancy.models import (
|
||||
Platform,
|
||||
Store,
|
||||
StorePlatform,
|
||||
StoreUser,
|
||||
StoreUserType,
|
||||
User,
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
@@ -338,7 +336,7 @@ class PlatformSignupService:
|
||||
hashed_password=self.auth_manager.hash_password(password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -373,14 +371,7 @@ class PlatformSignupService:
|
||||
db.add(store)
|
||||
db.flush()
|
||||
|
||||
# Create StoreUser (owner)
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=user.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
|
||||
|
||||
# Create StoreOnboarding record
|
||||
onboarding_service = OnboardingService(db)
|
||||
|
||||
@@ -42,8 +42,8 @@ class ConversationType(str, enum.Enum):
|
||||
class ParticipantType(str, enum.Enum):
|
||||
"""Type of participant in a conversation."""
|
||||
|
||||
ADMIN = "admin" # User with role="admin"
|
||||
STORE = "store" # User with role="store" (via StoreUser)
|
||||
ADMIN = "admin" # Platform admin user (super_admin or platform_admin)
|
||||
STORE = "store" # Store team user (merchant_owner or store_member)
|
||||
CUSTOMER = "customer" # Customer model
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""tenancy: RBAC cleanup — consolidate user roles and drop legacy columns
|
||||
|
||||
Migrates users.role from old 3-value scheme (admin/store/user) to new
|
||||
4-value scheme (super_admin/platform_admin/merchant_owner/store_member).
|
||||
Drops the now-redundant users.is_super_admin and store_users.user_type columns.
|
||||
|
||||
Revision ID: tenancy_003
|
||||
Revises: tenancy_002
|
||||
Create Date: 2026-02-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "tenancy_003"
|
||||
down_revision = "tenancy_002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Data migration — remap role values using is_super_admin
|
||||
# and merchant ownership to determine the new role.
|
||||
#
|
||||
# Order matters: the 'admin' rows must be split before we touch
|
||||
# 'store' or 'user' rows, and 'merchant_owner' for role='user'
|
||||
# (bug fix) must come before the catch-all 'store_member' update.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# admin + is_super_admin=true -> super_admin
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'super_admin' "
|
||||
"WHERE role = 'admin' AND is_super_admin = true"
|
||||
)
|
||||
|
||||
# admin + is_super_admin=false -> platform_admin
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'platform_admin' "
|
||||
"WHERE role = 'admin' AND is_super_admin = false"
|
||||
)
|
||||
|
||||
# store users who own a merchant -> merchant_owner
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'merchant_owner' "
|
||||
"WHERE role = 'store' "
|
||||
"AND id IN (SELECT owner_user_id FROM merchants)"
|
||||
)
|
||||
|
||||
# legacy 'user' role (bug — should have been 'store') -> merchant_owner
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'merchant_owner' "
|
||||
"WHERE role = 'user'"
|
||||
)
|
||||
|
||||
# remaining store users (not merchant owners) -> store_member
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'store_member' "
|
||||
"WHERE role = 'store' "
|
||||
"AND id NOT IN (SELECT owner_user_id FROM merchants)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Drop legacy columns that are now redundant
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# users.is_super_admin is replaced by role = 'super_admin'
|
||||
op.drop_column("users", "is_super_admin")
|
||||
|
||||
# store_users.user_type is replaced by users.role (merchant_owner vs store_member)
|
||||
op.drop_column("store_users", "user_type")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Re-add dropped columns
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_super_admin",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"store_users",
|
||||
sa.Column(
|
||||
"user_type",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="member",
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Reverse data migration — map new roles back to old scheme
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Mark super admins
|
||||
op.execute(
|
||||
"UPDATE users SET is_super_admin = true "
|
||||
"WHERE role = 'super_admin'"
|
||||
)
|
||||
|
||||
# super_admin + platform_admin -> admin
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'admin' "
|
||||
"WHERE role IN ('super_admin', 'platform_admin')"
|
||||
)
|
||||
|
||||
# merchant_owner + store_member -> store
|
||||
op.execute(
|
||||
"UPDATE users SET role = 'store' "
|
||||
"WHERE role IN ('merchant_owner', 'store_member')"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Restore store_users.user_type based on merchant ownership
|
||||
#
|
||||
# If the user owns the merchant that owns the store, they are
|
||||
# an 'owner'; otherwise 'member'.
|
||||
# ------------------------------------------------------------------
|
||||
op.execute(
|
||||
"UPDATE store_users SET user_type = 'owner' "
|
||||
"WHERE user_id IN ("
|
||||
" SELECT m.owner_user_id FROM merchants m"
|
||||
" JOIN stores s ON s.merchant_id = m.id"
|
||||
" WHERE s.id = store_users.store_id"
|
||||
")"
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,7 +57,7 @@ def admin_login(
|
||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||
|
||||
# Verify user is admin
|
||||
if login_result["user"].role != "admin":
|
||||
if not login_result["user"].is_admin:
|
||||
logger.warning(
|
||||
f"Non-admin user attempted admin login: {user_credentials.email_or_username}"
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ def get_all_users(
|
||||
store_id=su.store_id,
|
||||
store_code=su.store.store_code,
|
||||
store_name=su.store.name,
|
||||
user_type=su.user_type,
|
||||
role=su.user.role,
|
||||
is_active=su.is_active,
|
||||
)
|
||||
for su in (user.store_memberships or [])
|
||||
@@ -299,7 +299,7 @@ def get_user_details(
|
||||
store_id=su.store_id,
|
||||
store_code=su.store.store_code,
|
||||
store_name=su.store.name,
|
||||
user_type=su.user_type,
|
||||
role=su.user.role,
|
||||
is_active=su.is_active,
|
||||
)
|
||||
for su in (user.store_memberships or [])
|
||||
|
||||
@@ -57,7 +57,7 @@ class AdminUserResponse(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
is_active: bool
|
||||
is_super_admin: bool
|
||||
role: str
|
||||
platform_assignments: list[PlatformAssignmentResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -82,7 +82,7 @@ class CreateAdminUserRequest(BaseModel):
|
||||
password: str
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
is_super_admin: bool = False
|
||||
role: str = "platform_admin"
|
||||
platform_ids: list[int] = []
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ class AssignPlatformRequest(BaseModel):
|
||||
|
||||
|
||||
class ToggleSuperAdminRequest(BaseModel):
|
||||
"""Request to toggle super admin status."""
|
||||
"""Request to change admin role (super_admin or platform_admin)."""
|
||||
|
||||
is_super_admin: bool
|
||||
role: str # "super_admin" or "platform_admin"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -125,7 +125,7 @@ def _build_admin_response(admin: User) -> AdminUserResponse:
|
||||
first_name=admin.first_name,
|
||||
last_name=admin.last_name,
|
||||
is_active=admin.is_active,
|
||||
is_super_admin=admin.is_super_admin,
|
||||
role=admin.role,
|
||||
platform_assignments=assignments,
|
||||
created_at=admin.created_at,
|
||||
updated_at=admin.updated_at,
|
||||
@@ -174,14 +174,14 @@ def create_admin_user(
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
# Validate platform_ids required for non-super admin
|
||||
if not request.is_super_admin and not request.platform_ids:
|
||||
# Validate platform_ids required for platform admins
|
||||
if request.role != "super_admin" and not request.platform_ids:
|
||||
raise ValidationException(
|
||||
"Platform admins must be assigned to at least one platform",
|
||||
field="platform_ids",
|
||||
)
|
||||
|
||||
if request.is_super_admin:
|
||||
if request.role == "super_admin":
|
||||
# Create super admin using service
|
||||
user = admin_platform_service.create_super_admin(
|
||||
db=db,
|
||||
@@ -202,7 +202,7 @@ def create_admin_user(
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
is_active=user.is_active,
|
||||
is_super_admin=user.is_super_admin,
|
||||
role=user.role,
|
||||
platform_assignments=[],
|
||||
)
|
||||
# Create platform admin with assignments using service
|
||||
@@ -306,17 +306,17 @@ def toggle_super_admin_status(
|
||||
user = admin_platform_service.toggle_super_admin(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
is_super_admin=request.is_super_admin,
|
||||
is_super_admin=(request.role == "super_admin"),
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
action = "promoted to" if request.is_super_admin else "demoted from"
|
||||
action = "promoted to" if request.role == "super_admin" else "demoted from"
|
||||
|
||||
return {
|
||||
"message": f"Admin {action} super admin successfully",
|
||||
"user_id": user_id,
|
||||
"is_super_admin": user.is_super_admin,
|
||||
"role": user.role,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def store_login(
|
||||
user = login_result["user"]
|
||||
|
||||
# CRITICAL: Prevent admin users from using store login
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
logger.warning(f"Admin user attempted store login: {user.username}")
|
||||
raise InvalidCredentialsException(
|
||||
"Admins cannot access store portal. Please use admin portal."
|
||||
|
||||
@@ -118,7 +118,6 @@ class TeamMemberResponse(BaseModel):
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
full_name: str
|
||||
user_type: str = Field(..., description="'owner' or 'member'")
|
||||
role_name: str = Field(..., description="Role name")
|
||||
role_id: int | None
|
||||
permissions: list[str] = Field(
|
||||
|
||||
@@ -344,7 +344,7 @@ class AdminPlatformService:
|
||||
reason="User must be an admin to be promoted to super admin",
|
||||
)
|
||||
|
||||
user.is_super_admin = is_super_admin
|
||||
user.role = "super_admin" if is_super_admin else "platform_admin"
|
||||
user.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
@@ -402,9 +402,8 @@ class AdminPlatformService:
|
||||
hashed_password=auth_manager.hash_password(password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role="admin",
|
||||
role="platform_admin",
|
||||
is_active=True,
|
||||
is_super_admin=False, # Platform admin, not super admin
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
@@ -459,10 +458,12 @@ class AdminPlatformService:
|
||||
Returns:
|
||||
Tuple of (list of User objects, total count)
|
||||
"""
|
||||
query = db.query(User).filter(User.role == "admin")
|
||||
query = db.query(User).filter(
|
||||
User.role.in_(["super_admin", "platform_admin"])
|
||||
)
|
||||
|
||||
if not include_super_admins:
|
||||
query = query.filter(User.is_super_admin == False)
|
||||
query = query.filter(User.role == "platform_admin")
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(User.is_active == is_active)
|
||||
@@ -508,7 +509,10 @@ class AdminPlatformService:
|
||||
admin = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.admin_platforms))
|
||||
.filter(User.id == user_id, User.role == "admin")
|
||||
.filter(
|
||||
User.id == user_id,
|
||||
User.role.in_(["super_admin", "platform_admin"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -560,8 +564,7 @@ class AdminPlatformService:
|
||||
hashed_password=get_password_hash(password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role="admin",
|
||||
is_super_admin=True,
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -601,7 +604,10 @@ class AdminPlatformService:
|
||||
operation="deactivate own account",
|
||||
)
|
||||
|
||||
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
|
||||
admin = db.query(User).filter(
|
||||
User.id == user_id,
|
||||
User.role.in_(["super_admin", "platform_admin"]),
|
||||
).first()
|
||||
|
||||
if not admin:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
@@ -642,7 +648,10 @@ class AdminPlatformService:
|
||||
operation="delete own account",
|
||||
)
|
||||
|
||||
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
|
||||
admin = db.query(User).filter(
|
||||
User.id == user_id,
|
||||
User.role.in_(["super_admin", "platform_admin"]),
|
||||
).first()
|
||||
|
||||
if not admin:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
|
||||
@@ -69,7 +69,7 @@ class AdminService:
|
||||
raise CannotModifySelfException(user_id, "deactivate account")
|
||||
|
||||
# Check if user is another admin
|
||||
if user.role == "admin" and user.id != current_admin_id:
|
||||
if user.is_admin and user.id != current_admin_id:
|
||||
raise UserStatusChangeException(
|
||||
user_id=user_id,
|
||||
current_status="admin",
|
||||
@@ -248,7 +248,7 @@ class AdminService:
|
||||
user = self._get_user_by_id_or_raise(db, user_id)
|
||||
|
||||
# Prevent changing own admin status
|
||||
if user.id == current_admin_id and role and role != "admin":
|
||||
if user.id == current_admin_id and role and role not in ("super_admin", "platform_admin"):
|
||||
raise UserRoleChangeException(
|
||||
user_id=user_id,
|
||||
current_role=user.role,
|
||||
|
||||
@@ -69,7 +69,7 @@ class MerchantService:
|
||||
username=merchant_data.owner_email.split("@")[0],
|
||||
email=merchant_data.owner_email,
|
||||
hashed_password=auth_manager.hash_password(temp_password),
|
||||
role="user",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ class StoreService:
|
||||
|
||||
# Check if user is merchant owner or admin
|
||||
if (
|
||||
current_user.role != "admin"
|
||||
not current_user.is_admin
|
||||
and merchant.owner_user_id != current_user.id
|
||||
):
|
||||
raise UnauthorizedStoreAccessException(
|
||||
@@ -105,7 +105,7 @@ class StoreService:
|
||||
letzshop_csv_url_en=store_data.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=store_data.letzshop_csv_url_de,
|
||||
is_active=True,
|
||||
is_verified=(current_user.role == "admin"),
|
||||
is_verified=current_user.is_admin,
|
||||
)
|
||||
|
||||
db.add(new_store)
|
||||
@@ -153,7 +153,7 @@ class StoreService:
|
||||
query = db.query(Store)
|
||||
|
||||
# Non-admin users can only see active and verified stores, plus their own
|
||||
if current_user.role != "admin":
|
||||
if not current_user.is_admin:
|
||||
# Get store IDs the user owns through merchants
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
@@ -457,7 +457,7 @@ class StoreService:
|
||||
def _can_access_store(self, store: Store, user: User) -> bool:
|
||||
"""Check if user can access store."""
|
||||
# Admins can always access
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
return True
|
||||
|
||||
# Merchant owners can access their stores
|
||||
@@ -481,7 +481,7 @@ class StoreService:
|
||||
- Team members with appropriate role (owner role in StoreUser)
|
||||
"""
|
||||
# Admins can always update
|
||||
if user.role == "admin":
|
||||
if user.is_admin:
|
||||
return True
|
||||
|
||||
# Check if user is store owner via merchant
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.modules.tenancy.exceptions import (
|
||||
TeamMemberAlreadyExistsException,
|
||||
UserNotFoundException,
|
||||
)
|
||||
from app.modules.tenancy.models import Role, Store, StoreUser, StoreUserType, User
|
||||
from app.modules.tenancy.models import Role, Store, StoreUser, User
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -134,7 +134,7 @@ class StoreTeamService:
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=self.auth_manager.hash_password(temp_password),
|
||||
role="store", # Platform role
|
||||
role="store_member",
|
||||
is_active=False, # Will be activated when invitation accepted
|
||||
is_email_verified=False,
|
||||
)
|
||||
@@ -157,7 +157,6 @@ class StoreTeamService:
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=user.id,
|
||||
user_type=StoreUserType.TEAM_MEMBER.value,
|
||||
role_id=role.id,
|
||||
invited_by=inviter.id,
|
||||
invitation_token=invitation_token,
|
||||
@@ -416,7 +415,6 @@ class StoreTeamService:
|
||||
"first_name": vu.user.first_name,
|
||||
"last_name": vu.user.last_name,
|
||||
"full_name": vu.user.full_name,
|
||||
"user_type": vu.user_type,
|
||||
"role_name": vu.role.name if vu.role else "owner",
|
||||
"role_id": vu.role.id if vu.role else None,
|
||||
"permissions": vu.get_all_permissions(),
|
||||
|
||||
@@ -216,7 +216,7 @@ class TenancyMetricsProvider:
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.id.in_(platform_user_ids),
|
||||
User.role == "admin",
|
||||
User.role.in_(["super_admin", "platform_admin"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
@@ -230,11 +230,9 @@ class TenancyMetricsProvider:
|
||||
)
|
||||
|
||||
# Team members: distinct StoreUser users who are NOT merchant owners
|
||||
# Uses user_type="member" AND excludes owner user IDs to avoid overlap
|
||||
team_members = (
|
||||
db.query(func.count(func.distinct(StoreUser.user_id)))
|
||||
.filter(
|
||||
StoreUser.user_type == "member",
|
||||
~StoreUser.user_id.in_(db.query(Merchant.owner_user_id)),
|
||||
)
|
||||
.scalar() or 0
|
||||
|
||||
@@ -86,7 +86,7 @@ function adminUserDetailPage() {
|
||||
adminUserDetailLog.info(`Admin user loaded in ${duration}ms`, {
|
||||
id: this.adminUser.id,
|
||||
username: this.adminUser.username,
|
||||
is_super_admin: this.adminUser.is_super_admin,
|
||||
role: this.adminUser.role,
|
||||
is_active: this.adminUser.is_active
|
||||
});
|
||||
adminUserDetailLog.debug('Full admin user data:', this.adminUser);
|
||||
|
||||
@@ -101,7 +101,7 @@ function adminUserEditPage() {
|
||||
adminUserEditLog.info(`Admin user loaded in ${duration}ms`, {
|
||||
id: this.adminUser.id,
|
||||
username: this.adminUser.username,
|
||||
is_super_admin: this.adminUser.is_super_admin
|
||||
role: this.adminUser.role
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -128,7 +128,7 @@ function adminUserEditPage() {
|
||||
|
||||
// Get available platforms (not yet assigned)
|
||||
get availablePlatformsForAssignment() {
|
||||
if (!this.adminUser || this.adminUser.is_super_admin) return [];
|
||||
if (!this.adminUser || this.adminUser.role === 'super_admin') return [];
|
||||
const assignedIds = (this.adminUser.platforms || []).map(p => p.id);
|
||||
return this.platforms.filter(p => !assignedIds.includes(p.id));
|
||||
},
|
||||
@@ -143,12 +143,13 @@ function adminUserEditPage() {
|
||||
|
||||
// Toggle super admin status
|
||||
async toggleSuperAdmin() {
|
||||
const newStatus = !this.adminUser.is_super_admin;
|
||||
const action = newStatus ? 'promote to' : 'demote from';
|
||||
const isSuperAdmin = this.adminUser.role === 'super_admin';
|
||||
const newRole = isSuperAdmin ? 'platform_admin' : 'super_admin';
|
||||
const action = !isSuperAdmin ? 'promote to' : 'demote from';
|
||||
adminUserEditLog.info(`Toggle super admin: ${action}`);
|
||||
|
||||
// Prevent self-demotion
|
||||
if (this.adminUser.id === this.currentUserId && !newStatus) {
|
||||
if (this.adminUser.id === this.currentUserId && isSuperAdmin) {
|
||||
Utils.showToast(I18n.t('tenancy.messages.you_cannot_demote_yourself_from_super_ad'), 'error');
|
||||
return;
|
||||
}
|
||||
@@ -156,19 +157,19 @@ function adminUserEditPage() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = `/admin/admin-users/${this.userId}/super-admin`;
|
||||
window.LogConfig.logApiCall('PUT', url, { is_super_admin: newStatus }, 'request');
|
||||
window.LogConfig.logApiCall('PUT', url, { role: newRole }, 'request');
|
||||
|
||||
const response = await apiClient.put(url, { is_super_admin: newStatus });
|
||||
const response = await apiClient.put(url, { role: newRole });
|
||||
|
||||
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||
|
||||
this.adminUser.is_super_admin = response.is_super_admin;
|
||||
this.adminUser.role = response.role;
|
||||
// Clear platforms if promoted to super admin
|
||||
if (response.is_super_admin) {
|
||||
if (response.role === 'super_admin') {
|
||||
this.adminUser.platforms = [];
|
||||
}
|
||||
|
||||
const actionDone = newStatus ? 'promoted to' : 'demoted from';
|
||||
const actionDone = newRole === 'super_admin' ? 'promoted to' : 'demoted from';
|
||||
Utils.showToast(`Admin ${actionDone} super admin successfully`, 'success');
|
||||
adminUserEditLog.info(`Admin ${actionDone} super admin successfully`);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function adminUsersPage() {
|
||||
adminToDelete: null,
|
||||
filters: {
|
||||
search: '',
|
||||
is_super_admin: '',
|
||||
role: '',
|
||||
is_active: ''
|
||||
},
|
||||
stats: {
|
||||
@@ -143,7 +143,7 @@ function adminUsersPage() {
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.is_super_admin === 'false') {
|
||||
if (this.filters.role === 'platform_admin') {
|
||||
params.append('include_super_admins', 'false');
|
||||
}
|
||||
if (this.filters.is_active !== '') {
|
||||
@@ -174,9 +174,11 @@ function adminUsersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by super admin status
|
||||
if (this.filters.is_super_admin === 'true') {
|
||||
admins = admins.filter(admin => admin.is_super_admin);
|
||||
// Filter by role
|
||||
if (this.filters.role === 'super_admin') {
|
||||
admins = admins.filter(admin => admin.role === 'super_admin');
|
||||
} else if (this.filters.role === 'platform_admin') {
|
||||
admins = admins.filter(admin => admin.role === 'platform_admin');
|
||||
}
|
||||
|
||||
// Filter by active status
|
||||
@@ -227,8 +229,8 @@ function adminUsersPage() {
|
||||
// Compute stats from the data
|
||||
this.stats = {
|
||||
total_admins: admins.length,
|
||||
super_admins: admins.filter(a => a.is_super_admin).length,
|
||||
platform_admins: admins.filter(a => !a.is_super_admin).length,
|
||||
super_admins: admins.filter(a => a.role === 'super_admin').length,
|
||||
platform_admins: admins.filter(a => a.role === 'platform_admin').length,
|
||||
active_admins: admins.filter(a => a.is_active).length
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ function selectPlatform() {
|
||||
const response = await apiClient.get('/admin/auth/accessible-platforms');
|
||||
platformLog.debug('Platforms response:', response);
|
||||
|
||||
this.isSuperAdmin = response.is_super_admin;
|
||||
this.isSuperAdmin = response.role === 'super_admin';
|
||||
this.platforms = response.platforms || [];
|
||||
|
||||
if (this.isSuperAdmin) {
|
||||
|
||||
@@ -17,7 +17,7 @@ function adminUserCreate() {
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
is_super_admin: false,
|
||||
role: 'platform_admin',
|
||||
platform_ids: []
|
||||
},
|
||||
platforms: [],
|
||||
@@ -72,7 +72,7 @@ function adminUserCreate() {
|
||||
}
|
||||
|
||||
// Platform admin validation: must have at least one platform
|
||||
if (!this.formData.is_super_admin) {
|
||||
if (this.formData.role !== 'super_admin') {
|
||||
if (!this.formData.platform_ids || this.formData.platform_ids.length === 0) {
|
||||
this.errors.platform_ids = 'Platform admins must be assigned to at least one platform';
|
||||
}
|
||||
@@ -103,8 +103,8 @@ function adminUserCreate() {
|
||||
password: this.formData.password,
|
||||
first_name: this.formData.first_name || null,
|
||||
last_name: this.formData.last_name || null,
|
||||
is_super_admin: this.formData.is_super_admin,
|
||||
platform_ids: this.formData.is_super_admin ? [] : this.formData.platform_ids.map(id => parseInt(id))
|
||||
role: this.formData.role,
|
||||
platform_ids: this.formData.role === 'super_admin' ? [] : this.formData.platform_ids.map(id => parseInt(id))
|
||||
};
|
||||
|
||||
window.LogConfig.logApiCall('POST', url, { ...payload, password: '[REDACTED]' }, 'request');
|
||||
@@ -116,7 +116,7 @@ function adminUserCreate() {
|
||||
window.LogConfig.logApiCall('POST', url, response, 'response');
|
||||
window.LogConfig.logPerformance('Create Admin User', duration);
|
||||
|
||||
const userType = this.formData.is_super_admin ? 'Super admin' : 'Platform admin';
|
||||
const userType = this.formData.role === 'super_admin' ? 'Super admin' : 'Platform admin';
|
||||
Utils.showToast(`${userType} created successfully`, 'success');
|
||||
userCreateLog.info(`${userType} created successfully in ${duration}ms`, response);
|
||||
|
||||
|
||||
@@ -58,16 +58,16 @@
|
||||
<!-- Admin Type -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 rounded-full"
|
||||
:class="adminUser?.is_super_admin
|
||||
:class="adminUser?.role === 'super_admin'
|
||||
? 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500'
|
||||
: 'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500'">
|
||||
<span x-html="$icon(adminUser?.is_super_admin ? 'star' : 'shield', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon(adminUser?.role === 'super_admin' ? 'star' : 'shield', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Admin Type
|
||||
Role
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'Super Admin' : 'Platform Admin'">
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.role === 'super_admin' ? 'Super Admin' : 'Platform Admin'">
|
||||
-
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Platforms
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'All' : ((adminUser?.platforms || []).length || 0)">
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.role === 'super_admin' ? 'All' : ((adminUser?.platforms || []).length || 0)">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Platform Access (for platform admins) -->
|
||||
<template x-if="!adminUser?.is_super_admin">
|
||||
<template x-if="adminUser?.role !== 'super_admin'">
|
||||
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Access
|
||||
@@ -197,7 +197,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Super Admin Notice -->
|
||||
<template x-if="adminUser?.is_super_admin">
|
||||
<template x-if="adminUser?.role === 'super_admin'">
|
||||
<div class="px-4 py-3 mb-8 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('star', 'w-5 h-5 text-orange-500 mr-3')"></span>
|
||||
|
||||
@@ -37,23 +37,23 @@
|
||||
<!-- Toggle Super Admin -->
|
||||
<button
|
||||
@click="showToggleSuperAdminModal = true"
|
||||
:disabled="saving || (adminUser?.id === currentUserId && adminUser?.is_super_admin)"
|
||||
:disabled="saving || (adminUser?.id === currentUserId && adminUser?.role === 'super_admin')"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none disabled:opacity-50"
|
||||
:class="adminUser?.is_super_admin ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
:title="adminUser?.id === currentUserId && adminUser?.is_super_admin ? 'Cannot demote yourself' : ''">
|
||||
<span x-html="$icon(adminUser?.is_super_admin ? 'shield-x' : 'shield-check', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="adminUser?.is_super_admin ? 'Demote from Super Admin' : 'Promote to Super Admin'"></span>
|
||||
:class="adminUser?.role === 'super_admin' ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
:title="adminUser?.id === currentUserId && adminUser?.role === 'super_admin' ? 'Cannot demote yourself' : ''">
|
||||
<span x-html="$icon(adminUser?.role === 'super_admin' ? 'shield-x' : 'shield-check', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="adminUser?.role === 'super_admin' ? 'Demote from Super Admin' : 'Promote to Super Admin'"></span>
|
||||
</button>
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span
|
||||
x-show="adminUser?.is_super_admin"
|
||||
x-show="adminUser?.role === 'super_admin'"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-yellow-700 bg-yellow-100 rounded-full dark:bg-yellow-700 dark:text-yellow-100">
|
||||
Super Admin
|
||||
</span>
|
||||
<span
|
||||
x-show="!adminUser?.is_super_admin"
|
||||
x-show="adminUser?.role !== 'super_admin'"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100">
|
||||
Platform Admin
|
||||
</span>
|
||||
@@ -107,7 +107,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Platform Assignments Card (Only for Platform Admins) -->
|
||||
<template x-if="!adminUser?.is_super_admin">
|
||||
<template x-if="adminUser?.role !== 'super_admin'">
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
@@ -160,7 +160,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Super Admin Notice -->
|
||||
<template x-if="adminUser?.is_super_admin">
|
||||
<template x-if="adminUser?.role === 'super_admin'">
|
||||
<div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('shield-check', 'w-6 h-6 text-yellow-600 dark:text-yellow-400 mr-3')"></span>
|
||||
@@ -270,7 +270,7 @@
|
||||
{{ confirm_modal_dynamic(
|
||||
'toggleSuperAdminModal',
|
||||
'Toggle Super Admin',
|
||||
"'Are you sure you want to ' + (adminUser?.is_super_admin ? 'demote' : 'promote') + ' \"' + (adminUser?.username || '') + '\" ' + (adminUser?.is_super_admin ? 'from' : 'to') + ' super admin?'",
|
||||
"'Are you sure you want to ' + (adminUser?.role === 'super_admin' ? 'demote' : 'promote') + ' \"' + (adminUser?.username || '') + '\" ' + (adminUser?.role === 'super_admin' ? 'from' : 'to') + ' super admin?'",
|
||||
'toggleSuperAdmin()',
|
||||
'showToggleSuperAdminModal',
|
||||
'Confirm',
|
||||
|
||||
@@ -101,15 +101,15 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Admin Type Filter -->
|
||||
<!-- Admin Role Filter -->
|
||||
<select
|
||||
x-model="filters.is_super_admin"
|
||||
x-model="filters.role"
|
||||
@change="pagination.page = 1; loadAdminUsers()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Admin Types</option>
|
||||
<option value="true">Super Admins</option>
|
||||
<option value="false">Platform Admins</option>
|
||||
<option value="">All Admin Roles</option>
|
||||
<option value="super_admin">Super Admins</option>
|
||||
<option value="platform_admin">Platform Admins</option>
|
||||
</select>
|
||||
|
||||
<!-- Status Filter -->
|
||||
@@ -139,7 +139,7 @@
|
||||
<!-- Admin Users Table -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Admin', 'Email', 'Type', 'Platforms', 'Status', 'Last Login', 'Actions']) }}
|
||||
{{ table_header(['Admin', 'Email', 'Role', 'Platforms', 'Status', 'Last Login', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="adminUsers.length === 0">
|
||||
@@ -162,7 +162,7 @@
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm"
|
||||
:class="admin.is_super_admin ? 'bg-orange-500' : 'bg-purple-500'"
|
||||
:class="admin.role === 'super_admin' ? 'bg-orange-500' : 'bg-purple-500'"
|
||||
x-text="(admin.username || 'A').charAt(0).toUpperCase()">
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,22 +176,22 @@
|
||||
<!-- Email -->
|
||||
<td class="px-4 py-3 text-sm" x-text="admin.email"></td>
|
||||
|
||||
<!-- Type -->
|
||||
<!-- Role -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="admin.is_super_admin
|
||||
:class="admin.role === 'super_admin'
|
||||
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
|
||||
: 'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100'"
|
||||
x-text="admin.is_super_admin ? 'Super Admin' : 'Platform Admin'">
|
||||
x-text="admin.role === 'super_admin' ? 'Super Admin' : 'Platform Admin'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Platforms -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="admin.is_super_admin">
|
||||
<template x-if="admin.role === 'super_admin'">
|
||||
<span class="text-gray-500 dark:text-gray-400 italic">All platforms</span>
|
||||
</template>
|
||||
<template x-if="!admin.is_super_admin">
|
||||
<template x-if="admin.role !== 'super_admin'">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="platform in (admin.platforms || []).slice(0, 3)" :key="platform.id">
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="platform.code"></span>
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-text="membership.store_code"></span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
x-text="membership.user_type"></span>
|
||||
x-text="membership.is_owner ? 'Owner' : 'Member'"></span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -78,24 +78,26 @@
|
||||
<div class="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-300 mb-3">Admin Settings</h4>
|
||||
|
||||
<!-- Super Admin Toggle -->
|
||||
<label class="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="formData.is_super_admin"
|
||||
:disabled="saving"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Super Admin
|
||||
<!-- Admin Role Selection -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Admin Role <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<select
|
||||
x-model="formData.role"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select"
|
||||
>
|
||||
<option value="platform_admin">Platform Admin</option>
|
||||
<option value="super_admin">Super Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4 -mt-2 ml-6">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4 -mt-2">
|
||||
Super admins have access to all platforms and can manage other admins.
|
||||
</p>
|
||||
|
||||
<!-- Platform Assignment (only if not super admin) -->
|
||||
<template x-if="!formData.is_super_admin">
|
||||
<template x-if="formData.role !== 'super_admin'">
|
||||
<div>
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
|
||||
@@ -34,7 +34,7 @@ def ev_unverified_user(db):
|
||||
email=f"unverified_{uid}@test.com",
|
||||
username=f"unverified_{uid}",
|
||||
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
@@ -55,7 +55,7 @@ def ev_verified_user(db):
|
||||
email=f"verified_{uid}@test.com",
|
||||
username=f"verified_{uid}",
|
||||
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ def ma_owner(db):
|
||||
email=f"maowner_{uid}@test.com",
|
||||
username=f"maowner_{uid}",
|
||||
hashed_password=auth.hash_password("mapass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -70,7 +70,7 @@ def ma_non_merchant_user(db):
|
||||
email=f"nonmerch_{uid}@test.com",
|
||||
username=f"nonmerch_{uid}",
|
||||
hashed_password=auth.hash_password("nonmerch123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -91,7 +91,7 @@ def ma_inactive_user(db):
|
||||
email=f"inactive_{uid}@test.com",
|
||||
username=f"inactive_{uid}",
|
||||
hashed_password=auth.hash_password("inactive123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=False,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
@@ -32,7 +32,7 @@ def mpr_user(db):
|
||||
email=f"mpr_{uid}@test.com",
|
||||
username=f"mpr_{uid}",
|
||||
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ def mpr_inactive_user(db):
|
||||
email=f"mpri_{uid}@test.com",
|
||||
username=f"mpri_{uid}",
|
||||
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=False,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ def mt_owner(db):
|
||||
email=f"mtowner_{uid}@test.com",
|
||||
username=f"mtowner_{uid}",
|
||||
hashed_password=auth.hash_password("mtpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -95,7 +95,7 @@ def mt_auth(mt_owner, mt_merchant):
|
||||
id=mt_owner.id,
|
||||
email=mt_owner.email,
|
||||
username=mt_owner.username,
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
|
||||
from app.modules.tenancy.models.store import StoreUserType
|
||||
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
|
||||
|
||||
# ============================================================================
|
||||
@@ -33,7 +32,7 @@ def spr_owner(db):
|
||||
email=f"spr_{uid}@test.com",
|
||||
username=f"spr_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -83,7 +82,6 @@ def spr_store_user(db, spr_owner, spr_store):
|
||||
store_user = StoreUser(
|
||||
user_id=spr_owner.id,
|
||||
store_id=spr_store.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
|
||||
@@ -75,9 +75,8 @@ class TestAdminPlatformModel:
|
||||
email="temp_admin@example.com",
|
||||
username="temp_admin",
|
||||
hashed_password=auth_manager.hash_password("temppass"),
|
||||
role="admin",
|
||||
role="platform_admin",
|
||||
is_active=True,
|
||||
is_super_admin=False,
|
||||
)
|
||||
db.add(temp_admin)
|
||||
db.flush()
|
||||
@@ -168,13 +167,13 @@ class TestAdminPlatformModel:
|
||||
class TestUserAdminMethods:
|
||||
"""Test User model admin-related methods."""
|
||||
|
||||
def test_is_super_admin_user_true(self, db, test_super_admin):
|
||||
"""Test is_super_admin_user property for super admin."""
|
||||
assert test_super_admin.is_super_admin_user is True
|
||||
def test_is_super_admin_true(self, db, test_super_admin):
|
||||
"""Test is_super_admin property for super admin."""
|
||||
assert test_super_admin.is_super_admin is True
|
||||
|
||||
def test_is_super_admin_user_false_for_platform_admin(self, db, test_platform_admin):
|
||||
"""Test is_super_admin_user property for platform admin."""
|
||||
assert test_platform_admin.is_super_admin_user is False
|
||||
def test_is_super_admin_false_for_platform_admin(self, db, test_platform_admin):
|
||||
"""Test is_super_admin property for platform admin."""
|
||||
assert test_platform_admin.is_super_admin is False
|
||||
|
||||
def test_is_platform_admin_true(self, db, test_platform_admin):
|
||||
"""Test is_platform_admin property for platform admin."""
|
||||
|
||||
@@ -256,9 +256,8 @@ class TestAdminPlatformServiceQueries:
|
||||
email="another_padmin@example.com",
|
||||
username="another_padmin",
|
||||
hashed_password=auth_manager.hash_password("pass"), # noqa: SEC001
|
||||
role="admin",
|
||||
role="platform_admin",
|
||||
is_active=True,
|
||||
is_super_admin=False,
|
||||
)
|
||||
db.add(another_admin)
|
||||
db.flush()
|
||||
@@ -343,9 +342,8 @@ class TestAdminPlatformServiceSuperAdmin:
|
||||
email="another_super@example.com",
|
||||
username="another_super",
|
||||
hashed_password=auth_manager.hash_password("pass"), # noqa: SEC001
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_super_admin=True,
|
||||
)
|
||||
db.add(another_super)
|
||||
db.commit()
|
||||
@@ -427,8 +425,8 @@ class TestAdminPlatformServiceCreatePlatformAdmin:
|
||||
assert user is not None
|
||||
assert user.email == "new_padmin@example.com"
|
||||
assert user.username == "new_padmin"
|
||||
assert user.role == "admin"
|
||||
assert user.is_super_admin is False
|
||||
assert user.role == "platform_admin"
|
||||
assert user.is_super_admin is False # platform_admin, not super_admin
|
||||
assert user.first_name == "New"
|
||||
assert user.last_name == "Admin"
|
||||
assert len(assignments) == 2
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestEmailVerificationToken:
|
||||
email=f"evk_{uid}@test.com",
|
||||
username=f"evk_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.modules.tenancy.exceptions import (
|
||||
InvalidInvitationTokenException,
|
||||
UserNotFoundException,
|
||||
)
|
||||
from app.modules.tenancy.models import Role, Store, StoreUser, StoreUserType, User
|
||||
from app.modules.tenancy.models import Role, Store, StoreUser, User
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
|
||||
# =============================================================================
|
||||
@@ -43,7 +43,6 @@ def store_owner(db, team_store, test_user):
|
||||
store_user = StoreUser(
|
||||
store_id=team_store.id,
|
||||
user_id=test_user.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
@@ -67,7 +66,6 @@ def team_member(db, team_store, other_user, auth_manager):
|
||||
store_user = StoreUser(
|
||||
store_id=team_store.id,
|
||||
user_id=other_user.id,
|
||||
user_type=StoreUserType.TEAM_MEMBER.value,
|
||||
role_id=role.id,
|
||||
is_active=True,
|
||||
invitation_accepted_at=datetime.utcnow(),
|
||||
@@ -88,7 +86,7 @@ def pending_invitation(db, team_store, test_user, auth_manager):
|
||||
email=f"pending_{unique_id}@example.com",
|
||||
username=f"pending_{unique_id}",
|
||||
hashed_password=auth_manager.hash_password("temppass"), # noqa: SEC001
|
||||
role="store",
|
||||
role="store_member",
|
||||
is_active=False,
|
||||
)
|
||||
db.add(new_user)
|
||||
@@ -107,7 +105,6 @@ def pending_invitation(db, team_store, test_user, auth_manager):
|
||||
store_user = StoreUser(
|
||||
store_id=team_store.id,
|
||||
user_id=new_user.id,
|
||||
user_type=StoreUserType.TEAM_MEMBER.value,
|
||||
role_id=role.id,
|
||||
invited_by=test_user.id,
|
||||
invitation_token=f"pending_token_{unique_id}",
|
||||
@@ -130,7 +127,7 @@ def expired_invitation(db, team_store, test_user, auth_manager):
|
||||
email=f"expired_{unique_id}@example.com",
|
||||
username=f"expired_{unique_id}",
|
||||
hashed_password=auth_manager.hash_password("temppass"), # noqa: SEC001
|
||||
role="store",
|
||||
role="store_member",
|
||||
is_active=False,
|
||||
)
|
||||
db.add(new_user)
|
||||
@@ -149,7 +146,6 @@ def expired_invitation(db, team_store, test_user, auth_manager):
|
||||
store_user = StoreUser(
|
||||
store_id=team_store.id,
|
||||
user_id=new_user.id,
|
||||
user_type=StoreUserType.TEAM_MEMBER.value,
|
||||
role_id=role.id,
|
||||
invited_by=test_user.id,
|
||||
invitation_token=f"expired_token_{unique_id}",
|
||||
|
||||
@@ -32,7 +32,7 @@ class TestUserAuthServicePasswordReset:
|
||||
email=f"uas_{uid}@test.com",
|
||||
username=f"uas_{uid}",
|
||||
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -52,7 +52,7 @@ class TestUserAuthServicePasswordReset:
|
||||
email=f"uasi_{uid}@test.com",
|
||||
username=f"uasi_{uid}",
|
||||
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=False,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -118,7 +118,7 @@ class TestUserAuthServiceEmailVerification:
|
||||
email=f"uasev_{uid}@test.com",
|
||||
username=f"uasev_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
@@ -135,7 +135,7 @@ class TestUserAuthServiceEmailVerification:
|
||||
email=f"uasv_{uid}@test.com",
|
||||
username=f"uasv_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ class TestUserModel:
|
||||
email="db_test@example.com",
|
||||
username="dbtest",
|
||||
hashed_password="hashed_password_123", # noqa: SEC001
|
||||
role="user",
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestUserModel:
|
||||
assert user.id is not None
|
||||
assert user.email == "db_test@example.com"
|
||||
assert user.username == "dbtest"
|
||||
assert user.role == "user"
|
||||
assert user.role == "store_member"
|
||||
assert user.is_active is True
|
||||
assert user.created_at is not None
|
||||
assert user.updated_at is not None
|
||||
@@ -86,7 +86,7 @@ class TestUserModel:
|
||||
db.refresh(user)
|
||||
|
||||
assert user.is_active is True # Default
|
||||
assert user.role == "store" # Default (UserRole.STORE)
|
||||
assert user.role == "store_member" # Default (UserRole.STORE_MEMBER)
|
||||
|
||||
def test_user_optional_fields(self, db):
|
||||
"""Test User model with optional fields."""
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestUserPasswordResetToken:
|
||||
email=f"prt_{uid}@test.com",
|
||||
username=f"prt_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -225,7 +225,7 @@ function platformContext() {
|
||||
if (storedUser) {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
this.isSuperAdmin = user.is_super_admin === true;
|
||||
this.isSuperAdmin = user.role === 'super_admin';
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse stored user:', e);
|
||||
}
|
||||
|
||||
@@ -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/*
|
||||
|
||||
225
docs/api/rbac.md
225
docs/api/rbac.md
@@ -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
|
||||
|
||||
@@ -52,9 +52,13 @@ sequenceDiagram
|
||||
|
||||
## User Roles
|
||||
|
||||
The platform has three distinct user roles, each with specific permissions and access levels:
|
||||
The platform uses a **4-value role enum** on the `User` model to distinguish user types:
|
||||
|
||||
### Customer Role
|
||||
```
|
||||
User.role: "super_admin" | "platform_admin" | "merchant_owner" | "store_member"
|
||||
```
|
||||
|
||||
### Customer Role (Separate Model)
|
||||
|
||||
**Access**: Public shop and own account space
|
||||
|
||||
@@ -68,36 +72,56 @@ The platform has three distinct user roles, each with specific permissions and a
|
||||
|
||||
**Account Creation**: Self-registration via shop frontend (email verification required)
|
||||
|
||||
**Authentication**: Standard JWT authentication
|
||||
**Authentication**: Standard JWT authentication (separate `Customer` model, not `User`)
|
||||
|
||||
### Store Role
|
||||
### Merchant Owner (`role="merchant_owner"`)
|
||||
|
||||
**Access**: Store area based on permissions
|
||||
**Access**: Full access to owned store dashboards
|
||||
|
||||
**Types**:
|
||||
- **Store Owner**: Full access to store dashboard and settings
|
||||
- **Store Team Members**: Access based on assigned permissions
|
||||
**Characteristics**:
|
||||
- Has **ALL store permissions** automatically (no role record needed)
|
||||
- Ownership determined by `Merchant.owner_user_id`
|
||||
- `User.is_merchant_owner` property returns `True`
|
||||
- `User.is_store_user` property returns `True`
|
||||
- `User.is_owner_of(store_id)` checks ownership
|
||||
|
||||
**Capabilities**:
|
||||
- Manage products and inventory
|
||||
- Process orders
|
||||
- View analytics and reports
|
||||
- Configure shop settings (owners only)
|
||||
- Manage team members (owners only)
|
||||
- Configure shop settings
|
||||
- Manage team members (invite, remove, update roles)
|
||||
- Access store-specific APIs
|
||||
|
||||
**Account Creation**:
|
||||
- Owners: Created automatically when admin creates a store
|
||||
- Team members: Invited by store owner via email
|
||||
**Account Creation**: Created automatically when admin creates a store
|
||||
|
||||
**Permissions System**: Team members can have granular permissions for different areas
|
||||
### Store Member (`role="store_member"`)
|
||||
|
||||
### Admin Role
|
||||
**Access**: Store area based on assigned role permissions
|
||||
|
||||
**Access**: Full platform administration
|
||||
**Characteristics**:
|
||||
- Permissions come from `StoreUser.role_id -> Role.permissions`
|
||||
- `User.is_store_user` property returns `True`
|
||||
- Must be invited by merchant owner via email
|
||||
|
||||
**Capabilities**: Limited based on assigned role (Manager, Staff, Support, Viewer, Marketing, or custom)
|
||||
|
||||
**Account Creation**: Invited by merchant owner via email
|
||||
|
||||
**Permissions System**: Team members can have granular permissions for different areas (up to 75 permissions)
|
||||
|
||||
### Super Admin (`role="super_admin"`)
|
||||
|
||||
**Access**: Full platform administration across all platforms
|
||||
|
||||
**Characteristics**:
|
||||
- `User.is_super_admin` property returns `True` (computed: `role == "super_admin"`)
|
||||
- `User.is_admin` property returns `True`
|
||||
- Can access all platforms without restriction
|
||||
- Cannot access store portal (blocked by middleware)
|
||||
|
||||
**Capabilities**:
|
||||
- Manage all stores
|
||||
- Manage all stores across all platforms
|
||||
- Create/manage store accounts
|
||||
- Access system settings
|
||||
- View all data across the platform
|
||||
@@ -105,9 +129,24 @@ The platform has three distinct user roles, each with specific permissions and a
|
||||
- Access audit logs
|
||||
- Platform-wide analytics
|
||||
|
||||
**Account Creation**: Created by super admins on the backend
|
||||
**Account Creation**: Created by existing super admins on the backend
|
||||
|
||||
**Super Privileges**: Admins can access all areas including store and customer sections
|
||||
### Platform Admin (`role="platform_admin"`)
|
||||
|
||||
**Access**: Platform administration scoped to assigned platforms
|
||||
|
||||
**Characteristics**:
|
||||
- `User.is_platform_admin` property returns `True` (computed: `role == "platform_admin"`)
|
||||
- `User.is_admin` property returns `True`
|
||||
- Scoped to specific platforms via `AdminPlatform` association
|
||||
- Cannot access store portal (blocked by middleware)
|
||||
|
||||
**Capabilities**:
|
||||
- Manage stores within assigned platforms
|
||||
- Access platform-scoped settings and analytics
|
||||
- View data within assigned platforms
|
||||
|
||||
**Account Creation**: Created by super admins
|
||||
|
||||
## Application Areas & Access Control
|
||||
|
||||
@@ -115,22 +154,23 @@ The platform has three distinct areas with different access requirements:
|
||||
|
||||
| Area | URL Pattern | Access | Purpose |
|
||||
|------|-------------|--------|---------|
|
||||
| **Admin** | `/admin/*` or `admin.platform.com` | Admin users only | Platform administration and store management |
|
||||
| **Store** | `/store/*` | Store owners and team members | Store dashboard and shop management |
|
||||
| **Admin** | `/admin/*` or `admin.platform.com` | Super admins and platform admins (`is_admin`) | Platform administration and store management |
|
||||
| **Store** | `/store/*` | Merchant owners and store members (`is_store_user`) | Store dashboard and shop management |
|
||||
| **Shop** | `/shop/*`, custom domains, subdomains | Customers and public | Public-facing eCommerce storefront |
|
||||
| **API** | `/api/*` | All authenticated users (role-based) | REST API for all operations |
|
||||
|
||||
## Account Registration Flow
|
||||
|
||||
### Admin Accounts
|
||||
### Admin Accounts (Super Admin & Platform Admin)
|
||||
- ❌ Cannot register from frontend
|
||||
- ✅ Created by super admins on the backend
|
||||
- ✅ **Super Admins** (`role="super_admin"`): Created by existing super admins
|
||||
- ✅ **Platform Admins** (`role="platform_admin"`): Created by super admins
|
||||
- Used for: Platform administration
|
||||
|
||||
### Store Accounts
|
||||
### Store Accounts (Merchant Owner & Store Member)
|
||||
- ❌ Cannot register from frontend
|
||||
- ✅ **Store Owners**: Automatically created when admin creates a new store
|
||||
- ✅ **Team Members**: Invited by store owner via email invitation
|
||||
- ✅ **Merchant Owners** (`role="merchant_owner"`): Automatically created when admin creates a new store
|
||||
- ✅ **Store Members** (`role="store_member"`): Invited by merchant owner via email invitation
|
||||
- Activation: Upon clicking email verification link
|
||||
|
||||
### Customer Accounts
|
||||
@@ -201,7 +241,7 @@ def require_role(self, required_role: str) -> Callable
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `required_role` (str): The exact role name required (e.g., "admin", "store", "custom_role")
|
||||
- `required_role` (str): The exact role name required (e.g., `"super_admin"`, `"platform_admin"`, `"merchant_owner"`, `"store_member"`)
|
||||
|
||||
**Returns**: A decorator function that:
|
||||
1. Accepts a function as input
|
||||
@@ -216,31 +256,31 @@ from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/moderator-only")
|
||||
@auth_manager.require_role("moderator")
|
||||
async def moderator_endpoint(current_user: User):
|
||||
"""Only users with role='moderator' can access this."""
|
||||
return {"message": "Moderator access granted"}
|
||||
@router.get("/owner-only")
|
||||
@auth_manager.require_role("merchant_owner")
|
||||
async def owner_endpoint(current_user: User):
|
||||
"""Only users with role='merchant_owner' can access this."""
|
||||
return {"message": "Merchant owner access granted"}
|
||||
|
||||
# Can also be used with custom roles
|
||||
@router.get("/special-access")
|
||||
@auth_manager.require_role("special_user")
|
||||
async def special_endpoint(current_user: User):
|
||||
return {"data": "special content"}
|
||||
# Can also be used with other specific roles
|
||||
@router.get("/super-admin-only")
|
||||
@auth_manager.require_role("super_admin")
|
||||
async def super_admin_endpoint(current_user: User):
|
||||
return {"data": "super admin content"}
|
||||
```
|
||||
|
||||
**Error Response**:
|
||||
```json
|
||||
{
|
||||
"detail": "Required role 'moderator' not found. Current role: 'store'"
|
||||
"detail": "Required role 'merchant_owner' not found. Current role: 'store_member'"
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: For standard roles (admin, store, customer), prefer using the dedicated methods (`require_admin()`, `require_store()`, `require_customer()`) as they provide better error handling and custom exceptions.
|
||||
**Note**: For standard access patterns, prefer using the dedicated methods (`require_admin()`, `require_store()`, `require_customer()`) or the computed properties (`is_admin`, `is_store_user`) as they provide better error handling and custom exceptions.
|
||||
|
||||
### create_default_admin_user()
|
||||
|
||||
Creates a default admin user if one doesn't already exist. This is typically used during initial application setup or database seeding.
|
||||
Creates a default super admin user if one doesn't already exist. This is typically used during initial application setup or database seeding.
|
||||
|
||||
**Method Signature**:
|
||||
```python
|
||||
@@ -254,11 +294,11 @@ def create_default_admin_user(self, db: Session) -> User
|
||||
|
||||
**Behavior**:
|
||||
1. Checks if a user with username "admin" already exists
|
||||
2. If not found, creates a new admin user with:
|
||||
2. If not found, creates a new super admin user with:
|
||||
- Username: `admin`
|
||||
- Email: `admin@example.com`
|
||||
- Password: `admin123` (hashed with bcrypt)
|
||||
- Role: `admin`
|
||||
- Role: `super_admin`
|
||||
- Status: Active
|
||||
3. If found, returns the existing user without modification
|
||||
|
||||
@@ -295,7 +335,7 @@ def create_admin_from_env(db: Session):
|
||||
username=admin_username,
|
||||
email=admin_email,
|
||||
hashed_password=auth_manager.hash_password(admin_password),
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
@@ -318,7 +358,7 @@ def create_admin_from_env(db: Session):
|
||||
"sub": "123", // User ID (JWT standard claim)
|
||||
"username": "testuser", // Username for display
|
||||
"email": "user@example.com", // User email
|
||||
"role": "store", // User role
|
||||
"role": "merchant_owner", // User role (4-value enum)
|
||||
"exp": 1700000000, // Expiration timestamp (JWT standard)
|
||||
"iat": 1699999000 // Issued at timestamp (JWT standard)
|
||||
}
|
||||
@@ -342,23 +382,28 @@ JWT_EXPIRE_MINUTES=30
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Admin] --> B[Full Platform Access]
|
||||
A --> C[Can Access All Areas]
|
||||
A[Super Admin<br/>role=super_admin] --> B[Full Platform Access]
|
||||
A --> C[All Platforms]
|
||||
|
||||
D[Store Owner] --> E[Store Dashboard]
|
||||
AA[Platform Admin<br/>role=platform_admin] --> D2[Scoped Platform Access]
|
||||
AA --> D3[Assigned Platforms Only]
|
||||
|
||||
D[Merchant Owner<br/>role=merchant_owner] --> E[Store Dashboard]
|
||||
D --> F[Team Management]
|
||||
D --> G[Shop Settings]
|
||||
D --> H[All Store Data]
|
||||
D --> H[All Store Permissions - 75]
|
||||
|
||||
I[Store Team Member] --> E
|
||||
I --> J[Limited Based on Permissions]
|
||||
I[Store Member<br/>role=store_member] --> E
|
||||
I --> J[Role-Based Permissions]
|
||||
|
||||
K[Customer] --> L[Shop Access]
|
||||
K[Customer<br/>separate model] --> L[Shop Access]
|
||||
K --> M[Own Orders]
|
||||
K --> N[Own Profile]
|
||||
```
|
||||
|
||||
**Admin Override**: Admin users have implicit access to all areas, including store and customer sections. This allows admins to provide support and manage the platform effectively.
|
||||
**Admin Override**: Admin users (`is_admin`: super admins and platform admins) have access to the admin portal. They cannot access the store portal directly -- these are separate security boundaries enforced by middleware.
|
||||
|
||||
**Note**: `is_super_admin` is no longer a database column. It is a computed property: `User.role == "super_admin"`. JWT tokens no longer include an `is_super_admin` claim; derive it from the `role` claim instead.
|
||||
|
||||
## Security Features
|
||||
|
||||
@@ -505,7 +550,7 @@ def test_password_hashing():
|
||||
|
||||
def test_create_token():
|
||||
auth_manager = AuthManager()
|
||||
user = create_test_user(role="store")
|
||||
user = create_test_user(role="merchant_owner")
|
||||
|
||||
token_data = auth_manager.create_access_token(user)
|
||||
|
||||
@@ -600,11 +645,11 @@ async def dashboard(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Accessible by all authenticated users, but returns different data."""
|
||||
if current_user.role == "admin":
|
||||
# Admin sees everything
|
||||
if current_user.is_admin:
|
||||
# Admin (super_admin or platform_admin) sees platform data
|
||||
data = get_admin_dashboard(db)
|
||||
elif current_user.role == "store":
|
||||
# Store sees their data only
|
||||
elif current_user.is_store_user:
|
||||
# Store user (merchant_owner or store_member) sees their store data
|
||||
data = get_store_dashboard(db, current_user.id)
|
||||
else:
|
||||
# Customer sees their orders
|
||||
|
||||
@@ -35,18 +35,20 @@ from app.modules.tenancy.models import User # noqa: API-007 violation
|
||||
id: int # User ID
|
||||
email: str # Email address
|
||||
username: str # Username
|
||||
role: str # "admin" or "store"
|
||||
role: str # "super_admin", "platform_admin", "merchant_owner", or "store_member"
|
||||
is_active: bool # Account status
|
||||
```
|
||||
|
||||
### Admin-Specific Fields
|
||||
```python
|
||||
is_super_admin: bool # True for super admins
|
||||
is_super_admin: bool # Computed: role == "super_admin" (not stored in DB or JWT)
|
||||
accessible_platform_ids: list[int] | None # Platform IDs (None = all for super admin)
|
||||
token_platform_id: int | None # Selected platform from JWT
|
||||
token_platform_code: str | None # Selected platform code from JWT
|
||||
```
|
||||
|
||||
**Note**: `is_super_admin` is no longer a database column or JWT claim. It is derived from `role == "super_admin"`. On the `User` model it is a computed property; on `UserContext` it is populated from the role field during `from_user()` construction.
|
||||
|
||||
### Store-Specific Fields
|
||||
```python
|
||||
token_store_id: int | None # Store ID from JWT
|
||||
@@ -80,7 +82,9 @@ preferred_language: str | None
|
||||
```
|
||||
1. POST /api/v1/admin/auth/login
|
||||
- Returns LoginResponse with user data and token
|
||||
- Token includes: user_id, role, is_super_admin, accessible_platforms
|
||||
- Token includes: user_id, role (e.g. "super_admin" or "platform_admin"),
|
||||
accessible_platforms
|
||||
- Note: is_super_admin is NOT in the JWT; derive from role == "super_admin"
|
||||
|
||||
2. GET /api/v1/admin/auth/accessible-platforms
|
||||
- Returns list of platforms admin can access
|
||||
@@ -93,29 +97,30 @@ preferred_language: str | None
|
||||
4. Subsequent API calls
|
||||
- Token decoded → UserContext populated
|
||||
- current_user.token_platform_id available
|
||||
- current_user.is_super_admin derived from role
|
||||
```
|
||||
|
||||
### JWT Token → UserContext Mapping
|
||||
|
||||
When a JWT token is decoded, these fields are mapped:
|
||||
|
||||
| JWT Claim | UserContext Field |
|
||||
|-----------|-------------------|
|
||||
| `sub` | `id` |
|
||||
| `username` | `username` |
|
||||
| `email` | `email` |
|
||||
| `role` | `role` |
|
||||
| `is_super_admin` | `is_super_admin` |
|
||||
| `accessible_platforms` | `accessible_platform_ids` |
|
||||
| `platform_id` | `token_platform_id` |
|
||||
| `platform_code` | `token_platform_code` |
|
||||
| `store_id` | `token_store_id` |
|
||||
| `store_code` | `token_store_code` |
|
||||
| `store_role` | `token_store_role` |
|
||||
| JWT Claim | UserContext Field | Notes |
|
||||
|-----------|-------------------|-------|
|
||||
| `sub` | `id` | |
|
||||
| `username` | `username` | |
|
||||
| `email` | `email` | |
|
||||
| `role` | `role` | 4-value enum: `super_admin`, `platform_admin`, `merchant_owner`, `store_member` |
|
||||
| *(derived from role)* | `is_super_admin` | Computed: `role == "super_admin"` (no longer a JWT claim) |
|
||||
| `accessible_platforms` | `accessible_platform_ids` | |
|
||||
| `platform_id` | `token_platform_id` | |
|
||||
| `platform_code` | `token_platform_code` | |
|
||||
| `store_id` | `token_store_id` | |
|
||||
| `store_code` | `token_store_code` | |
|
||||
| `store_role` | `token_store_role` | |
|
||||
|
||||
## Helper Methods
|
||||
|
||||
`UserContext` provides helper methods:
|
||||
`UserContext` provides helper methods and computed properties:
|
||||
|
||||
```python
|
||||
# Check platform access
|
||||
@@ -127,10 +132,16 @@ platform_ids = current_user.get_accessible_platform_ids()
|
||||
# Returns None for super admins (all platforms)
|
||||
# Returns list[int] for platform admins
|
||||
|
||||
# Check role
|
||||
if current_user.is_admin:
|
||||
# Check role categories (computed from role field)
|
||||
if current_user.is_admin: # role in ("super_admin", "platform_admin")
|
||||
...
|
||||
if current_user.is_store:
|
||||
if current_user.is_super_admin: # role == "super_admin"
|
||||
...
|
||||
if current_user.is_platform_admin: # role == "platform_admin"
|
||||
...
|
||||
if current_user.is_merchant_owner: # role == "merchant_owner"
|
||||
...
|
||||
if current_user.is_store_user: # role in ("merchant_owner", "store_member")
|
||||
...
|
||||
|
||||
# Full name
|
||||
|
||||
@@ -155,23 +155,36 @@ StorePermissions.IMPORTS_CANCEL
|
||||
|
||||
---
|
||||
|
||||
## User Role Properties
|
||||
|
||||
```python
|
||||
# Check if super admin (computed: role == "super_admin")
|
||||
user.is_super_admin # bool
|
||||
|
||||
# Check if any admin (computed: role in ("super_admin", "platform_admin"))
|
||||
user.is_admin # bool
|
||||
|
||||
# Check if platform admin (computed: role == "platform_admin")
|
||||
user.is_platform_admin # bool
|
||||
|
||||
# Check if merchant owner (computed: role == "merchant_owner")
|
||||
user.is_merchant_owner # bool
|
||||
|
||||
# Check if store-level user (computed: role in ("merchant_owner", "store_member"))
|
||||
user.is_store_user # bool
|
||||
```
|
||||
|
||||
## User Helper Methods
|
||||
|
||||
```python
|
||||
# Check if admin
|
||||
user.is_admin # bool
|
||||
|
||||
# Check if store
|
||||
user.is_store # bool
|
||||
|
||||
# Check store ownership
|
||||
# Check store ownership (via Merchant.owner_user_id)
|
||||
user.is_owner_of(store_id) # bool
|
||||
|
||||
# Check store membership
|
||||
user.is_member_of(store_id) # bool
|
||||
|
||||
# Get role in store
|
||||
user.get_store_role(store_id) # str: "owner" | "member" | None
|
||||
user.get_store_role(store_id) # str: "owner" | role name | None
|
||||
|
||||
# Check specific permission
|
||||
user.has_store_permission(store_id, "products.create") # bool
|
||||
@@ -182,7 +195,7 @@ user.has_store_permission(store_id, "products.create") # bool
|
||||
## StoreUser Helper Methods
|
||||
|
||||
```python
|
||||
# Check if owner
|
||||
# Check if owner (derived from User.role and Merchant.owner_user_id)
|
||||
store_user.is_owner # bool
|
||||
|
||||
# Check if team member
|
||||
@@ -331,7 +344,8 @@ async function getPermissions() {
|
||||
### Unit Test
|
||||
```python
|
||||
def test_owner_has_all_permissions():
|
||||
store_user = create_store_user(user_type="owner")
|
||||
user = create_user(role="merchant_owner")
|
||||
store_user = create_store_user(user=user, store=store)
|
||||
assert store_user.has_permission("products.create")
|
||||
assert store_user.has_permission("team.invite")
|
||||
```
|
||||
@@ -423,8 +437,9 @@ token = "eyJ0eXAi..."
|
||||
decoded = jwt.decode(token, verify=False)
|
||||
print(f"User ID: {decoded['sub']}")
|
||||
print(f"Username: {decoded['username']}")
|
||||
print(f"Role: {decoded['role']}")
|
||||
print(f"Role: {decoded['role']}") # super_admin, platform_admin, merchant_owner, or store_member
|
||||
print(f"Expires: {decoded['exp']}")
|
||||
# Note: is_super_admin is no longer in JWT tokens; derive from role == "super_admin"
|
||||
```
|
||||
|
||||
### Check Cookie
|
||||
|
||||
@@ -8,56 +8,76 @@ The store dashboard implements a **Role-Based Access Control (RBAC)** system tha
|
||||
|
||||
## User Types
|
||||
|
||||
### 1. Store Owner
|
||||
### 1. Merchant Owner
|
||||
|
||||
**Who:** The user who created the store account.
|
||||
**Who:** The user who created/owns the store account.
|
||||
|
||||
**Characteristics:**
|
||||
- Has **ALL permissions** automatically (no role needed)
|
||||
- Cannot be removed or have permissions restricted
|
||||
- Can invite team members
|
||||
- Can create and manage roles
|
||||
- Identified by `StoreUser.user_type = "owner"`
|
||||
- Linked via `Store.owner_user_id → User.id`
|
||||
- Identified by `User.role = "merchant_owner"` and `Merchant.owner_user_id`
|
||||
- `User.is_merchant_owner` property returns `True`
|
||||
- `User.is_store_user` property returns `True`
|
||||
- Ownership checked via `User.is_owner_of(store_id)`
|
||||
|
||||
**Database:**
|
||||
```python
|
||||
# User record for merchant owner
|
||||
{
|
||||
"id": 5,
|
||||
"role": "merchant_owner", # Role on User model
|
||||
}
|
||||
|
||||
# StoreUser record for owner
|
||||
{
|
||||
"store_id": 1,
|
||||
"user_id": 5,
|
||||
"user_type": "owner", # ✓ Owner
|
||||
"role_id": None, # No role needed
|
||||
"role_id": None, # No role needed (owner has all perms)
|
||||
"is_active": True
|
||||
}
|
||||
|
||||
# Merchant record links ownership
|
||||
{
|
||||
"owner_user_id": 5 # Links to User.id
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `user_type` column was removed from StoreUser. Ownership is determined by `User.role == "merchant_owner"` and `Merchant.owner_user_id`, not by a field on StoreUser.
|
||||
|
||||
**Permissions:**
|
||||
- ✅ **All 75 permissions** (complete access)
|
||||
- See full list below
|
||||
|
||||
---
|
||||
|
||||
### 2. Team Members
|
||||
### 2. Store Members (Team Members)
|
||||
|
||||
**Who:** Users invited by the store owner to help manage the store.
|
||||
**Who:** Users invited by the merchant owner to help manage the store.
|
||||
|
||||
**Characteristics:**
|
||||
- Have **limited permissions** based on assigned role
|
||||
- Must be invited via email
|
||||
- Invitation must be accepted before activation
|
||||
- Can be assigned one of the pre-defined roles or custom role
|
||||
- Identified by `StoreUser.user_type = "member"`
|
||||
- Permissions come from `StoreUser.role_id → Role.permissions`
|
||||
- Identified by `User.role = "store_member"`
|
||||
- `User.is_store_user` property returns `True`
|
||||
- Permissions come from `StoreUser.role_id -> Role.permissions`
|
||||
|
||||
**Database:**
|
||||
```python
|
||||
# User record for store member
|
||||
{
|
||||
"id": 7,
|
||||
"role": "store_member", # Role on User model
|
||||
}
|
||||
|
||||
# StoreUser record for team member
|
||||
{
|
||||
"store_id": 1,
|
||||
"user_id": 7,
|
||||
"user_type": "member", # ✓ Team member
|
||||
"role_id": 3, # ✓ Role required
|
||||
"role_id": 3, # Role required for permission lookup
|
||||
"is_active": True,
|
||||
"invitation_token": None, # Accepted
|
||||
"invitation_accepted_at": "2024-11-15 10:30:00"
|
||||
@@ -463,8 +483,7 @@ CREATE TABLE store_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
store_id INTEGER NOT NULL REFERENCES stores(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_type VARCHAR NOT NULL, -- 'owner' or 'member'
|
||||
role_id INTEGER REFERENCES roles(id), -- NULL for owners
|
||||
role_id INTEGER REFERENCES roles(id), -- NULL for merchant owners
|
||||
invited_by INTEGER REFERENCES users(id),
|
||||
invitation_token VARCHAR,
|
||||
invitation_sent_at TIMESTAMP,
|
||||
@@ -473,6 +492,8 @@ CREATE TABLE store_users (
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
-- Note: user_type column removed. Ownership determined by
|
||||
-- User.role = 'merchant_owner' and Merchant.owner_user_id.
|
||||
```
|
||||
|
||||
### Role Table
|
||||
@@ -495,9 +516,11 @@ CREATE TABLE roles (
|
||||
### 1. Invitation
|
||||
|
||||
```
|
||||
Owner invites user → StoreUser created:
|
||||
Merchant owner invites user:
|
||||
→ User created with role="store_member"
|
||||
→ StoreUser created:
|
||||
{
|
||||
"user_type": "member",
|
||||
"role_id": 3, -- Assigned role
|
||||
"is_active": False,
|
||||
"invitation_token": "abc123...",
|
||||
"invitation_sent_at": "2024-11-29 10:00:00",
|
||||
@@ -642,21 +665,22 @@ HTTP 403 Forbidden
|
||||
|
||||
## Summary
|
||||
|
||||
### Owner vs Team Member
|
||||
### Merchant Owner vs Store Member
|
||||
|
||||
| Feature | Owner | Team Member |
|
||||
| Feature | Merchant Owner (`role="merchant_owner"`) | Store Member (`role="store_member"`) |
|
||||
|---------|-------|-------------|
|
||||
| **Permissions** | All 75 (automatic) | Based on role (0-75) |
|
||||
| **Role Required** | No | Yes |
|
||||
| **Can Be Removed** | No | Yes |
|
||||
| **Team Management** | ✅ Yes | ❌ No |
|
||||
| **Critical Settings** | ✅ Yes | ❌ No (usually) |
|
||||
| **Team Management** | Yes | No |
|
||||
| **Critical Settings** | Yes | No (usually) |
|
||||
| **Invitation Required** | No (creates store) | Yes |
|
||||
| **Ownership Determined By** | `Merchant.owner_user_id` | N/A |
|
||||
|
||||
### Permission Hierarchy
|
||||
|
||||
```
|
||||
Owner (75 permissions)
|
||||
Merchant Owner (75 permissions)
|
||||
└─ Manager (43 permissions)
|
||||
└─ Staff (10 permissions)
|
||||
└─ Support (6 permissions)
|
||||
|
||||
@@ -176,14 +176,11 @@ class AuthManager:
|
||||
"iat": datetime.now(UTC), # Issued at time (JWT standard claim)
|
||||
}
|
||||
|
||||
# Include admin-specific information for admin users
|
||||
if user.is_admin:
|
||||
payload["is_super_admin"] = user.is_super_admin
|
||||
# For platform admins, include their accessible platform IDs
|
||||
if not user.is_super_admin:
|
||||
accessible = user.get_accessible_platform_ids()
|
||||
if accessible is not None:
|
||||
payload["accessible_platforms"] = accessible
|
||||
# For platform admins, include their accessible platform IDs
|
||||
if user.is_admin and not user.is_super_admin:
|
||||
accessible = user.get_accessible_platform_ids()
|
||||
if accessible is not None:
|
||||
payload["accessible_platforms"] = accessible
|
||||
|
||||
# Include platform context for platform admins
|
||||
if platform_id is not None:
|
||||
@@ -262,8 +259,6 @@ class AuthManager:
|
||||
}
|
||||
|
||||
# Include admin-specific information if present
|
||||
if "is_super_admin" in payload:
|
||||
user_data["is_super_admin"] = payload["is_super_admin"]
|
||||
if "accessible_platforms" in payload:
|
||||
user_data["accessible_platforms"] = payload["accessible_platforms"]
|
||||
|
||||
@@ -334,9 +329,6 @@ class AuthManager:
|
||||
raise UserNotActiveException()
|
||||
|
||||
# Attach admin-specific information to user object if present in token
|
||||
# These become dynamic attributes on the user object for this request
|
||||
if "is_super_admin" in user_data:
|
||||
user.token_is_super_admin = user_data["is_super_admin"]
|
||||
if "accessible_platforms" in user_data:
|
||||
user.token_accessible_platforms = user_data["accessible_platforms"]
|
||||
|
||||
@@ -410,28 +402,24 @@ class AuthManager:
|
||||
Raises:
|
||||
AdminRequiredException: If user does not have admin role
|
||||
"""
|
||||
# Verify user has admin role
|
||||
if current_user.role != "admin":
|
||||
if not current_user.is_admin:
|
||||
raise AdminRequiredException()
|
||||
return current_user
|
||||
|
||||
def require_store(self, current_user: User) -> User:
|
||||
"""
|
||||
Require store role (store or admin).
|
||||
|
||||
Stores and admins can access store areas.
|
||||
Require store user role (merchant_owner or store_member).
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
User: The user if they have store or admin role
|
||||
User: The user if they have a store-level role
|
||||
|
||||
Raises:
|
||||
InsufficientPermissionsException: If user is not store or admin
|
||||
InsufficientPermissionsException: If user is not a store user
|
||||
"""
|
||||
# Check if user has store or admin role (admins have full access)
|
||||
if current_user.role not in ["store", "admin"]:
|
||||
if not current_user.is_store_user:
|
||||
raise InsufficientPermissionsException(
|
||||
message="Store access required", required_permission="store"
|
||||
)
|
||||
@@ -491,9 +479,8 @@ class AuthManager:
|
||||
email="admin@example.com",
|
||||
username="admin",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_super_admin=True,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,7 +217,6 @@ DEMO_TEAM_MEMBERS = [
|
||||
"first_name": "Alice",
|
||||
"last_name": "Manager",
|
||||
"store_codes": ["WIZATECH", "WIZAGADGETS"], # manages two stores
|
||||
"user_type": "member",
|
||||
},
|
||||
{
|
||||
"merchant_index": 0,
|
||||
@@ -226,7 +225,6 @@ DEMO_TEAM_MEMBERS = [
|
||||
"first_name": "Charlie",
|
||||
"last_name": "Staff",
|
||||
"store_codes": ["WIZAHOME"],
|
||||
"user_type": "member",
|
||||
},
|
||||
# Fashion Group team
|
||||
{
|
||||
@@ -236,7 +234,6 @@ DEMO_TEAM_MEMBERS = [
|
||||
"first_name": "Diana",
|
||||
"last_name": "Stylist",
|
||||
"store_codes": ["FASHIONHUB", "FASHIONOUTLET"],
|
||||
"user_type": "member",
|
||||
},
|
||||
{
|
||||
"merchant_index": 1,
|
||||
@@ -245,7 +242,6 @@ DEMO_TEAM_MEMBERS = [
|
||||
"first_name": "Eric",
|
||||
"last_name": "Sales",
|
||||
"store_codes": ["FASHIONOUTLET"],
|
||||
"user_type": "member",
|
||||
},
|
||||
# BookWorld team
|
||||
{
|
||||
@@ -255,7 +251,6 @@ DEMO_TEAM_MEMBERS = [
|
||||
"first_name": "Fiona",
|
||||
"last_name": "Editor",
|
||||
"store_codes": ["BOOKSTORE", "BOOKDIGITAL"],
|
||||
"user_type": "member",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -505,7 +500,9 @@ def check_environment():
|
||||
def check_admin_exists(db: Session) -> bool:
|
||||
"""Check if admin user exists."""
|
||||
|
||||
admin = db.execute(select(User).where(User.role == "admin").limit(1)).scalar_one_or_none()
|
||||
admin = db.execute(
|
||||
select(User).where(User.role.in_(["super_admin", "platform_admin"])).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
print_error("No admin user found!")
|
||||
@@ -574,7 +571,7 @@ def reset_all_data(db: Session):
|
||||
db.execute(delete(table))
|
||||
|
||||
# Delete non-admin users
|
||||
db.execute(delete(User).where(User.role != "admin"))
|
||||
db.execute(delete(User).where(User.role.not_in(["super_admin", "platform_admin"])))
|
||||
|
||||
db.commit()
|
||||
print_success("All data deleted (admin preserved)")
|
||||
@@ -618,7 +615,7 @@ def create_demo_merchants(db: Session, auth_manager: AuthManager) -> list[Mercha
|
||||
hashed_password=auth_manager.hash_password( # noqa: SEC001
|
||||
merchant_data["owner_password"]
|
||||
),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
first_name=merchant_data["owner_first_name"],
|
||||
last_name=merchant_data["owner_last_name"],
|
||||
is_active=True,
|
||||
@@ -706,15 +703,7 @@ def create_demo_stores(
|
||||
db.add(store) # noqa: PERF006
|
||||
db.flush()
|
||||
|
||||
# Link merchant owner to store as owner
|
||||
store_user_link = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=merchant.owner_user_id,
|
||||
user_type="owner",
|
||||
is_active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(store_user_link) # noqa: PERF006
|
||||
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
|
||||
|
||||
# Create store theme
|
||||
theme_colors = THEME_PRESETS.get(
|
||||
@@ -781,7 +770,7 @@ def create_demo_team_members(
|
||||
username=member_data["email"].split("@")[0],
|
||||
email=member_data["email"],
|
||||
hashed_password=auth_manager.hash_password(member_data["password"]), # noqa: SEC001
|
||||
role="store",
|
||||
role="store_member",
|
||||
first_name=member_data["first_name"],
|
||||
last_name=member_data["last_name"],
|
||||
is_active=True,
|
||||
@@ -820,12 +809,11 @@ def create_demo_team_members(
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=user.id,
|
||||
user_type=member_data["user_type"],
|
||||
is_active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(store_user) # noqa: PERF006
|
||||
print_success(f" Assigned {user.first_name} to {store.name} as {member_data['user_type']}")
|
||||
print_success(f" Assigned {user.first_name} to {store.name} as team member")
|
||||
|
||||
db.flush()
|
||||
return team_users
|
||||
@@ -1110,7 +1098,7 @@ def print_summary(db: Session):
|
||||
merchant_count = db.query(Merchant).count()
|
||||
store_count = db.query(Store).count()
|
||||
user_count = db.query(User).count()
|
||||
team_member_count = db.query(StoreUser).filter(StoreUser.user_type == "member").count()
|
||||
team_member_count = db.query(StoreUser).count()
|
||||
customer_count = db.query(Customer).count()
|
||||
product_count = db.query(Product).count()
|
||||
platform_pages = db.query(ContentPage).filter(ContentPage.store_id is None).count()
|
||||
@@ -1172,7 +1160,8 @@ def print_summary(db: Session):
|
||||
for su in store_users:
|
||||
user = db.query(User).filter(User.id == su.user_id).first()
|
||||
if user:
|
||||
print(f" Team: {user.email} ({su.user_type})")
|
||||
role_name = su.role.name if su.role else "owner"
|
||||
print(f" Team: {user.email} ({role_name})")
|
||||
|
||||
print("\n🔐 Demo Merchant Owner Credentials:")
|
||||
print("─" * 70)
|
||||
|
||||
@@ -471,7 +471,7 @@ const Auth = {
|
||||
*/
|
||||
isAdmin() {
|
||||
const user = this.getCurrentUser();
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
const isAdmin = user && ['super_admin', 'platform_admin'].includes(user.role);
|
||||
apiLog.debug('Admin check:', isAdmin ? 'is admin' : 'not admin');
|
||||
return isAdmin;
|
||||
},
|
||||
@@ -487,7 +487,7 @@ const Auth = {
|
||||
});
|
||||
|
||||
// Store token and user
|
||||
if (response.user.role === 'admin') {
|
||||
if (['super_admin', 'platform_admin'].includes(response.user.role)) {
|
||||
apiLog.info('Storing admin credentials');
|
||||
localStorage.setItem('admin_token', response.access_token);
|
||||
localStorage.setItem('admin_user', JSON.stringify(response.user));
|
||||
@@ -579,4 +579,4 @@ if (document.readyState === 'loading') {
|
||||
initTableScrollDetection();
|
||||
}
|
||||
|
||||
apiLog.info('API Client module loaded');
|
||||
apiLog.info('API Client module loaded');
|
||||
|
||||
3
tests/fixtures/admin_platform_fixtures.py
vendored
3
tests/fixtures/admin_platform_fixtures.py
vendored
@@ -78,10 +78,9 @@ def platform_admin_with_platform(db, auth_manager, test_platform, test_super_adm
|
||||
email=f"padmin_{unique_id}@example.com",
|
||||
username=f"padmin_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="platform_admin",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
is_super_admin=False,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
18
tests/fixtures/auth_fixtures.py
vendored
18
tests/fixtures/auth_fixtures.py
vendored
@@ -29,7 +29,7 @@ def test_user(db, auth_manager):
|
||||
email=f"test_{unique_id}@example.com",
|
||||
username=f"testuser_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="user",
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -48,10 +48,9 @@ def test_admin(db, auth_manager):
|
||||
email=f"admin_{unique_id}@example.com",
|
||||
username=f"admin_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
is_super_admin=True, # Full platform access
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
@@ -68,10 +67,9 @@ def test_super_admin(db, auth_manager):
|
||||
email=f"superadmin_{unique_id}@example.com",
|
||||
username=f"superadmin_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
is_super_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
@@ -88,10 +86,9 @@ def test_platform_admin(db, auth_manager):
|
||||
email=f"platformadmin_{unique_id}@example.com",
|
||||
username=f"platformadmin_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="platform_admin",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
is_super_admin=False, # Platform admin, not super admin
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
@@ -132,10 +129,9 @@ def another_admin(db, auth_manager):
|
||||
email=f"another_admin_{unique_id}@example.com",
|
||||
username=f"another_admin_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
is_super_admin=True, # Full platform access
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
@@ -152,7 +148,7 @@ def other_user(db, auth_manager):
|
||||
email=f"other_{unique_id}@example.com",
|
||||
username=f"otheruser_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="user",
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -194,7 +190,7 @@ def test_store_user(db, auth_manager):
|
||||
email=f"store_{unique_id}@example.com",
|
||||
username=f"storeuser_{unique_id}",
|
||||
hashed_password=hashed_password,
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
5
tests/fixtures/store_fixtures.py
vendored
5
tests/fixtures/store_fixtures.py
vendored
@@ -70,7 +70,7 @@ def test_store(db, test_merchant):
|
||||
@pytest.fixture
|
||||
def test_store_with_store_user(db, test_store_user):
|
||||
"""Create a store owned by a store user (for testing store API endpoints)."""
|
||||
from app.modules.tenancy.models import StoreUser, StoreUserType
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8].upper()
|
||||
|
||||
@@ -98,11 +98,10 @@ def test_store_with_store_user(db, test_store_user):
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
# Create StoreUser association
|
||||
# Create StoreUser association (ownership determined via Merchant.owner_user_id)
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=test_store_user.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestAdminUsersListAPI:
|
||||
data = response.json()
|
||||
# All returned admins should not be super admins
|
||||
for admin in data["admins"]:
|
||||
assert admin["is_super_admin"] is False
|
||||
assert admin["role"] != "super_admin"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -84,7 +84,7 @@ class TestAdminUsersCreateAPI:
|
||||
data = response.json()
|
||||
assert data["email"] == "new_platform_admin@example.com"
|
||||
assert data["username"] == "new_platform_admin"
|
||||
assert data["is_super_admin"] is False
|
||||
assert data["role"] == "platform_admin"
|
||||
assert len(data["platform_assignments"]) == 2
|
||||
|
||||
def test_create_platform_admin_duplicate_email(
|
||||
@@ -208,13 +208,13 @@ class TestAdminUsersSuperAdminToggleAPI:
|
||||
"""Test promoting a platform admin to super admin."""
|
||||
response = client.put(
|
||||
f"/api/v1/admin/admin-users/{test_platform_admin.id}/super-admin",
|
||||
json={"is_super_admin": True},
|
||||
json={"role": "super_admin"},
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_super_admin"] is True
|
||||
assert data["role"] == "super_admin"
|
||||
|
||||
def test_demote_from_super_admin(
|
||||
self, client, super_admin_headers, db, auth_manager
|
||||
@@ -227,22 +227,21 @@ class TestAdminUsersSuperAdminToggleAPI:
|
||||
email="demote_test@example.com",
|
||||
username="demote_test",
|
||||
hashed_password=auth_manager.hash_password("pass"),
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
is_super_admin=True,
|
||||
)
|
||||
db.add(another_super)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1/admin/admin-users/{another_super.id}/super-admin",
|
||||
json={"is_super_admin": False},
|
||||
json={"role": "platform_admin"},
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_super_admin"] is False
|
||||
assert data["role"] == "platform_admin"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestStoreDashboardAPI:
|
||||
email=f"orphan_{uuid.uuid4().hex[:8]}@example.com",
|
||||
username=f"orphanstore_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=hashed_password,
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(orphan_user)
|
||||
@@ -111,11 +111,10 @@ class TestStoreDashboardAPI:
|
||||
db.add(store)
|
||||
db.commit()
|
||||
|
||||
# Associate with user as owner
|
||||
# Associate with user (ownership determined via Merchant.owner_user_id)
|
||||
store_user = StoreUser(
|
||||
store_id=store.id,
|
||||
user_id=test_store_user.id,
|
||||
user_type="owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
|
||||
@@ -211,7 +211,7 @@ class TestValidateCustomerToken:
|
||||
email=f"owner_{uid}@test.com",
|
||||
username=f"owner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -353,7 +353,7 @@ class TestGetCurrentAdminFromCookieOrHeader:
|
||||
|
||||
result = get_current_admin_from_cookie_or_header(request, creds, None, db)
|
||||
assert result.id == test_admin.id
|
||||
assert result.role == "admin"
|
||||
assert result.is_admin is True
|
||||
|
||||
def test_valid_admin_via_cookie(self, db, auth_manager, test_admin):
|
||||
"""Admin user with valid cookie token returns UserContext."""
|
||||
@@ -394,7 +394,7 @@ class TestGetCurrentAdminApi:
|
||||
|
||||
result = get_current_admin_api(creds, db)
|
||||
assert result.id == test_admin.id
|
||||
assert result.role == "admin"
|
||||
assert result.is_admin is True
|
||||
|
||||
def test_rejects_non_admin(self, db, auth_manager, test_store_user):
|
||||
"""Non-admin user rejected with AdminRequiredException."""
|
||||
@@ -474,7 +474,7 @@ class TestGetCurrentStoreFromCookieOrHeader:
|
||||
|
||||
result = get_current_store_from_cookie_or_header(request, creds, None, db)
|
||||
assert result.id == test_store_user.id
|
||||
assert result.role == "store"
|
||||
assert result.is_store_user is True
|
||||
|
||||
def test_valid_store_user_via_cookie(self, db, auth_manager, test_store_user):
|
||||
"""Store user with valid cookie token returns UserContext."""
|
||||
@@ -626,7 +626,7 @@ def _create_merchant_owner(db, auth_manager):
|
||||
email=f"merchant_{uid}@example.com",
|
||||
username=f"merchant_{uid}",
|
||||
hashed_password=auth_manager.hash_password("testpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -786,7 +786,7 @@ class TestGetCurrentCustomerFromCookieOrHeader:
|
||||
email=f"csowner_{uid}@test.com",
|
||||
username=f"csowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -879,7 +879,7 @@ class TestGetCurrentCustomerApi:
|
||||
email=f"caowner_{uid}@test.com",
|
||||
username=f"caowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -962,7 +962,7 @@ class TestGetCurrentCustomerOptional:
|
||||
email=f"coowner_{uid}@test.com",
|
||||
username=f"coowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -1293,7 +1293,7 @@ class TestGetUserStore:
|
||||
email=f"other_{uid2}@example.com",
|
||||
username=f"other_{uid2}",
|
||||
hashed_password=auth_manager.hash_password("testpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is True
|
||||
assert payload["role"] == "super_admin"
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_platform_admin_with_platforms(self, db, auth_manager, test_platform_admin):
|
||||
@@ -155,7 +155,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert payload["role"] == "platform_admin"
|
||||
assert payload["accessible_platforms"] == [1, 2, 3]
|
||||
|
||||
def test_platform_admin_without_platforms(self, db, auth_manager, test_platform_admin):
|
||||
@@ -166,7 +166,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert payload["role"] == "platform_admin"
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_store_context(self, auth_manager, test_user):
|
||||
@@ -223,7 +223,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert "is_super_admin" not in payload
|
||||
assert payload["role"] not in ("super_admin", "platform_admin")
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
|
||||
@@ -271,10 +271,10 @@ class TestVerifyToken:
|
||||
|
||||
def test_valid_admin_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(is_super_admin=True), auth_manager.secret_key
|
||||
self._base_payload(role="super_admin"), auth_manager.secret_key
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["is_super_admin"] is True
|
||||
assert data["role"] == "super_admin"
|
||||
|
||||
def test_valid_platform_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
@@ -288,7 +288,7 @@ class TestVerifyToken:
|
||||
def test_valid_all_claims(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(
|
||||
is_super_admin=False,
|
||||
role="platform_admin",
|
||||
accessible_platforms=[1, 2],
|
||||
platform_id=1,
|
||||
platform_code="us",
|
||||
@@ -383,7 +383,7 @@ class TestGetCurrentUser:
|
||||
token_data = auth_manager.create_access_token(test_super_admin)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.token_is_super_admin is True
|
||||
assert user.is_super_admin is True
|
||||
|
||||
def test_attaches_platform_attrs(self, db, auth_manager, test_admin):
|
||||
token_data = auth_manager.create_access_token(
|
||||
@@ -408,7 +408,7 @@ class TestGetCurrentUser:
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert not hasattr(user, "token_is_super_admin")
|
||||
assert user.is_super_admin is False
|
||||
assert not hasattr(user, "token_platform_id")
|
||||
assert not hasattr(user, "token_store_id")
|
||||
|
||||
@@ -468,38 +468,50 @@ class TestRequireRole:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireAdmin:
|
||||
"""Test require_admin method."""
|
||||
"""Test require_admin method (accepts super_admin and platform_admin)."""
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
def test_super_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
user.is_admin = True
|
||||
result = auth_manager.require_admin(user)
|
||||
assert result is user
|
||||
|
||||
def test_platform_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.is_admin = True
|
||||
result = auth_manager.require_admin(user)
|
||||
assert result is user
|
||||
|
||||
def test_non_admin_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "user"
|
||||
user.is_admin = False
|
||||
with pytest.raises(AdminRequiredException):
|
||||
auth_manager.require_admin(user)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireStore:
|
||||
"""Test require_store method (accepts store and admin roles)."""
|
||||
"""Test require_store method (accepts merchant_owner and store_member)."""
|
||||
|
||||
def test_store_accepted(self, auth_manager):
|
||||
def test_merchant_owner_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
user.is_store_user = True
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
def test_store_member_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
user.is_store_user = True
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_admin_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.is_store_user = False
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_store(user)
|
||||
|
||||
def test_customer_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "customer"
|
||||
user.is_store_user = False
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_store(user)
|
||||
|
||||
@@ -520,7 +532,7 @@ class TestRequireCustomer:
|
||||
|
||||
def test_store_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
user.role = "merchant_owner"
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_customer(user)
|
||||
|
||||
@@ -535,7 +547,7 @@ class TestCreateDefaultAdminUser:
|
||||
def test_creates_admin_when_none_exists(self, db, auth_manager):
|
||||
user = auth_manager.create_default_admin_user(db)
|
||||
assert user.username == "admin"
|
||||
assert user.role == "admin"
|
||||
assert user.role == "super_admin"
|
||||
assert user.is_super_admin is True
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@@ -65,19 +65,19 @@ class TestUserCreateSchema:
|
||||
password="securepass", # noqa: SEC001
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
)
|
||||
assert user.email == "admin@example.com"
|
||||
assert user.role == "admin"
|
||||
assert user.role == "super_admin"
|
||||
|
||||
def test_default_role_is_store(self):
|
||||
"""Test default role is store."""
|
||||
def test_default_role_is_store_member(self):
|
||||
"""Test default role is store_member."""
|
||||
user = UserCreate(
|
||||
email="store@example.com",
|
||||
username="storeuser",
|
||||
password="securepass", # noqa: SEC001
|
||||
)
|
||||
assert user.role == "store"
|
||||
assert user.role == "store_member"
|
||||
|
||||
def test_invalid_role(self):
|
||||
"""Test invalid role raises ValidationError."""
|
||||
@@ -135,10 +135,10 @@ class TestUserUpdateSchema:
|
||||
|
||||
def test_valid_role_update(self):
|
||||
"""Test valid role values."""
|
||||
admin_update = UserUpdate(role="admin")
|
||||
store_update = UserUpdate(role="store")
|
||||
assert admin_update.role == "admin"
|
||||
assert store_update.role == "store"
|
||||
admin_update = UserUpdate(role="super_admin")
|
||||
store_update = UserUpdate(role="merchant_owner")
|
||||
assert admin_update.role == "super_admin"
|
||||
assert store_update.role == "merchant_owner"
|
||||
|
||||
def test_empty_update(self):
|
||||
"""Test empty update is valid (all fields optional)."""
|
||||
@@ -159,7 +159,7 @@ class TestUserResponseSchema:
|
||||
"id": 1,
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"role": "store",
|
||||
"role": "merchant_owner",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
@@ -177,7 +177,7 @@ class TestUserResponseSchema:
|
||||
"id": 1,
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"role": "store",
|
||||
"role": "merchant_owner",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
|
||||
Reference in New Issue
Block a user