Files
orion/app/modules/tenancy/services/store_team_service.py
Samir Boulahtit 1dcb0e6c33
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat: RBAC Phase 1 — consolidate user roles into 4-value enum
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>
2026-02-19 22:44:29 +01:00

535 lines
16 KiB
Python

# app/modules/tenancy/services/store_team_service.py
"""
Store team management service.
Handles:
- Team member invitations
- Invitation acceptance
- Role assignment
- Permission management
"""
import logging
import secrets
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
def get_preset_permissions(preset_name: str) -> set[str]:
"""Get permissions for a preset role."""
return permission_discovery_service.get_preset_permissions(preset_name)
from app.modules.billing.exceptions import TierLimitExceededException
from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
class StoreTeamService:
"""Service for managing store team members."""
def __init__(self):
self.auth_manager = AuthManager()
def invite_team_member(
self,
db: Session,
store: Store,
inviter: User,
email: str,
role_name: str,
custom_permissions: list[str] | None = None,
) -> dict[str, Any]:
"""
Invite a new team member to a store.
Creates:
1. User account (if doesn't exist)
2. Role (if custom permissions provided)
3. StoreUser relationship with invitation token
Args:
db: Database session
store: Store to invite to
inviter: User sending the invitation
email: Email of person to invite
role_name: Role name (manager, staff, support, etc.)
custom_permissions: Optional custom permissions (overrides preset)
Returns:
Dict with invitation details
"""
try:
# Check team size limit from subscription
from app.modules.billing.services import subscription_service
subscription_service.check_team_limit(db, store.id)
# Check if user already exists
user = db.query(User).filter(User.email == email).first()
if user:
# Check if already a member
existing_membership = (
db.query(StoreUser)
.filter(
StoreUser.store_id == store.id,
StoreUser.user_id == user.id,
)
.first()
)
if existing_membership:
if existing_membership.is_active:
raise TeamMemberAlreadyExistsException(
email, store.store_code
)
# Reactivate old membership
existing_membership.is_active = (
False # Will be activated on acceptance
)
existing_membership.invitation_token = (
self._generate_invitation_token()
)
existing_membership.invitation_sent_at = datetime.utcnow()
existing_membership.invitation_accepted_at = None
db.flush()
logger.info(
f"Re-invited user {email} to store {store.store_code}"
)
return {
"invitation_token": existing_membership.invitation_token,
"email": email,
"existing_user": True,
}
else:
# Create new user account (inactive until invitation accepted)
username = email.split("@")[0]
# Ensure unique username
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}{counter}"
counter += 1
# Generate temporary password (user will set real one on activation)
temp_password = secrets.token_urlsafe(16)
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(temp_password),
role="store_member",
is_active=False, # Will be activated when invitation accepted
is_email_verified=False,
)
db.add(user)
db.flush() # Get user.id
logger.info(f"Created new user account for invitation: {email}")
# Get or create role
role = self._get_or_create_role(
db=db,
store=store,
role_name=role_name,
custom_permissions=custom_permissions,
)
# Create store membership with invitation
invitation_token = self._generate_invitation_token()
store_user = StoreUser(
store_id=store.id,
user_id=user.id,
role_id=role.id,
invited_by=inviter.id,
invitation_token=invitation_token,
invitation_sent_at=datetime.utcnow(),
is_active=False, # Will be activated on acceptance
)
db.add(store_user)
db.flush()
logger.info(
f"Invited {email} to store {store.store_code} "
f"as {role_name} by {inviter.username}"
)
# TODO: Send invitation email
# self._send_invitation_email(email, store, invitation_token)
return {
"invitation_token": invitation_token,
"email": email,
"role": role_name,
"existing_user": user.is_active,
}
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
raise
except SQLAlchemyError as e:
logger.error(f"Error inviting team member: {str(e)}")
raise
def accept_invitation(
self,
db: Session,
invitation_token: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
) -> dict[str, Any]:
"""
Accept a team invitation and activate account.
Args:
db: Database session
invitation_token: Invitation token from email
password: New password to set
first_name: Optional first name
last_name: Optional last name
Returns:
Dict with user and store info
"""
try:
# Find invitation
store_user = (
db.query(StoreUser)
.filter(
StoreUser.invitation_token == invitation_token,
)
.first()
)
if not store_user:
raise InvalidInvitationTokenException()
# Check if already accepted
if store_user.invitation_accepted_at is not None:
raise TeamInvitationAlreadyAcceptedException()
# Check token expiration (7 days)
if store_user.invitation_sent_at:
expiry_date = store_user.invitation_sent_at + timedelta(days=7)
if datetime.utcnow() > expiry_date:
raise InvalidInvitationTokenException("Invitation has expired")
user = store_user.user
store = store_user.store
# Update user
user.hashed_password = self.auth_manager.hash_password(password)
user.is_active = True
user.is_email_verified = True
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
# Activate membership
store_user.is_active = True
store_user.invitation_accepted_at = datetime.utcnow()
store_user.invitation_token = None # Clear token
db.flush()
logger.info(
f"User {user.email} accepted invitation to store {store.store_code}"
)
return {
"user": user,
"store": store,
"role": store_user.role.name if store_user.role else "member",
}
except (
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
):
raise
except SQLAlchemyError as e:
logger.error(f"Error accepting invitation: {str(e)}")
raise
def remove_team_member(
self,
db: Session,
store: Store,
user_id: int,
) -> bool:
"""
Remove a team member from a store.
Cannot remove owner.
Args:
db: Database session
store: Store to remove from
user_id: User ID to remove
Returns:
True if removed
"""
try:
store_user = (
db.query(StoreUser)
.filter(
StoreUser.store_id == store.id,
StoreUser.user_id == user_id,
)
.first()
)
if not store_user:
raise UserNotFoundException(str(user_id))
# Cannot remove owner
if store_user.is_owner:
raise CannotRemoveOwnerException(user_id, store.id)
# Soft delete - just deactivate
store_user.is_active = False
logger.info(f"Removed user {user_id} from store {store.store_code}")
return True
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except SQLAlchemyError as e:
logger.error(f"Error removing team member: {str(e)}")
raise
def update_member_role(
self,
db: Session,
store: Store,
user_id: int,
new_role_name: str,
custom_permissions: list[str] | None = None,
) -> StoreUser:
"""
Update a team member's role.
Args:
db: Database session
store: Store
user_id: User ID
new_role_name: New role name
custom_permissions: Optional custom permissions
Returns:
Updated StoreUser
"""
try:
store_user = (
db.query(StoreUser)
.filter(
StoreUser.store_id == store.id,
StoreUser.user_id == user_id,
)
.first()
)
if not store_user:
raise UserNotFoundException(str(user_id))
# Cannot change owner's role
if store_user.is_owner:
raise CannotRemoveOwnerException(user_id, store.id)
# Get or create new role
new_role = self._get_or_create_role(
db=db,
store=store,
role_name=new_role_name,
custom_permissions=custom_permissions,
)
store_user.role_id = new_role.id
db.flush()
logger.info(
f"Updated role for user {user_id} in store {store.store_code} "
f"to {new_role_name}"
)
return store_user
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except SQLAlchemyError as e:
logger.error(f"Error updating member role: {str(e)}")
raise
def get_team_members(
self,
db: Session,
store: Store,
include_inactive: bool = False,
) -> list[dict[str, Any]]:
"""
Get all team members for a store.
Args:
db: Database session
store: Store
include_inactive: Include inactive members
Returns:
List of team member info
"""
query = db.query(StoreUser).filter(
StoreUser.store_id == store.id,
)
if not include_inactive:
query = query.filter(StoreUser.is_active == True)
store_users = query.all()
members = []
for vu in store_users:
members.append(
{
"id": vu.user.id,
"email": vu.user.email,
"username": vu.user.username,
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"full_name": vu.user.full_name,
"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(),
"is_active": vu.is_active,
"is_owner": vu.is_owner,
"invitation_pending": vu.is_invitation_pending,
"invited_at": vu.invitation_sent_at,
"accepted_at": vu.invitation_accepted_at,
"joined_at": vu.invitation_accepted_at or vu.created_at or vu.user.created_at,
}
)
return members
def get_store_roles(self, db: Session, store_id: int) -> list[dict[str, Any]]:
"""
Get all roles for a store.
Creates default preset roles if none exist.
Args:
db: Database session
store_id: Store ID
Returns:
List of role info dicts
"""
roles = db.query(Role).filter(Role.store_id == store_id).all()
# Create default roles if none exist
if not roles:
default_role_names = ["manager", "staff", "support", "viewer", "marketing"]
for role_name in default_role_names:
permissions = list(get_preset_permissions(role_name))
role = Role(
store_id=store_id,
name=role_name,
permissions=permissions,
)
db.add(role) # noqa: PERF006
db.flush() # Flush to get IDs without committing (endpoint commits)
roles = db.query(Role).filter(Role.store_id == store_id).all()
return [
{
"id": role.id,
"name": role.name,
"permissions": role.permissions or [],
"store_id": role.store_id,
"created_at": role.created_at,
"updated_at": role.updated_at,
}
for role in roles
]
# Private helper methods
def _generate_invitation_token(self) -> str:
"""Generate a secure invitation token."""
return secrets.token_urlsafe(32)
def _get_or_create_role(
self,
db: Session,
store: Store,
role_name: str,
custom_permissions: list[str] | None = None,
) -> Role:
"""Get existing role or create new one with preset/custom permissions."""
# Try to find existing role with same name
role = (
db.query(Role)
.filter(
Role.store_id == store.id,
Role.name == role_name,
)
.first()
)
if role and custom_permissions is None:
# Use existing role
return role
# Determine permissions
if custom_permissions:
permissions = custom_permissions
else:
# Get preset permissions
permissions = list(get_preset_permissions(role_name))
if role:
# Update existing role with new permissions
role.permissions = permissions
else:
# Create new role
role = Role(
store_id=store.id,
name=role_name,
permissions=permissions,
)
db.add(role)
db.flush()
return role
def _send_invitation_email(self, email: str, store: Store, token: str):
"""Send invitation email (TODO: implement)."""
# TODO: Implement email sending
# Should include:
# - Link to accept invitation: /store/invitation/accept?token={token}
# - Store name
# - Inviter name
# - Expiry date
# Create service instance
store_team_service = StoreTeamService()