This commit is contained in:
2026-01-08 01:00:26 +10:30
commit 211854ca0a
70 changed files with 5241 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""Services package."""
from app.services.auth_service import AuthService
from app.services.user_service import UserService
from app.services.organization_service import OrganizationService
from app.services.session_service import SessionService
from app.services.audit_service import AuditService
__all__ = [
"AuthService",
"UserService",
"OrganizationService",
"SessionService",
"AuditService",
]
+107
View File
@@ -0,0 +1,107 @@
"""Audit service."""
from flask import request, g
from app.models.audit_log import AuditLog
from app.utils.constants import AuditAction
class AuditService:
"""Service for audit logging."""
@staticmethod
def log_action(
action,
user_id=None,
organization_id=None,
resource_type=None,
resource_id=None,
metadata=None,
description=None,
success=True,
error_message=None,
):
"""
Create an audit log entry.
Args:
action: AuditAction enum value
user_id: ID of user performing the action
organization_id: ID of related organization
resource_type: Type of resource being acted upon
resource_id: ID of resource being acted upon
metadata: Additional metadata dictionary
description: Human-readable description
success: Whether the action succeeded
error_message: Error message if action failed
Returns:
AuditLog instance
"""
# Get request details if available
ip_address = None
user_agent = None
request_id = None
try:
if request:
ip_address = request.remote_addr
user_agent = request.headers.get("User-Agent")
request_id = g.get("request_id")
except RuntimeError:
# No request context
pass
log_entry = AuditLog(
action=action,
user_id=user_id,
organization_id=organization_id,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
metadata=metadata,
description=description,
success=success,
error_message=error_message,
)
log_entry.save()
return log_entry
@staticmethod
def get_user_activity(user_id, limit=50):
"""
Get recent activity for a user.
Args:
user_id: User ID
limit: Maximum number of records to return
Returns:
List of AuditLog instances
"""
return (
AuditLog.query.filter_by(user_id=user_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.all()
)
@staticmethod
def get_organization_activity(organization_id, limit=50):
"""
Get recent activity for an organization.
Args:
organization_id: Organization ID
limit: Maximum number of records to return
Returns:
List of AuditLog instances
"""
return (
AuditLog.query.filter_by(organization_id=organization_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.all()
)
+215
View File
@@ -0,0 +1,215 @@
"""Authentication service."""
from datetime import datetime, timedelta
from flask import request, g
from app.extensions import db, bcrypt
from app.models.user import User
from app.models.authentication_method import AuthenticationMethod
from app.models.session import Session
from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
from app.services.audit_service import AuditService
import secrets
class AuthService:
"""Service for authentication operations."""
@staticmethod
def register_user(email, password, full_name=None):
"""
Register a new user with email/password.
Args:
email: User email address
password: Plain text password
full_name: Optional full name
Returns:
User instance
Raises:
EmailAlreadyExistsError: If email is already registered
"""
# Check if email already exists
existing_user = User.query.filter_by(email=email.lower()).first()
if existing_user and existing_user.deleted_at is None:
raise EmailAlreadyExistsError()
# Create user
user = User(
email=email.lower(),
full_name=full_name,
status=UserStatus.ACTIVE,
)
user.save()
# Create password authentication method
password_hash = bcrypt.generate_password_hash(password).decode("utf-8")
auth_method = AuthenticationMethod(
user_id=user.id,
method_type=AuthMethodType.PASSWORD,
password_hash=password_hash,
is_primary=True,
verified=True,
)
auth_method.save()
# Log the registration
AuditService.log_action(
action=AuditAction.USER_REGISTER,
user_id=user.id,
resource_type="user",
resource_id=user.id,
description=f"User registered with email: {email}",
)
return user
@staticmethod
def authenticate(email, password):
"""
Authenticate user with email/password.
Args:
email: User email
password: Plain text password
Returns:
User instance if authentication succeeds
Raises:
InvalidCredentialsError: If credentials are invalid
AccountSuspendedError: If account is suspended
AccountInactiveError: If account is inactive
"""
# Find user
user = User.query.filter_by(email=email.lower(), deleted_at=None).first()
if not user:
raise InvalidCredentialsError()
# Check account status
if user.status == UserStatus.SUSPENDED:
raise AccountSuspendedError()
if user.status == UserStatus.INACTIVE:
raise AccountInactiveError()
# Find password auth method
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.PASSWORD,
deleted_at=None,
).first()
if not auth_method or not auth_method.password_hash:
raise InvalidCredentialsError()
# Verify password
if not bcrypt.check_password_hash(auth_method.password_hash, password):
raise InvalidCredentialsError()
# Update last login
user.last_login_at = datetime.utcnow()
user.last_login_ip = request.remote_addr
auth_method.last_used_at = datetime.utcnow()
db.session.commit()
return user
@staticmethod
def create_session(user, duration_seconds=86400):
"""
Create a new session for the user.
Args:
user: User instance
duration_seconds: Session duration in seconds
Returns:
Session instance
"""
# Generate session token
token = secrets.token_urlsafe(32)
# Create session
session = Session(
user_id=user.id,
token=token,
status=SessionStatus.ACTIVE,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
expires_at=datetime.utcnow() + timedelta(seconds=duration_seconds),
last_activity_at=datetime.utcnow(),
)
session.save()
# Log session creation
AuditService.log_action(
action=AuditAction.SESSION_CREATE,
user_id=user.id,
resource_type="session",
resource_id=session.id,
description="User session created",
)
return session
@staticmethod
def change_password(user, current_password, new_password):
"""
Change user password.
Args:
user: User instance
current_password: Current password
new_password: New password
Raises:
InvalidCredentialsError: If current password is incorrect
"""
# Find password auth method
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.PASSWORD,
deleted_at=None,
).first()
if not auth_method or not auth_method.password_hash:
raise InvalidCredentialsError("No password authentication method found")
# Verify current password
if not bcrypt.check_password_hash(auth_method.password_hash, current_password):
raise InvalidCredentialsError("Current password is incorrect")
# Update password
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
db.session.commit()
# Log password change
AuditService.log_action(
action=AuditAction.PASSWORD_CHANGE,
user_id=user.id,
description="User changed password",
)
@staticmethod
def revoke_session(session_id, reason=None):
"""
Revoke a session.
Args:
session_id: Session ID to revoke
reason: Optional revocation reason
"""
session = Session.query.get(session_id)
if session:
session.revoke(reason=reason)
# Log session revocation
AuditService.log_action(
action=AuditAction.SESSION_REVOKE,
user_id=session.user_id,
resource_type="session",
resource_id=session.id,
description=f"Session revoked: {reason or 'User logout'}",
)
+280
View File
@@ -0,0 +1,280 @@
"""Organization service."""
from datetime import datetime
from app.extensions import db
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from app.utils.constants import OrganizationRole, AuditAction
from app.services.audit_service import AuditService
class OrganizationService:
"""Service for organization operations."""
@staticmethod
def create_organization(name, slug, owner_user_id, description=None, logo_url=None):
"""
Create a new organization.
Args:
name: Organization name
slug: Unique organization slug
owner_user_id: ID of the user who will be the owner
description: Optional description
logo_url: Optional logo URL
Returns:
Organization instance
Raises:
ConflictError: If slug already exists
"""
# Check if slug already exists
existing = Organization.query.filter_by(slug=slug, deleted_at=None).first()
if existing:
raise ConflictError("Organization slug already exists")
# Create organization
org = Organization(
name=name,
slug=slug,
description=description,
logo_url=logo_url,
is_active=True,
)
org.save()
# Add owner as member
member = OrganizationMember(
user_id=owner_user_id,
organization_id=org.id,
role=OrganizationRole.OWNER,
joined_at=datetime.utcnow(),
)
member.save()
# Log organization creation
AuditService.log_action(
action=AuditAction.ORG_CREATE,
user_id=owner_user_id,
organization_id=org.id,
resource_type="organization",
resource_id=org.id,
description=f"Organization created: {name}",
)
return org
@staticmethod
def get_organization_by_id(org_id):
"""
Get organization by ID.
Args:
org_id: Organization ID
Returns:
Organization instance
Raises:
OrganizationNotFoundError: If organization not found
"""
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
if not org:
raise OrganizationNotFoundError()
return org
@staticmethod
def get_organization_by_slug(slug):
"""
Get organization by slug.
Args:
slug: Organization slug
Returns:
Organization instance or None
"""
return Organization.query.filter_by(slug=slug, deleted_at=None).first()
@staticmethod
def update_organization(org, user_id, **kwargs):
"""
Update organization.
Args:
org: Organization instance
user_id: ID of user performing the update
**kwargs: Fields to update
Returns:
Updated Organization instance
"""
allowed_fields = ["name", "description", "logo_url"]
update_data = {k: v for k, v in kwargs.items() if k in allowed_fields}
if update_data:
org.update(**update_data)
# Log organization update
AuditService.log_action(
action=AuditAction.ORG_UPDATE,
user_id=user_id,
organization_id=org.id,
resource_type="organization",
resource_id=org.id,
metadata=update_data,
description="Organization updated",
)
return org
@staticmethod
def delete_organization(org, user_id, soft=True):
"""
Delete organization.
Args:
org: Organization instance
user_id: ID of user performing the delete
soft: If True, performs soft delete
Returns:
Deleted Organization instance
"""
org.delete(soft=soft)
# Log organization deletion
AuditService.log_action(
action=AuditAction.ORG_DELETE,
user_id=user_id,
organization_id=org.id,
resource_type="organization",
resource_id=org.id,
description=f"Organization {'soft' if soft else 'hard'} deleted",
)
return org
@staticmethod
def add_member(org, user_id, role, inviter_id):
"""
Add a member to the organization.
Args:
org: Organization instance
user_id: ID of user to add
role: OrganizationRole
inviter_id: ID of user performing the invitation
Returns:
OrganizationMember instance
Raises:
ConflictError: If user is already a member
"""
# Check if already a member
existing = OrganizationMember.query.filter_by(
user_id=user_id,
organization_id=org.id,
deleted_at=None,
).first()
if existing:
raise ConflictError("User is already a member of this organization")
# Create membership
member = OrganizationMember(
user_id=user_id,
organization_id=org.id,
role=role,
invited_by_id=inviter_id,
invited_at=datetime.utcnow(),
joined_at=datetime.utcnow(),
)
member.save()
# Log member addition
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ADD,
user_id=inviter_id,
organization_id=org.id,
resource_type="organization_member",
resource_id=member.id,
metadata={"added_user_id": user_id, "role": role.value},
description=f"Member added to organization with role: {role.value}",
)
return member
@staticmethod
def remove_member(org, user_id, remover_id):
"""
Remove a member from the organization.
Args:
org: Organization instance
user_id: ID of user to remove
remover_id: ID of user performing the removal
"""
member = OrganizationMember.query.filter_by(
user_id=user_id,
organization_id=org.id,
deleted_at=None,
).first()
if member:
member.delete(soft=True)
# Log member removal
AuditService.log_action(
action=AuditAction.ORG_MEMBER_REMOVE,
user_id=remover_id,
organization_id=org.id,
resource_type="organization_member",
resource_id=member.id,
metadata={"removed_user_id": user_id},
description="Member removed from organization",
)
@staticmethod
def update_member_role(org, user_id, new_role, updater_id):
"""
Update a member's role in the organization.
Args:
org: Organization instance
user_id: ID of user whose role to update
new_role: New OrganizationRole
updater_id: ID of user performing the update
Returns:
Updated OrganizationMember instance
"""
member = OrganizationMember.query.filter_by(
user_id=user_id,
organization_id=org.id,
deleted_at=None,
).first()
if member:
old_role = member.role
member.role = new_role
db.session.commit()
# Log role change
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ROLE_CHANGE,
user_id=updater_id,
organization_id=org.id,
resource_type="organization_member",
resource_id=member.id,
metadata={
"target_user_id": user_id,
"old_role": old_role.value,
"new_role": new_role.value,
},
description=f"Member role changed from {old_role.value} to {new_role.value}",
)
return member
+76
View File
@@ -0,0 +1,76 @@
"""Session service."""
from datetime import datetime
from app.models.session import Session
from app.utils.constants import SessionStatus
class SessionService:
"""Service for session operations."""
@staticmethod
def get_active_session_by_token(token):
"""Get active session by token.
Args:
token: The session token string
Returns:
Session object if found and active, None otherwise
"""
from app.models.session import Session
from app.utils.constants import SessionStatus
return Session.query.filter_by(
token=token,
status=SessionStatus.ACTIVE,
deleted_at=None
).first()
@staticmethod
def get_user_sessions(user_id, active_only=True):
"""
Get all sessions for a user.
Args:
user_id: User ID
active_only: If True, only return active sessions
Returns:
List of Session instances
"""
query = Session.query.filter_by(user_id=user_id, deleted_at=None)
if active_only:
query = query.filter_by(status=SessionStatus.ACTIVE).filter(
Session.expires_at > datetime.utcnow()
)
return query.all()
@staticmethod
def revoke_user_sessions(user_id, reason="User logged out from all devices"):
"""
Revoke all active sessions for a user.
Args:
user_id: User ID
reason: Reason for revocation
"""
sessions = SessionService.get_user_sessions(user_id, active_only=True)
for session in sessions:
session.revoke(reason=reason)
@staticmethod
def cleanup_expired_sessions():
"""Clean up expired sessions."""
expired_sessions = Session.query.filter(
Session.status == SessionStatus.ACTIVE,
Session.expires_at < datetime.utcnow(),
Session.deleted_at.is_(None),
).all()
for session in expired_sessions:
session.status = SessionStatus.EXPIRED
session.save()
return len(expired_sessions)
+110
View File
@@ -0,0 +1,110 @@
"""User service."""
from app.extensions import db
from app.models.user import User
from app.exceptions.validation_exceptions import UserNotFoundError
from app.utils.constants import AuditAction
from app.services.audit_service import AuditService
class UserService:
"""Service for user operations."""
@staticmethod
def get_user_by_id(user_id):
"""
Get user by ID.
Args:
user_id: User ID
Returns:
User instance
Raises:
UserNotFoundError: If user not found
"""
user = User.query.filter_by(id=user_id, deleted_at=None).first()
if not user:
raise UserNotFoundError()
return user
@staticmethod
def get_user_by_email(email):
"""
Get user by email.
Args:
email: User email
Returns:
User instance or None
"""
return User.query.filter_by(email=email.lower(), deleted_at=None).first()
@staticmethod
def update_user(user, **kwargs):
"""
Update user profile.
Args:
user: User instance
**kwargs: Fields to update
Returns:
Updated User instance
"""
allowed_fields = ["full_name", "avatar_url"]
update_data = {k: v for k, v in kwargs.items() if k in allowed_fields}
if update_data:
user.update(**update_data)
# Log user update
AuditService.log_action(
action=AuditAction.USER_UPDATE,
user_id=user.id,
resource_type="user",
resource_id=user.id,
metadata=update_data,
description="User profile updated",
)
return user
@staticmethod
def delete_user(user, soft=True):
"""
Delete user account.
Args:
user: User instance
soft: If True, performs soft delete
Returns:
Deleted User instance
"""
user.delete(soft=soft)
# Log user deletion
AuditService.log_action(
action=AuditAction.USER_DELETE,
user_id=user.id,
resource_type="user",
resource_id=user.id,
description=f"User account {'soft' if soft else 'hard'} deleted",
)
return user
@staticmethod
def get_user_organizations(user):
"""
Get all organizations the user is a member of.
Args:
user: User instance
Returns:
List of organizations
"""
return user.get_organizations()