feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.
This commit is contained in:
+4
-8
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6382
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
+5
-9
@@ -1,5 +1,5 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=Fulfillment And Logistics
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=8000
|
||||
APP_ENV=local
|
||||
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.104.95:5432/saas_local
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6381
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
+4
-8
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6383
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""split_module_access_table
|
||||
|
||||
Revision ID: 63b95ea5b967
|
||||
Revises: 88cfc7dee19d
|
||||
Create Date: 2026-01-23 11:07:09.706000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '63b95ea5b967'
|
||||
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_code', sa.String(), nullable=False),
|
||||
sa.Column('category', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('sync_checksum', sa.String(), nullable=True),
|
||||
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('module_id', 'access_code', name='uq_module_access_code')
|
||||
)
|
||||
op.create_index(op.f('ix_module_accesses_access_code'), 'module_accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_category'), 'module_accesses', ['category'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_id'), 'module_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_module_id'), 'module_accesses', ['module_id'], unique=False)
|
||||
op.drop_index(op.f('ix_access_code_module'), table_name='accesses', postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_access_code_saas'), table_name='accesses', postgresql_where='(module_id IS NULL)')
|
||||
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
|
||||
op.drop_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
op.add_column('accesses', sa.Column('module_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
op.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_code_saas'), 'accesses', ['access_code'], unique=True, postgresql_where='(module_id IS NULL)')
|
||||
op.create_index(op.f('ix_access_code_module'), 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_module_accesses_module_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_category'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
|
||||
op.drop_table('module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""scoped_access_code_uniqueness
|
||||
|
||||
Revision ID: 88cfc7dee19d
|
||||
Revises: 03a1b1f05e99
|
||||
Create Date: 2026-01-22 19:44:27.431776
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '88cfc7dee19d'
|
||||
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
|
||||
op.drop_index('ix_access_code_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add_parent_id_to_module_access
|
||||
|
||||
Revision ID: 91cc93992a91
|
||||
Revises: 63b95ea5b967
|
||||
Create Date: 2026-01-23 11:16:02.998798
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '91cc93992a91'
|
||||
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('role_module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access')
|
||||
)
|
||||
op.create_index(op.f('ix_role_module_accesses_module_access_id'), 'role_module_accesses', ['module_access_id'], unique=False)
|
||||
op.create_index(op.f('ix_role_module_accesses_role_id'), 'role_module_accesses', ['role_id'], unique=False)
|
||||
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
|
||||
op.create_index(op.f('ix_module_accesses_parent_id'), 'module_accesses', ['parent_id'], unique=False)
|
||||
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'module_accesses', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
||||
op.drop_column('module_accesses', 'parent_id')
|
||||
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
|
||||
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
|
||||
op.drop_table('role_module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_provisioning_endpoint_to_module_environment
|
||||
|
||||
Revision ID: c37ba6143f83
|
||||
Revises: 91cc93992a91
|
||||
Create Date: 2026-01-24 10:04:06.239899
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c37ba6143f83'
|
||||
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('module_environments', 'provisioning_endpoint')
|
||||
# ### end Alembic commands ###
|
||||
+62
-3
@@ -111,10 +111,19 @@ def create_app() -> FastAPI:
|
||||
from app.routes.theme.color_palette import router as palette_router
|
||||
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
||||
|
||||
# === Admin Routes ===
|
||||
from app.routes.admin.modules import router as admin_modules_router
|
||||
from app.routes.admin.module_environments import router as admin_module_env_router
|
||||
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
|
||||
|
||||
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
||||
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||
|
||||
|
||||
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
async def startup_event():
|
||||
logger.info("Testing database connection...")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
@@ -125,9 +134,60 @@ def create_app() -> FastAPI:
|
||||
logger.error(f"Database connection failed: {e}")
|
||||
raise
|
||||
|
||||
from app.core.redis import redis_client
|
||||
await redis_client.connect()
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
from app.services.auth.event_service import EventService
|
||||
from app.config.database import SessionLocal
|
||||
from app.core.redis import redis_client, sync_redis_client
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
async def redis_event_consumer():
|
||||
logger.info("Redis Event Consumer STARTED")
|
||||
while True:
|
||||
try:
|
||||
if not redis_client.client:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||
|
||||
if result:
|
||||
_, event_id = result
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def fallback_poller():
|
||||
logger.info("Fallback Event Poller STARTED")
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_outbox, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback poller error: {e}")
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
asyncio.create_task(redis_event_consumer())
|
||||
asyncio.create_task(fallback_poller())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Shutting down...")
|
||||
from app.core.redis import redis_client
|
||||
await redis_client.close()
|
||||
|
||||
|
||||
# === Basic Routes ===
|
||||
@app.get("/", tags=["Root"])
|
||||
@@ -169,5 +229,4 @@ def create_app() -> FastAPI:
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
app = create_app()
|
||||
+10
-23
@@ -1,7 +1,3 @@
|
||||
"""
|
||||
Security utilities for authentication and authorization.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import bcrypt
|
||||
@@ -10,10 +6,8 @@ from fastapi import HTTPException, status
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.config.settings import settings
|
||||
|
||||
|
||||
class SecurityUtils:
|
||||
"""Security utility class for authentication and authorization."""
|
||||
|
||||
@@ -129,14 +123,20 @@ class SecurityUtils:
|
||||
if len(password) < 8:
|
||||
return False
|
||||
|
||||
# Check for at least one uppercase letter
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False
|
||||
|
||||
# Check for at least one lowercase letter
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False
|
||||
|
||||
if not re.search(r'\d', password):
|
||||
return False
|
||||
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
|
||||
"""Generate short-lived module-scoped JWT (15 min default)."""
|
||||
@@ -145,31 +145,20 @@ class SecurityUtils:
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"type": "module_access",
|
||||
"aud": str(module_id), # Audience enforcement
|
||||
"aud": str(module_id),
|
||||
"iat": datetime.now(timezone.utc).timestamp()
|
||||
})
|
||||
|
||||
# Enterprise Contract: Identity tokens must be signed with RS256
|
||||
if not settings.SAAS_PRIVATE_KEY:
|
||||
# Dev fallback or error? Plan says "Private key loaded from...".
|
||||
# We must strictly enforce RS256. If no key, we can't sign.
|
||||
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
|
||||
|
||||
# NOTE: Module access tokens are verified ONLY by modules.
|
||||
# SaaS never verifies module-scoped tokens after issuance.
|
||||
# Modules MUST enforce aud == module_id.
|
||||
# Failure to do so is a security violation.
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.SAAS_PRIVATE_KEY,
|
||||
algorithm="RS256",
|
||||
headers={"kid": settings.SAAS_KEY_ID} # Key Rotation Support
|
||||
headers={"kid": settings.SAAS_KEY_ID}
|
||||
)
|
||||
|
||||
# REMOVED: verify_module_token
|
||||
# SaaS must never verify module tokens. This is the responsibility of the module.
|
||||
# We strictly enforce RS256 for identity, and SaaS only holds the private key.
|
||||
@staticmethod
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email format."""
|
||||
@@ -184,6 +173,4 @@ class SecurityUtils:
|
||||
|
||||
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
|
||||
|
||||
|
||||
# Create instance for easy importing
|
||||
security = SecurityUtils()
|
||||
+20
-52
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_service import ModuleService
|
||||
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
|
||||
from app.models.auth.module_model import Module
|
||||
|
||||
class ModuleController:
|
||||
@staticmethod
|
||||
def list_modules(db: Session) -> List[ModuleResponse]:
|
||||
modules = ModuleService.list_modules(db)
|
||||
return [ModuleResponse.model_validate(m) for m in modules]
|
||||
|
||||
@staticmethod
|
||||
def create_module(db: Session, module_data: ModuleCreate) -> ModuleResponse:
|
||||
module = ModuleService.create_module(db, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def get_available_modules(db: Session, current_user) -> List[ModuleResponse]:
|
||||
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||
results = ModuleService.get_available_modules(db, current_user.tenant_id)
|
||||
|
||||
response = []
|
||||
for item in results:
|
||||
mod = item["module"]
|
||||
response.append(ModuleAvailableResponse(
|
||||
module_id=mod.module_id,
|
||||
module_name=mod.module_name,
|
||||
description=mod.description,
|
||||
icon_url=mod.icon_url,
|
||||
display_order=mod.display_order or 0,
|
||||
is_active=item["is_active"]
|
||||
))
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_module(db: Session, module_id: str) -> ModuleResponse:
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> ModuleResponse:
|
||||
module = ModuleService.update_module(db, module_id, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def delete_module(db: Session, module_id: str):
|
||||
ModuleService.delete_module(db, module_id)
|
||||
return {"message": "Module deleted successfully"}
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_environment_service import ModuleEnvironmentService
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
|
||||
|
||||
class ModuleEnvironmentController:
|
||||
@staticmethod
|
||||
def list_environments(db: Session, module_id: str) -> List[EnvironmentResponse]:
|
||||
environments = ModuleEnvironmentService.list_environments(db, module_id)
|
||||
return [EnvironmentResponse.model_validate(env) for env in environments]
|
||||
|
||||
@staticmethod
|
||||
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.create_environment(db, module_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.update_environment(db, module_id, env_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def set_default_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.set_default_environment(db, module_id, env_id)
|
||||
return {"message": "Environment set as default"}
|
||||
|
||||
@staticmethod
|
||||
def delete_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.delete_environment(db, module_id, env_id)
|
||||
return {"message": "Environment deleted successfully"}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Request, HTTPException
|
||||
from typing import Optional, Dict, Any
|
||||
import uuid
|
||||
from app.services.auth.sso_service import SSOService
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
class SSOController:
|
||||
@staticmethod
|
||||
def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User):
|
||||
return SSOService.generate_signed_payload(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
module_id=request.module_id,
|
||||
tenant_id=current_user.tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def exchange_grant(
|
||||
db: Session,
|
||||
payload: SSOExchangeRequest,
|
||||
x_module_signature: Optional[str] = None,
|
||||
x_module_key: Optional[str] = None
|
||||
):
|
||||
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.slug == payload.environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
headers = {}
|
||||
if x_module_signature:
|
||||
headers["X-Module-Signature"] = x_module_signature
|
||||
if x_module_key:
|
||||
headers["X-Module-Key"] = x_module_key
|
||||
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
request_body=""
|
||||
)
|
||||
|
||||
return SSOService.exchange_grant(
|
||||
db=db,
|
||||
grant_code=payload.grant_code,
|
||||
module_id=payload.module_id,
|
||||
environment_slug=payload.environment_slug
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.models.auth.module_model import Module
|
||||
import uuid
|
||||
from app.services.auth.tenant_module_service import TenantModuleService
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
|
||||
|
||||
class TenantModuleController:
|
||||
@staticmethod
|
||||
def list_tenant_modules(db: Session, tenant_id: str) -> List[TenantModuleResponse]:
|
||||
results = TenantModuleService.list_tenant_modules(db, tenant_id)
|
||||
|
||||
response_list = []
|
||||
for tm, mod in results:
|
||||
response_list.append(TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=mod.module_name,
|
||||
module_icon_url=mod.icon_url,
|
||||
assigned_environment_slug=tm.assigned_environment_slug or "prod",
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
))
|
||||
return response_list
|
||||
|
||||
@staticmethod
|
||||
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.assign_module(db, tenant_id, assignment_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.update_assignment(db, tenant_id, tenant_module_id, update_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
|
||||
TenantModuleService.remove_assignment(db, tenant_id, tenant_module_id)
|
||||
return {"message": "Module removed from tenant successfully"}
|
||||
@@ -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()
|
||||
@@ -2,4 +2,6 @@ from app.models.auth.access_model import Access
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
@@ -12,12 +12,8 @@ class Access(Base):
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
|
||||
|
||||
|
||||
# Module Integration
|
||||
scope = Column(String, default="saas", nullable=False, index=True) # "saas" or "module"
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=True, index=True)
|
||||
|
||||
# For sync tracking
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -25,7 +21,6 @@ class Access(Base):
|
||||
|
||||
parent = relationship("Access", remote_side=[id], backref="children")
|
||||
role_accesses = relationship("RoleAccess", back_populates="access")
|
||||
module = relationship("Module")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Access {self.access_code}>"
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class ModuleAccess(Base):
|
||||
__tablename__ = "module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
access_code = Column(String, nullable=False, index=True)
|
||||
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
|
||||
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="module_accesses")
|
||||
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
|
||||
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
|
||||
@@ -9,20 +9,18 @@ class ModuleEnvironment(Base):
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
slug = Column(String, nullable=False, index=True) # custom name: "prod", "staging", "client-a-prod"
|
||||
slug = Column(String, nullable=False, index=True)
|
||||
|
||||
# Frontend configuration
|
||||
frontend_base_url = Column(String, nullable=False) # https://inventory.example.com
|
||||
sso_entry_path = Column(String, default="/sso/start") # Path to handle SSO grant
|
||||
frontend_base_url = Column(String, nullable=False)
|
||||
sso_entry_path = Column(String, default="/sso/start")
|
||||
|
||||
# Backend configuration
|
||||
backend_base_url = Column(String, nullable=False) # https://api.inventory.example.com
|
||||
backend_base_url = Column(String, nullable=False)
|
||||
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
|
||||
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
|
||||
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
||||
|
||||
# Trust configuration
|
||||
trust_type = Column(String, nullable=False) # hmac, mtls, static_key
|
||||
trust_credentials = Column(JSON, nullable=False) # {hmac_secret, cert_path, etc.}
|
||||
trust_type = Column(String, nullable=False)
|
||||
trust_credentials = Column(JSON, nullable=False)
|
||||
|
||||
is_default = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -36,4 +34,4 @@ class ModuleEnvironment(Base):
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
@@ -8,19 +8,18 @@ class Module(Base):
|
||||
__tablename__ = "modules"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(String, unique=True, nullable=False, index=True) # e.g., "inventory"
|
||||
module_name = Column(String, nullable=False) # e.g., "Inventory Management"
|
||||
module_id = Column(String, unique=True, nullable=False, index=True)
|
||||
module_name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
status = Column(String, default="active") # active, disabled
|
||||
status = Column(String, default="active")
|
||||
icon_url = Column(String, nullable=True)
|
||||
display_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
|
||||
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
|
||||
permissions = relationship("Access", back_populates="module")
|
||||
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Module {self.module_id}>"
|
||||
return f"<Module {self.module_id}>"
|
||||
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
@@ -20,12 +19,14 @@ class Role(Base):
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="roles")
|
||||
users = relationship("User", back_populates="role")
|
||||
role_accesses = relationship(
|
||||
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
role_module_accesses = relationship(
|
||||
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.role_name}>"
|
||||
return f"<Role {self.role_name}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class RoleModuleAccess(Base):
|
||||
__tablename__ = "role_module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
|
||||
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_module_accesses")
|
||||
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
|
||||
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
@@ -20,8 +19,9 @@ class Tenant(Base):
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="tenant")
|
||||
roles = relationship("Role", back_populates="tenant")
|
||||
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
|
||||
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
@@ -11,25 +11,18 @@ class TenantModule(Base):
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
|
||||
# Environment routing
|
||||
assigned_environment_slug = Column(String, nullable=True) # e.g. "prod" or "staging"
|
||||
assigned_environment_slug = Column(String, nullable=True)
|
||||
|
||||
# Access control
|
||||
is_active = Column(Boolean, default=True)
|
||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
deactivated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Business metadata
|
||||
plan_tier = Column(String, nullable=True) # basic, premium, enterprise
|
||||
module_config = Column(JSON, nullable=True) # custom settings per tenant-module
|
||||
plan_tier = Column(String, nullable=True)
|
||||
module_config = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# tenant relationship is backref'd from Tenant model or created here if Tenant model is not loaded yet
|
||||
# But usually we define it in one place.
|
||||
# The implementation plan showed: tenant = relationship("Tenant", backref="tenant_modules")
|
||||
# Let's align with that.
|
||||
tenant = relationship("Tenant", backref="tenant_modules")
|
||||
tenant = relationship("Tenant", back_populates="tenant_modules")
|
||||
module = relationship("Module", back_populates="tenant_modules")
|
||||
|
||||
__table_args__ = (
|
||||
@@ -37,4 +30,4 @@ class TenantModule(Base):
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
|
||||
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
|
||||
@@ -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)
|
||||
@@ -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)}")
|
||||
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, require_access, User
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
|
||||
from app.controllers.auth.tenant_module_controller import TenantModuleController
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse])
|
||||
def list_tenant_modules(
|
||||
tenant_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return TenantModuleController.list_tenant_modules(db, tenant_id)
|
||||
|
||||
@router.post("/{tenant_id}/modules", response_model=TenantModuleResponse)
|
||||
def assign_module_to_tenant(
|
||||
tenant_id: str,
|
||||
assignment_data: TenantModuleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return TenantModuleController.assign_module(db, tenant_id, assignment_data)
|
||||
|
||||
@router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
|
||||
def update_tenant_module(
|
||||
tenant_id: str,
|
||||
tenant_module_id: str,
|
||||
update_data: TenantModuleUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data)
|
||||
|
||||
@router.delete("/{tenant_id}/modules/{tenant_module_id}")
|
||||
def remove_module_from_tenant(
|
||||
tenant_id: str,
|
||||
tenant_module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id)
|
||||
@@ -4,21 +4,12 @@ from typing import List
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, User
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from pydantic import BaseModel
|
||||
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||
from app.controllers.auth.module_controller import ModuleController
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ModuleResponse(BaseModel):
|
||||
module_id: str
|
||||
module_name: str
|
||||
description: str | None
|
||||
icon_url: str | None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
|
||||
@router.get("/available", response_model=List[ModuleResponse])
|
||||
@router.get("/available", response_model=List[ModuleAvailableResponse])
|
||||
def get_available_modules(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -27,37 +18,4 @@ def get_available_modules(
|
||||
List modules available to the current user (based on tenant subscription).
|
||||
For platform admin (tenant_id=None), lists all active modules.
|
||||
"""
|
||||
if current_user.tenant_id:
|
||||
# Tenant user: join with TenantModule
|
||||
results = db.query(Module, TenantModule.is_active).join(
|
||||
TenantModule,
|
||||
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == current_user.tenant_id)
|
||||
).filter(
|
||||
Module.status == "active",
|
||||
TenantModule.is_active == True
|
||||
).order_by(Module.display_order).all()
|
||||
|
||||
modules = []
|
||||
for mod, is_active in results:
|
||||
modules.append(ModuleResponse(
|
||||
module_id=mod.module_id,
|
||||
module_name=mod.module_name,
|
||||
description=mod.description,
|
||||
icon_url=mod.icon_url,
|
||||
display_order=mod.display_order or 0,
|
||||
is_active=is_active
|
||||
))
|
||||
return modules
|
||||
else:
|
||||
# Platform admin: list all active modules
|
||||
modules = db.query(Module).filter(Module.status == "active").order_by(Module.display_order).all()
|
||||
return [
|
||||
ModuleResponse(
|
||||
module_id=m.module_id,
|
||||
module_name=m.module_name,
|
||||
description=m.description,
|
||||
icon_url=m.icon_url,
|
||||
display_order=m.display_order or 0,
|
||||
is_active=True
|
||||
) for m in modules
|
||||
]
|
||||
return ModuleController.get_available_modules(db, current_user)
|
||||
+10
-59
@@ -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
|
||||
)
|
||||
@@ -11,7 +11,9 @@ class AccessBase(BaseModel):
|
||||
class AccessResponse(AccessBase):
|
||||
id: uuid.UUID
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
module_id: Optional[uuid.UUID] = None
|
||||
module_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
class EnvironmentCreate(BaseModel):
|
||||
slug: str
|
||||
frontend_base_url: str
|
||||
backend_base_url: str
|
||||
sso_entry_path: str = "/sso/callback"
|
||||
permission_sync_endpoint: str = "/internal/permissions"
|
||||
sso_exchange_endpoint: Optional[str] = "/internal/sso/exchange"
|
||||
provisioning_endpoint: str = "/internal/tenants/provision"
|
||||
trust_type: str = "hmac"
|
||||
trust_credentials: Dict[str, Any]
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
class EnvironmentUpdate(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
frontend_base_url: Optional[str] = None
|
||||
backend_base_url: Optional[str] = None
|
||||
sso_entry_path: Optional[str] = None
|
||||
permission_sync_endpoint: Optional[str] = None
|
||||
sso_exchange_endpoint: Optional[str] = None
|
||||
provisioning_endpoint: Optional[str] = None
|
||||
trust_type: Optional[str] = None
|
||||
trust_credentials: Optional[Dict[str, Any]] = None
|
||||
is_default: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class EnvironmentResponse(BaseModel):
|
||||
id: UUID
|
||||
module_id: UUID
|
||||
slug: str
|
||||
frontend_base_url: str
|
||||
backend_base_url: str
|
||||
sso_entry_path: str
|
||||
sso_entry_path: str
|
||||
permission_sync_endpoint: str
|
||||
sso_exchange_endpoint: Optional[str]
|
||||
provisioning_endpoint: Optional[str] = "/internal/tenants/provision"
|
||||
trust_type: str
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,19 +1,88 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.auth.access_model import Access
|
||||
from typing import List
|
||||
from typing import List, Any
|
||||
from app.core.redis import sync_redis_client
|
||||
import json
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.module_model import Module
|
||||
from sqlalchemy.orm import joinedload
|
||||
from datetime import datetime
|
||||
|
||||
class AccessService:
|
||||
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[Access]:
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[any]:
|
||||
|
||||
cache_key = f"saas:access:v2:all:{category if category else 'full'}"
|
||||
cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None
|
||||
|
||||
if cached_data:
|
||||
try:
|
||||
data_list = json.loads(cached_data)
|
||||
class SimpleAccess:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
deserialized_list = []
|
||||
for item in data_list:
|
||||
if "created_at" in item and item["created_at"]:
|
||||
try:
|
||||
item["created_at"] = datetime.fromisoformat(item["created_at"])
|
||||
except ValueError:
|
||||
item["created_at"] = None
|
||||
deserialized_list.append(SimpleAccess(**item))
|
||||
|
||||
return deserialized_list
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
query = db.query(Access)
|
||||
|
||||
if category:
|
||||
query = query.filter(Access.category == category)
|
||||
|
||||
return query.all()
|
||||
saas_accesses = query.all()
|
||||
for access in saas_accesses:
|
||||
access.module_name = "SaaS (Internal)"
|
||||
|
||||
module_query = db.query(ModuleAccess).options(joinedload(ModuleAccess.module))
|
||||
|
||||
if category:
|
||||
module_query = module_query.filter(ModuleAccess.category == category)
|
||||
|
||||
module_accesses = module_query.all()
|
||||
|
||||
for ma in module_accesses:
|
||||
if ma.module:
|
||||
ma.module_name = ma.module.module_name
|
||||
|
||||
result = saas_accesses + module_accesses
|
||||
|
||||
try:
|
||||
if sync_redis_client.client:
|
||||
serialized = []
|
||||
for item in result:
|
||||
serialized.append({
|
||||
"id": str(item.id),
|
||||
"access_code": item.access_code,
|
||||
"name": item.name,
|
||||
"category": item.category,
|
||||
"parent_id": str(item.parent_id) if item.parent_id else None,
|
||||
"module_name": getattr(item, "module_name", None),
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
})
|
||||
|
||||
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600) # 1 hour cache
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_access_categories(db: Session) -> List[str]:
|
||||
categories = db.query(Access.category).distinct().all()
|
||||
return [cat[0] for cat in categories]
|
||||
module_categories = db.query(ModuleAccess.category).distinct().all()
|
||||
|
||||
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
|
||||
return list(all_cats)
|
||||
@@ -6,12 +6,14 @@ from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.system.event_log_model import EventLog, EventStatus
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,10 +29,9 @@ class EventService:
|
||||
Emits an event by writing it to the Outbox (event_logs).
|
||||
Scopes delivery to relevant modules based on tenant_id.
|
||||
"""
|
||||
event_id = str(uuid.uuid4()) # Idempotency Key
|
||||
event_id = str(uuid.uuid4())
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Enforce Idempotency Contract: payload must include event_id
|
||||
if "event_id" not in payload:
|
||||
payload["event_id"] = event_id
|
||||
|
||||
@@ -41,24 +42,39 @@ class EventService:
|
||||
"data": payload
|
||||
}
|
||||
|
||||
# Scope: Find targets
|
||||
targets = []
|
||||
if tenant_id:
|
||||
# Send to modules active for this tenant
|
||||
targets: List[ModuleEnvironment] = []
|
||||
|
||||
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||
|
||||
targets_list = payload.get("targets")
|
||||
|
||||
if targets_list and isinstance(targets_list, list):
|
||||
target_configs = targets_list
|
||||
for target in target_configs:
|
||||
module_id = target.get("module_id")
|
||||
env_slug = target.get("environment_slug")
|
||||
|
||||
if module_id and env_slug:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module_id,
|
||||
ModuleEnvironment.slug == env_slug
|
||||
).first()
|
||||
if env:
|
||||
targets.append(env)
|
||||
|
||||
elif tenant_id:
|
||||
tenant_modules = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
|
||||
for tm in tenant_modules:
|
||||
# Resolve env
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod") # fallback logic could be better
|
||||
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod")
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
# Try default
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.is_default == True
|
||||
@@ -66,16 +82,22 @@ class EventService:
|
||||
|
||||
if env:
|
||||
targets.append(env)
|
||||
else:
|
||||
# System-wide event? Or broadcast?
|
||||
# Plan says "No broadcasting". But if we update a global setting?
|
||||
# For now, we assume user/tenant scope. If no tenant, we might log warning or skip.
|
||||
logger.warning("Event emitted without tenant_id - skipping delivery scoping")
|
||||
|
||||
if not targets:
|
||||
logger.warning(f"Event {event_type} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
|
||||
return
|
||||
|
||||
# Write to Outbox
|
||||
for env in targets:
|
||||
target_url = f"{env.backend_base_url}/internal/events"
|
||||
base = env.backend_base_url.rstrip('/')
|
||||
|
||||
if event_type == "TENANT_PROVISION_REQUESTED" and env.provisioning_endpoint:
|
||||
endpoint = env.provisioning_endpoint.lstrip('/')
|
||||
logger.info(f"Trace: base='{base}', endpoint='{endpoint}'")
|
||||
target_url = f"{base}/{endpoint}"
|
||||
logger.info(f"Trace: Calculated target_url='{target_url}'")
|
||||
else:
|
||||
logger.info(f"Using default event stream for env '{env.slug}'. ProvEndpoint: '{env.provisioning_endpoint}'")
|
||||
target_url = f"{base}/api/internal/events"
|
||||
|
||||
log = EventLog(
|
||||
event_id=uuid.UUID(event_id),
|
||||
@@ -84,14 +106,86 @@ class EventService:
|
||||
target_module_id=env.module_id,
|
||||
target_environment_slug=env.slug,
|
||||
target_url=target_url,
|
||||
status=EventStatus.PENDING
|
||||
status=EventStatus.PENDING,
|
||||
next_retry_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
# IMPORTANT: emit_event must be called within a transaction
|
||||
# that is committed by the caller. db.flush details the insert
|
||||
# so it's ready for commit.
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
sync_redis_client.rpush("saas:events:queue", event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to push event to Redis queue: {e}")
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(db: Session, event_id: str):
|
||||
"""
|
||||
Process all pending EventLogs associated with the given logical event_id.
|
||||
"""
|
||||
logs = db.query(EventLog).filter(
|
||||
EventLog.event_id == uuid.UUID(event_id),
|
||||
EventLog.status == EventStatus.PENDING
|
||||
).all()
|
||||
|
||||
if not logs:
|
||||
return 0
|
||||
|
||||
processed_count = 0
|
||||
for log in logs:
|
||||
try:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
log.status = EventStatus.FAILED
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
|
||||
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None
|
||||
processed_count += 1
|
||||
else:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
db.commit()
|
||||
|
||||
return processed_count
|
||||
|
||||
@staticmethod
|
||||
def process_outbox(db: Session, batch_size: int = 50):
|
||||
@@ -105,9 +199,11 @@ class EventService:
|
||||
EventLog.next_retry_at <= now
|
||||
).limit(batch_size).all()
|
||||
|
||||
if logs:
|
||||
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
|
||||
|
||||
for log in logs:
|
||||
try:
|
||||
# 1. Resolve Credentials for Signing
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
@@ -118,50 +214,37 @@ class EventService:
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
# 2. Sign Payload
|
||||
# We need to construct the request to sign it
|
||||
# Method POST, Path /internal/events (derived from target_url but we should be consistent)
|
||||
# But target_url might be full "http://.../internal/events"
|
||||
# We need relative path for signature if module expects it.
|
||||
# Standard convention: path is "/internal/events"
|
||||
|
||||
path = "/internal/events"
|
||||
# Note: if target_url has different path, signature validation will fail.
|
||||
# We assume standard convention or parse from target_url.
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
secret = env.trust_credentials.get("hmac_secret")
|
||||
|
||||
signature = TrustService.sign_outbound_payload(
|
||||
environment=env,
|
||||
method="POST",
|
||||
path=path,
|
||||
payload_json=payload_json,
|
||||
timestamp=timestamp
|
||||
)
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Timestamp": timestamp,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
# 3. Send
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
|
||||
# 4. Handle Result
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None # Clear errors if any
|
||||
log.error_log = None
|
||||
else:
|
||||
# Retry logic
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400) # Cap at 24h
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
|
||||
if log.retry_count > 10: # Max retries
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
@@ -170,5 +253,6 @@ class EventService:
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
# Commit processing state
|
||||
db.commit()
|
||||
|
||||
return len(logs)
|
||||
@@ -0,0 +1,111 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
from typing import List
|
||||
import uuid
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate
|
||||
|
||||
class ModuleEnvironmentService:
|
||||
@staticmethod
|
||||
def list_environments(db: Session, module_id: str) -> List[ModuleEnvironment]:
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
return db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id
|
||||
).order_by(ModuleEnvironment.is_default.desc(), ModuleEnvironment.slug).all()
|
||||
|
||||
@staticmethod
|
||||
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> ModuleEnvironment:
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
if env_data.is_default:
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
try:
|
||||
environment = ModuleEnvironment(
|
||||
module_id=module.id,
|
||||
slug=env_data.slug,
|
||||
frontend_base_url=env_data.frontend_base_url,
|
||||
backend_base_url=env_data.backend_base_url,
|
||||
sso_entry_path=env_data.sso_entry_path,
|
||||
permission_sync_endpoint=env_data.permission_sync_endpoint,
|
||||
sso_exchange_endpoint=env_data.sso_exchange_endpoint,
|
||||
trust_type=env_data.trust_type,
|
||||
trust_credentials=env_data.trust_credentials,
|
||||
is_default=env_data.is_default,
|
||||
is_active=env_data.is_active
|
||||
)
|
||||
db.add(environment)
|
||||
db.commit()
|
||||
db.refresh(environment)
|
||||
return environment
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Environment slug already exists for this module")
|
||||
|
||||
@staticmethod
|
||||
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> ModuleEnvironment:
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
if env_data.is_default and env_data.is_default != environment.is_default:
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == environment.module_id,
|
||||
ModuleEnvironment.id != environment.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
update_data = env_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(environment, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(environment)
|
||||
return environment
|
||||
|
||||
@staticmethod
|
||||
def set_default_environment(db: Session, module_id: str, env_id: str):
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == environment.module_id
|
||||
).update({"is_default": False})
|
||||
|
||||
environment.is_default = True
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete_environment(db: Session, module_id: str, env_id: str):
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
if environment.is_default:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete default environment. Set another environment as default first.")
|
||||
|
||||
db.delete(environment)
|
||||
db.commit()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -3,11 +3,18 @@ from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status, BackgroundTasks
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
||||
from app.config.security import security
|
||||
from app.services.auth.event_service import EventService
|
||||
import logging
|
||||
import json
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserService:
|
||||
|
||||
@@ -37,26 +44,35 @@ class UserService:
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.flush() # Get ID but don't commit yet
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
# Emit event (adds to session/flush)
|
||||
targets = []
|
||||
if user.role_id:
|
||||
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
payload = {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone_number": user.phone_number,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status,
|
||||
"targets": targets
|
||||
}
|
||||
|
||||
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="user.created",
|
||||
payload={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status
|
||||
},
|
||||
event_type="USER_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.commit() # Atomic commit of user + event
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@@ -82,6 +98,11 @@ class UserService:
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
|
||||
old_role_id = user.role_id
|
||||
old_targets = []
|
||||
if old_role_id:
|
||||
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
|
||||
|
||||
update_dict = user_data.model_dump(exclude_unset=True)
|
||||
if tenant_id:
|
||||
@@ -109,29 +130,86 @@ class UserService:
|
||||
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
new_targets = []
|
||||
if user.role_id:
|
||||
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
# Emit event
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="user.updated",
|
||||
payload={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status
|
||||
},
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
role_changed = (old_role_id != user.role_id)
|
||||
|
||||
base_payload = {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone_number": user.phone_number,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status
|
||||
}
|
||||
|
||||
if role_changed:
|
||||
old_mids = {t["module_id"] for t in old_targets}
|
||||
new_mids = {t["module_id"] for t in new_targets}
|
||||
removed_mids = old_mids - new_mids
|
||||
|
||||
db.commit() # Atomic commit
|
||||
deprovision_targets = [t for t in old_targets if t["module_id"] in removed_mids]
|
||||
|
||||
if deprovision_targets:
|
||||
payload = {**base_payload, "targets": deprovision_targets}
|
||||
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
if new_targets:
|
||||
payload = {**base_payload, "targets": new_targets}
|
||||
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
else:
|
||||
if new_targets:
|
||||
payload = {**base_payload, "targets": new_targets}
|
||||
logger.info(f"USER_UPDATED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_UPDATED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
|
||||
targets = []
|
||||
if user.role_id:
|
||||
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
if targets:
|
||||
payload = {
|
||||
"user_id": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"targets": targets
|
||||
}
|
||||
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully"}
|
||||
@@ -179,4 +257,44 @@ class UserService:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Helper to resolve which modules/envs a role targets.
|
||||
Mirrors logic in RoleService.
|
||||
"""
|
||||
active_modules = (
|
||||
db.query(ModuleAccess.module_id)
|
||||
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == role_id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not active_modules:
|
||||
return []
|
||||
|
||||
module_ids = [m[0] for m in active_modules]
|
||||
|
||||
env_map = {}
|
||||
if tenant_id:
|
||||
tm_assignments = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.module_id.in_(module_ids),
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
for tm in tm_assignments:
|
||||
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||
|
||||
targets = []
|
||||
for mid_uuid in module_ids:
|
||||
mid = str(mid_uuid)
|
||||
env_slug = env_map.get(mid, "prod")
|
||||
targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": env_slug
|
||||
})
|
||||
|
||||
return targets
|
||||
@@ -8,20 +8,12 @@ from app.schemas.theme.color_palette_schema import (
|
||||
ColorPaletteUpdate,
|
||||
)
|
||||
|
||||
|
||||
class PaletteService:
|
||||
@staticmethod
|
||||
def get_all_palettes(
|
||||
db: Session, tenant_id: Optional[UUID] = None
|
||||
) -> List[ColorPalette]:
|
||||
query = db.query(ColorPalette)
|
||||
if tenant_id:
|
||||
query = query.filter(
|
||||
(ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id)
|
||||
)
|
||||
else:
|
||||
pass
|
||||
return query.all()
|
||||
return db.query(ColorPalette).all()
|
||||
|
||||
@staticmethod
|
||||
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
|
||||
@@ -35,21 +27,13 @@ class PaletteService:
|
||||
db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None
|
||||
) -> ColorPalette:
|
||||
if data.is_default:
|
||||
if tenant_id:
|
||||
db.query(ColorPalette).filter(
|
||||
ColorPalette.tenant_id == tenant_id
|
||||
).update({"is_default": False})
|
||||
else:
|
||||
db.query(ColorPalette).filter(ColorPalette.tenant_id == None).update(
|
||||
{"is_default": False}
|
||||
)
|
||||
db.query(ColorPalette).update({"is_default": False})
|
||||
|
||||
db_palette = ColorPalette(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
colors=data.colors.model_dump(),
|
||||
is_default=data.is_default,
|
||||
# tenant_id=tenant_id, # Model does not support tenant_id yet
|
||||
)
|
||||
db.add(db_palette)
|
||||
db.commit()
|
||||
@@ -70,16 +54,9 @@ class PaletteService:
|
||||
palette.colors = data.colors.model_dump()
|
||||
if data.is_default is not None:
|
||||
if data.is_default:
|
||||
tenant_id = palette.tenant_id
|
||||
if tenant_id:
|
||||
db.query(ColorPalette).filter(
|
||||
ColorPalette.tenant_id == tenant_id,
|
||||
ColorPalette.id != palette_id,
|
||||
).update({"is_default": False})
|
||||
else:
|
||||
db.query(ColorPalette).filter(
|
||||
ColorPalette.tenant_id == None, ColorPalette.id != palette_id
|
||||
).update({"is_default": False})
|
||||
db.query(ColorPalette).filter(
|
||||
ColorPalette.id != palette_id
|
||||
).update({"is_default": False})
|
||||
|
||||
palette.is_default = data.is_default
|
||||
|
||||
@@ -93,4 +70,4 @@ class PaletteService:
|
||||
|
||||
db.delete(palette)
|
||||
db.commit()
|
||||
return True
|
||||
return True
|
||||
+2
-1
@@ -7,4 +7,5 @@ alembic==1.17.2
|
||||
pydantic-settings==2.12.0
|
||||
bcrypt>=4.0.1
|
||||
pyjwt>=2.8.0
|
||||
email-validator>=2.1.0
|
||||
email-validator>=2.1.0
|
||||
redis==7.1.0
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user