Merge pull request 'feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.' (#9) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/9
This commit is contained in:
+4
-8
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
|||||||
|
|
||||||
#Database Configuration
|
#Database Configuration
|
||||||
DB_SSL=False
|
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 Configuration
|
||||||
# REDIS_URL=""
|
REDIS_HOST=106.51.105.22
|
||||||
# port=""
|
REDIS_PORT=6382
|
||||||
# host=""
|
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||||
# password=""
|
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
SMTP_HOST=smtp.hostinger.com
|
SMTP_HOST=smtp.hostinger.com
|
||||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
|||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||||
|
|
||||||
# External SaaS Webhook
|
|
||||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
|
||||||
|
|
||||||
# AWS S3 Configuration
|
# AWS S3 Configuration
|
||||||
AWS_ACCESS_KEY_ID=""
|
AWS_ACCESS_KEY_ID=""
|
||||||
AWS_SECRET_ACCESS_KEY=""
|
AWS_SECRET_ACCESS_KEY=""
|
||||||
|
|||||||
+5
-9
@@ -1,5 +1,5 @@
|
|||||||
# Project Configuration
|
# Project Configuration
|
||||||
PROJECT_NAME=Fulfillment And Logistics
|
PROJECT_NAME=SaaS Architecture
|
||||||
VERSION=1.0.0
|
VERSION=1.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
|||||||
|
|
||||||
#Database Configuration
|
#Database Configuration
|
||||||
DB_SSL=False
|
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 Configuration
|
||||||
# REDIS_URL=""
|
REDIS_HOST=106.51.105.22
|
||||||
# port=""
|
REDIS_PORT=6381
|
||||||
# host=""
|
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||||
# password=""
|
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
SMTP_HOST=smtp.hostinger.com
|
SMTP_HOST=smtp.hostinger.com
|
||||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
|||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||||
|
|
||||||
# External SaaS Webhook
|
|
||||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
|
||||||
|
|
||||||
# AWS S3 Configuration
|
# AWS S3 Configuration
|
||||||
AWS_ACCESS_KEY_ID=""
|
AWS_ACCESS_KEY_ID=""
|
||||||
AWS_SECRET_ACCESS_KEY=""
|
AWS_SECRET_ACCESS_KEY=""
|
||||||
|
|||||||
+4
-8
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
|||||||
|
|
||||||
#Database Configuration
|
#Database Configuration
|
||||||
DB_SSL=False
|
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 Configuration
|
||||||
# REDIS_URL=""
|
REDIS_HOST=106.51.105.22
|
||||||
# port=""
|
REDIS_PORT=6383
|
||||||
# host=""
|
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||||
# password=""
|
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
SMTP_HOST=smtp.hostinger.com
|
SMTP_HOST=smtp.hostinger.com
|
||||||
@@ -44,9 +43,6 @@ REFRESH_TOKEN_EXPIRES=864000
|
|||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||||
|
|
||||||
# External SaaS Webhook
|
|
||||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
|
||||||
|
|
||||||
# AWS S3 Configuration
|
# AWS S3 Configuration
|
||||||
AWS_ACCESS_KEY_ID=""
|
AWS_ACCESS_KEY_ID=""
|
||||||
AWS_SECRET_ACCESS_KEY=""
|
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 ###
|
||||||
+61
-2
@@ -111,10 +111,19 @@ def create_app() -> FastAPI:
|
|||||||
from app.routes.theme.color_palette import router as palette_router
|
from app.routes.theme.color_palette import router as palette_router
|
||||||
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
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) ===
|
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup_event():
|
async def startup_event():
|
||||||
logger.info("Testing database connection...")
|
logger.info("Testing database connection...")
|
||||||
try:
|
try:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
@@ -125,10 +134,61 @@ def create_app() -> FastAPI:
|
|||||||
logger.error(f"Database connection failed: {e}")
|
logger.error(f"Database connection failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
from app.core.redis import redis_client
|
||||||
|
await redis_client.connect()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
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 ===
|
# === Basic Routes ===
|
||||||
@app.get("/", tags=["Root"])
|
@app.get("/", tags=["Root"])
|
||||||
def root():
|
def root():
|
||||||
@@ -169,5 +229,4 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
return app
|
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 datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -10,10 +6,8 @@ from fastapi import HTTPException, status
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class SecurityUtils:
|
class SecurityUtils:
|
||||||
"""Security utility class for authentication and authorization."""
|
"""Security utility class for authentication and authorization."""
|
||||||
|
|
||||||
@@ -129,14 +123,20 @@ class SecurityUtils:
|
|||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for at least one uppercase letter
|
|
||||||
if not re.search(r'[A-Z]', password):
|
if not re.search(r'[A-Z]', password):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for at least one lowercase letter
|
|
||||||
if not re.search(r'[a-z]', password):
|
if not re.search(r'[a-z]', password):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if not re.search(r'\d', password):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
|
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)."""
|
"""Generate short-lived module-scoped JWT (15 min default)."""
|
||||||
@@ -145,31 +145,20 @@ class SecurityUtils:
|
|||||||
to_encode.update({
|
to_encode.update({
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "module_access",
|
"type": "module_access",
|
||||||
"aud": str(module_id), # Audience enforcement
|
"aud": str(module_id),
|
||||||
"iat": datetime.now(timezone.utc).timestamp()
|
"iat": datetime.now(timezone.utc).timestamp()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Enterprise Contract: Identity tokens must be signed with RS256
|
|
||||||
if not settings.SAAS_PRIVATE_KEY:
|
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.")
|
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(
|
return jwt.encode(
|
||||||
to_encode,
|
to_encode,
|
||||||
settings.SAAS_PRIVATE_KEY,
|
settings.SAAS_PRIVATE_KEY,
|
||||||
algorithm="RS256",
|
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
|
@staticmethod
|
||||||
def validate_email(email: str) -> bool:
|
def validate_email(email: str) -> bool:
|
||||||
"""Validate email format."""
|
"""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
|
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()
|
security = SecurityUtils()
|
||||||
+19
-51
@@ -2,32 +2,25 @@ from pydantic_settings import BaseSettings
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Load environment variables from .env files
|
|
||||||
app_env = os.getenv("APP_ENV", "local")
|
app_env = os.getenv("APP_ENV", "local")
|
||||||
env_filename = f".env.{app_env}"
|
env_filename = f".env.{app_env}"
|
||||||
|
|
||||||
# Define paths
|
|
||||||
base_path = Path(__file__).resolve().parent.parent.parent
|
base_path = Path(__file__).resolve().parent.parent.parent
|
||||||
backend_path = Path(__file__).resolve().parent.parent
|
backend_path = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Load specific environment file (e.g., .env.development)
|
load_dotenv(dotenv_path=base_path / '.env')
|
||||||
# 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=backend_path / '.env')
|
load_dotenv(dotenv_path=backend_path / '.env')
|
||||||
|
|
||||||
# Override with specific environment config
|
|
||||||
if (base_path / env_filename).exists():
|
if (base_path / env_filename).exists():
|
||||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||||
if (backend_path / env_filename).exists():
|
if (backend_path / env_filename).exists():
|
||||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# Project
|
PROJECT_NAME: str
|
||||||
PROJECT_NAME: str = "SaaS Architecture"
|
VERSION: str
|
||||||
VERSION: str = "1.0.0"
|
|
||||||
|
|
||||||
# FastAPI
|
# FastAPI
|
||||||
PORT: int
|
PORT: int
|
||||||
@@ -38,9 +31,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_URL: str
|
FRONTEND_URL: str
|
||||||
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
|
|
||||||
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
||||||
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
|
|
||||||
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
@@ -52,14 +43,24 @@ class Settings(BaseSettings):
|
|||||||
DB_SSL: bool = False
|
DB_SSL: bool = False
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_HOST: str
|
||||||
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
|
REDIS_PORT: int
|
||||||
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
|
REDIS_PASSWORD: Optional[str]
|
||||||
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
|
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
|
# Email
|
||||||
SMTP_HOST: str
|
SMTP_HOST: str
|
||||||
SMTP_PORT: int = 587
|
SMTP_PORT: int
|
||||||
SMTP_SECURE: bool = True
|
SMTP_SECURE: bool = True
|
||||||
SMTP_USER: str
|
SMTP_USER: str
|
||||||
SMTP_PASSWORD: str
|
SMTP_PASSWORD: str
|
||||||
@@ -78,9 +79,6 @@ class Settings(BaseSettings):
|
|||||||
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
||||||
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
||||||
|
|
||||||
# External SaaS Integration
|
|
||||||
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
|
|
||||||
|
|
||||||
# Module Integration Security (RS256)
|
# Module Integration Security (RS256)
|
||||||
SAAS_PRIVATE_KEY: Optional[str] = None
|
SAAS_PRIVATE_KEY: Optional[str] = None
|
||||||
SAAS_KEY_ID: str = "saas-key-v1"
|
SAAS_KEY_ID: str = "saas-key-v1"
|
||||||
@@ -91,40 +89,10 @@ class Settings(BaseSettings):
|
|||||||
PAYPAL_MODE: str = "sandbox"
|
PAYPAL_MODE: str = "sandbox"
|
||||||
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
|
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
|
||||||
|
|
||||||
# AWS S3 settings
|
REDIS_CHAT_TTL: int = 86400
|
||||||
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
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL: str = "info"
|
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
|
@property
|
||||||
def MAIL_USERNAME(self) -> str:
|
def MAIL_USERNAME(self) -> str:
|
||||||
return self.SMTP_USER
|
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"}
|
||||||
@@ -61,6 +61,18 @@ class RoleController:
|
|||||||
for ra in role.role_accesses
|
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(
|
return RoleWithAccessesResponse(
|
||||||
id=role.id,
|
id=role.id,
|
||||||
role_name=role.role_name,
|
role_name=role.role_name,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,3 +3,5 @@ from app.models.auth.role_model import Role
|
|||||||
from app.models.auth.role_access_model import RoleAccess
|
from app.models.auth.role_access_model import RoleAccess
|
||||||
from app.models.auth.tenant_model import Tenant
|
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
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -13,11 +13,7 @@ class Access(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
|
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)
|
sync_checksum = Column(String, nullable=True)
|
||||||
last_synced_at = Column(DateTime(timezone=True), 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")
|
parent = relationship("Access", remote_side=[id], backref="children")
|
||||||
role_accesses = relationship("RoleAccess", back_populates="access")
|
role_accesses = relationship("RoleAccess", back_populates="access")
|
||||||
module = relationship("Module")
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Access {self.access_code}>"
|
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)
|
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)
|
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)
|
||||||
frontend_base_url = Column(String, nullable=False) # https://inventory.example.com
|
sso_entry_path = Column(String, default="/sso/start")
|
||||||
sso_entry_path = Column(String, default="/sso/start") # Path to handle SSO grant
|
|
||||||
|
|
||||||
# Backend configuration
|
backend_base_url = Column(String, nullable=False)
|
||||||
backend_base_url = Column(String, nullable=False) # https://api.inventory.example.com
|
|
||||||
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
|
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
|
||||||
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
|
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)
|
||||||
trust_type = Column(String, nullable=False) # hmac, mtls, static_key
|
trust_credentials = Column(JSON, nullable=False)
|
||||||
trust_credentials = Column(JSON, nullable=False) # {hmac_secret, cert_path, etc.}
|
|
||||||
|
|
||||||
is_default = Column(Boolean, default=False)
|
is_default = Column(Boolean, default=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|||||||
@@ -8,19 +8,18 @@ class Module(Base):
|
|||||||
__tablename__ = "modules"
|
__tablename__ = "modules"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
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_id = Column(String, unique=True, nullable=False, index=True)
|
||||||
module_name = Column(String, nullable=False) # e.g., "Inventory Management"
|
module_name = Column(String, nullable=False)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
status = Column(String, default="active") # active, disabled
|
status = Column(String, default="active")
|
||||||
icon_url = Column(String, nullable=True)
|
icon_url = Column(String, nullable=True)
|
||||||
display_order = Column(Integer, default=0)
|
display_order = Column(Integer, default=0)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
|
||||||
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
|
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
|
||||||
tenant_modules = relationship("TenantModule", 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):
|
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 sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Role(Base):
|
class Role(Base):
|
||||||
__tablename__ = "roles"
|
__tablename__ = "roles"
|
||||||
|
|
||||||
@@ -20,12 +19,14 @@ class Role(Base):
|
|||||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
|
||||||
tenant = relationship("Tenant", back_populates="roles")
|
tenant = relationship("Tenant", back_populates="roles")
|
||||||
users = relationship("User", back_populates="role")
|
users = relationship("User", back_populates="role")
|
||||||
role_accesses = relationship(
|
role_accesses = relationship(
|
||||||
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
role_module_accesses = relationship(
|
||||||
|
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
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 sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Tenant(Base):
|
class Tenant(Base):
|
||||||
__tablename__ = "tenants"
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
@@ -20,8 +19,9 @@ class Tenant(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
users = relationship("User", back_populates="tenant")
|
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
roles = relationship("Role", back_populates="tenant")
|
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
|
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __repr__(self):
|
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)
|
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)
|
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||||
|
|
||||||
# Environment routing
|
assigned_environment_slug = Column(String, nullable=True)
|
||||||
assigned_environment_slug = Column(String, nullable=True) # e.g. "prod" or "staging"
|
|
||||||
|
|
||||||
# Access control
|
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
deactivated_at = Column(DateTime(timezone=True), nullable=True)
|
deactivated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Business metadata
|
plan_tier = Column(String, nullable=True)
|
||||||
plan_tier = Column(String, nullable=True) # basic, premium, enterprise
|
module_config = Column(JSON, nullable=True)
|
||||||
module_config = Column(JSON, nullable=True) # custom settings per tenant-module
|
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
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
|
tenant = relationship("Tenant", back_populates="tenant_modules")
|
||||||
# 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")
|
|
||||||
module = relationship("Module", back_populates="tenant_modules")
|
module = relationship("Module", back_populates="tenant_modules")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -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.config.database import get_db
|
||||||
from app.middleware.auth_middleware import get_current_user, User
|
from app.middleware.auth_middleware import get_current_user, User
|
||||||
from app.models.auth.module_model import Module
|
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||||
from app.models.auth.tenant_module_model import TenantModule
|
from app.controllers.auth.module_controller import ModuleController
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class ModuleResponse(BaseModel):
|
@router.get("/available", response_model=List[ModuleAvailableResponse])
|
||||||
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])
|
|
||||||
def get_available_modules(
|
def get_available_modules(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
@@ -27,37 +18,4 @@ def get_available_modules(
|
|||||||
List modules available to the current user (based on tenant subscription).
|
List modules available to the current user (based on tenant subscription).
|
||||||
For platform admin (tenant_id=None), lists all active modules.
|
For platform admin (tenant_id=None), lists all active modules.
|
||||||
"""
|
"""
|
||||||
if current_user.tenant_id:
|
return ModuleController.get_available_modules(db, current_user)
|
||||||
# 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
|
|
||||||
]
|
|
||||||
+9
-58
@@ -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 sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.config.database import get_db
|
from app.config.database import get_db
|
||||||
from app.middleware.auth_middleware import get_current_user, User
|
from app.middleware.auth_middleware import get_current_user, User
|
||||||
from app.services.auth.sso_service import SSOService
|
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
|
||||||
from app.services.auth.trust_service import TrustService
|
from app.controllers.auth.sso_controller import SSOController
|
||||||
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
|
|
||||||
|
|
||||||
public_router = APIRouter()
|
public_router = APIRouter()
|
||||||
internal_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")
|
@public_router.post("/initiate")
|
||||||
def initiate_sso(
|
def initiate_sso(
|
||||||
request: SSOInitiateRequest,
|
request: SSOInitiateRequest,
|
||||||
@@ -31,15 +18,9 @@ def initiate_sso(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
User-facing endpoint to start SSO flow.
|
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(
|
return SSOController.initiate_sso(db, request, current_user)
|
||||||
db=db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
module_id=request.module_id,
|
|
||||||
tenant_id=current_user.tenant_id
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@internal_router.post("/exchange")
|
@internal_router.post("/exchange")
|
||||||
def exchange_grant(
|
def exchange_grant(
|
||||||
@@ -53,39 +34,9 @@ def exchange_grant(
|
|||||||
Internal server-to-server endpoint for modules to exchange grant code for token.
|
Internal server-to-server endpoint for modules to exchange grant code for token.
|
||||||
Must be signed or authenticated via trust credentials.
|
Must be signed or authenticated via trust credentials.
|
||||||
"""
|
"""
|
||||||
# 1. Resolve Module & Environment to get Trust Config
|
return SSOController.exchange_grant(
|
||||||
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(
|
|
||||||
db=db,
|
db=db,
|
||||||
grant_code=payload.grant_code,
|
payload=payload,
|
||||||
module_id=payload.module_id,
|
x_module_signature=x_module_signature,
|
||||||
environment_slug=payload.environment_slug
|
x_module_key=x_module_key
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class AccessBase(BaseModel):
|
|||||||
class AccessResponse(AccessBase):
|
class AccessResponse(AccessBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
parent_id: Optional[uuid.UUID] = None
|
parent_id: Optional[uuid.UUID] = None
|
||||||
|
module_id: Optional[uuid.UUID] = None
|
||||||
|
module_name: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -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_domain: str = Field(..., min_length=3, max_length=255)
|
||||||
tenant_logo_url: Optional[str] = None
|
tenant_logo_url: Optional[str] = None
|
||||||
|
|
||||||
|
class TenantModuleCreate(BaseModel):
|
||||||
|
module_id: uuid.UUID
|
||||||
|
environment_slug: str
|
||||||
|
|
||||||
class TenantCreate(TenantBase):
|
class TenantCreate(TenantBase):
|
||||||
pass
|
modules: List[TenantModuleCreate] = []
|
||||||
|
|
||||||
class TenantUpdate(BaseModel):
|
class TenantUpdate(BaseModel):
|
||||||
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
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_domain: Optional[str] = Field(None, min_length=3, max_length=255)
|
||||||
tenant_logo_url: Optional[str] = None
|
tenant_logo_url: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
modules: Optional[List[TenantModuleCreate]] = None
|
||||||
|
|
||||||
class TenantResponse(TenantBase):
|
class TenantResponse(TenantBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
|||||||
@@ -1,19 +1,88 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.auth.access_model import Access
|
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:
|
class AccessService:
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
query = db.query(Access)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
query = query.filter(Access.category == 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
|
@staticmethod
|
||||||
def get_access_categories(db: Session) -> List[str]:
|
def get_access_categories(db: Session) -> List[str]:
|
||||||
categories = db.query(Access.category).distinct().all()
|
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 typing import Dict, Any, List, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
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.models.auth.tenant_module_model import TenantModule
|
from app.models.auth.tenant_module_model import TenantModule
|
||||||
from app.models.system.event_log_model import EventLog, EventStatus
|
from app.models.system.event_log_model import EventLog, EventStatus
|
||||||
from app.services.auth.trust_service import TrustService
|
from app.services.auth.trust_service import TrustService
|
||||||
|
from app.core.redis import sync_redis_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,10 +29,9 @@ class EventService:
|
|||||||
Emits an event by writing it to the Outbox (event_logs).
|
Emits an event by writing it to the Outbox (event_logs).
|
||||||
Scopes delivery to relevant modules based on tenant_id.
|
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()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
# Enforce Idempotency Contract: payload must include event_id
|
|
||||||
if "event_id" not in payload:
|
if "event_id" not in payload:
|
||||||
payload["event_id"] = event_id
|
payload["event_id"] = event_id
|
||||||
|
|
||||||
@@ -41,24 +42,39 @@ class EventService:
|
|||||||
"data": payload
|
"data": payload
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scope: Find targets
|
targets: List[ModuleEnvironment] = []
|
||||||
targets = []
|
|
||||||
if tenant_id:
|
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||||
# Send to modules active for this tenant
|
|
||||||
|
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(
|
tenant_modules = db.query(TenantModule).filter(
|
||||||
TenantModule.tenant_id == tenant_id,
|
TenantModule.tenant_id == tenant_id,
|
||||||
TenantModule.is_active == True
|
TenantModule.is_active == True
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for tm in tenant_modules:
|
for tm in tenant_modules:
|
||||||
# Resolve env
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == tm.module_id,
|
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()
|
).first()
|
||||||
|
|
||||||
if not env:
|
if not env:
|
||||||
# Try default
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == tm.module_id,
|
ModuleEnvironment.module_id == tm.module_id,
|
||||||
ModuleEnvironment.is_default == True
|
ModuleEnvironment.is_default == True
|
||||||
@@ -66,16 +82,22 @@ class EventService:
|
|||||||
|
|
||||||
if env:
|
if env:
|
||||||
targets.append(env)
|
targets.append(env)
|
||||||
else:
|
|
||||||
# System-wide event? Or broadcast?
|
if not targets:
|
||||||
# Plan says "No broadcasting". But if we update a global setting?
|
logger.warning(f"Event {event_type} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
|
||||||
# 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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Write to Outbox
|
|
||||||
for env in targets:
|
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(
|
log = EventLog(
|
||||||
event_id=uuid.UUID(event_id),
|
event_id=uuid.UUID(event_id),
|
||||||
@@ -84,15 +106,87 @@ class EventService:
|
|||||||
target_module_id=env.module_id,
|
target_module_id=env.module_id,
|
||||||
target_environment_slug=env.slug,
|
target_environment_slug=env.slug,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
status=EventStatus.PENDING
|
status=EventStatus.PENDING,
|
||||||
|
next_retry_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
db.add(log)
|
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()
|
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
|
@staticmethod
|
||||||
def process_outbox(db: Session, batch_size: int = 50):
|
def process_outbox(db: Session, batch_size: int = 50):
|
||||||
"""
|
"""
|
||||||
@@ -105,9 +199,11 @@ class EventService:
|
|||||||
EventLog.next_retry_at <= now
|
EventLog.next_retry_at <= now
|
||||||
).limit(batch_size).all()
|
).limit(batch_size).all()
|
||||||
|
|
||||||
|
if logs:
|
||||||
|
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
|
||||||
|
|
||||||
for log in logs:
|
for log in logs:
|
||||||
try:
|
try:
|
||||||
# 1. Resolve Credentials for Signing
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == log.target_module_id,
|
ModuleEnvironment.module_id == log.target_module_id,
|
||||||
ModuleEnvironment.slug == log.target_environment_slug
|
ModuleEnvironment.slug == log.target_environment_slug
|
||||||
@@ -118,50 +214,37 @@ class EventService:
|
|||||||
log.error_log = "Target environment config missing"
|
log.error_log = "Target environment config missing"
|
||||||
continue
|
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)
|
payload_json = json.dumps(log.payload)
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
secret = env.trust_credentials.get("hmac_secret")
|
||||||
|
|
||||||
signature = TrustService.sign_outbound_payload(
|
if secret:
|
||||||
environment=env,
|
signature = hmac.new(
|
||||||
method="POST",
|
secret.encode("utf-8"),
|
||||||
path=path,
|
payload_json.encode("utf-8"),
|
||||||
payload_json=payload_json,
|
hashlib.sha256
|
||||||
timestamp=timestamp
|
).hexdigest()
|
||||||
)
|
else:
|
||||||
|
signature = ""
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-SaaS-Signature": signature,
|
"X-SaaS-Signature": signature,
|
||||||
"X-SaaS-Timestamp": timestamp,
|
|
||||||
"X-SaaS-Event-Source": "saas-core"
|
"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)
|
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||||
|
|
||||||
# 4. Handle Result
|
|
||||||
if response.status_code in range(200, 300):
|
if response.status_code in range(200, 300):
|
||||||
log.status = EventStatus.COMPLETED
|
log.status = EventStatus.COMPLETED
|
||||||
log.error_log = None # Clear errors if any
|
log.error_log = None
|
||||||
else:
|
else:
|
||||||
# Retry logic
|
|
||||||
log.retry_count += 1
|
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.next_retry_at = now + timedelta(seconds=backoff)
|
||||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
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
|
log.status = EventStatus.FAILED
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -170,5 +253,6 @@ class EventService:
|
|||||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||||
log.error_log = str(e)
|
log.error_log = str(e)
|
||||||
|
|
||||||
# Commit processing state
|
|
||||||
db.commit()
|
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 sqlalchemy.orm import Session
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
from app.models.auth.module_model import Module
|
from app.models.auth.module_model import Module
|
||||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||||
from app.models.auth.access_model import Access
|
from app.models.auth.access_model import Access
|
||||||
from app.services.auth.trust_service import TrustService
|
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:
|
class ModulePermissionService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sync_permissions(db: Session, module_id: str):
|
def sync_permissions(db: Session, module_id: str):
|
||||||
"""
|
"""
|
||||||
Connects to the module's default environment and fetches defined permissions.
|
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
|
try:
|
||||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
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:
|
if not module:
|
||||||
raise HTTPException(status_code=404, detail="Module not found")
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
# Use default environment for sync
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == module.id,
|
ModuleEnvironment.module_id == module.id,
|
||||||
ModuleEnvironment.is_default == True
|
ModuleEnvironment.is_default == True
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not env:
|
if not env:
|
||||||
# Fallback to any active env
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == module.id,
|
ModuleEnvironment.module_id == module.id,
|
||||||
ModuleEnvironment.is_active == True
|
ModuleEnvironment.is_active == True
|
||||||
@@ -37,72 +46,105 @@ class ModulePermissionService:
|
|||||||
if not env:
|
if not env:
|
||||||
raise HTTPException(status_code=400, detail="No active environment to sync from")
|
raise HTTPException(status_code=400, detail="No active environment to sync from")
|
||||||
|
|
||||||
# 2. Call Module API
|
|
||||||
try:
|
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)
|
payload_body = "{}"
|
||||||
# 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.
|
|
||||||
|
|
||||||
headers = {
|
secret = env.trust_credentials.get("hmac_secret")
|
||||||
"Content-Type": "application/json"
|
if not secret:
|
||||||
}
|
|
||||||
if env.trust_type == "hmac":
|
|
||||||
# TODO: implement outbound signing in TrustService
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, timeout=10)
|
if secret:
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode("utf-8"),
|
||||||
|
payload_body.encode("utf-8"),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
else:
|
||||||
|
signature = ""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-SaaS-Signature": signature
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
response.raise_for_status()
|
||||||
data = response.json() # Expecting list of { code, category, description, ... }
|
data = response.json()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to fetch permissions from module: {str(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", [])
|
permissions: List[Dict[str, Any]] = data.get("permissions", [])
|
||||||
|
|
||||||
synced_count = 0
|
synced_count = 0
|
||||||
timestamp = datetime.now(timezone.utc)
|
timestamp = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
permission_map = {}
|
||||||
|
|
||||||
for perm in permissions:
|
for perm in permissions:
|
||||||
code = perm.get("permission_code")
|
code = perm.get("permission_code")
|
||||||
if not code:
|
if not code:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if exists
|
access = db.query(ModuleAccess).filter(
|
||||||
access = db.query(Access).filter(
|
ModuleAccess.module_id == module.id,
|
||||||
Access.module_id == module.id,
|
ModuleAccess.access_code == code
|
||||||
Access.access_code == code
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not access:
|
if not access:
|
||||||
access = Access(
|
access = ModuleAccess(
|
||||||
access_code=code,
|
access_code=code,
|
||||||
scope="module",
|
|
||||||
module_id=module.id,
|
module_id=module.id,
|
||||||
name=perm.get("name", code), # Fallback name
|
name=perm.get("name", code),
|
||||||
category=perm.get("category", "General"),
|
category=perm.get("category", "General"),
|
||||||
# entity/action not in Access model yet, mapped to name/category or ignored
|
|
||||||
)
|
)
|
||||||
db.add(access)
|
db.add(access)
|
||||||
else:
|
else:
|
||||||
# Update metadata
|
|
||||||
access.name = perm.get("name", access.name)
|
access.name = perm.get("name", access.name)
|
||||||
access.category = perm.get("category", access.category)
|
access.category = perm.get("category", access.category)
|
||||||
|
|
||||||
access.last_synced_at = timestamp
|
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
|
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()
|
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
|
@staticmethod
|
||||||
def get_module_permissions(db: Session, module_id: str):
|
def get_module_permissions(db: Session, module_id: str):
|
||||||
@@ -111,6 +153,6 @@ class ModulePermissionService:
|
|||||||
if not module:
|
if not module:
|
||||||
raise HTTPException(status_code=404, detail="Module not found")
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
return db.query(Access).filter(
|
return db.query(ModuleAccess).filter(
|
||||||
Access.module_id == module.id
|
ModuleAccess.module_id == module.id
|
||||||
).all()
|
).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.orm import Session
|
||||||
from sqlalchemy import or_, cast, String
|
from sqlalchemy import or_, cast, String
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from app.models.auth.role_model import Role
|
from app.models.auth.role_model import Role
|
||||||
from app.models.auth.role_access_model import RoleAccess
|
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.models.auth.access_model import Access
|
||||||
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
|
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
|
||||||
from typing import List, Optional
|
from app.services.auth.event_service import EventService
|
||||||
import uuid
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class RoleService:
|
class RoleService:
|
||||||
|
|
||||||
@@ -40,22 +48,85 @@ class RoleService:
|
|||||||
if role_data.access_ids:
|
if role_data.access_ids:
|
||||||
RoleService.assign_accesses(db, role.id, 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
|
return role
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
|
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(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
|
||||||
|
db.query(RoleModuleAccess).filter(RoleModuleAccess.role_id == role_id).delete()
|
||||||
|
|
||||||
for access_id in access_ids:
|
if not access_ids:
|
||||||
access = db.query(Access).filter(Access.id == access_id).first()
|
return
|
||||||
if not access:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Access {access_id} not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
role_access = RoleAccess(role_id=role_id, access_id=access_id)
|
saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
|
||||||
db.add(role_access)
|
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()
|
db.commit()
|
||||||
|
|
||||||
@@ -84,7 +155,25 @@ class RoleService:
|
|||||||
) -> Role:
|
) -> Role:
|
||||||
role = RoleService.get_role_by_id(db, role_id)
|
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)
|
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:
|
if role.is_default and not is_superadmin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -102,6 +191,61 @@ class RoleService:
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(role)
|
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
|
return role
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -114,6 +258,49 @@ class RoleService:
|
|||||||
detail="Default roles can only be deleted by superadmins.",
|
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.delete(role)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Role deleted successfully"}
|
return {"message": "Role deleted successfully"}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from app.models.auth.sso_grant_model import SSOGrant
|
from app.models.auth.sso_grant_model import SSOGrant
|
||||||
from app.models.auth.module_model import Module
|
from app.models.auth.module_model import Module
|
||||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
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.models.auth.user_model import User
|
||||||
from app.config.security import security
|
from app.config.security import security
|
||||||
from app.services.auth.trust_service import TrustService
|
from app.services.auth.trust_service import TrustService
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
class SSOService:
|
class SSOService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -24,7 +25,6 @@ class SSOService:
|
|||||||
Generates a one-time SSO grant code for the specified module.
|
Generates a one-time SSO grant code for the specified module.
|
||||||
Resolves the correct environment URL based on tenant/user config.
|
Resolves the correct environment URL based on tenant/user config.
|
||||||
"""
|
"""
|
||||||
# 1. Find Module
|
|
||||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||||
if not module:
|
if not module:
|
||||||
raise HTTPException(status_code=404, detail="Module not found")
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
@@ -32,12 +32,9 @@ class SSOService:
|
|||||||
if module.status != "active":
|
if module.status != "active":
|
||||||
raise HTTPException(status_code=403, detail="Module is disabled")
|
raise HTTPException(status_code=403, detail="Module is disabled")
|
||||||
|
|
||||||
# 2. Resolve Environment
|
environment_slug = "prod"
|
||||||
# Default behavior: checks TenantModule assignment, else default env
|
|
||||||
environment_slug = "prod" # Default fallback
|
|
||||||
|
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
# Check if tenant has access and specific env assignment
|
|
||||||
tm = db.query(TenantModule).filter(
|
tm = db.query(TenantModule).filter(
|
||||||
TenantModule.tenant_id == tenant_id,
|
TenantModule.tenant_id == tenant_id,
|
||||||
TenantModule.module_id == module.id
|
TenantModule.module_id == module.id
|
||||||
@@ -49,13 +46,11 @@ class SSOService:
|
|||||||
if tm.assigned_environment_slug:
|
if tm.assigned_environment_slug:
|
||||||
environment_slug = tm.assigned_environment_slug
|
environment_slug = tm.assigned_environment_slug
|
||||||
|
|
||||||
# Get actual environment config
|
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == module.id,
|
ModuleEnvironment.module_id == module.id,
|
||||||
ModuleEnvironment.slug == environment_slug
|
ModuleEnvironment.slug == environment_slug
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If slug invalid, fallback to default
|
|
||||||
if not env:
|
if not env:
|
||||||
env = db.query(ModuleEnvironment).filter(
|
env = db.query(ModuleEnvironment).filter(
|
||||||
ModuleEnvironment.module_id == module.id,
|
ModuleEnvironment.module_id == module.id,
|
||||||
@@ -63,14 +58,12 @@ class SSOService:
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not env:
|
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)
|
grant_code = str(uuid.uuid4().hex)
|
||||||
|
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=60)
|
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}"
|
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
|
||||||
|
|
||||||
grant = SSOGrant(
|
grant = SSOGrant(
|
||||||
@@ -79,7 +72,6 @@ class SSOService:
|
|||||||
module_id=module.id,
|
module_id=module.id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
environment_slug=env.slug,
|
environment_slug=env.slug,
|
||||||
# redirect_url removed
|
|
||||||
expires_at=expires_at
|
expires_at=expires_at
|
||||||
)
|
)
|
||||||
db.add(grant)
|
db.add(grant)
|
||||||
@@ -91,6 +83,89 @@ class SSOService:
|
|||||||
"redirect_url": redirect_url
|
"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
|
@staticmethod
|
||||||
def exchange_grant(
|
def exchange_grant(
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -102,59 +177,45 @@ class SSOService:
|
|||||||
Validates grant and returns a short-lived module-scoped token.
|
Validates grant and returns a short-lived module-scoped token.
|
||||||
This is called by the Module Backend.
|
This is called by the Module Backend.
|
||||||
"""
|
"""
|
||||||
# 1. Find Grant
|
|
||||||
grant = db.query(SSOGrant).filter(SSOGrant.grant_code == grant_code).first()
|
grant = db.query(SSOGrant).filter(SSOGrant.grant_code == grant_code).first()
|
||||||
if not grant:
|
if not grant:
|
||||||
raise HTTPException(status_code=401, detail="Invalid grant code")
|
raise HTTPException(status_code=401, detail="Invalid grant code")
|
||||||
|
|
||||||
# 2. Validate Grant
|
|
||||||
if grant.is_used:
|
if grant.is_used:
|
||||||
raise HTTPException(status_code=401, detail="Grant code already used")
|
raise HTTPException(status_code=401, detail="Grant code already used")
|
||||||
|
|
||||||
if grant.expires_at < datetime.now(timezone.utc):
|
if grant.expires_at < datetime.now(timezone.utc):
|
||||||
raise HTTPException(status_code=401, detail="Grant code expired")
|
raise HTTPException(status_code=401, detail="Grant code expired")
|
||||||
|
|
||||||
# 3. Validate Module Context
|
|
||||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||||
if not module or module.id != grant.module_id:
|
if not module or module.id != grant.module_id:
|
||||||
raise HTTPException(status_code=401, detail="Grant invalid for this module")
|
raise HTTPException(status_code=401, detail="Grant invalid for this module")
|
||||||
|
|
||||||
if grant.environment_slug != environment_slug:
|
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")
|
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()
|
user = db.query(User).filter(User.id == grant.user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
if grant.tenant_id:
|
if grant.tenant_id:
|
||||||
# Ensure user still belongs to this tenant or has access
|
|
||||||
if user.tenant_id != grant.tenant_id:
|
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(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Tenant mismatch for SSO grant"
|
detail="Tenant mismatch for SSO grant"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Mark Used
|
|
||||||
grant.is_used = True
|
grant.is_used = True
|
||||||
grant.used_at = datetime.now(timezone.utc)
|
grant.used_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 6. Get User Permissions for this Module
|
|
||||||
permissions = []
|
permissions = []
|
||||||
if user.role:
|
if user.role:
|
||||||
for ra in user.role.role_accesses:
|
if user.role.role_module_accesses:
|
||||||
access = ra.access
|
for rma in user.role.role_module_accesses:
|
||||||
# Include SaaS global permissions (scope='saas') OR module specific (scope='module' and matching module_id)
|
if rma.module_access and rma.module_access.module_id == module.id:
|
||||||
if access.scope == 'saas' or (access.scope == 'module' and access.module_id == module.id):
|
permissions.append(rma.module_access.access_code)
|
||||||
permissions.append(access.access_code)
|
|
||||||
|
|
||||||
# 6. Generate Token
|
|
||||||
token_payload = {
|
token_payload = {
|
||||||
"sub": str(user.id),
|
"sub": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
@@ -170,7 +231,7 @@ class SSOService:
|
|||||||
return {
|
return {
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"expires_in": 900, # 15 minutes
|
"expires_in": 900,
|
||||||
"user": {
|
"user": {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
|||||||
@@ -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 sqlalchemy import or_, cast, String
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from app.models.auth.tenant_model import Tenant
|
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
|
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from app.services.auth.event_service import EventService
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class TenantService:
|
class TenantService:
|
||||||
|
|
||||||
@@ -24,16 +29,63 @@ class TenantService:
|
|||||||
detail="Tenant domain already exists"
|
detail="Tenant domain already exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
tenant = Tenant(
|
provisioning_id = str(uuid.uuid4())
|
||||||
tenant_name=tenant_data.tenant_name,
|
|
||||||
tenant_domain=tenant_data.tenant_domain,
|
|
||||||
tenant_logo_url=tenant_data.tenant_logo_url
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(tenant)
|
try:
|
||||||
db.commit()
|
# 1. Create Tenant
|
||||||
db.refresh(tenant)
|
tenant = Tenant(
|
||||||
return 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
|
@staticmethod
|
||||||
def get_all_tenants(db: Session):
|
def get_all_tenants(db: Session):
|
||||||
@@ -55,19 +107,128 @@ class TenantService:
|
|||||||
|
|
||||||
update_dict = tenant_data.model_dump(exclude_unset=True)
|
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:
|
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()
|
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Tenant name already exists")
|
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:
|
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()
|
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Tenant domain already exists")
|
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():
|
for key, value in update_dict.items():
|
||||||
setattr(tenant, key, value)
|
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.commit()
|
||||||
db.refresh(tenant)
|
db.refresh(tenant)
|
||||||
return tenant
|
return tenant
|
||||||
@@ -75,6 +236,33 @@ class TenantService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||||
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
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.delete(tenant)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Tenant deleted successfully"}
|
return {"message": "Tenant deleted successfully"}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ class TrustService:
|
|||||||
Currently supports HMAC-SHA256.
|
Currently supports HMAC-SHA256.
|
||||||
"""
|
"""
|
||||||
if environment.trust_type != "hmac":
|
if environment.trust_type != "hmac":
|
||||||
# For now only HMAC is fully implemented
|
|
||||||
if environment.trust_type == "static_key":
|
if environment.trust_type == "static_key":
|
||||||
# Simple key check (not recommended for prod but useful for dev)
|
|
||||||
secret = environment.trust_credentials.get("secret_key")
|
secret = environment.trust_credentials.get("secret_key")
|
||||||
return signature == secret
|
return signature == secret
|
||||||
return False
|
return False
|
||||||
@@ -31,6 +29,24 @@ class TrustService:
|
|||||||
|
|
||||||
return hmac.compare_digest(expected_signature, signature)
|
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
|
@staticmethod
|
||||||
def sign_outbound_payload(environment: ModuleEnvironment, method: str, path: str, payload_json: str, timestamp: str) -> str:
|
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))
|
Signature = HMAC-SHA256(secret, method + path + timestamp + SHA256(payload))
|
||||||
"""
|
"""
|
||||||
if environment.trust_type != "hmac":
|
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
|
pass
|
||||||
|
|
||||||
secret = environment.trust_credentials.get("hmac_secret")
|
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 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")
|
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()
|
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}"
|
string_to_sign = f"{method.upper()}{path}{timestamp}{payload_hash}"
|
||||||
|
|
||||||
# 3. Sign
|
|
||||||
signature = hmac.new(
|
signature = hmac.new(
|
||||||
secret.encode("utf-8"),
|
secret.encode("utf-8"),
|
||||||
string_to_sign.encode("utf-8"),
|
string_to_sign.encode("utf-8"),
|
||||||
@@ -102,7 +108,6 @@ class TrustService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# TODO: Implement mTLS support
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
detail=f"Trust type {environment.trust_type} not supported yet"
|
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 fastapi import HTTPException, status, BackgroundTasks
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
from app.models.auth.user_model import User
|
from app.models.auth.user_model import User
|
||||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
||||||
from app.config.security import security
|
from app.config.security import security
|
||||||
from app.services.auth.event_service import EventService
|
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:
|
class UserService:
|
||||||
|
|
||||||
@@ -37,26 +44,35 @@ class UserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.flush() # Get ID but don't commit yet
|
db.flush()
|
||||||
db.refresh(user)
|
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(
|
EventService.emit_event(
|
||||||
db=db,
|
db=db,
|
||||||
event_type="user.created",
|
event_type="USER_PROVISION_REQUESTED",
|
||||||
payload={
|
payload=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
|
tenant_id=user.tenant_id
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit() # Atomic commit of user + event
|
db.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -83,6 +99,11 @@ class UserService:
|
|||||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
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)
|
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)
|
update_dict = user_data.model_dump(exclude_unset=True)
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
update_dict.pop("tenant_id", None)
|
update_dict.pop("tenant_id", None)
|
||||||
@@ -110,28 +131,85 @@ class UserService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
# Emit event
|
new_targets = []
|
||||||
EventService.emit_event(
|
if user.role_id:
|
||||||
db=db,
|
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit() # Atomic commit
|
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
|
||||||
|
|
||||||
|
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
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
|
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)
|
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.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "User deleted successfully"}
|
return {"message": "User deleted successfully"}
|
||||||
@@ -180,3 +258,43 @@ class UserService:
|
|||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
total_pages=total_pages,
|
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,
|
ColorPaletteUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaletteService:
|
class PaletteService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_palettes(
|
def get_all_palettes(
|
||||||
db: Session, tenant_id: Optional[UUID] = None
|
db: Session, tenant_id: Optional[UUID] = None
|
||||||
) -> List[ColorPalette]:
|
) -> List[ColorPalette]:
|
||||||
query = db.query(ColorPalette)
|
return db.query(ColorPalette).all()
|
||||||
if tenant_id:
|
|
||||||
query = query.filter(
|
|
||||||
(ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
return query.all()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
|
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
|
db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None
|
||||||
) -> ColorPalette:
|
) -> ColorPalette:
|
||||||
if data.is_default:
|
if data.is_default:
|
||||||
if tenant_id:
|
db.query(ColorPalette).update({"is_default": False})
|
||||||
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_palette = ColorPalette(
|
db_palette = ColorPalette(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
colors=data.colors.model_dump(),
|
colors=data.colors.model_dump(),
|
||||||
is_default=data.is_default,
|
is_default=data.is_default,
|
||||||
# tenant_id=tenant_id, # Model does not support tenant_id yet
|
|
||||||
)
|
)
|
||||||
db.add(db_palette)
|
db.add(db_palette)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -70,16 +54,9 @@ class PaletteService:
|
|||||||
palette.colors = data.colors.model_dump()
|
palette.colors = data.colors.model_dump()
|
||||||
if data.is_default is not None:
|
if data.is_default is not None:
|
||||||
if data.is_default:
|
if data.is_default:
|
||||||
tenant_id = palette.tenant_id
|
db.query(ColorPalette).filter(
|
||||||
if tenant_id:
|
ColorPalette.id != palette_id
|
||||||
db.query(ColorPalette).filter(
|
).update({"is_default": False})
|
||||||
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})
|
|
||||||
|
|
||||||
palette.is_default = data.is_default
|
palette.is_default = data.is_default
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pydantic-settings==2.12.0
|
|||||||
bcrypt>=4.0.1
|
bcrypt>=4.0.1
|
||||||
pyjwt>=2.8.0
|
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.update", "Superadmin", "Allow access to update color palettes", None),
|
||||||
("superadmin.palette.delete", "Superadmin", "Allow access to delete 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
|
# All Accesses hereafter are applicable for a Tenant Admin
|
||||||
|
|
||||||
# Administration category
|
# Administration category
|
||||||
|
|||||||
Reference in New Issue
Block a user