feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.

This commit is contained in:
Furqan-14
2026-02-02 17:33:35 +05:30
parent 249e03aa95
commit 7c326dea94
51 changed files with 2329 additions and 499 deletions
+4 -8
View File
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6382
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
+5 -9
View File
@@ -1,5 +1,5 @@
# Project Configuration
PROJECT_NAME=Fulfillment And Logistics
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=8000
APP_ENV=local
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.104.95:5432/saas_local
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6381
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
+4 -8
View File
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6383
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
@@ -0,0 +1,70 @@
"""split_module_access_table
Revision ID: 63b95ea5b967
Revises: 88cfc7dee19d
Create Date: 2026-01-23 11:07:09.706000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '63b95ea5b967'
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('module_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.UUID(), nullable=False),
sa.Column('access_code', sa.String(), nullable=False),
sa.Column('category', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('sync_checksum', sa.String(), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('module_id', 'access_code', name='uq_module_access_code')
)
op.create_index(op.f('ix_module_accesses_access_code'), 'module_accesses', ['access_code'], unique=False)
op.create_index(op.f('ix_module_accesses_category'), 'module_accesses', ['category'], unique=False)
op.create_index(op.f('ix_module_accesses_id'), 'module_accesses', ['id'], unique=False)
op.create_index(op.f('ix_module_accesses_module_id'), 'module_accesses', ['module_id'], unique=False)
op.drop_index(op.f('ix_access_code_module'), table_name='accesses', postgresql_where='(module_id IS NOT NULL)')
op.drop_index(op.f('ix_access_code_saas'), table_name='accesses', postgresql_where='(module_id IS NULL)')
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
op.drop_column('accesses', 'module_id')
op.drop_column('accesses', 'scope')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
op.add_column('accesses', sa.Column('module_id', sa.UUID(), autoincrement=False, nullable=True))
op.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
op.create_index(op.f('ix_access_code_saas'), 'accesses', ['access_code'], unique=True, postgresql_where='(module_id IS NULL)')
op.create_index(op.f('ix_access_code_module'), 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where='(module_id IS NOT NULL)')
op.drop_index(op.f('ix_module_accesses_module_id'), table_name='module_accesses')
op.drop_index(op.f('ix_module_accesses_id'), table_name='module_accesses')
op.drop_index(op.f('ix_module_accesses_category'), table_name='module_accesses')
op.drop_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
op.drop_table('module_accesses')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""scoped_access_code_uniqueness
Revision ID: 88cfc7dee19d
Revises: 03a1b1f05e99
Create Date: 2026-01-22 19:44:27.431776
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '88cfc7dee19d'
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
op.drop_index('ix_access_code_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
# ### end Alembic commands ###
@@ -0,0 +1,51 @@
"""add_parent_id_to_module_access
Revision ID: 91cc93992a91
Revises: 63b95ea5b967
Create Date: 2026-01-23 11:16:02.998798
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '91cc93992a91'
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('role_module_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('role_id', sa.UUID(), nullable=False),
sa.Column('module_access_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access')
)
op.create_index(op.f('ix_role_module_accesses_module_access_id'), 'role_module_accesses', ['module_access_id'], unique=False)
op.create_index(op.f('ix_role_module_accesses_role_id'), 'role_module_accesses', ['role_id'], unique=False)
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
op.create_index(op.f('ix_module_accesses_parent_id'), 'module_accesses', ['parent_id'], unique=False)
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'module_accesses', type_='foreignkey')
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
op.drop_column('module_accesses', 'parent_id')
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
op.drop_table('role_module_accesses')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add_provisioning_endpoint_to_module_environment
Revision ID: c37ba6143f83
Revises: 91cc93992a91
Create Date: 2026-01-24 10:04:06.239899
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c37ba6143f83'
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('module_environments', 'provisioning_endpoint')
# ### end Alembic commands ###
+62 -3
View File
@@ -111,10 +111,19 @@ def create_app() -> FastAPI:
from app.routes.theme.color_palette import router as palette_router
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
# === Admin Routes ===
from app.routes.admin.modules import router as admin_modules_router
from app.routes.admin.module_environments import router as admin_module_env_router
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
@app.on_event("startup")
def startup_event():
async def startup_event():
logger.info("Testing database connection...")
try:
with engine.connect() as conn:
@@ -125,9 +134,60 @@ def create_app() -> FastAPI:
logger.error(f"Database connection failed: {e}")
raise
from app.core.redis import redis_client
await redis_client.connect()
logger.info(
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
)
import asyncio
from app.services.auth.event_service import EventService
from app.config.database import SessionLocal
from app.core.redis import redis_client, sync_redis_client
from fastapi.concurrency import run_in_threadpool
async def redis_event_consumer():
logger.info("Redis Event Consumer STARTED")
while True:
try:
if not redis_client.client:
await asyncio.sleep(5)
continue
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
if result:
_, event_id = result
try:
with SessionLocal() as db:
await run_in_threadpool(EventService.process_queue_item, db, event_id)
except Exception as e:
logger.error(f"Error processing event {event_id}: {e}")
except Exception as e:
await asyncio.sleep(1)
async def fallback_poller():
logger.info("Fallback Event Poller STARTED")
while True:
try:
with SessionLocal() as db:
await run_in_threadpool(EventService.process_outbox, db)
except Exception as e:
logger.error(f"Fallback poller error: {e}")
await asyncio.sleep(60)
asyncio.create_task(redis_event_consumer())
asyncio.create_task(fallback_poller())
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Shutting down...")
from app.core.redis import redis_client
await redis_client.close()
# === Basic Routes ===
@app.get("/", tags=["Root"])
@@ -169,5 +229,4 @@ def create_app() -> FastAPI:
return app
app = create_app()
app = create_app()
+10 -23
View File
@@ -1,7 +1,3 @@
"""
Security utilities for authentication and authorization.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import bcrypt
@@ -10,10 +6,8 @@ from fastapi import HTTPException, status
import re
import secrets
import string
from app.config.settings import settings
class SecurityUtils:
"""Security utility class for authentication and authorization."""
@@ -129,14 +123,20 @@ class SecurityUtils:
if len(password) < 8:
return False
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password):
return False
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password):
return False
if not re.search(r'\d', password):
return False
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False
return True
@staticmethod
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
"""Generate short-lived module-scoped JWT (15 min default)."""
@@ -145,31 +145,20 @@ class SecurityUtils:
to_encode.update({
"exp": expire,
"type": "module_access",
"aud": str(module_id), # Audience enforcement
"aud": str(module_id),
"iat": datetime.now(timezone.utc).timestamp()
})
# Enterprise Contract: Identity tokens must be signed with RS256
if not settings.SAAS_PRIVATE_KEY:
# Dev fallback or error? Plan says "Private key loaded from...".
# We must strictly enforce RS256. If no key, we can't sign.
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
# NOTE: Module access tokens are verified ONLY by modules.
# SaaS never verifies module-scoped tokens after issuance.
# Modules MUST enforce aud == module_id.
# Failure to do so is a security violation.
return jwt.encode(
to_encode,
settings.SAAS_PRIVATE_KEY,
algorithm="RS256",
headers={"kid": settings.SAAS_KEY_ID} # Key Rotation Support
headers={"kid": settings.SAAS_KEY_ID}
)
# REMOVED: verify_module_token
# SaaS must never verify module tokens. This is the responsibility of the module.
# We strictly enforce RS256 for identity, and SaaS only holds the private key.
@staticmethod
def validate_email(email: str) -> bool:
"""Validate email format."""
@@ -184,6 +173,4 @@ class SecurityUtils:
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
# Create instance for easy importing
security = SecurityUtils()
+20 -52
View File
@@ -2,32 +2,25 @@ from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
from dotenv import load_dotenv
import os
# Load environment variables from .env files
app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent.parent
backend_path = Path(__file__).resolve().parent.parent
# Load specific environment file (e.g., .env.development)
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
load_dotenv(dotenv_path=base_path / '.env')
load_dotenv(dotenv_path=backend_path / '.env')
# Override with specific environment config
if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists():
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
class Settings(BaseSettings):
# Project
PROJECT_NAME: str = "SaaS Architecture"
VERSION: str = "1.0.0"
PROJECT_NAME: str
VERSION: str
# FastAPI
PORT: int
@@ -38,9 +31,7 @@ class Settings(BaseSettings):
# Frontend
FRONTEND_URL: str
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
CORS_ALLOWED_ORIGINS: Optional[str] = None
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
# Security
@@ -52,14 +43,24 @@ class Settings(BaseSettings):
DB_SSL: bool = False
# Redis Configuration
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
REDIS_HOST: str
REDIS_PORT: int
REDIS_PASSWORD: Optional[str]
REDIS_DB: int = 0
REDIS_ENABLED: bool = True
REDIS_TIMEOUT: int = 10
REDIS_MAX_CONNECTIONS: int = 10
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Email
SMTP_HOST: str
SMTP_PORT: int = 587
SMTP_PORT: int
SMTP_SECURE: bool = True
SMTP_USER: str
SMTP_PASSWORD: str
@@ -78,9 +79,6 @@ class Settings(BaseSettings):
SUPER_ADMIN_FIRST_NAME: str = "Super"
SUPER_ADMIN_LAST_NAME: str = "Admin"
# External SaaS Integration
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
# Module Integration Security (RS256)
SAAS_PRIVATE_KEY: Optional[str] = None
SAAS_KEY_ID: str = "saas-key-v1"
@@ -90,41 +88,11 @@ class Settings(BaseSettings):
PAYPAL_CLIENT_SECRET: str
PAYPAL_MODE: str = "sandbox"
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
# AWS S3 settings
AWS_SECRET_ACCESS_KEY: Optional[str] = None
AWS_ACCESS_KEY_ID: Optional[str] = None
S3_BUCKET_NAME: Optional[str] = None
AWS_REGION: Optional[str] = "us-east-1"
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
@property
def AWS_S3_BUCKET(self) -> Optional[str]:
return self.S3_BUCKET_NAME
# S3 Dataset Processing Settings
S3_PROCESSING_WORKERS: int = 4
DOCUMENT_CHUNK_SIZE: int = 1000
DOCUMENT_CHUNK_OVERLAP: int = 200
PINECONE_BATCH_SIZE: int = 100
# Redis Chat Settings
REDIS_CHAT_TTL: int = 86400 # 24 hours
REDIS_CHAT_TTL: int = 86400
# Logging
LOG_LEVEL: str = "info"
# Rate Limiting
RATE_LIMIT_REQUESTS: int = 100
RATE_LIMIT_WINDOW: int = 60
# Integration Settings (Optional for development)
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
# INTEGRATION_TIMEOUT: int = 30
# Properties for FastAPI Mail compatibility
@property
def MAIL_USERNAME(self) -> str:
return self.SMTP_USER
+49
View File
@@ -0,0 +1,49 @@
from sqlalchemy.orm import Session
from typing import List
from app.services.auth.module_service import ModuleService
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
from app.models.auth.module_model import Module
class ModuleController:
@staticmethod
def list_modules(db: Session) -> List[ModuleResponse]:
modules = ModuleService.list_modules(db)
return [ModuleResponse.model_validate(m) for m in modules]
@staticmethod
def create_module(db: Session, module_data: ModuleCreate) -> ModuleResponse:
module = ModuleService.create_module(db, module_data)
return ModuleResponse.model_validate(module)
@staticmethod
def get_available_modules(db: Session, current_user) -> List[ModuleResponse]:
from app.schemas.auth.module_schema import ModuleAvailableResponse
results = ModuleService.get_available_modules(db, current_user.tenant_id)
response = []
for item in results:
mod = item["module"]
response.append(ModuleAvailableResponse(
module_id=mod.module_id,
module_name=mod.module_name,
description=mod.description,
icon_url=mod.icon_url,
display_order=mod.display_order or 0,
is_active=item["is_active"]
))
return response
@staticmethod
def get_module(db: Session, module_id: str) -> ModuleResponse:
module = ModuleService.get_module(db, module_id)
return ModuleResponse.model_validate(module)
@staticmethod
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> ModuleResponse:
module = ModuleService.update_module(db, module_id, module_data)
return ModuleResponse.model_validate(module)
@staticmethod
def delete_module(db: Session, module_id: str):
ModuleService.delete_module(db, module_id)
return {"message": "Module deleted successfully"}
@@ -0,0 +1,30 @@
from sqlalchemy.orm import Session
from typing import List
from app.services.auth.module_environment_service import ModuleEnvironmentService
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
class ModuleEnvironmentController:
@staticmethod
def list_environments(db: Session, module_id: str) -> List[EnvironmentResponse]:
environments = ModuleEnvironmentService.list_environments(db, module_id)
return [EnvironmentResponse.model_validate(env) for env in environments]
@staticmethod
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> EnvironmentResponse:
environment = ModuleEnvironmentService.create_environment(db, module_id, env_data)
return EnvironmentResponse.model_validate(environment)
@staticmethod
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> EnvironmentResponse:
environment = ModuleEnvironmentService.update_environment(db, module_id, env_id, env_data)
return EnvironmentResponse.model_validate(environment)
@staticmethod
def set_default_environment(db: Session, module_id: str, env_id: str):
ModuleEnvironmentService.set_default_environment(db, module_id, env_id)
return {"message": "Environment set as default"}
@staticmethod
def delete_environment(db: Session, module_id: str, env_id: str):
ModuleEnvironmentService.delete_environment(db, module_id, env_id)
return {"message": "Environment deleted successfully"}
+12
View File
@@ -60,6 +60,18 @@ class RoleController:
}
for ra in role.role_accesses
]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
"access_code": rma.module_access.access_code,
"category": rma.module_access.category,
"name": rma.module_access.name,
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
}
for rma in role.role_module_accesses
])
return RoleWithAccessesResponse(
id=role.id,
+58
View File
@@ -0,0 +1,58 @@
from sqlalchemy.orm import Session
from fastapi import Request, HTTPException
from typing import Optional, Dict, Any
import uuid
from app.services.auth.sso_service import SSOService
from app.services.auth.trust_service import TrustService
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.user_model import User
class SSOController:
@staticmethod
def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User):
return SSOService.generate_signed_payload(
db=db,
user_id=current_user.id,
module_id=request.module_id,
tenant_id=current_user.tenant_id
)
@staticmethod
def exchange_grant(
db: Session,
payload: SSOExchangeRequest,
x_module_signature: Optional[str] = None,
x_module_key: Optional[str] = None
):
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == payload.environment_slug
).first()
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
headers = {}
if x_module_signature:
headers["X-Module-Signature"] = x_module_signature
if x_module_key:
headers["X-Module-Key"] = x_module_key
TrustService.validate_module_trust(
environment=env,
request_headers=headers,
request_body=""
)
return SSOService.exchange_grant(
db=db,
grant_code=payload.grant_code,
module_id=payload.module_id,
environment_slug=payload.environment_slug
)
@@ -0,0 +1,67 @@
from sqlalchemy.orm import Session
from typing import List
from app.models.auth.module_model import Module
import uuid
from app.services.auth.tenant_module_service import TenantModuleService
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
class TenantModuleController:
@staticmethod
def list_tenant_modules(db: Session, tenant_id: str) -> List[TenantModuleResponse]:
results = TenantModuleService.list_tenant_modules(db, tenant_id)
response_list = []
for tm, mod in results:
response_list.append(TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=mod.module_name,
module_icon_url=mod.icon_url,
assigned_environment_slug=tm.assigned_environment_slug or "prod",
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
))
return response_list
@staticmethod
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModuleResponse:
tm = TenantModuleService.assign_module(db, tenant_id, assignment_data)
module = db.query(Module).filter(Module.id == tm.module_id).first()
return TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=module.module_name if module else "Unknown",
module_icon_url=module.icon_url if module else None,
assigned_environment_slug=tm.assigned_environment_slug,
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
)
@staticmethod
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModuleResponse:
tm = TenantModuleService.update_assignment(db, tenant_id, tenant_module_id, update_data)
module = db.query(Module).filter(Module.id == tm.module_id).first()
return TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=module.module_name if module else "Unknown",
module_icon_url=module.icon_url if module else None,
assigned_environment_slug=tm.assigned_environment_slug,
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
)
@staticmethod
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
TenantModuleService.remove_assignment(db, tenant_id, tenant_module_id)
return {"message": "Module removed from tenant successfully"}
+133
View File
@@ -0,0 +1,133 @@
import logging
from typing import Optional
from redis.asyncio import Redis, from_url as async_from_url
from app.config.settings import settings
logger = logging.getLogger(__name__)
class RedisClient:
def __init__(self):
self._redis: Optional[Redis] = None
async def connect(self):
"""
Initializes the Redis connection pool (Async).
"""
if settings.REDIS_ENABLED:
try:
self._redis = async_from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
ping = await self._redis.ping()
if ping:
logger.info("Connected to Redis (Async)")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
self._redis = None
else:
logger.info("Redis is disabled in settings")
async def close(self):
"""
Closes the Redis connection.
"""
if self._redis:
await self._redis.close()
logger.info("Redis connection closed")
async def get(self, key: str) -> Optional[str]:
"""
Get a value by key.
"""
if not self._redis:
return None
try:
return await self._redis.get(key)
except Exception as e:
logger.error(f"Redis GET error for key {key}: {e}")
return None
async def set(self, key: str, value: str, expire: int = None) -> bool:
"""
Set a value by key with optional expiration time in seconds.
"""
if not self._redis:
return False
try:
return await self._redis.set(key, value, ex=expire)
except Exception as e:
logger.error(f"Redis SET error for key {key}: {e}")
return False
async def delete(self, key: str) -> bool:
"""
Delete a value by key.
"""
if not self._redis:
return False
try:
return await self._redis.delete(key) > 0
except Exception as e:
logger.error(f"Redis DELETE error for key {key}: {e}")
return False
@property
def client(self) -> Optional[Redis]:
"""
Expose the raw Redis client if needed for advanced operations.
"""
return self._redis
class SyncRedisClient:
def __init__(self):
self._redis = None
def connect(self):
"""
Initializes the Redis connection pool (Sync).
"""
if settings.REDIS_ENABLED:
try:
import redis
self._redis = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
if self._redis.ping():
logger.info("Connected to Redis (Sync)")
except Exception as e:
logger.error(f"Failed to connect to Redis (Sync): {e}")
self._redis = None
def close(self):
if self._redis:
self._redis.close()
logger.info("Redis connection closed (Sync)")
def rpush(self, key: str, *values) -> int:
if not self._redis:
try:
self.connect()
except:
pass
if not self._redis:
return 0
try:
return self._redis.rpush(key, *values)
except Exception as e:
logger.error(f"Redis RPUSH error: {e}")
return 0
@property
def client(self):
return self._redis
redis_client = RedisClient()
sync_redis_client = SyncRedisClient()
+3 -1
View File
@@ -2,4 +2,6 @@ from app.models.auth.access_model import Access
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User
from app.models.auth.user_model import User
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
+2 -7
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
@@ -12,12 +12,8 @@ class Access(Base):
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
# Module Integration
scope = Column(String, default="saas", nullable=False, index=True) # "saas" or "module"
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=True, index=True)
# For sync tracking
sync_checksum = Column(String, nullable=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
@@ -25,7 +21,6 @@ class Access(Base):
parent = relationship("Access", remote_side=[id], backref="children")
role_accesses = relationship("RoleAccess", back_populates="access")
module = relationship("Module")
def __repr__(self):
return f"<Access {self.access_code}>"
+32
View File
@@ -0,0 +1,32 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class ModuleAccess(Base):
__tablename__ = "module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
access_code = Column(String, nullable=False, index=True)
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
sync_checksum = Column(String, nullable=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
module = relationship("Module", back_populates="module_accesses")
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
__table_args__ = (
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
)
def __repr__(self):
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
+8 -10
View File
@@ -9,20 +9,18 @@ class ModuleEnvironment(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
slug = Column(String, nullable=False, index=True) # custom name: "prod", "staging", "client-a-prod"
slug = Column(String, nullable=False, index=True)
# Frontend configuration
frontend_base_url = Column(String, nullable=False) # https://inventory.example.com
sso_entry_path = Column(String, default="/sso/start") # Path to handle SSO grant
frontend_base_url = Column(String, nullable=False)
sso_entry_path = Column(String, default="/sso/start")
# Backend configuration
backend_base_url = Column(String, nullable=False) # https://api.inventory.example.com
backend_base_url = Column(String, nullable=False)
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
# Trust configuration
trust_type = Column(String, nullable=False) # hmac, mtls, static_key
trust_credentials = Column(JSON, nullable=False) # {hmac_secret, cert_path, etc.}
trust_type = Column(String, nullable=False)
trust_credentials = Column(JSON, nullable=False)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
@@ -36,4 +34,4 @@ class ModuleEnvironment(Base):
)
def __repr__(self):
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
+5 -6
View File
@@ -8,19 +8,18 @@ class Module(Base):
__tablename__ = "modules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(String, unique=True, nullable=False, index=True) # e.g., "inventory"
module_name = Column(String, nullable=False) # e.g., "Inventory Management"
module_id = Column(String, unique=True, nullable=False, index=True)
module_name = Column(String, nullable=False)
description = Column(String, nullable=True)
status = Column(String, default="active") # active, disabled
status = Column(String, default="active")
icon_url = Column(String, nullable=True)
display_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
permissions = relationship("Access", back_populates="module")
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
def __repr__(self):
return f"<Module {self.module_id}>"
return f"<Module {self.module_id}>"
+4 -3
View File
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Role(Base):
__tablename__ = "roles"
@@ -20,12 +19,14 @@ class Role(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenant = relationship("Tenant", back_populates="roles")
users = relationship("User", back_populates="role")
role_accesses = relationship(
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
)
role_module_accesses = relationship(
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Role {self.role_name}>"
return f"<Role {self.role_name}>"
@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class RoleModuleAccess(Base):
__tablename__ = "role_module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
role = relationship("Role", back_populates="role_module_accesses")
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
__table_args__ = (
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
)
def __repr__(self):
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
+4 -4
View File
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Tenant(Base):
__tablename__ = "tenants"
@@ -20,8 +19,9 @@ class Tenant(Base):
)
# Relationships
users = relationship("User", back_populates="tenant")
roles = relationship("Role", back_populates="tenant")
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
def __repr__(self):
return f"<Tenant {self.tenant_name}>"
return f"<Tenant {self.tenant_name}>"
+5 -12
View File
@@ -11,25 +11,18 @@ class TenantModule(Base):
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
# Environment routing
assigned_environment_slug = Column(String, nullable=True) # e.g. "prod" or "staging"
assigned_environment_slug = Column(String, nullable=True)
# Access control
is_active = Column(Boolean, default=True)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
deactivated_at = Column(DateTime(timezone=True), nullable=True)
# Business metadata
plan_tier = Column(String, nullable=True) # basic, premium, enterprise
module_config = Column(JSON, nullable=True) # custom settings per tenant-module
plan_tier = Column(String, nullable=True)
module_config = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# tenant relationship is backref'd from Tenant model or created here if Tenant model is not loaded yet
# But usually we define it in one place.
# The implementation plan showed: tenant = relationship("Tenant", backref="tenant_modules")
# Let's align with that.
tenant = relationship("Tenant", backref="tenant_modules")
tenant = relationship("Tenant", back_populates="tenant_modules")
module = relationship("Module", back_populates="tenant_modules")
__table_args__ = (
@@ -37,4 +30,4 @@ class TenantModule(Base):
)
def __repr__(self):
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
from app.controllers.auth.module_environment_controller import ModuleEnvironmentController
router = APIRouter()
@router.get("/{module_id}/environments", response_model=List[EnvironmentResponse])
def list_environments(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.list_environments(db, module_id)
@router.post("/{module_id}/environments", response_model=EnvironmentResponse)
def create_environment(
module_id: str,
env_data: EnvironmentCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.create_environment(db, module_id, env_data)
@router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse)
def update_environment(
module_id: str,
env_id: str,
env_data: EnvironmentUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data)
@router.patch("/{module_id}/environments/{env_id}/default")
def set_default_environment(
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.set_default_environment(db, module_id, env_id)
@router.delete("/{module_id}/environments/{env_id}")
def delete_environment(
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.delete_environment(db, module_id, env_id)
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.models.auth.module_model import Module
from app.models.auth.access_model import Access
from app.models.auth.module_access_model import ModuleAccess
import uuid
from app.config.database import get_db
from app.services.auth.module_permission_service import ModulePermissionService
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
from app.controllers.auth.module_controller import ModuleController
router = APIRouter()
@router.get("/", response_model=List[ModuleResponse])
def list_modules(
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleController.list_modules(db)
@router.post("/", response_model=ModuleResponse)
def create_module(
module_data: ModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleController.create_module(db, module_data)
@router.get("/{module_id}", response_model=ModuleResponse)
def get_module(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleController.get_module(db, module_id)
@router.put("/{module_id}", response_model=ModuleResponse)
def update_module(
module_id: str,
module_data: ModuleUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleController.update_module(db, module_id, module_data)
@router.delete("/{module_id}")
def delete_module(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
return ModuleController.delete_module(db, module_id)
@router.get("/{module_id}/permissions", response_model=List[dict])
def get_module_permissions(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
permissions = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id
).all()
return [{
"id": str(p.id),
"access_code": p.access_code,
"name": p.name,
"category": p.category,
"parent_id": str(p.parent_id) if p.parent_id else None,
"scope": "module",
"module_id": str(p.module_id)
} for p in permissions]
@router.post("/{module_id}/permissions/sync")
def sync_module_permissions(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
try:
result = ModulePermissionService.sync_permissions(db, module_id)
return result
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=f"Permission sync failed: {str(e)}")
+50
View File
@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
from app.controllers.auth.tenant_module_controller import TenantModuleController
router = APIRouter()
@router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse])
def list_tenant_modules(
tenant_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
return TenantModuleController.list_tenant_modules(db, tenant_id)
@router.post("/{tenant_id}/modules", response_model=TenantModuleResponse)
def assign_module_to_tenant(
tenant_id: str,
assignment_data: TenantModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
return TenantModuleController.assign_module(db, tenant_id, assignment_data)
@router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
def update_tenant_module(
tenant_id: str,
tenant_module_id: str,
update_data: TenantModuleUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
return TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data)
@router.delete("/{tenant_id}/modules/{tenant_module_id}")
def remove_module_from_tenant(
tenant_id: str,
tenant_module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
return TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id)
+4 -46
View File
@@ -4,21 +4,12 @@ from typing import List
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.models.auth.module_model import Module
from app.models.auth.tenant_module_model import TenantModule
from pydantic import BaseModel
from app.schemas.auth.module_schema import ModuleAvailableResponse
from app.controllers.auth.module_controller import ModuleController
router = APIRouter()
class ModuleResponse(BaseModel):
module_id: str
module_name: str
description: str | None
icon_url: str | None
display_order: int
is_active: bool
@router.get("/available", response_model=List[ModuleResponse])
@router.get("/available", response_model=List[ModuleAvailableResponse])
def get_available_modules(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -27,37 +18,4 @@ def get_available_modules(
List modules available to the current user (based on tenant subscription).
For platform admin (tenant_id=None), lists all active modules.
"""
if current_user.tenant_id:
# Tenant user: join with TenantModule
results = db.query(Module, TenantModule.is_active).join(
TenantModule,
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == current_user.tenant_id)
).filter(
Module.status == "active",
TenantModule.is_active == True
).order_by(Module.display_order).all()
modules = []
for mod, is_active in results:
modules.append(ModuleResponse(
module_id=mod.module_id,
module_name=mod.module_name,
description=mod.description,
icon_url=mod.icon_url,
display_order=mod.display_order or 0,
is_active=is_active
))
return modules
else:
# Platform admin: list all active modules
modules = db.query(Module).filter(Module.status == "active").order_by(Module.display_order).all()
return [
ModuleResponse(
module_id=m.module_id,
module_name=m.module_name,
description=m.description,
icon_url=m.icon_url,
display_order=m.display_order or 0,
is_active=True
) for m in modules
]
return ModuleController.get_available_modules(db, current_user)
+10 -59
View File
@@ -1,28 +1,15 @@
from fastapi import APIRouter, Depends, Header, Request, HTTPException, status
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.orm import Session
from typing import Optional
from pydantic import BaseModel
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.services.auth.sso_service import SSOService
from app.services.auth.trust_service import TrustService
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.module_model import Module
from app.models.auth.module_model import Module
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
from app.controllers.auth.sso_controller import SSOController
public_router = APIRouter()
internal_router = APIRouter()
class SSOInitiateRequest(BaseModel):
module_id: str
class SSOExchangeRequest(BaseModel):
grant_code: str
module_id: str
environment_slug: str
@public_router.post("/initiate")
def initiate_sso(
request: SSOInitiateRequest,
@@ -31,15 +18,9 @@ def initiate_sso(
):
"""
User-facing endpoint to start SSO flow.
Returns a redirect URL to the module's SSO entry path.
Returns a signed payload and target URL for the client to POST.
"""
result = SSOService.generate_grant(
db=db,
user_id=current_user.id,
module_id=request.module_id,
tenant_id=current_user.tenant_id
)
return result
return SSOController.initiate_sso(db, request, current_user)
@internal_router.post("/exchange")
def exchange_grant(
@@ -53,39 +34,9 @@ def exchange_grant(
Internal server-to-server endpoint for modules to exchange grant code for token.
Must be signed or authenticated via trust credentials.
"""
# 1. Resolve Module & Environment to get Trust Config
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == payload.environment_slug
).first()
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
# 2. Verify Trust using headers
# Construct headers dict for service
headers = {}
if x_module_signature:
headers["X-Module-Signature"] = x_module_signature
if x_module_key:
headers["X-Module-Key"] = x_module_key
TrustService.validate_module_trust(
environment=env,
request_headers=headers,
request_body="" # TODO: Ideally verify body payload signature
)
# 3. Exchange Grant
result = SSOService.exchange_grant(
return SSOController.exchange_grant(
db=db,
grant_code=payload.grant_code,
module_id=payload.module_id,
environment_slug=payload.environment_slug
)
return result
payload=payload,
x_module_signature=x_module_signature,
x_module_key=x_module_key
)
+3 -1
View File
@@ -11,7 +11,9 @@ class AccessBase(BaseModel):
class AccessResponse(AccessBase):
id: uuid.UUID
parent_id: Optional[uuid.UUID] = None
module_id: Optional[uuid.UUID] = None
module_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
from_attributes = True
@@ -0,0 +1,51 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class EnvironmentCreate(BaseModel):
slug: str
frontend_base_url: str
backend_base_url: str
sso_entry_path: str = "/sso/callback"
permission_sync_endpoint: str = "/internal/permissions"
sso_exchange_endpoint: Optional[str] = "/internal/sso/exchange"
provisioning_endpoint: str = "/internal/tenants/provision"
trust_type: str = "hmac"
trust_credentials: Dict[str, Any]
is_default: bool = False
is_active: bool = True
class EnvironmentUpdate(BaseModel):
slug: Optional[str] = None
frontend_base_url: Optional[str] = None
backend_base_url: Optional[str] = None
sso_entry_path: Optional[str] = None
permission_sync_endpoint: Optional[str] = None
sso_exchange_endpoint: Optional[str] = None
provisioning_endpoint: Optional[str] = None
trust_type: Optional[str] = None
trust_credentials: Optional[Dict[str, Any]] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class EnvironmentResponse(BaseModel):
id: UUID
module_id: UUID
slug: str
frontend_base_url: str
backend_base_url: str
sso_entry_path: str
sso_entry_path: str
permission_sync_endpoint: str
sso_exchange_endpoint: Optional[str]
provisioning_endpoint: Optional[str] = "/internal/tenants/provision"
trust_type: str
is_default: bool
is_active: bool
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class ModuleCreate(BaseModel):
module_id: str
module_name: str
description: Optional[str] = None
icon_url: Optional[str] = None
status: str = "active"
display_order: int = 0
class ModuleUpdate(BaseModel):
module_name: Optional[str] = None
description: Optional[str] = None
icon_url: Optional[str] = None
status: Optional[str] = None
display_order: Optional[int] = None
class ModuleResponse(BaseModel):
id: UUID
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
status: str
display_order: int
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class ModuleAvailableResponse(BaseModel):
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
display_order: int
is_active: bool
class Config:
from_attributes = True
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class SSOInitiateRequest(BaseModel):
module_id: str
class SSOExchangeRequest(BaseModel):
grant_code: str
module_id: str
environment_slug: str
+29
View File
@@ -0,0 +1,29 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class TenantModuleCreate(BaseModel):
module_id: str
assigned_environment_slug: Optional[str] = "prod"
is_active: bool = True
module_config: Optional[Dict[str, Any]] = None
class TenantModuleUpdate(BaseModel):
assigned_environment_slug: Optional[str] = None
is_active: Optional[bool] = None
module_config: Optional[Dict[str, Any]] = None
class TenantModuleResponse(BaseModel):
id: UUID
tenant_id: UUID
module_id: UUID
module_name: str
module_icon_url: Optional[str]
assigned_environment_slug: str
is_active: bool
module_config: Optional[Dict[str, Any]]
created_at: datetime
class Config:
from_attributes = True
+6 -1
View File
@@ -8,14 +8,19 @@ class TenantBase(BaseModel):
tenant_domain: str = Field(..., min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
class TenantModuleCreate(BaseModel):
module_id: uuid.UUID
environment_slug: str
class TenantCreate(TenantBase):
pass
modules: List[TenantModuleCreate] = []
class TenantUpdate(BaseModel):
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
is_active: Optional[bool] = None
modules: Optional[List[TenantModuleCreate]] = None
class TenantResponse(TenantBase):
id: uuid.UUID
+73 -4
View File
@@ -1,19 +1,88 @@
from sqlalchemy.orm import Session
from app.models.auth.access_model import Access
from typing import List
from typing import List, Any
from app.core.redis import sync_redis_client
import json
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.module_model import Module
from sqlalchemy.orm import joinedload
from datetime import datetime
class AccessService:
@staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[Access]:
def get_all_accesses(db: Session, category: str = None) -> List[any]:
cache_key = f"saas:access:v2:all:{category if category else 'full'}"
cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None
if cached_data:
try:
data_list = json.loads(cached_data)
class SimpleAccess:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
deserialized_list = []
for item in data_list:
if "created_at" in item and item["created_at"]:
try:
item["created_at"] = datetime.fromisoformat(item["created_at"])
except ValueError:
item["created_at"] = None
deserialized_list.append(SimpleAccess(**item))
return deserialized_list
except Exception as e:
pass
query = db.query(Access)
if category:
query = query.filter(Access.category == category)
return query.all()
saas_accesses = query.all()
for access in saas_accesses:
access.module_name = "SaaS (Internal)"
module_query = db.query(ModuleAccess).options(joinedload(ModuleAccess.module))
if category:
module_query = module_query.filter(ModuleAccess.category == category)
module_accesses = module_query.all()
for ma in module_accesses:
if ma.module:
ma.module_name = ma.module.module_name
result = saas_accesses + module_accesses
try:
if sync_redis_client.client:
serialized = []
for item in result:
serialized.append({
"id": str(item.id),
"access_code": item.access_code,
"name": item.name,
"category": item.category,
"parent_id": str(item.parent_id) if item.parent_id else None,
"module_name": getattr(item, "module_name", None),
"created_at": item.created_at.isoformat() if item.created_at else None,
})
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600) # 1 hour cache
except Exception as e:
pass
return result
@staticmethod
def get_access_categories(db: Session) -> List[str]:
categories = db.query(Access.category).distinct().all()
return [cat[0] for cat in categories]
module_categories = db.query(ModuleAccess.category).distinct().all()
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
return list(all_cats)
+133 -49
View File
@@ -6,12 +6,14 @@ from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
import hmac
import hashlib
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.module_model import Module
from app.models.auth.tenant_module_model import TenantModule
from app.models.system.event_log_model import EventLog, EventStatus
from app.services.auth.trust_service import TrustService
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
@@ -27,10 +29,9 @@ class EventService:
Emits an event by writing it to the Outbox (event_logs).
Scopes delivery to relevant modules based on tenant_id.
"""
event_id = str(uuid.uuid4()) # Idempotency Key
event_id = str(uuid.uuid4())
timestamp = datetime.now(timezone.utc).isoformat()
# Enforce Idempotency Contract: payload must include event_id
if "event_id" not in payload:
payload["event_id"] = event_id
@@ -41,24 +42,39 @@ class EventService:
"data": payload
}
# Scope: Find targets
targets = []
if tenant_id:
# Send to modules active for this tenant
targets: List[ModuleEnvironment] = []
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
targets_list = payload.get("targets")
if targets_list and isinstance(targets_list, list):
target_configs = targets_list
for target in target_configs:
module_id = target.get("module_id")
env_slug = target.get("environment_slug")
if module_id and env_slug:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.slug == env_slug
).first()
if env:
targets.append(env)
elif tenant_id:
tenant_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.is_active == True
).all()
for tm in tenant_modules:
# Resolve env
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == tm.module_id,
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod") # fallback logic could be better
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod")
).first()
if not env:
# Try default
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == tm.module_id,
ModuleEnvironment.is_default == True
@@ -66,16 +82,22 @@ class EventService:
if env:
targets.append(env)
else:
# System-wide event? Or broadcast?
# Plan says "No broadcasting". But if we update a global setting?
# For now, we assume user/tenant scope. If no tenant, we might log warning or skip.
logger.warning("Event emitted without tenant_id - skipping delivery scoping")
if not targets:
logger.warning(f"Event {event_type} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
return
# Write to Outbox
for env in targets:
target_url = f"{env.backend_base_url}/internal/events"
base = env.backend_base_url.rstrip('/')
if event_type == "TENANT_PROVISION_REQUESTED" and env.provisioning_endpoint:
endpoint = env.provisioning_endpoint.lstrip('/')
logger.info(f"Trace: base='{base}', endpoint='{endpoint}'")
target_url = f"{base}/{endpoint}"
logger.info(f"Trace: Calculated target_url='{target_url}'")
else:
logger.info(f"Using default event stream for env '{env.slug}'. ProvEndpoint: '{env.provisioning_endpoint}'")
target_url = f"{base}/api/internal/events"
log = EventLog(
event_id=uuid.UUID(event_id),
@@ -84,14 +106,86 @@ class EventService:
target_module_id=env.module_id,
target_environment_slug=env.slug,
target_url=target_url,
status=EventStatus.PENDING
status=EventStatus.PENDING,
next_retry_at=datetime.now(timezone.utc)
)
db.add(log)
# IMPORTANT: emit_event must be called within a transaction
# that is committed by the caller. db.flush details the insert
# so it's ready for commit.
db.flush()
try:
sync_redis_client.rpush("saas:events:queue", event_id)
except Exception as e:
logger.error(f"Failed to push event to Redis queue: {e}")
@staticmethod
def process_queue_item(db: Session, event_id: str):
"""
Process all pending EventLogs associated with the given logical event_id.
"""
logs = db.query(EventLog).filter(
EventLog.event_id == uuid.UUID(event_id),
EventLog.status == EventStatus.PENDING
).all()
if not logs:
return 0
processed_count = 0
for log in logs:
try:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == log.target_module_id,
ModuleEnvironment.slug == log.target_environment_slug
).first()
if not env:
log.status = EventStatus.FAILED
log.error_log = "Target environment config missing"
continue
payload_json = json.dumps(log.payload)
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_json.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature,
"X-SaaS-Event-Source": "saas-core"
}
logger.info(f"Sending event {log.event_type} to {log.target_url}")
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
if response.status_code in range(200, 300):
log.status = EventStatus.COMPLETED
log.error_log = None
processed_count += 1
else:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
log.error_log = f"HTTP {response.status_code}: {response.text}"
if log.retry_count > 10:
log.status = EventStatus.FAILED
except Exception as e:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
log.error_log = str(e)
db.commit()
return processed_count
@staticmethod
def process_outbox(db: Session, batch_size: int = 50):
@@ -105,9 +199,11 @@ class EventService:
EventLog.next_retry_at <= now
).limit(batch_size).all()
if logs:
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
for log in logs:
try:
# 1. Resolve Credentials for Signing
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == log.target_module_id,
ModuleEnvironment.slug == log.target_environment_slug
@@ -118,50 +214,37 @@ class EventService:
log.error_log = "Target environment config missing"
continue
# 2. Sign Payload
# We need to construct the request to sign it
# Method POST, Path /internal/events (derived from target_url but we should be consistent)
# But target_url might be full "http://.../internal/events"
# We need relative path for signature if module expects it.
# Standard convention: path is "/internal/events"
path = "/internal/events"
# Note: if target_url has different path, signature validation will fail.
# We assume standard convention or parse from target_url.
payload_json = json.dumps(log.payload)
timestamp = datetime.now(timezone.utc).isoformat()
secret = env.trust_credentials.get("hmac_secret")
signature = TrustService.sign_outbound_payload(
environment=env,
method="POST",
path=path,
payload_json=payload_json,
timestamp=timestamp
)
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_json.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature,
"X-SaaS-Timestamp": timestamp,
"X-SaaS-Event-Source": "saas-core"
}
# 3. Send
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
# 4. Handle Result
if response.status_code in range(200, 300):
log.status = EventStatus.COMPLETED
log.error_log = None # Clear errors if any
log.error_log = None
else:
# Retry logic
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400) # Cap at 24h
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = now + timedelta(seconds=backoff)
log.error_log = f"HTTP {response.status_code}: {response.text}"
if log.retry_count > 10: # Max retries
if log.retry_count > 10:
log.status = EventStatus.FAILED
except Exception as e:
@@ -170,5 +253,6 @@ class EventService:
log.next_retry_at = now + timedelta(seconds=backoff)
log.error_log = str(e)
# Commit processing state
db.commit()
return len(logs)
@@ -0,0 +1,111 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List
import uuid
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate
class ModuleEnvironmentService:
@staticmethod
def list_environments(db: Session, module_id: str) -> List[ModuleEnvironment]:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id
).order_by(ModuleEnvironment.is_default.desc(), ModuleEnvironment.slug).all()
@staticmethod
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> ModuleEnvironment:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
if env_data.is_default:
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).update({"is_default": False})
try:
environment = ModuleEnvironment(
module_id=module.id,
slug=env_data.slug,
frontend_base_url=env_data.frontend_base_url,
backend_base_url=env_data.backend_base_url,
sso_entry_path=env_data.sso_entry_path,
permission_sync_endpoint=env_data.permission_sync_endpoint,
sso_exchange_endpoint=env_data.sso_exchange_endpoint,
trust_type=env_data.trust_type,
trust_credentials=env_data.trust_credentials,
is_default=env_data.is_default,
is_active=env_data.is_active
)
db.add(environment)
db.commit()
db.refresh(environment)
return environment
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Environment slug already exists for this module")
@staticmethod
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> ModuleEnvironment:
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
if env_data.is_default and env_data.is_default != environment.is_default:
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == environment.module_id,
ModuleEnvironment.id != environment.id,
ModuleEnvironment.is_default == True
).update({"is_default": False})
update_data = env_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(environment, key, value)
db.commit()
db.refresh(environment)
return environment
@staticmethod
def set_default_environment(db: Session, module_id: str, env_id: str):
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == environment.module_id
).update({"is_default": False})
environment.is_default = True
db.commit()
@staticmethod
def delete_environment(db: Session, module_id: str, env_id: str):
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
if environment.is_default:
raise HTTPException(status_code=400, detail="Cannot delete default environment. Set another environment as default first.")
db.delete(environment)
db.commit()
+80 -38
View File
@@ -3,32 +3,41 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from fastapi import HTTPException
from typing import List, Dict, Any
import hmac
import hashlib
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.access_model import Access
from app.services.auth.trust_service import TrustService
from app.models.auth.module_access_model import ModuleAccess
import uuid
from app.core.redis import sync_redis_client
class ModulePermissionService:
@staticmethod
def sync_permissions(db: Session, module_id: str):
"""
Connects to the module's default environment and fetches defined permissions.
Updates the local Access table to mirror these permissions.
Updates the local ModuleAccess table to mirror these permissions.
:param module_id: The UUID string of the module
"""
# 1. Get Module & Environment
module = db.query(Module).filter(Module.module_id == module_id).first()
try:
module_uuid = uuid.UUID(module_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid module UUID")
module = db.query(Module).filter(Module.id == module_uuid).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
# Use default environment for sync
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).first()
if not env:
# Fallback to any active env
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_active == True
@@ -37,72 +46,105 @@ class ModulePermissionService:
if not env:
raise HTTPException(status_code=400, detail="No active environment to sync from")
# 2. Call Module API
try:
# We need to sign this request so module knows it's us
url = f"{env.backend_base_url}{env.permission_sync_endpoint}"
# Simple signature logic (outbound)
# In a real impl, we would use TrustService to sign.
# For now assuming module trusts us if we have the shared secret?
# TrustService was verify_request_signature (inbound).
# We need sign_outbound_request.
# Let's assume we send X-SaaS-Signature.
# But the plan didn't strictly specify SaaS->Module auth details other than "Secure Internal APIs".
# I will skip complex signing for this step to keep it simple, or add a basic header.
payload_body = "{}"
secret = env.trust_credentials.get("hmac_secret")
if not secret:
pass
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_body.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json"
"Content-Type": "application/json",
"X-SaaS-Signature": signature
}
if env.trust_type == "hmac":
# TODO: implement outbound signing in TrustService
pass
response = requests.post(url, headers=headers, timeout=10)
url = f"{env.backend_base_url}{env.permission_sync_endpoint}"
response = requests.post(url, headers=headers, data=payload_body, timeout=10)
response.raise_for_status()
data = response.json() # Expecting list of { code, category, description, ... }
data = response.json()
except Exception as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch permissions from module: {str(e)}")
# 3. Update Access Table
permissions: List[Dict[str, Any]] = data.get("permissions", [])
synced_count = 0
timestamp = datetime.now(timezone.utc)
permission_map = {}
for perm in permissions:
code = perm.get("permission_code")
if not code:
continue
# Check if exists
access = db.query(Access).filter(
Access.module_id == module.id,
Access.access_code == code
access = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code == code
).first()
if not access:
access = Access(
access = ModuleAccess(
access_code=code,
scope="module",
module_id=module.id,
name=perm.get("name", code), # Fallback name
name=perm.get("name", code),
category=perm.get("category", "General"),
# entity/action not in Access model yet, mapped to name/category or ignored
)
db.add(access)
else:
# Update metadata
access.name = perm.get("name", access.name)
access.category = perm.get("category", access.category)
access.last_synced_at = timestamp
access.sync_checksum = perm.get("hash") # optional
access.sync_checksum = perm.get("hash")
permission_map[code] = access
synced_count += 1
db.flush()
for perm in permissions:
code = perm.get("permission_code")
parent_code = perm.get("parent_code")
if not code or not parent_code:
continue
access = permission_map.get(code)
parent_access = permission_map.get(parent_code)
if not parent_access:
parent_access = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code == parent_code
).first()
if access and parent_access:
access.parent_id = parent_access.id
db.commit()
return {"status": "success", "synced_count": synced_count}
try:
if sync_redis_client.client:
sync_redis_client.client.delete("saas:access:v2:all:full")
except Exception:
pass
return {
"status": "success",
"message": "Permissions synced successfully",
"synced_count": synced_count
}
@staticmethod
def get_module_permissions(db: Session, module_id: str):
@@ -111,6 +153,6 @@ class ModulePermissionService:
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return db.query(Access).filter(
Access.module_id == module.id
).all()
return db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id
).all()
+77
View File
@@ -0,0 +1,77 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List, Optional
import uuid
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.module_model import Module
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate
class ModuleService:
@staticmethod
def list_modules(db: Session) -> List[Module]:
return db.query(Module).order_by(Module.display_order, Module.module_name).all()
@staticmethod
def create_module(db: Session, module_data: ModuleCreate) -> Module:
try:
module = Module(
module_id=module_data.module_id,
module_name=module_data.module_name,
description=module_data.description,
icon_url=module_data.icon_url,
status=module_data.status,
display_order=module_data.display_order
)
db.add(module)
db.commit()
db.refresh(module)
return module
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Module ID already exists")
@staticmethod
def get_module(db: Session, module_id: str) -> Module:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return module
@staticmethod
def get_available_modules(db: Session, tenant_id: Optional[uuid.UUID]) -> List[dict]:
if tenant_id:
results = db.query(Module, TenantModule.is_active).join(
TenantModule,
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == tenant_id)
).filter(
Module.status == "active",
TenantModule.is_active == True
).order_by(Module.display_order).all()
return [{"module": mod, "is_active": is_active} for mod, is_active in results]
else:
modules = db.query(Module).filter(Module.status == "active").order_by(Module.display_order).all()
return [{"module": mod, "is_active": True} for mod in modules]
@staticmethod
def get_module_by_module_id(db: Session, module_id_str: str) -> Optional[Module]:
return db.query(Module).filter(Module.module_id == module_id_str).first()
@staticmethod
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> Module:
module = ModuleService.get_module(db, module_id)
update_data = module_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(module, key, value)
db.commit()
db.refresh(module)
return module
@staticmethod
def delete_module(db: Session, module_id: str):
module = ModuleService.get_module(db, module_id)
db.delete(module)
db.commit()
+198 -11
View File
@@ -1,12 +1,20 @@
import uuid
import json
import logging
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from fastapi import HTTPException, status
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.access_model import Access
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
from typing import List, Optional
import uuid
from app.services.auth.event_service import EventService
logger = logging.getLogger(__name__)
class RoleService:
@@ -39,23 +47,86 @@ class RoleService:
if role_data.access_ids:
RoleService.assign_accesses(db, role.id, role_data.access_ids)
assigned_modules = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.all()
)
if assigned_modules:
module_map = {}
for rma, ma in assigned_modules:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
from app.models.auth.tenant_module_model import TenantModule
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_([uuid.UUID(m) for m in module_map.keys()])
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for mid, codes in module_map.items():
env_slug = env_map.get(mid, "prod")
targets.append({
"module_id": mid,
"environment_slug": env_slug,
"permissions": codes
})
if targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"role_name": role.role_name,
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": targets
}
logger.info(f"ROLE_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_PROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
return role
@staticmethod
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
db.query(RoleModuleAccess).filter(RoleModuleAccess.role_id == role_id).delete()
for access_id in access_ids:
access = db.query(Access).filter(Access.id == access_id).first()
if not access:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Access {access_id} not found",
)
if not access_ids:
return
role_access = RoleAccess(role_id=role_id, access_id=access_id)
db.add(role_access)
saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
saas_ids = {a.id for a in saas_accesses}
for access in saas_accesses:
db.add(RoleAccess(role_id=role_id, access_id=access.id))
remaining_ids = set(access_ids) - saas_ids
if remaining_ids:
module_accesses = db.query(ModuleAccess).filter(ModuleAccess.id.in_(remaining_ids)).all()
for access in module_accesses:
db.add(RoleModuleAccess(role_id=role_id, module_access_id=access.id))
db.commit()
@@ -84,7 +155,25 @@ class RoleService:
) -> Role:
role = RoleService.get_role_by_id(db, role_id)
def get_module_permissions_snapshot(r_id):
snapshot_data = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == r_id)
.all()
)
snapshot_map = {}
for rma, ma in snapshot_data:
mid = str(ma.module_id)
if mid not in snapshot_map:
snapshot_map[mid] = set()
snapshot_map[mid].add(ma.access_code)
return snapshot_map
before_snapshot = get_module_permissions_snapshot(role_id)
update_dict = role_data.model_dump(exclude_unset=True)
role_name_changed = "role_name" in update_dict
if role.is_default and not is_superadmin:
raise HTTPException(
@@ -102,6 +191,61 @@ class RoleService:
db.commit()
db.refresh(role)
after_snapshot = get_module_permissions_snapshot(role_id)
all_modules = set(before_snapshot.keys()) | set(after_snapshot.keys())
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_([uuid.UUID(m) for m in all_modules])
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
diff_targets = []
for mid in all_modules:
before_set = before_snapshot.get(mid, set())
after_set = after_snapshot.get(mid, set())
added = list(after_set - before_set)
removed = list(before_set - after_set)
is_active_module = mid in after_snapshot and len(after_snapshot[mid]) > 0
if added or removed or (role_name_changed and is_active_module):
env_slug = env_map.get(mid, "prod")
diff_targets.append({
"module_id": mid,
"environment_slug": env_slug,
"added_permissions": added,
"removed_permissions": removed
})
if diff_targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"role_name": role.role_name,
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": diff_targets
}
logger.info(f"ROLE_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_UPDATED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
return role
@staticmethod
@@ -113,6 +257,49 @@ class RoleService:
status_code=status.HTTP_403_FORBIDDEN,
detail="Default roles can only be deleted by superadmins.",
)
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.distinct()
.all()
)
if active_modules:
module_ids = [m[0] for m in active_modules]
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_(module_ids)
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for m in active_modules:
mid = str(m[0])
env_slug = env_map.get(mid, "prod")
targets.append({"module_id": mid, "environment_slug": env_slug})
if targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": targets
}
logger.info(f"ROLE_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.delete(role)
db.commit()
+93 -32
View File
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.sso_grant_model import SSOGrant
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
@@ -11,6 +10,8 @@ from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.user_model import User
from app.config.security import security
from app.services.auth.trust_service import TrustService
import json
import time
class SSOService:
@staticmethod
@@ -24,7 +25,6 @@ class SSOService:
Generates a one-time SSO grant code for the specified module.
Resolves the correct environment URL based on tenant/user config.
"""
# 1. Find Module
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
@@ -32,12 +32,9 @@ class SSOService:
if module.status != "active":
raise HTTPException(status_code=403, detail="Module is disabled")
# 2. Resolve Environment
# Default behavior: checks TenantModule assignment, else default env
environment_slug = "prod" # Default fallback
environment_slug = "prod"
if tenant_id:
# Check if tenant has access and specific env assignment
tm = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.module_id == module.id
@@ -49,13 +46,11 @@ class SSOService:
if tm.assigned_environment_slug:
environment_slug = tm.assigned_environment_slug
# Get actual environment config
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == environment_slug
).first()
# If slug invalid, fallback to default
if not env:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
@@ -63,14 +58,12 @@ class SSOService:
).first()
if not env:
raise HTTPException(status_code=500, detail="No active environment found for module")
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
# 3. Generate Grant
grant_code = str(uuid.uuid4().hex)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=60)
# redirect_url is dynamic, not stored
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
grant = SSOGrant(
@@ -79,7 +72,6 @@ class SSOService:
module_id=module.id,
tenant_id=tenant_id,
environment_slug=env.slug,
# redirect_url removed
expires_at=expires_at
)
db.add(grant)
@@ -91,6 +83,89 @@ class SSOService:
"redirect_url": redirect_url
}
@staticmethod
def generate_signed_payload(
db: Session,
user_id: uuid.UUID,
module_id: str,
tenant_id: Optional[uuid.UUID] = None
) -> Dict[str, Any]:
"""
Generates a signed payload for the client to POST directly to the module backend.
"""
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or module.status != "active":
raise HTTPException(status_code=404, detail="Module not found or disabled")
environment_slug = "prod"
if tenant_id:
tm = db.query(TenantModule).filter(TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id).first()
if tm and tm.is_active and tm.assigned_environment_slug:
environment_slug = tm.assigned_environment_slug
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first()
if not env:
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first()
if not env:
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
permissions = []
if user.role:
for ra in user.role.role_accesses:
if ra.access:
pass
if user.role.role_module_accesses:
for rma in user.role.role_module_accesses:
if rma.module_access and rma.module_access.module_id == module.id:
permissions.append(rma.module_access.access_code)
timestamp = int(time.time() * 1000)
payload_data = {
"user_id": str(user.id),
"email": user.email,
"tenant_id": str(tenant_id) if tenant_id else None,
"permissions": permissions,
"timestamp": timestamp,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.role_name if user.role else None
}
tenant_id_str = str(tenant_id) if tenant_id else ""
canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}&timestamp={timestamp}"
try:
signature = TrustService.sign_payload(env, canonical_string)
except ValueError:
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
base_url = env.backend_base_url.rstrip('/')
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
if not path.startswith('/'):
path = '/' + path
target_url = f"{base_url}{path}"
return {
"target_url": target_url,
"payload": payload_data,
"headers": {
"X-App-Id": "saas",
"X-App-Id": module.module_id,
"X-Signature": signature
},
"redirect_url": env.frontend_base_url
}
@staticmethod
def exchange_grant(
db: Session,
@@ -102,59 +177,45 @@ class SSOService:
Validates grant and returns a short-lived module-scoped token.
This is called by the Module Backend.
"""
# 1. Find Grant
grant = db.query(SSOGrant).filter(SSOGrant.grant_code == grant_code).first()
if not grant:
raise HTTPException(status_code=401, detail="Invalid grant code")
# 2. Validate Grant
if grant.is_used:
raise HTTPException(status_code=401, detail="Grant code already used")
if grant.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Grant code expired")
# 3. Validate Module Context
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or module.id != grant.module_id:
raise HTTPException(status_code=401, detail="Grant invalid for this module")
if grant.environment_slug != environment_slug:
# Strict environment check: grant issued for 'prod' cannot be exchanged by 'staging'
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
# 4. Validate Tenant Context (Anti-replay/Consistency)
user = db.query(User).filter(User.id == grant.user_id).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if grant.tenant_id:
# Ensure user still belongs to this tenant or has access
if user.tenant_id != grant.tenant_id:
# It's possible for superadmins to switch contexts, but for regular flow
# the user's current tenant context should match.
# Actually, if the grant was issued for Tenant A, we must ensure
# the token we issue is for Tenant A.
raise HTTPException(
status_code=401,
detail="Tenant mismatch for SSO grant"
)
# 5. Mark Used
grant.is_used = True
grant.used_at = datetime.now(timezone.utc)
db.commit()
# 6. Get User Permissions for this Module
permissions = []
if user.role:
for ra in user.role.role_accesses:
access = ra.access
# Include SaaS global permissions (scope='saas') OR module specific (scope='module' and matching module_id)
if access.scope == 'saas' or (access.scope == 'module' and access.module_id == module.id):
permissions.append(access.access_code)
if user.role.role_module_accesses:
for rma in user.role.role_module_accesses:
if rma.module_access and rma.module_access.module_id == module.id:
permissions.append(rma.module_access.access_code)
# 6. Generate Token
token_payload = {
"sub": str(user.id),
"email": user.email,
@@ -170,11 +231,11 @@ class SSOService:
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 900, # 15 minutes
"expires_in": 900,
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name
}
}
}
@@ -0,0 +1,87 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List, Tuple
import uuid
from app.models.auth.tenant_model import Tenant
from app.models.auth.module_model import Module
from app.models.auth.tenant_module_model import TenantModule
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate
class TenantModuleService:
@staticmethod
def list_tenant_modules(db: Session, tenant_id: str) -> List[Tuple[TenantModule, Module]]:
tenant = db.query(Tenant).filter(Tenant.id == uuid.UUID(tenant_id)).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return db.query(TenantModule, Module).join(
Module, TenantModule.module_id == Module.id
).filter(
TenantModule.tenant_id == tenant.id
).all()
@staticmethod
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModule:
tenant = db.query(Tenant).filter(Tenant.id == uuid.UUID(tenant_id)).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
module = db.query(Module).filter(Module.id == uuid.UUID(assignment_data.module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
existing = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.module_id == module.id
).first()
if existing:
raise HTTPException(status_code=409, detail="Module already assigned to this tenant")
try:
tenant_module = TenantModule(
tenant_id=tenant.id,
module_id=module.id,
assigned_environment_slug=assignment_data.assigned_environment_slug,
is_active=assignment_data.is_active,
module_config=assignment_data.module_config
)
db.add(tenant_module)
db.commit()
db.refresh(tenant_module)
return tenant_module
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Module assignment conflict")
@staticmethod
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModule:
tenant_module = db.query(TenantModule).filter(
TenantModule.id == uuid.UUID(tenant_module_id),
TenantModule.tenant_id == uuid.UUID(tenant_id)
).first()
if not tenant_module:
raise HTTPException(status_code=404, detail="Tenant module assignment not found")
update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(tenant_module, key, value)
db.commit()
db.refresh(tenant_module)
return tenant_module
@staticmethod
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
tenant_module = db.query(TenantModule).filter(
TenantModule.id == uuid.UUID(tenant_module_id),
TenantModule.tenant_id == uuid.UUID(tenant_id)
).first()
if not tenant_module:
raise HTTPException(status_code=404, detail="Tenant module assignment not found")
db.delete(tenant_module)
db.commit()
+198 -10
View File
@@ -2,9 +2,14 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from fastapi import HTTPException, status
from app.models.auth.tenant_model import Tenant
from app.models.auth.tenant_module_model import TenantModule
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
import uuid
from typing import Optional
from app.services.auth.event_service import EventService
import logging
logger = logging.getLogger(__name__)
class TenantService:
@@ -24,16 +29,63 @@ class TenantService:
detail="Tenant domain already exists"
)
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url
)
provisioning_id = str(uuid.uuid4())
db.add(tenant)
db.commit()
db.refresh(tenant)
return tenant
try:
# 1. Create Tenant
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url
)
db.add(tenant)
db.flush()
# 2. Create Tenant Modules & Build Event Targets
event_targets = []
if tenant_data.modules:
for mod_data in tenant_data.modules:
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_data.module_id,
assigned_environment_slug=mod_data.environment_slug,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(mod_data.module_id),
"environment_slug": mod_data.environment_slug
})
if event_targets:
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"provisioning_id": provisioning_id,
"targets": event_targets
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
logger.info("Event TENANT_PROVISION_REQUESTED emitted to outbox.")
db.commit()
db.refresh(tenant)
return tenant
except Exception as e:
db.rollback()
raise e
@staticmethod
def get_all_tenants(db: Session):
@@ -55,19 +107,128 @@ class TenantService:
update_dict = tenant_data.model_dump(exclude_unset=True)
should_emit_update = False
should_emit_status = False
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant name already exists")
should_emit_update = True
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant domain already exists")
should_emit_update = True
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
should_emit_update = True
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "modules" in update_dict:
modules_data = update_dict.pop("modules")
if modules_data is not None:
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
new_map = {m["module_id"]: m for m in modules_data}
event_targets = []
provisioning_id = str(uuid.uuid4())
for module_id, data in new_map.items():
new_env_slug = data.get("environment_slug")
if module_id in current_map:
tm = current_map[module_id]
if tm.assigned_environment_slug != new_env_slug or not tm.is_active:
tm.assigned_environment_slug = new_env_slug
tm.is_active = True
event_targets.append({
"module_id": str(module_id),
"environment_slug": new_env_slug
})
else:
tm = TenantModule(
tenant_id=tenant.id,
module_id=module_id,
assigned_environment_slug=new_env_slug,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(module_id),
"environment_slug": new_env_slug
})
for module_id, tm in current_map.items():
if module_id not in new_map:
tm.is_active = False
db.flush()
if event_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name, # Note: using current name (might be old if not updated yet, but usually distinct requests)
"provisioning_id": provisioning_id,
"targets": event_targets
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
for key, value in update_dict.items():
setattr(tenant, key, value)
if should_emit_update or should_emit_status:
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
if should_emit_update:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_UPDATED",
payload=payload,
tenant_id=tenant.id
)
if should_emit_status:
payload = {
"tenant_id": str(tenant.id),
"is_active": tenant.is_active,
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_STATUS_CHANGED",
payload=payload,
tenant_id=tenant.id
)
db.commit()
db.refresh(tenant)
return tenant
@@ -75,6 +236,33 @@ class TenantService:
@staticmethod
def delete_tenant(db: Session, tenant_id: uuid.UUID):
tenant = TenantService.get_tenant_by_id(db, tenant_id)
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
if active_modules:
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
db.delete(tenant)
db.commit()
return {"message": "Tenant deleted successfully"}
+19 -14
View File
@@ -12,9 +12,7 @@ class TrustService:
Currently supports HMAC-SHA256.
"""
if environment.trust_type != "hmac":
# For now only HMAC is fully implemented
if environment.trust_type == "static_key":
# Simple key check (not recommended for prod but useful for dev)
secret = environment.trust_credentials.get("secret_key")
return signature == secret
return False
@@ -31,6 +29,24 @@ class TrustService:
return hmac.compare_digest(expected_signature, signature)
@staticmethod
def sign_payload(environment: ModuleEnvironment, payload: str) -> str:
"""
Signs a raw payload string using the environment's HMAC secret.
Used for direct signed POST flows.
"""
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret'")
signature = hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
@staticmethod
def sign_outbound_payload(environment: ModuleEnvironment, method: str, path: str, payload_json: str, timestamp: str) -> str:
"""
@@ -38,26 +54,16 @@ class TrustService:
Signature = HMAC-SHA256(secret, method + path + timestamp + SHA256(payload))
"""
if environment.trust_type != "hmac":
# If trusting via static key, we might technically rely on that, but the plan mandates HMAC for outbound.
# We'll allow it if a secret is present in credentials even if type isn't explicitly set to only hmac,
# but strictly we should check.
pass
secret = environment.trust_credentials.get("hmac_secret")
# Fallback for static key if we want to use that as secret, but plan says separate.
# Let's enforce hmac_secret presence.
if not secret:
# If no HMAC secret is explicitly defined, we cannot sign.
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret' for outbound signing")
# 1. Hash the payload
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
# 2. Construct string to sign
# Canonical string: METHOD + PATH + TIMESTAMP + PAYLOAD_HASH
string_to_sign = f"{method.upper()}{path}{timestamp}{payload_hash}"
# 3. Sign
signature = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
@@ -102,8 +108,7 @@ class TrustService:
)
else:
# TODO: Implement mTLS support
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=f"Trust type {environment.trust_type} not supported yet"
)
)
+149 -31
View File
@@ -3,11 +3,18 @@ from sqlalchemy import or_, cast, String
from fastapi import HTTPException, status, BackgroundTasks
from datetime import datetime
import uuid
from typing import Optional
from typing import Optional, List, Dict, Any
from app.models.auth.user_model import User
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.config.security import security
from app.services.auth.event_service import EventService
import logging
import json
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
logger = logging.getLogger(__name__)
class UserService:
@@ -37,26 +44,35 @@ class UserService:
)
db.add(user)
db.flush() # Get ID but don't commit yet
db.flush()
db.refresh(user)
# Emit event (adds to session/flush)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
payload = {
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status,
"targets": targets
}
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="user.created",
payload={
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status
},
event_type="USER_PROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.commit() # Atomic commit of user + event
db.commit()
return user
@staticmethod
@@ -82,6 +98,11 @@ class UserService:
@staticmethod
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
user = UserService.get_user_by_id(db, user_id, tenant_id)
old_role_id = user.role_id
old_targets = []
if old_role_id:
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
update_dict = user_data.model_dump(exclude_unset=True)
if tenant_id:
@@ -109,29 +130,86 @@ class UserService:
db.flush()
db.refresh(user)
new_targets = []
if user.role_id:
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
# Emit event
EventService.emit_event(
db=db,
event_type="user.updated",
payload={
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status
},
tenant_id=user.tenant_id
)
role_changed = (old_role_id != user.role_id)
base_payload = {
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status
}
if role_changed:
old_mids = {t["module_id"] for t in old_targets}
new_mids = {t["module_id"] for t in new_targets}
removed_mids = old_mids - new_mids
db.commit() # Atomic commit
deprovision_targets = [t for t in old_targets if t["module_id"] in removed_mids]
if deprovision_targets:
payload = {**base_payload, "targets": deprovision_targets}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
if new_targets:
payload = {**base_payload, "targets": new_targets}
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_PROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
else:
if new_targets:
payload = {**base_payload, "targets": new_targets}
logger.info(f"USER_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_UPDATED",
payload=payload,
tenant_id=user.tenant_id
)
db.commit()
return user
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
user = UserService.get_user_by_id(db, user_id, tenant_id)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
if targets:
payload = {
"user_id": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"targets": targets
}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
@@ -179,4 +257,44 @@ class UserService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
@staticmethod
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
"""
Helper to resolve which modules/envs a role targets.
Mirrors logic in RoleService.
"""
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role_id)
.distinct()
.all()
)
if not active_modules:
return []
module_ids = [m[0] for m in active_modules]
env_map = {}
if tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.module_id.in_(module_ids),
TenantModule.is_active == True
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for mid_uuid in module_ids:
mid = str(mid_uuid)
env_slug = env_map.get(mid, "prod")
targets.append({
"module_id": mid,
"environment_slug": env_slug
})
return targets
+6 -29
View File
@@ -8,20 +8,12 @@ from app.schemas.theme.color_palette_schema import (
ColorPaletteUpdate,
)
class PaletteService:
@staticmethod
def get_all_palettes(
db: Session, tenant_id: Optional[UUID] = None
) -> List[ColorPalette]:
query = db.query(ColorPalette)
if tenant_id:
query = query.filter(
(ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id)
)
else:
pass
return query.all()
return db.query(ColorPalette).all()
@staticmethod
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
@@ -35,21 +27,13 @@ class PaletteService:
db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None
) -> ColorPalette:
if data.is_default:
if tenant_id:
db.query(ColorPalette).filter(
ColorPalette.tenant_id == tenant_id
).update({"is_default": False})
else:
db.query(ColorPalette).filter(ColorPalette.tenant_id == None).update(
{"is_default": False}
)
db.query(ColorPalette).update({"is_default": False})
db_palette = ColorPalette(
name=data.name,
description=data.description,
colors=data.colors.model_dump(),
is_default=data.is_default,
# tenant_id=tenant_id, # Model does not support tenant_id yet
)
db.add(db_palette)
db.commit()
@@ -70,16 +54,9 @@ class PaletteService:
palette.colors = data.colors.model_dump()
if data.is_default is not None:
if data.is_default:
tenant_id = palette.tenant_id
if tenant_id:
db.query(ColorPalette).filter(
ColorPalette.tenant_id == tenant_id,
ColorPalette.id != palette_id,
).update({"is_default": False})
else:
db.query(ColorPalette).filter(
ColorPalette.tenant_id == None, ColorPalette.id != palette_id
).update({"is_default": False})
db.query(ColorPalette).filter(
ColorPalette.id != palette_id
).update({"is_default": False})
palette.is_default = data.is_default
@@ -93,4 +70,4 @@ class PaletteService:
db.delete(palette)
db.commit()
return True
return True
+2 -1
View File
@@ -7,4 +7,5 @@ alembic==1.17.2
pydantic-settings==2.12.0
bcrypt>=4.0.1
pyjwt>=2.8.0
email-validator>=2.1.0
email-validator>=2.1.0
redis==7.1.0
-27
View File
@@ -1,27 +0,0 @@
import sys
import os
from sqlalchemy import text
# Add parent directory to path so we can import app
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.config.database import engine
def fix_alembic_version():
target_version = '8acd83604252'
print(f"Attempting to reset alembic_version to {target_version}...")
try:
with engine.begin() as conn:
# Check if table exists
conn.execute(text("DROP TABLE IF EXISTS alembic_version"))
conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"))
conn.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{target_version}')"))
print("Successfully checked/created alembic_version table and inserted target version.")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
fix_alembic_version()
+5
View File
@@ -47,6 +47,11 @@ PREDEFINED_ACCESSES = [
("superadmin.palette.update", "Superadmin", "Allow access to update color palettes", None),
("superadmin.palette.delete", "Superadmin", "Allow access to delete color palettes", None),
# Module Registry
("modules.view", "Superadmin", "Allow access to view module registry", None),
("modules.manage", "Superadmin", "Allow access to manage modules", None),
("tenants.manage", "Superadmin", "Allow access to manage tenant module assignments", None),
# All Accesses hereafter are applicable for a Tenant Admin
# Administration category