diff --git a/.env.development b/.env.development index 89a9c97..e7d79c5 100644 --- a/.env.development +++ b/.env.development @@ -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="" diff --git a/.env.local b/.env.local index f86d4ac..18f9f83 100644 --- a/.env.local +++ b/.env.local @@ -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="" diff --git a/.env.testing b/.env.testing index fbccb3f..3ae3efc 100644 --- a/.env.testing +++ b/.env.testing @@ -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="" diff --git a/alembic/versions/63b95ea5b967_split_module_access_table.py b/alembic/versions/63b95ea5b967_split_module_access_table.py new file mode 100644 index 0000000..918879a --- /dev/null +++ b/alembic/versions/63b95ea5b967_split_module_access_table.py @@ -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 ### diff --git a/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py b/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py new file mode 100644 index 0000000..73deead --- /dev/null +++ b/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py @@ -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 ### diff --git a/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py b/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py new file mode 100644 index 0000000..0992e31 --- /dev/null +++ b/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py @@ -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 ### diff --git a/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py b/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py new file mode 100644 index 0000000..b7d4693 --- /dev/null +++ b/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py @@ -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 ### diff --git a/app/__init__.py b/app/__init__.py index 27da908..0d82ab2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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() \ No newline at end of file diff --git a/app/config/security.py b/app/config/security.py index 9c9bf3e..da0cbc5 100644 --- a/app/config/security.py +++ b/app/config/security.py @@ -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() \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py index a7c36dc..9be5507 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -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 diff --git a/app/controllers/auth/module_controller.py b/app/controllers/auth/module_controller.py new file mode 100644 index 0000000..0902f16 --- /dev/null +++ b/app/controllers/auth/module_controller.py @@ -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"} \ No newline at end of file diff --git a/app/controllers/auth/module_environment_controller.py b/app/controllers/auth/module_environment_controller.py new file mode 100644 index 0000000..fcbc9d3 --- /dev/null +++ b/app/controllers/auth/module_environment_controller.py @@ -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"} \ No newline at end of file diff --git a/app/controllers/auth/role_controller.py b/app/controllers/auth/role_controller.py index c40d68e..6d6c3fc 100644 --- a/app/controllers/auth/role_controller.py +++ b/app/controllers/auth/role_controller.py @@ -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, diff --git a/app/controllers/auth/sso_controller.py b/app/controllers/auth/sso_controller.py new file mode 100644 index 0000000..80f21b1 --- /dev/null +++ b/app/controllers/auth/sso_controller.py @@ -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 + ) \ No newline at end of file diff --git a/app/controllers/auth/tenant_module_controller.py b/app/controllers/auth/tenant_module_controller.py new file mode 100644 index 0000000..3018650 --- /dev/null +++ b/app/controllers/auth/tenant_module_controller.py @@ -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"} \ No newline at end of file diff --git a/app/core/redis.py b/app/core/redis.py new file mode 100644 index 0000000..60bb8fb --- /dev/null +++ b/app/core/redis.py @@ -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() \ No newline at end of file diff --git a/app/models/auth/__init__.py b/app/models/auth/__init__.py index c1c54d8..ea39255 100644 --- a/app/models/auth/__init__.py +++ b/app/models/auth/__init__.py @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/app/models/auth/access_model.py b/app/models/auth/access_model.py index 5e9f227..8c2aa28 100644 --- a/app/models/auth/access_model.py +++ b/app/models/auth/access_model.py @@ -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"" \ No newline at end of file diff --git a/app/models/auth/module_access_model.py b/app/models/auth/module_access_model.py new file mode 100644 index 0000000..c8843dc --- /dev/null +++ b/app/models/auth/module_access_model.py @@ -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"" \ No newline at end of file diff --git a/app/models/auth/module_environment_model.py b/app/models/auth/module_environment_model.py index 869fb3a..17216d9 100644 --- a/app/models/auth/module_environment_model.py +++ b/app/models/auth/module_environment_model.py @@ -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"" + return f"" \ No newline at end of file diff --git a/app/models/auth/module_model.py b/app/models/auth/module_model.py index 6310002..5fb4e04 100644 --- a/app/models/auth/module_model.py +++ b/app/models/auth/module_model.py @@ -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"" + return f"" \ No newline at end of file diff --git a/app/models/auth/role_model.py b/app/models/auth/role_model.py index 644b941..cfe9af7 100644 --- a/app/models/auth/role_model.py +++ b/app/models/auth/role_model.py @@ -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"" + return f"" \ No newline at end of file diff --git a/app/models/auth/role_module_access_model.py b/app/models/auth/role_module_access_model.py new file mode 100644 index 0000000..d9c5a7e --- /dev/null +++ b/app/models/auth/role_module_access_model.py @@ -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"" \ No newline at end of file diff --git a/app/models/auth/tenant_model.py b/app/models/auth/tenant_model.py index f3f58b7..0ee4719 100644 --- a/app/models/auth/tenant_model.py +++ b/app/models/auth/tenant_model.py @@ -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"" + return f"" \ No newline at end of file diff --git a/app/models/auth/tenant_module_model.py b/app/models/auth/tenant_module_model.py index 19ea4ea..2ab756d 100644 --- a/app/models/auth/tenant_module_model.py +++ b/app/models/auth/tenant_module_model.py @@ -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"" + return f"" \ No newline at end of file diff --git a/app/routes/admin/module_environments.py b/app/routes/admin/module_environments.py new file mode 100644 index 0000000..385842e --- /dev/null +++ b/app/routes/admin/module_environments.py @@ -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) \ No newline at end of file diff --git a/app/routes/admin/modules.py b/app/routes/admin/modules.py new file mode 100644 index 0000000..4777fda --- /dev/null +++ b/app/routes/admin/modules.py @@ -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)}") \ No newline at end of file diff --git a/app/routes/admin/tenant_modules.py b/app/routes/admin/tenant_modules.py new file mode 100644 index 0000000..1eb0928 --- /dev/null +++ b/app/routes/admin/tenant_modules.py @@ -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) \ No newline at end of file diff --git a/app/routes/api/module.py b/app/routes/api/module.py index 1b1ad98..55af756 100644 --- a/app/routes/api/module.py +++ b/app/routes/api/module.py @@ -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) \ No newline at end of file diff --git a/app/routes/auth/sso.py b/app/routes/auth/sso.py index 51a52d9..f14a840 100644 --- a/app/routes/auth/sso.py +++ b/app/routes/auth/sso.py @@ -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 + ) \ No newline at end of file diff --git a/app/schemas/auth/access_schema.py b/app/schemas/auth/access_schema.py index c110042..12d6ec9 100644 --- a/app/schemas/auth/access_schema.py +++ b/app/schemas/auth/access_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/module_environment_schema.py b/app/schemas/auth/module_environment_schema.py new file mode 100644 index 0000000..73f5503 --- /dev/null +++ b/app/schemas/auth/module_environment_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/module_schema.py b/app/schemas/auth/module_schema.py new file mode 100644 index 0000000..a154c8a --- /dev/null +++ b/app/schemas/auth/module_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/sso_schema.py b/app/schemas/auth/sso_schema.py new file mode 100644 index 0000000..f330c02 --- /dev/null +++ b/app/schemas/auth/sso_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/tenant_module_schema.py b/app/schemas/auth/tenant_module_schema.py new file mode 100644 index 0000000..ccb9be5 --- /dev/null +++ b/app/schemas/auth/tenant_module_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/tenant_schema.py b/app/schemas/auth/tenant_schema.py index a8852ba..bc90407 100644 --- a/app/schemas/auth/tenant_schema.py +++ b/app/schemas/auth/tenant_schema.py @@ -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 diff --git a/app/services/auth/access_service.py b/app/services/auth/access_service.py index 31544b6..57f5043 100644 --- a/app/services/auth/access_service.py +++ b/app/services/auth/access_service.py @@ -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] \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/services/auth/event_service.py b/app/services/auth/event_service.py index 3bb3614..910dcb1 100644 --- a/app/services/auth/event_service.py +++ b/app/services/auth/event_service.py @@ -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) \ No newline at end of file diff --git a/app/services/auth/module_environment_service.py b/app/services/auth/module_environment_service.py new file mode 100644 index 0000000..4832bd8 --- /dev/null +++ b/app/services/auth/module_environment_service.py @@ -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() \ No newline at end of file diff --git a/app/services/auth/module_permission_service.py b/app/services/auth/module_permission_service.py index cdf6966..98223f5 100644 --- a/app/services/auth/module_permission_service.py +++ b/app/services/auth/module_permission_service.py @@ -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() \ No newline at end of file diff --git a/app/services/auth/module_service.py b/app/services/auth/module_service.py new file mode 100644 index 0000000..2d1550e --- /dev/null +++ b/app/services/auth/module_service.py @@ -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() \ No newline at end of file diff --git a/app/services/auth/role_service.py b/app/services/auth/role_service.py index 4b236fd..123c3b8 100644 --- a/app/services/auth/role_service.py +++ b/app/services/auth/role_service.py @@ -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() diff --git a/app/services/auth/sso_service.py b/app/services/auth/sso_service.py index 32764ce..69f2bdf 100644 --- a/app/services/auth/sso_service.py +++ b/app/services/auth/sso_service.py @@ -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}×tamp={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 } - } + } \ No newline at end of file diff --git a/app/services/auth/tenant_module_service.py b/app/services/auth/tenant_module_service.py new file mode 100644 index 0000000..2463eb3 --- /dev/null +++ b/app/services/auth/tenant_module_service.py @@ -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() \ No newline at end of file diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py index fa217a6..efdea34 100644 --- a/app/services/auth/tenant_service.py +++ b/app/services/auth/tenant_service.py @@ -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"} diff --git a/app/services/auth/trust_service.py b/app/services/auth/trust_service.py index 5656a2d..b6fa1f4 100644 --- a/app/services/auth/trust_service.py +++ b/app/services/auth/trust_service.py @@ -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" - ) + ) \ No newline at end of file diff --git a/app/services/auth/user_service.py b/app/services/auth/user_service.py index 51de4be..4dd68be 100644 --- a/app/services/auth/user_service.py +++ b/app/services/auth/user_service.py @@ -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, - ) \ No newline at end of file + ) + + @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 \ No newline at end of file diff --git a/app/services/theme/color_palette_service.py b/app/services/theme/color_palette_service.py index b7c818c..5b88bd7 100644 --- a/app/services/theme/color_palette_service.py +++ b/app/services/theme/color_palette_service.py @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index afbeb46..65a3ba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +email-validator>=2.1.0 +redis==7.1.0 \ No newline at end of file diff --git a/scripts/fix_migration.py b/scripts/fix_migration.py deleted file mode 100644 index 5d862d6..0000000 --- a/scripts/fix_migration.py +++ /dev/null @@ -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() diff --git a/scripts/seed_superadmin.py b/scripts/seed_superadmin.py index 63234cf..9022ef2 100644 --- a/scripts/seed_superadmin.py +++ b/scripts/seed_superadmin.py @@ -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