Author SHA1 Message Date
Inamul-hasan-tec 75b23efaf4 fix(rbac): scope tenant roles to subscribed modules 2026-09-08 16:24:58 +05:30
Inamul-hasan-tec f87b70099e feat(saas): canonical tenant identity provisioning and asymmetric sso signing 2026-09-05 13:17:31 +05:30
Inamul-hasan-tec 1bd3050474 chore(security): stop tracking environment files and add sanitized template 2026-09-05 13:12:25 +05:30
Inamul-hasan-tec 9178041844 feat(onboarding): add atomic tenant owner and application provisioning 2026-09-04 15:06:42 +05:30
Inamul-hasan-tec 5073833831 feat(local): add isolated ecosystem module seeder 2026-09-04 12:11:31 +05:30
Inamul-hasan-tec de1d35c18f fix(config): preserve SaaS process environment precedence 2026-09-04 11:42:38 +05:30
Inamul-hasan-tec 66d21563c0 fix(tenants): preserve RLS context and atomic onboarding 2026-09-02 16:11:58 +05:30
Inamul-hasan-tec 6b57c0b19f fix(auth): allow verified platform users through RLS 2026-09-02 15:55:35 +05:30
Inamul-hasan-tec 552fcd7be5 feat(auth): gate module SSO by user assignment 2026-09-02 15:11:09 +05:30
Inamul-hasan-tec 1e9da77555 fix(auth): separate entitlements from role permissions 2026-09-02 11:23:24 +05:30
Inamul-hasan-tec 9d322c579b fix(sso): align module trust and identity claims 2026-09-02 10:47:00 +05:30
Inamul-hasan-tec bb51048958 fix(rbac): synchronize signed module permissions 2026-09-01 12:45:18 +05:30
Inamul-hasan-tec bd6477784c fix(auth): establish tenant context for authentication 2026-09-01 11:37:14 +05:30
Inamul-hasan-tec fffe605d53 feat(sso): issue one-time grants for module launch 2026-09-01 11:21:47 +05:30
furqan 7ab9cc3bee Merge pull request 'changes done on subscription module with saas' (#21) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/21
2026-04-30 05:06:07 +00:00
azeeee05 d68145effa changes done on subscription module with saas 2026-04-29 11:45:43 +03:00
furqan cb81429595 Merge pull request 'saas issues fixed' (#20) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/20
2026-04-25 05:45:42 +00:00
azeeee05 276daa7fc7 saas issues fixed 2026-04-25 08:44:33 +03:00
furqan ee62cb4963 Merge pull request 'ameenah' (#19) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/19
2026-04-23 10:38:19 +00:00
amee abf0219587 fix: tenant subs plan expired logic 2026-04-20 11:10:03 +05:30
amee f3deee5479 feat: tenat subscription, duration, start and expiry date 2026-04-18 16:20:17 +05:30
furqan 53d6e92399 Merge pull request 'filter functionality added on all modules.' (#18) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/18
2026-04-17 05:13:39 +00:00
amee df83ec42d0 filter functionality added on all modules. 2026-04-17 10:34:51 +05:30
furqan 41c91554f2 Merge pull request 'feat: Implemented subscription module base' (#17) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/17
2026-04-09 06:27:44 +00:00
Furqan-14 bab1c3d718 feat: Implemented subscription module base 2026-04-09 11:55:58 +05:30
furqan f200604ca5 Merge pull request 'ameenah' (#16) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/16
2026-04-03 10:11:57 +00:00
amee 9fff9fa9c1 fix: _get_client_ip 2026-04-03 15:38:00 +05:30
amee a091c11214 fix: _get_client_ip 2026-04-03 15:27:46 +05:30
furqan 2689adfb7a Merge pull request 'fix: audit logs for tenants, user, roles, modules and environment and modules of tenant and modules.' (#14) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/14
2026-04-03 09:37:28 +00:00
amee dc711fe82c fix: audit logs for tenants, user, roles, modules and environment and modules of tenant and modules.
2 migration scripts
2026-04-03 12:15:56 +05:30
furqan 5fc241e59c Merge pull request 'fix: saas restart logic' (#13) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/13
2026-03-09 05:32:42 +00:00
Furqan-14 83def84f15 fix: saas restart logic 2026-03-09 11:02:12 +05:30
furqan e16f7c30af Merge pull request 'furqan' (#12) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/12
2026-02-17 06:28:01 +00:00
Furqan-14 512c39e4f4 fix: stronger handling of data 2026-02-17 11:57:24 +05:30
Furqan-14 c3017b81da fix: security fix 2026-02-16 15:32:05 +05:30
furqan 4c9fa8bc45 Merge pull request 'feat: implement pydantic multi env setup and removed node based version' (#11) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/11
2026-02-05 09:00:41 +00:00
Furqan-14 9b41301b09 feat: implement pydantic multi env setup and removed node based version 2026-02-05 14:30:07 +05:30
furqan 571894b890 Merge pull request 'fix: missing package and migration issue' (#10) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/10
2026-02-02 18:06:40 +00:00
Furqan-14 2678e9548a fix: missing package and migration issue 2026-02-02 23:36:04 +05:30
furqan a1dc191a06 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
2026-02-02 12:04:08 +00:00
Furqan-14 7c326dea94 feat: Implement module-based access control system with integrated authentication, authorization, and security utilities. 2026-02-02 17:33:35 +05:30
furqan 8a8ecc3d2e Merge pull request 'furqan' (#7) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/7
2026-01-20 12:08:48 +00:00
Furqan-14 249e03aa95 feat: added the base for module integrations 2026-01-20 17:38:01 +05:30
furqan 0f0fcd6de3 Merge pull request 'dev' (#6) from dev into furqan
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/6
2026-01-19 12:01:25 +00:00
furqan a9b189b463 Merge pull request 'fixed tenant id issues on palettes' (#5) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/5
2026-01-19 12:01:15 +00:00
azeeee05 bcbd0ccc5a fixed tenant id issues on palettes 2026-01-19 17:27:41 +05:30
furqan 6e9acc13eb Merge pull request 'dev' (#4) from dev into furqan
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/4
2026-01-19 05:12:00 +00:00
furqan bd4f0f1e1d Merge pull request 'fixed import issues' (#3) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/3
2026-01-19 05:11:50 +00:00
azeeee05 1d832b9eda fixed import issues 2026-01-19 10:39:12 +05:30
furqan e5895d058f Merge pull request 'fix: seed palettes run error' (#2) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/2
2026-01-19 04:54:15 +00:00
Furqan-14 d6981d7123 fix: seed palettes run error 2026-01-19 10:23:51 +05:30
furqan 56408cadbe Merge pull request 'fix: port change and DB urls' (#1) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/1
2026-01-17 11:10:08 +00:00
Furqan-14 935bbc18f6 fix: port change and DB urls 2026-01-17 16:36:05 +05:30
115 changed files with 8928 additions and 866 deletions
-66
View File
@@ -1,66 +0,0 @@
# Project Configuration
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=11001
APP_ENV=development
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-dev.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-dev.maskantech.in
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
# Super Admin Configuration
SUPER_ADMIN_EMAIL=admin@maskantech.in
SUPER_ADMIN_PASSWORD=SuperAdmin@123
SUPER_ADMIN_FIRST_NAME=Super
SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION=""
S3_BUCKET_NAME=""
# PayPal Integration
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""
PAYPAL_MODE=sandbox
# Logging
LOG_LEVEL=info
# Development flags
DEBUG_MODE=true
ENABLE_DEBUG_LOGGING=true
+62
View File
@@ -0,0 +1,62 @@
# SaaS Core Backend Environment Template
# Duplicate this file to .env.local and populate with your credentials.
PROJECT_NAME=
VERSION=
PORT=
APP_ENV=
SECRET_KEY=
ALLOWED_HOSTS=
HOST=
FRONTEND_URL=
CORS_ALLOWED_ORIGINS=
ENCRYPTION_KEY=
LOG_LEVEL=
DEBUG_MODE=
ENABLE_DEBUG_LOGGING=
# Database Configuration
DATABASE_URL=
DB_SSL=
# Super Administrator
SUPER_ADMIN_EMAIL=
SUPER_ADMIN_PASSWORD=
SUPER_ADMIN_FIRST_NAME=
SUPER_ADMIN_LAST_NAME=
# Redis & Caching
REDIS_HOST=
REDIS_PORT=
REDIS_PASSWORD=
# SaaS Trust Fabric & SSO
SAAS_TRUST_SECRET=
SSO_SECRET=
# SMTP / Email
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM=
# JWT & Authentication
ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRES=
REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRES=
JWT_ALGORITHM=
ADMIN_JWT=
# Object Storage / AWS S3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET_NAME=
# Payment Provider
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_MODE=
-66
View File
@@ -1,66 +0,0 @@
# Project Configuration
PROJECT_NAME=Fulfillment And Logistics
VERSION=1.0.0
PORT=8000
APP_ENV=local
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=http://localhost:3000
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
# Super Admin Configuration
SUPER_ADMIN_EMAIL=admin@maskantech.in
SUPER_ADMIN_PASSWORD=SuperAdmin@123
SUPER_ADMIN_FIRST_NAME=Super
SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://saas_user:nMCuFvGoG%28%23Q%40Q4%5E@106.51.104.95:5432/saas_local
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION=""
S3_BUCKET_NAME=""
# PayPal Integration
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""
PAYPAL_MODE=sandbox
# Logging
LOG_LEVEL=info
# Development flags
DEBUG_MODE=true
ENABLE_DEBUG_LOGGING=true
-66
View File
@@ -1,66 +0,0 @@
# Project Configuration
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=8000
APP_ENV=production
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=http://localhost:3000
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
# Super Admin Configuration
SUPER_ADMIN_EMAIL=admin@maskantech.in
SUPER_ADMIN_PASSWORD=SuperAdmin@123
SUPER_ADMIN_FIRST_NAME=Super
SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://postgres:azeem3105@localhost:5434/
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION=""
S3_BUCKET_NAME=""
# PayPal Integration
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""
PAYPAL_MODE=sandbox
# Logging
LOG_LEVEL=info
# Development flags
DEBUG_MODE=true
ENABLE_DEBUG_LOGGING=true
-66
View File
@@ -1,66 +0,0 @@
# Project Configuration
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=11002
APP_ENV=testing
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-test.maskantech.in
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
# Super Admin Configuration
SUPER_ADMIN_EMAIL=admin@maskantech.in
SUPER_ADMIN_PASSWORD=SuperAdmin@123
SUPER_ADMIN_FIRST_NAME=Super
SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION=""
S3_BUCKET_NAME=""
# PayPal Integration
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""
PAYPAL_MODE=sandbox
# Logging
LOG_LEVEL=info
# Development flags
DEBUG_MODE=true
ENABLE_DEBUG_LOGGING=true
+5 -1
View File
@@ -6,7 +6,11 @@ __pycache__/
*.pyc
# Environment
.env
.env*
!.env.example
keys/
*.log
node_modules
# IDE
+17 -15
View File
@@ -20,9 +20,10 @@ A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
## Prerequisites
- Python 3.12+
- PostgreSQL 12+
- Python 3.10+
- PostgreSQL 12+
- Node.js (for npm scripts)
## Getting Started
@@ -44,7 +45,7 @@ python -m venv venv
```bash
pip install -r requirements.txt
npm install # For cross-env support in npm scripts
pip install -r requirements.txt
```
### 4. Environment Configuration
@@ -112,11 +113,11 @@ alembic revision --autogenerate -m "Initial schema"
#### Run Migrations
```bash
# Using npm scripts (recommended - handles APP_ENV automatically)
npm run migrate:local
npm run migrate:dev
npm run migrate:prod
npm run migrate:test
# Using manage.py (recommended - handles APP_ENV automatically)
python manage.py migrate --env local
python manage.py migrate --env development
python manage.py migrate --env production
python manage.py migrate --env testing
# Or using alembic directly
$env:APP_ENV="local" # Set environment first
@@ -148,20 +149,20 @@ After running migrations, seed the database with initial data:
```bash
# Seed super admin user
npm run seed:superadmin:local
python manage.py seed superadmin --env local
# Seed default color palettes
npm run seed:palettes:local
python manage.py seed palettes --env local
```
### 7. Run the Application
```bash
# Using npm scripts (recommended)
npm run local # Local environment
npm run dev # Development environment
npm run prod # Production environment
npm run test # Testing environment
# Using manage.py (recommended)
python manage.py run --env local # Local environment
python manage.py run --env development # Development environment
python manage.py run --env production # Production environment
python manage.py run --env testing # Testing environment
# Or using Python directly
python run.py
@@ -209,7 +210,8 @@ backend/
│ ├── seed_palettes.py
│ └── seed_superadmin.py
├── alembic.ini # Alembic configuration
├── package.json # NPM scripts
├── alembic.ini # Alembic configuration
├── manage.py # Management CLI script
├── requirements.txt # Python dependencies
└── run.py # Application entry point
```
+3 -22
View File
@@ -11,26 +11,6 @@ from alembic import context
# Add parent directory to path to import app modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Load environment variables before importing app
from dotenv import load_dotenv
app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent
backend_path = base_path
# Load environment variables
load_dotenv(dotenv_path=base_path / '.env')
load_dotenv(dotenv_path=backend_path / '.env')
# Override with specific environment config
if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists():
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
# Import app settings and database
from app.config.settings import settings
from app.config.database import Base
@@ -42,6 +22,7 @@ import app.models.auth.tenant_model
import app.models.auth.access_model
import app.models.auth.role_access_model
import app.models.theme.color_palette_model
import app.models.auth.subscription_plan_model
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -52,8 +33,8 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from app settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Set the database URL from app settings (escape % for ConfigParser interpolation)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("%", "%%"))
# add your model's MetaData object here
# for 'autogenerate' support
@@ -0,0 +1,53 @@
"""create_event_log_model
Revision ID: 03a1b1f05e99
Revises: cd8ba77ffd9e
Create Date: 2026-01-20 17:00:41.702680
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '03a1b1f05e99'
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
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('event_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('event_id', sa.UUID(), nullable=False),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('target_module_id', sa.UUID(), nullable=False),
sa.Column('target_environment_slug', sa.String(), nullable=False),
sa.Column('target_url', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=True),
sa.Column('retry_count', sa.Integer(), nullable=True),
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_log', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_event_logs_event_id'), 'event_logs', ['event_id'], unique=False)
op.create_index(op.f('ix_event_logs_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_event_logs_status'), table_name='event_logs')
op.drop_index(op.f('ix_event_logs_next_retry_at'), table_name='event_logs')
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
op.drop_table('event_logs')
# ### end Alembic commands ###
@@ -0,0 +1,48 @@
"""add tenant lifecycle fields
Revision ID: 5f2e9c1a7b44
Revises: 720027c97104
Create Date: 2026-04-18 14:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5f2e9c1a7b44"
down_revision: Union[str, Sequence[str], None] = "720027c97104"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS start_date DATE")
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS end_date DATE")
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS status VARCHAR")
op.execute("ALTER TABLE tenants ALTER COLUMN status SET DEFAULT 'ACTIVE'")
op.execute(
"""
UPDATE tenants
SET status = CASE
WHEN is_active = true THEN 'ACTIVE'
ELSE 'INACTIVE'
END
WHERE status IS NULL
"""
)
op.execute("ALTER TABLE tenants ALTER COLUMN status SET NOT NULL")
op.execute("ALTER TABLE tenants ALTER COLUMN status DROP DEFAULT")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_tenants_status ON tenants (status)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_tenants_status")
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS status")
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS end_date")
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS start_date")
@@ -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,29 @@
"""add duration days to subscription plans
Revision ID: 6a1b2c3d4e55
Revises: 5f2e9c1a7b44
Create Date: 2026-04-18 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6a1b2c3d4e55"
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS duration_days INTEGER"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS duration_days"
)
@@ -0,0 +1,32 @@
"""add_follow_up_event_to_event_logs
Revision ID: 720027c97104
Revises: 9283c3f52a76
Create Date: 2026-04-07 12:56:10.500329
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '720027c97104'
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
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('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('event_logs', 'follow_up_event')
# ### end Alembic commands ###
@@ -0,0 +1,43 @@
"""create audit logs table
Revision ID: 73b754d5b2c5
Revises: c37ba6143f83
Create Date: 2026-03-30 15:43:16.849980
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '73b754d5b2c5'
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
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('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_name', sa.String(length=100), nullable=False),
sa.Column('action_type', sa.String(length=20), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###
@@ -0,0 +1,157 @@
"""add_module_registry
Revision ID: 74b6ccfaee8e
Revises: 8acd83604252
Create Date: 2026-01-20 15:11:19.596874
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '74b6ccfaee8e'
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
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('modules',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.String(), nullable=False),
sa.Column('module_name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('icon_url', sa.String(), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_modules_module_id'), 'modules', ['module_id'], unique=True)
op.create_table('module_environments',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.UUID(), nullable=False),
sa.Column('slug', sa.String(), nullable=False),
sa.Column('frontend_base_url', sa.String(), nullable=False),
sa.Column('sso_entry_path', sa.String(), nullable=True),
sa.Column('backend_base_url', sa.String(), nullable=False),
sa.Column('sso_exchange_endpoint', sa.String(), nullable=True),
sa.Column('permission_sync_endpoint', sa.String(), nullable=True),
sa.Column('trust_type', sa.String(), nullable=False),
sa.Column('trust_credentials', sa.JSON(), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('module_id', 'slug', name='uq_module_env_slug')
)
op.create_index(op.f('ix_module_environments_module_id'), 'module_environments', ['module_id'], unique=False)
op.create_index(op.f('ix_module_environments_slug'), 'module_environments', ['slug'], unique=False)
op.create_table('tenant_modules',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.UUID(), nullable=False),
sa.Column('assigned_environment_slug', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('activated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('deactivated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('plan_tier', sa.String(), nullable=True),
sa.Column('module_config', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module')
)
op.create_index(op.f('ix_tenant_modules_module_id'), 'tenant_modules', ['module_id'], unique=False)
op.create_index(op.f('ix_tenant_modules_tenant_id'), 'tenant_modules', ['tenant_id'], unique=False)
op.create_table('sso_grants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('grant_code', sa.String(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=True),
sa.Column('environment_slug', sa.String(), nullable=False),
sa.Column('redirect_url', sa.String(), nullable=False),
sa.Column('is_used', sa.Boolean(), nullable=True),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sso_grants_grant_code'), 'sso_grants', ['grant_code'], unique=True)
op.create_index(op.f('ix_sso_grants_module_id'), 'sso_grants', ['module_id'], unique=False)
op.create_index(op.f('ix_sso_grants_tenant_id'), 'sso_grants', ['tenant_id'], unique=False)
op.create_index(op.f('ix_sso_grants_user_id'), 'sso_grants', ['user_id'], unique=False)
# Add scope column as nullable first
op.add_column('accesses', sa.Column('scope', sa.String(), nullable=True))
op.add_column('accesses', sa.Column('module_id', sa.UUID(), nullable=True))
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), nullable=True))
op.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
# Update existing rows with default scope
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
# Now make it not null
op.alter_column('accesses', 'scope', nullable=False)
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
# Inspect to see if constraint/column exists to avoid transaction abortion on failure
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check and drop foreign key
fks = inspector.get_foreign_keys('users')
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
# Check and drop column
columns = [c['name'] for c in inspector.get_columns('users')]
if 'palette_id' in columns:
op.drop_column('users', 'palette_id')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('palette_id', sa.UUID(), autoincrement=False, nullable=True))
op.create_foreign_key(op.f('users_palette_id_fkey'), 'users', 'color_palettes', ['palette_id'], ['id'])
op.drop_constraint(None, 'accesses', type_='foreignkey')
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
op.drop_column('accesses', 'last_synced_at')
op.drop_column('accesses', 'sync_checksum')
op.drop_column('accesses', 'module_id')
op.drop_column('accesses', 'scope')
op.drop_index(op.f('ix_sso_grants_user_id'), table_name='sso_grants')
op.drop_index(op.f('ix_sso_grants_tenant_id'), table_name='sso_grants')
op.drop_index(op.f('ix_sso_grants_module_id'), table_name='sso_grants')
op.drop_index(op.f('ix_sso_grants_grant_code'), table_name='sso_grants')
op.drop_table('sso_grants')
op.drop_index(op.f('ix_tenant_modules_tenant_id'), table_name='tenant_modules')
op.drop_index(op.f('ix_tenant_modules_module_id'), table_name='tenant_modules')
op.drop_table('tenant_modules')
op.drop_index(op.f('ix_module_environments_slug'), table_name='module_environments')
op.drop_index(op.f('ix_module_environments_module_id'), table_name='module_environments')
op.drop_table('module_environments')
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
op.drop_table('modules')
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add max users allowed to subscription plans
Revision ID: 7b2c4d5e6f77
Revises: 6a1b2c3d4e55
Create Date: 2026-04-28 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7b2c4d5e6f77"
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS max_users_allowed INTEGER"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS max_users_allowed"
)
@@ -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,132 @@
"""initial migration
Revision ID: 8acd83604252
Revises:
Create Date: 2026-01-17 16:23:50.226790
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8acd83604252'
down_revision: Union[str, Sequence[str], None] = None
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('accesses',
sa.Column('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('parent_id', sa.UUID(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['accesses.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
op.create_index(op.f('ix_accesses_category'), 'accesses', ['category'], unique=False)
op.create_index(op.f('ix_accesses_id'), 'accesses', ['id'], unique=False)
op.create_index(op.f('ix_accesses_parent_id'), 'accesses', ['parent_id'], unique=False)
op.create_table('color_palettes',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('colors', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tenants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_name', sa.String(), nullable=False),
sa.Column('tenant_domain', sa.String(), nullable=False),
sa.Column('tenant_logo_url', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
op.create_index(op.f('ix_tenants_tenant_domain'), 'tenants', ['tenant_domain'], unique=True)
op.create_index(op.f('ix_tenants_tenant_name'), 'tenants', ['tenant_name'], unique=True)
op.create_table('roles',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('role_name', sa.String(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False)
op.create_table('role_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('role_id', sa.UUID(), nullable=False),
sa.Column('access_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_id', 'access_id', name='uq_role_access')
)
op.create_index(op.f('ix_role_accesses_access_id'), 'role_accesses', ['access_id'], unique=False)
op.create_index(op.f('ix_role_accesses_role_id'), 'role_accesses', ['role_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password', sa.String(), nullable=False),
sa.Column('first_name', sa.String(), nullable=False),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('phone_number', sa.String(), nullable=True),
sa.Column('preferred_language', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=True),
sa.Column('role_id', sa.UUID(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('password_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_role_id'), 'users', ['role_id'], unique=False)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_role_id'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_role_accesses_role_id'), table_name='role_accesses')
op.drop_index(op.f('ix_role_accesses_access_id'), table_name='role_accesses')
op.drop_table('role_accesses')
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_tenants_tenant_name'), table_name='tenants')
op.drop_index(op.f('ix_tenants_tenant_domain'), table_name='tenants')
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
op.drop_table('tenants')
op.drop_table('color_palettes')
op.drop_index(op.f('ix_accesses_parent_id'), table_name='accesses')
op.drop_index(op.f('ix_accesses_id'), table_name='accesses')
op.drop_index(op.f('ix_accesses_category'), table_name='accesses')
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.drop_table('accesses')
# ### 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,86 @@
"""Add subscription model
Revision ID: 9283c3f52a76
Revises: f9cf173f48f9
Create Date: 2026-04-06 14:26:41.926020
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9283c3f52a76'
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
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('subscription_plans',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_subscription_plans_id'), 'subscription_plans', ['id'], unique=False)
op.create_index(op.f('ix_subscription_plans_name'), 'subscription_plans', ['name'], unique=True)
op.create_table('plan_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('plan_id', sa.UUID(), nullable=False),
sa.Column('access_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('plan_id', 'access_id', name='uq_plan_access')
)
op.create_index(op.f('ix_plan_accesses_access_id'), 'plan_accesses', ['access_id'], unique=False)
op.create_index(op.f('ix_plan_accesses_id'), 'plan_accesses', ['id'], unique=False)
op.create_index(op.f('ix_plan_accesses_plan_id'), 'plan_accesses', ['plan_id'], unique=False)
op.create_table('plan_module_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('plan_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'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access')
)
op.create_index(op.f('ix_plan_module_accesses_id'), 'plan_module_accesses', ['id'], unique=False)
op.create_index(op.f('ix_plan_module_accesses_module_access_id'), 'plan_module_accesses', ['module_access_id'], unique=False)
op.create_index(op.f('ix_plan_module_accesses_plan_id'), 'plan_module_accesses', ['plan_id'], unique=False)
op.add_column('tenants', sa.Column('plan_id', sa.UUID(), nullable=True))
op.create_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tenants', type_='foreignkey')
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
op.drop_column('tenants', 'plan_id')
op.drop_index(op.f('ix_plan_module_accesses_plan_id'), table_name='plan_module_accesses')
op.drop_index(op.f('ix_plan_module_accesses_module_access_id'), table_name='plan_module_accesses')
op.drop_index(op.f('ix_plan_module_accesses_id'), table_name='plan_module_accesses')
op.drop_table('plan_module_accesses')
op.drop_index(op.f('ix_plan_accesses_plan_id'), table_name='plan_accesses')
op.drop_index(op.f('ix_plan_accesses_id'), table_name='plan_accesses')
op.drop_index(op.f('ix_plan_accesses_access_id'), table_name='plan_accesses')
op.drop_table('plan_accesses')
op.drop_index(op.f('ix_subscription_plans_name'), table_name='subscription_plans')
op.drop_index(op.f('ix_subscription_plans_id'), table_name='subscription_plans')
op.drop_table('subscription_plans')
# ### 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 ###
@@ -0,0 +1,32 @@
"""make_sso_grants_stateless
Revision ID: cd8ba77ffd9e
Revises: 74b6ccfaee8e
Create Date: 2026-01-20 16:58:53.362779
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cd8ba77ffd9e'
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
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_column('sso_grants', 'redirect_url')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
# ### end Alembic commands ###
@@ -0,0 +1,50 @@
"""add_audit_log_extra_columns
Revision ID: f9cf173f48f9
Revises: 73b754d5b2c5
Create Date: 2026-03-31 13:57:11.328101
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'f9cf173f48f9'
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
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('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True))
op.add_column('audit_logs', sa.Column('entity_name', sa.String(length=255), nullable=True))
op.add_column('audit_logs', sa.Column('performed_by_id', sa.UUID(), nullable=True))
op.add_column('audit_logs', sa.Column('performed_by_email', sa.String(length=255), nullable=True))
op.add_column('audit_logs', sa.Column('ip_address', sa.String(length=45), nullable=True))
op.add_column('audit_logs', sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('audit_logs', sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
op.drop_column('audit_logs', 'new_values')
op.drop_column('audit_logs', 'old_values')
op.drop_column('audit_logs', 'ip_address')
op.drop_column('audit_logs', 'performed_by_email')
op.drop_column('audit_logs', 'performed_by_id')
op.drop_column('audit_logs', 'entity_name')
op.drop_column('audit_logs', 'entity_id')
# ### end Alembic commands ###
+132 -23
View File
@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
@@ -5,12 +6,24 @@ import logging
from sqlalchemy import text
from app.config.settings import settings
from app.config.database import engine
# Import models for Alembic
from app.routes.admin import audit_logs
import app.models.auth.user_model
import app.models.auth.role_model
import app.models.auth.tenant_model
import app.models.theme.color_palette_model
import app.models.auth.module_model
import app.models.auth.module_environment_model
import app.models.auth.tenant_module_model
import app.models.auth.sso_grant_model
import app.models.auth.access_model
import app.models.auth.subscription_plan_model
import app.models.system.event_log_model
import app.models.system.audit_log
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
# Configure logging
logging.basicConfig(
@@ -21,6 +34,95 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
async def wait_for_db(retries: int = 10, base_delay: float = 3.0, max_delay: float = 30.0):
"""Wait for the database to become available with exponential backoff."""
for attempt in range(1, retries + 1):
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
logger.info("Database connection successful!")
return
except Exception as e:
if attempt == retries:
logger.error(f"Database unreachable after {retries} attempts: {e}")
raise
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
logger.warning(
f"Database not ready (attempt {attempt}/{retries}): {e}. "
f"Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting SaaS application...")
await wait_for_db()
app.state.redis_available = False
try:
await redis_client.connect()
await run_in_threadpool(sync_redis_client.connect)
app.state.redis_available = True
logger.info("Redis connected successfully.")
except Exception as e:
logger.error(f"Redis unavailable at startup: {e}")
logger.warning("Continuing without Redis — event queue disabled.")
logger.info(
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
)
async def redis_event_consumer():
logger.info("Redis Event Consumer STARTED")
while True:
try:
if not app.state.redis_available or 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:
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)
background_tasks = [
asyncio.create_task(redis_event_consumer()),
asyncio.create_task(fallback_poller()),
]
yield
logger.info("Shutting down...")
for task in background_tasks:
task.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
if app.state.redis_available:
try:
await redis_client.close()
await run_in_threadpool(sync_redis_client.close)
except Exception:
pass
def create_app() -> FastAPI:
app = FastAPI(
@@ -30,6 +132,7 @@ def create_app() -> FastAPI:
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
# === OpenAPI Security Scheme ===
@@ -64,16 +167,19 @@ def create_app() -> FastAPI:
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
if origin.strip()
]
if settings.FRONTEND_URL and settings.FRONTEND_URL not in origins:
origins.append(settings.FRONTEND_URL)
if not origins:
raise RuntimeError(
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -84,30 +190,35 @@ def create_app() -> FastAPI:
from app.routes.auth.role import router as role_router
from app.routes.auth.access import router as access_router
from app.routes.auth.user import router as user_router
from app.routes.auth.sso import public_router as sso_public_router, internal_router as sso_internal_router
from app.routes.api.module import router as module_router
from app.routes.auth.subscription_plan import router as subscription_plan_router
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
app.include_router(role_router, prefix="/api/role", tags=["Role Management"])
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
from app.routes.internal.module import router as internal_module_router
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
@app.on_event("startup")
def startup_event():
logger.info("Testing database connection...")
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
conn.commit()
logger.info("Database connection successful!")
except Exception as e:
logger.error(f"Database connection failed: {e}")
raise
from app.routes.theme.color_palette import router as palette_router
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
logger.info(
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
)
# === 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(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
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"])
# === Basic Routes ===
@app.get("/", tags=["Root"])
@@ -138,7 +249,8 @@ def create_app() -> FastAPI:
conn.execute(text("SELECT 1"))
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
logger.error(f"Health check DB error: {e}")
db_status = "unhealthy"
return {
"status": "healthy" if db_status == "healthy" else "degraded",
@@ -147,7 +259,4 @@ def create_app() -> FastAPI:
"version": settings.VERSION,
}
return app
app = create_app()
return app
+16 -2
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.exc import SQLAlchemyError
from fastapi import HTTPException
@@ -31,6 +31,20 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
@event.listens_for(SessionLocal, "after_begin")
def restore_request_rls_context(session, transaction, connection):
"""Reapply request identity after an internal service commit starts a new transaction."""
if session.info.get("rls_bypass") is True:
connection.execute(text("SELECT set_config('app.bypass_rls', 'on', true)"))
return
tenant_id = session.info.get("rls_tenant_id")
if tenant_id:
connection.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(tenant_id)},
)
def get_db():
db = SessionLocal()
try:
@@ -50,4 +64,4 @@ def get_db():
db.rollback()
raise
finally:
db.close()
db.close()
+68 -20
View File
@@ -1,18 +1,17 @@
"""
Security utilities for authentication and authorization.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import bcrypt
import jwt
import uuid
import logging
from fastapi import HTTPException, status
import re
import secrets
import string
from app.config.settings import settings
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
class SecurityUtils:
"""Security utility class for authentication and authorization."""
@@ -33,24 +32,24 @@ class SecurityUtils:
"""Generate JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "access"})
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
return jwt.encode(
to_encode,
settings.ACCESS_TOKEN_SECRET,
algorithm="HS256"
)
@staticmethod
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
"""Generate JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "refresh"})
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
# Include tenant_id if provided
if tenant_id:
@@ -77,7 +76,20 @@ class SecurityUtils:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
jti = payload.get("jti")
if jti and sync_redis_client.client:
try:
if sync_redis_client.client.get(f"blacklist:{jti}"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked"
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Redis blacklist check failed: {e}")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
@@ -129,24 +141,62 @@ class SecurityUtils:
if len(password) < 8:
return False
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password):
return False
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password):
return False
# Check for at least one digit
if not re.search(r'\d', password):
return False
# Check for at least one special character
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False
return True
@staticmethod
def get_saas_private_key() -> Optional[str]:
"""Load RSA private key from settings or persistent file (local/testing only)."""
if settings.SAAS_PRIVATE_KEY and settings.SAAS_PRIVATE_KEY.strip():
return settings.SAAS_PRIVATE_KEY
# Check persistent keys directory only in local or testing environments
env = (settings.APP_ENV or "").lower().strip()
if env in {"local", "testing"}:
from pathlib import Path
key_file = Path(__file__).resolve().parent.parent.parent / "keys" / "saas_private_key.pem"
if key_file.exists():
try:
return key_file.read_text().strip()
except Exception:
pass
return None
@staticmethod
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
"""Generate short-lived module-scoped JWT (15 min default)."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
to_encode.update({
"exp": expire,
"type": "module_access",
"aud": str(module_id),
"iat": datetime.now(timezone.utc).timestamp()
})
private_key = SecurityUtils.get_saas_private_key()
if not private_key:
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
return jwt.encode(
to_encode,
private_key,
algorithm="RS256",
headers={"kid": settings.SAAS_KEY_ID}
)
@staticmethod
def validate_email(email: str) -> bool:
"""Validate email format."""
@@ -161,6 +211,4 @@ class SecurityUtils:
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
# Create instance for easy importing
security = SecurityUtils()
+45 -64
View File
@@ -1,33 +1,35 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
from pathlib import Path
from dotenv import load_dotenv
import os
# Load environment variables from .env files
app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent.parent
backend_path = Path(__file__).resolve().parent.parent
# Load specific environment file (e.g., .env.development)
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
load_dotenv(dotenv_path=backend_path / '.env')
env_files = []
if (base_path / ".env").is_file():
env_files.append(str(base_path / ".env"))
if (backend_path / ".env").is_file() and (backend_path / ".env") != (base_path / ".env"):
env_files.append(str(backend_path / ".env"))
if (base_path / env_filename).is_file():
env_files.append(str(base_path / env_filename))
if (backend_path / env_filename).is_file() and (backend_path / env_filename) != (base_path / env_filename):
env_files.append(str(backend_path / env_filename))
# Override with specific environment config
if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists():
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
class Settings(BaseSettings):
# Project
PROJECT_NAME: str = "SaaS Architecture"
VERSION: str = "1.0.0"
model_config = SettingsConfigDict(
env_file=env_files,
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
PROJECT_NAME: str
VERSION: str
# FastAPI
PORT: int
@@ -38,9 +40,7 @@ class Settings(BaseSettings):
# Frontend
FRONTEND_URL: str
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
CORS_ALLOWED_ORIGINS: Optional[str] = None
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
# Security
@@ -52,14 +52,24 @@ class Settings(BaseSettings):
DB_SSL: bool = False
# Redis Configuration
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
REDIS_HOST: str
REDIS_PORT: int
REDIS_PASSWORD: Optional[str]
REDIS_DB: int = 0
REDIS_ENABLED: bool = True
REDIS_TIMEOUT: int = 10
REDIS_MAX_CONNECTIONS: int = 10
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Email
SMTP_HOST: str
SMTP_PORT: int = 587
SMTP_PORT: int
SMTP_SECURE: bool = True
SMTP_USER: str
SMTP_PASSWORD: str
@@ -67,60 +77,35 @@ class Settings(BaseSettings):
# JWT settings
ACCESS_TOKEN_SECRET: str
ACCESS_TOKEN_EXPIRES: int = 86400
ACCESS_TOKEN_EXPIRES: int = 900
REFRESH_TOKEN_SECRET: str
REFRESH_TOKEN_EXPIRES: int = 864000
JWT_ALGORITHM: str = "HS256"
# Cookie settings
COOKIE_SECURE: bool = False
COOKIE_DOMAIN: Optional[str] = None
# Super Admin Setup
SUPER_ADMIN_EMAIL: str
SUPER_ADMIN_PASSWORD: str
SUPER_ADMIN_FIRST_NAME: str = "Super"
SUPER_ADMIN_LAST_NAME: str = "Admin"
# External SaaS Integration
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
# Module Integration Security (RS256)
SAAS_PRIVATE_KEY: Optional[str] = None
SAAS_KEY_ID: str = "saas-key-v1"
# PayPal Integration
PAYPAL_CLIENT_ID: str
PAYPAL_CLIENT_SECRET: str
PAYPAL_MODE: str = "sandbox"
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
# AWS S3 settings
AWS_SECRET_ACCESS_KEY: Optional[str] = None
AWS_ACCESS_KEY_ID: Optional[str] = None
S3_BUCKET_NAME: Optional[str] = None
AWS_REGION: Optional[str] = "us-east-1"
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
@property
def AWS_S3_BUCKET(self) -> Optional[str]:
return self.S3_BUCKET_NAME
# S3 Dataset Processing Settings
S3_PROCESSING_WORKERS: int = 4
DOCUMENT_CHUNK_SIZE: int = 1000
DOCUMENT_CHUNK_OVERLAP: int = 200
PINECONE_BATCH_SIZE: int = 100
# Redis Chat Settings
REDIS_CHAT_TTL: int = 86400 # 24 hours
REDIS_CHAT_TTL: int = 86400
# Logging
LOG_LEVEL: str = "info"
# Rate Limiting
RATE_LIMIT_REQUESTS: int = 100
RATE_LIMIT_WINDOW: int = 60
# Integration Settings (Optional for development)
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
# INTEGRATION_TIMEOUT: int = 30
# Properties for FastAPI Mail compatibility
@property
def MAIL_USERNAME(self) -> str:
return self.SMTP_USER
@@ -141,9 +126,5 @@ class Settings(BaseSettings):
def MAIL_FROM(self) -> str:
return self.EMAIL_FROM
model_config = {
"case_sensitive": True,
"extra": "ignore",
}
settings = Settings()
+8 -3
View File
@@ -1,9 +1,14 @@
from sqlalchemy.orm import Session
from typing import List
from typing import List, Optional
import uuid
from app.services.auth.access_service import AccessService
from app.schemas.auth.access_schema import AccessResponse
class AccessController:
@staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
return AccessService.get_all_accesses(db, category)
def get_all_accesses(
db: Session,
category: str = None,
tenant_id: Optional[uuid.UUID] = None,
) -> List[AccessResponse]:
return AccessService.get_all_accesses(db, category, tenant_id=tenant_id)
+6 -2
View File
@@ -27,6 +27,10 @@ class AuthController:
def refresh_token(db: Session, token_data: RefreshTokenRequest):
return AuthService.refresh_access_token(db, token_data.refresh_token)
@staticmethod
def refresh_token_raw(db: Session, refresh_token: str):
return AuthService.refresh_access_token(db, refresh_token)
@staticmethod
def update_user(
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
@@ -40,8 +44,8 @@ class AuthController:
)
@staticmethod
def logout(current_user: User):
return AuthService.logout(current_user)
def logout(current_user: User, token: str):
return AuthService.logout(current_user, token)
@staticmethod
def me(db: Session, current_user: User):
+51
View File
@@ -0,0 +1,51 @@
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, current_user.role_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"}
+20
View File
@@ -60,6 +60,18 @@ class RoleController:
}
for ra in role.role_accesses
]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
"access_code": rma.module_access.access_code,
"category": rma.module_access.category,
"name": rma.module_access.name,
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
}
for rma in role.role_module_accesses
])
return RoleWithAccessesResponse(
id=role.id,
@@ -77,6 +89,10 @@ class RoleController:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
filter_role_names: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> RolePaginatedResponse:
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
return RoleService.get_roles_paginated(
@@ -85,4 +101,8 @@ class RoleController:
page=page,
page_size=page_size,
search=search,
filter_role_names=filter_role_names,
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+79
View File
@@ -0,0 +1,79 @@
from sqlalchemy.orm import Session
from fastapi import Request, HTTPException
from typing import Optional, Dict, Any
import uuid
import logging
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
logger = logging.getLogger(__name__)
class SSOController:
@staticmethod
def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User):
return SSOService.generate_grant(
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:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).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
actual_body = payload.model_dump_json()
try:
TrustService.validate_module_trust(
environment=env,
request_headers=headers,
request_body=actual_body
)
except HTTPException:
logger.warning(
"HMAC verify with body failed for %s, trying empty fallback (DEPRECATED)",
payload.module_id,
)
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,51 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from app.schemas.auth.subscription_plan_schema import SubscriptionPlanCreate, SubscriptionPlanUpdate
from app.services.auth.subscription_plan_service import SubscriptionPlanService
class SubscriptionPlanController:
@staticmethod
def create_plan(db: Session, plan_data: SubscriptionPlanCreate):
return SubscriptionPlanService.create_plan(db, plan_data)
@staticmethod
def update_plan(db: Session, plan_id: uuid.UUID, plan_data: SubscriptionPlanUpdate):
return SubscriptionPlanService.update_plan(db, plan_id, plan_data)
@staticmethod
def get_plan(db: Session, plan_id: uuid.UUID):
return SubscriptionPlanService.get_plan(db, plan_id)
@staticmethod
def get_all_plans(db: Session, is_public: Optional[bool] = None, status: Optional[str] = None):
return SubscriptionPlanService.get_all_plans(db, is_public, status)
@staticmethod
def get_paginated_plans(
db: Session,
page: int,
page_size: int,
search: Optional[str],
filter_names: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
visibility: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
):
return SubscriptionPlanService.get_paginated_plans(
db,
page,
page_size,
search,
filter_names=filter_names,
statuses=statuses,
visibility=visibility,
sort_by=sort_by,
sort_order=sort_order,
)
@staticmethod
def delete_plan(db: Session, plan_id: uuid.UUID):
return SubscriptionPlanService.delete_plan(db, plan_id)
+15 -3
View File
@@ -2,13 +2,13 @@ from sqlalchemy.orm import Session
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate
from app.services.auth.tenant_service import TenantService
import uuid
from typing import Optional
from typing import List, Optional
class TenantController:
@staticmethod
def create_tenant(db: Session, tenant_data: TenantCreate):
return TenantService.create_tenant(db, tenant_data)
def create_tenant(db: Session, tenant_data: TenantCreate, commit: bool = True):
return TenantService.create_tenant(db, tenant_data, commit=commit)
@staticmethod
def get_all_tenants(db: Session):
@@ -33,6 +33,12 @@ class TenantController:
page_size: int,
search: Optional[str],
is_active: Optional[bool],
filter_tenant_names: Optional[List[str]] = None,
filter_tenant_domains: Optional[List[str]] = None,
filter_plan_ids: Optional[List[uuid.UUID]] = None,
statuses: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
):
return TenantService.get_tenants_paginated(
db=db,
@@ -40,4 +46,10 @@ class TenantController:
page_size=page_size,
search=search,
is_active=is_active,
filter_tenant_names=filter_tenant_names,
filter_tenant_domains=filter_tenant_domains,
filter_plan_ids=filter_plan_ids,
statuses=statuses,
sort_by=sort_by,
sort_order=sort_order,
)
@@ -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"}
+19 -7
View File
@@ -1,5 +1,5 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from fastapi import HTTPException, status, BackgroundTasks
from typing import List, Optional
import uuid
from app.models.auth.user_model import User
@@ -21,9 +21,9 @@ class UserController:
return current_user.tenant_id
@staticmethod
def create_user(db: Session, user_data: UserCreate, current_user: User) -> User:
def create_user(db: Session, user_data: UserCreate, current_user: User, background_tasks: BackgroundTasks) -> User:
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
return UserService.create_user(db, user_data, tenant_id)
return UserService.create_user(db, user_data, tenant_id, background_tasks)
@staticmethod
def get_all_users(db: Session, current_user: User) -> List[User]:
@@ -36,12 +36,12 @@ class UserController:
return UserService.get_user_by_id(db, user_id, tenant_id)
@staticmethod
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User) -> User:
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> User:
if current_user.tenant_id is not None and user_data.tenant_id is not None:
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
tenant_id = current_user.tenant_id
return UserService.update_user(db, user_id, user_data, tenant_id)
return UserService.update_user(db, user_id, user_data, tenant_id, background_tasks)
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
@@ -55,7 +55,13 @@ class UserController:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
status: Optional[str] = None,
filter_names: Optional[List[str]] = None,
filter_emails: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
filter_role_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
):
tenant_id = current_user.tenant_id
return UserService.get_users_paginated(
@@ -64,5 +70,11 @@ class UserController:
page=page,
page_size=page_size,
search=search,
status=status,
filter_names=filter_names,
filter_emails=filter_emails,
statuses=statuses,
filter_tenant_ids=filter_tenant_ids,
filter_role_ids=filter_role_ids,
sort_by=sort_by,
sort_order=sort_order,
)
@@ -1,28 +1,33 @@
from sqlalchemy.orm import Session
from uuid import UUID
from typing import List, Optional
from app.models.auth.user_models import User
from app.models.theme.color_palette_models import ColorPalette
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
from app.services.theme.palette_service import PaletteService
from typing import List
from app.models.auth.user_model import User
from app.models.theme.color_palette_model import ColorPalette
from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
)
from app.services.theme.color_palette_service import PaletteService
class PaletteController:
@staticmethod
def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]:
tenant_id = current_user.tenant_id
return PaletteService.get_all_palettes(db, tenant_id)
return PaletteService.get_all_palettes(db)
@staticmethod
def get_palette(db: Session, palette_id: UUID) -> ColorPalette:
return PaletteService.get_palette_by_id(db, palette_id)
@staticmethod
def create_palette(db: Session, data: ColorPaletteCreate, current_user: User) -> ColorPalette:
return PaletteService.create_palette(db, data, tenant_id=None)
def create_palette(
db: Session, data: ColorPaletteCreate, current_user: User
) -> ColorPalette:
return PaletteService.create_palette(db, data)
@staticmethod
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
def update_palette(
db: Session, palette_id: UUID, data: ColorPaletteUpdate
) -> ColorPalette:
return PaletteService.update_palette(db, palette_id, data)
@staticmethod
+133
View File
@@ -0,0 +1,133 @@
import logging
from typing import Optional
from redis.asyncio import Redis, from_url as async_from_url
from app.config.settings import settings
logger = logging.getLogger(__name__)
class RedisClient:
def __init__(self):
self._redis: Optional[Redis] = None
async def connect(self):
"""
Initializes the Redis connection pool (Async).
"""
if settings.REDIS_ENABLED:
try:
self._redis = async_from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
ping = await self._redis.ping()
if ping:
logger.info("Connected to Redis (Async)")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
self._redis = None
else:
logger.info("Redis is disabled in settings")
async def close(self):
"""
Closes the Redis connection.
"""
if self._redis:
await self._redis.close()
logger.info("Redis connection closed")
async def get(self, key: str) -> Optional[str]:
"""
Get a value by key.
"""
if not self._redis:
return None
try:
return await self._redis.get(key)
except Exception as e:
logger.error(f"Redis GET error for key {key}: {e}")
return None
async def set(self, key: str, value: str, expire: int = None) -> bool:
"""
Set a value by key with optional expiration time in seconds.
"""
if not self._redis:
return False
try:
return await self._redis.set(key, value, ex=expire)
except Exception as e:
logger.error(f"Redis SET error for key {key}: {e}")
return False
async def delete(self, key: str) -> bool:
"""
Delete a value by key.
"""
if not self._redis:
return False
try:
return await self._redis.delete(key) > 0
except Exception as e:
logger.error(f"Redis DELETE error for key {key}: {e}")
return False
@property
def client(self) -> Optional[Redis]:
"""
Expose the raw Redis client if needed for advanced operations.
"""
return self._redis
class SyncRedisClient:
def __init__(self):
self._redis = None
def connect(self):
"""
Initializes the Redis connection pool (Sync).
"""
if settings.REDIS_ENABLED:
try:
import redis
self._redis = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
if self._redis.ping():
logger.info("Connected to Redis (Sync)")
except Exception as e:
logger.error(f"Failed to connect to Redis (Sync): {e}")
self._redis = None
def close(self):
if self._redis:
self._redis.close()
logger.info("Redis connection closed (Sync)")
def rpush(self, key: str, *values) -> int:
if not self._redis:
try:
self.connect()
except Exception:
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()
+1
View File
@@ -0,0 +1 @@
from .helpers import get_client_ip
+7
View File
@@ -0,0 +1,7 @@
from fastapi import Request
def get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+83 -17
View File
@@ -1,10 +1,17 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timezone
from app.config.database import get_db
from app.config.security import security
from app.models.auth.user_model import User
from app.models.auth.access_model import Access
from app.models.auth.tenant_model import Tenant
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
security_scheme = HTTPBearer(auto_error=False)
@@ -27,6 +34,25 @@ def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
# The verified access token carries the canonical tenant UUID. Scope
# the database transaction before reading any tenant-owned records.
token_tenant_id = payload.get("tenant_id")
if token_tenant_id:
db.info.pop("rls_bypass", None)
db.info["rls_tenant_id"] = str(token_tenant_id)
db.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(token_tenant_id)},
)
else:
# A platform user's signed token intentionally has no tenant claim.
# Enable transaction-local discovery only long enough to resolve the
# exact token subject; the result is validated below before platform
# access remains enabled for the rest of this request.
db.info.pop("rls_tenant_id", None)
db.info["rls_bypass"] = True
db.execute(text("SELECT set_config('app.bypass_rls', 'on', true)"))
except HTTPException:
raise
except Exception:
@@ -37,10 +63,31 @@ def get_current_user(
user = db.query(User).filter(User.id == user_id).first()
if user is None:
if not token_tenant_id:
db.info.pop("rls_bypass", None)
db.execute(text("SELECT set_config('app.bypass_rls', 'off', true)"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if token_tenant_id:
if str(user.tenant_id) != str(token_tenant_id):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token tenant does not match user tenant"
)
elif user.tenant_id is not None:
# Fail closed for a validly signed tenant-user token that is malformed,
# stale, or missing its canonical tenant claim.
db.info.pop("rls_bypass", None)
db.execute(text("SELECT set_config('app.bypass_rls', 'off', true)"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Tenant claim required for tenant user"
)
setattr(user, "_saas_db_session", db)
if user.status != "active":
raise HTTPException(
@@ -48,6 +95,27 @@ def get_current_user(
detail="User is inactive"
)
if user.tenant_id is not None:
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
today = datetime.now(timezone.utc).date()
if tenant.end_date and tenant.end_date <= today and tenant.status != "EXPIRED":
tenant.status = "EXPIRED"
tenant.is_active = False
db.commit()
db.refresh(tenant)
if not tenant.is_active or tenant.status in {"INACTIVE", "EXPIRED"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant is inactive"
)
return user
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
@@ -59,24 +127,21 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User:
return current_user
def has_access(user: User, access_code: str) -> bool:
if not user.role:
db = getattr(user, "_saas_db_session", None)
if db is None:
return False
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
return access_code in user_access_codes
def can_access(user: User, access_code: str, db: Session) -> bool:
if not user.role:
return False
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
if access_code in user_access_codes:
return True
from app.models.auth.access_models import Access
return True
requested_access = db.query(Access).filter(
Access.access_code == access_code
).first()
@@ -93,10 +158,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
return False
def get_user_accesses(user: User) -> List[str]:
if not user.role:
db = getattr(user, "_saas_db_session", None)
if db is None:
return []
return [ra.access.access_code for ra in user.role.role_accesses]
return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user))
def require_access(access_code: str):
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
@@ -121,4 +187,4 @@ def require_access_hierarchical(access_code: str):
)
return True
return check_permission
return check_permission
+6 -1
View File
@@ -2,4 +2,9 @@ from app.models.auth.access_model import Access
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User
from app.models.auth.user_model import User
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
+5 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
@@ -12,6 +12,10 @@ class Access(Base):
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
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())
+32
View File
@@ -0,0 +1,32 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class ModuleAccess(Base):
__tablename__ = "module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
access_code = Column(String, nullable=False, index=True)
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
sync_checksum = Column(String, nullable=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
module = relationship("Module", back_populates="module_accesses")
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
__table_args__ = (
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
)
def __repr__(self):
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
@@ -0,0 +1,37 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class ModuleEnvironment(Base):
__tablename__ = "module_environments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
slug = Column(String, nullable=False, index=True)
frontend_base_url = Column(String, nullable=False)
sso_entry_path = Column(String, default="/sso/start")
backend_base_url = Column(String, nullable=False)
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
trust_type = Column(String, nullable=False)
trust_credentials = Column(JSON, nullable=False)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
module = relationship("Module", back_populates="environments")
__table_args__ = (
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
)
def __repr__(self):
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
+25
View File
@@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Column, String, Integer, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Module(Base):
__tablename__ = "modules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(String, unique=True, nullable=False, index=True)
module_name = Column(String, nullable=False)
description = Column(String, nullable=True)
status = Column(String, default="active")
icon_url = Column(String, nullable=True)
display_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
def __repr__(self):
return f"<Module {self.module_id}>"
+24
View File
@@ -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 PlanAccess(Base):
__tablename__ = "plan_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
access_id = Column(UUID(as_uuid=True), ForeignKey("accesses.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
plan = relationship("SubscriptionPlan", back_populates="plan_accesses")
access = relationship("Access")
__table_args__ = (
UniqueConstraint('plan_id', 'access_id', name='uq_plan_access'),
)
def __repr__(self):
return f"<PlanAccess plan={self.plan_id} access={self.access_id}>"
@@ -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 PlanModuleAccess(Base):
__tablename__ = "plan_module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
plan = relationship("SubscriptionPlan", back_populates="plan_module_accesses")
module_access = relationship("ModuleAccess")
__table_args__ = (
UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access'),
)
def __repr__(self):
return f"<PlanModuleAccess plan={self.plan_id} module_access={self.module_access_id}>"
+12 -3
View File
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Role(Base):
__tablename__ = "roles"
@@ -20,12 +19,22 @@ class Role(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenant = relationship("Tenant", back_populates="roles")
users = relationship("User", back_populates="role")
role_accesses = relationship(
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
)
role_module_accesses = relationship(
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
)
@property
def assigned_modules(self):
names = set()
for rma in self.role_module_accesses:
if rma.module_access and rma.module_access.module:
names.add(rma.module_access.module.module_name)
return sorted(list(names))
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}>"
+31
View File
@@ -0,0 +1,31 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class SSOGrant(Base):
__tablename__ = "sso_grants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
grant_code = Column(String, unique=True, nullable=False, index=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
environment_slug = Column(String, nullable=False)
# redirect_url removed - stateless grants
is_used = Column(Boolean, default=False)
used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=False) # 60 seconds
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User")
module = relationship("Module")
tenant = relationship("Tenant")
def __repr__(self):
return f"<SSOGrant {self.grant_code} for user {self.user_id}>"
@@ -0,0 +1,30 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, Integer, func, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class SubscriptionPlan(Base):
__tablename__ = "subscription_plans"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String, nullable=True)
price = Column(Numeric(10, 2), nullable=True)
duration_days = Column(Integer, nullable=True)
max_users_allowed = Column(Integer, nullable=True)
is_public = Column(Boolean, default=True)
status = Column(String, default="active")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenants = relationship("Tenant", back_populates="plan")
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
def __repr__(self):
return f"<SubscriptionPlan {self.name}>"
+14 -5
View File
@@ -1,10 +1,9 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
from sqlalchemy import Column, String, Boolean, Date, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Tenant(Base):
__tablename__ = "tenants"
@@ -13,6 +12,10 @@ class Tenant(Base):
tenant_domain = Column(String, unique=True, nullable=False, index=True)
tenant_logo_url = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True, index=True)
start_date = Column(Date, nullable=True)
end_date = Column(Date, nullable=True)
status = Column(String, nullable=False, default="ACTIVE", index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
@@ -20,8 +23,14 @@ class Tenant(Base):
)
# Relationships
users = relationship("User", back_populates="tenant")
roles = relationship("Role", back_populates="tenant")
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
plan = relationship("SubscriptionPlan", back_populates="tenants")
@property
def tenant_id(self):
return self.id
def __repr__(self):
return f"<Tenant {self.tenant_name}>"
return f"<Tenant {self.tenant_name}>"
+33
View File
@@ -0,0 +1,33 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class TenantModule(Base):
__tablename__ = "tenant_modules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
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)
assigned_environment_slug = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
deactivated_at = Column(DateTime(timezone=True), nullable=True)
plan_tier = Column(String, nullable=True)
module_config = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
tenant = relationship("Tenant", back_populates="tenant_modules")
module = relationship("Module", back_populates="tenant_modules")
__table_args__ = (
UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module'),
)
def __repr__(self):
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
+5
View File
@@ -27,6 +27,11 @@ class User(Base):
tenant = relationship("Tenant", back_populates="users")
role = relationship("Role", back_populates="users")
@property
def canonical_user_id(self) -> uuid.UUID:
"""authoritative canonical user identity across the SaaS platform."""
return self.id
def __repr__(self):
return f"<User {self.email}>"
+38
View File
@@ -0,0 +1,38 @@
import uuid
from sqlalchemy import Column, String, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from app.config.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
module_name = Column(String(100), nullable=False, index=True)
action_type = Column(String(20), nullable=False, index=True)
description = Column(Text, nullable=True)
entity_id = Column(String(255), nullable=True)
entity_name = Column(String(255), nullable=True)
performed_by_id = Column(UUID(as_uuid=True), nullable=True)
performed_by_email = Column(String(255), nullable=True, index=True)
ip_address = Column(String(45), nullable=True)
old_values = Column(JSONB, nullable=True)
new_values = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def __repr__(self):
return (
f"<AuditLog {self.action_type} on {self.module_name}"
f" by {self.performed_by_email}"
)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.config.database import Base
import enum
class EventStatus(str, enum.Enum):
PENDING = "PENDING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
class EventLog(Base):
__tablename__ = "event_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Idempotency Key
event_type = Column(String, nullable=False)
payload = Column(JSONB, nullable=False)
target_module_id = Column(UUID(as_uuid=True), nullable=False)
target_environment_slug = Column(String, nullable=False)
target_url = Column(String, nullable=False)
status = Column(String, default=EventStatus.PENDING, index=True)
retry_count = Column(Integer, default=0)
next_retry_at = Column(DateTime(timezone=True), default=func.now(), index=True)
error_log = Column(Text, nullable=True)
follow_up_event = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<EventLog {self.event_type} -> {self.target_module_id} ({self.status})>"
+85
View File
@@ -0,0 +1,85 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc, or_, cast, String
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.models.system.audit_log import AuditLog
from app.schemas.auth.audit_schema import AuditLogListResponse
router = APIRouter()
@router.get("/", response_model=AuditLogListResponse)
def get_audit_logs(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
module_name: Optional[str] = Query(None, description="Filter by module name"),
action_type: Optional[str] = Query(None, description="CREATE | UPDATE | DELETE"),
performed_by_email: Optional[str] = Query(None, description="Filter by actor email"),
search: Optional[str] = Query(None, description="Search audit logs"),
module_names: Optional[List[str]] = Query(None, description="Filter by module names"),
action_types: Optional[List[str]] = Query(None, description="Filter by action types"),
performed_by_emails: Optional[List[str]] = Query(None, description="Filter by actor emails"),
sort_by: Optional[str] = Query("created_at", description="Sort by created_at, module_name, action_type, or performed_by_email"),
sort_order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""
Fetch audit logs with optional filters.
Only accessible to authenticated users (super-admin in practice).
"""
query = db.query(AuditLog)
if module_name:
query = query.filter(AuditLog.module_name == module_name)
if module_names:
query = query.filter(AuditLog.module_name.in_(module_names))
if action_type:
query = query.filter(AuditLog.action_type == action_type.upper())
if action_types:
normalized_action_types = [item.upper() for item in action_types]
query = query.filter(AuditLog.action_type.in_(normalized_action_types))
if performed_by_email:
query = query.filter(
AuditLog.performed_by_email.ilike(f"%{performed_by_email}%")
)
if performed_by_emails:
query = query.filter(AuditLog.performed_by_email.in_(performed_by_emails))
if search:
search_term = search.strip()
query = query.filter(
or_(
AuditLog.module_name.ilike(f"%{search_term}%"),
AuditLog.action_type.ilike(f"%{search_term}%"),
AuditLog.entity_name.ilike(f"%{search_term}%"),
AuditLog.description.ilike(f"%{search_term}%"),
AuditLog.performed_by_email.ilike(f"%{search_term}%"),
AuditLog.ip_address.ilike(f"%{search_term}%"),
cast(AuditLog.entity_id, String).ilike(f"%{search_term}%"),
)
)
total = query.count()
sort_column_map = {
"created_at": AuditLog.created_at,
"module_name": AuditLog.module_name,
"action_type": AuditLog.action_type,
"performed_by_email": AuditLog.performed_by_email,
}
sort_column = sort_column_map.get(sort_by or "created_at", AuditLog.created_at)
order_fn = asc if (sort_order or "").lower() == "asc" else desc
items = (
query.order_by(order_fn(sort_column))
.offset(offset)
.limit(limit)
.all()
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
+150
View File
@@ -0,0 +1,150 @@
import uuid as uuid_module
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.controllers.auth.module_environment_controller import ModuleEnvironmentController
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentResponse, EnvironmentUpdate
from app.services.auth.module_service import ModuleService
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
def _get_env_snapshot(db: Session, module_id: str, env_id: str) -> dict:
"""Helper to capture state before changes for the Audit Log."""
try:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid_module.UUID(env_id),
ModuleEnvironment.module_id == uuid_module.UUID(module_id)
).first()
if not env: return {}
return {
"slug": env.slug,
"backend_base_url": env.backend_base_url,
"is_default": env.is_default,
"is_active": env.is_active
}
except: return {}
@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, status_code=201)
def create_environment(
request: Request,
module_id: str,
env_data: EnvironmentCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
result = ModuleEnvironmentController.create_environment(db, module_id, env_data)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="CREATE",
entity_id=str(result.id),
entity_name=f"{module.module_name} / {result.slug}",
description=f"Environment '{result.slug}' created for '{module.module_name}'",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=env_data.model_dump(exclude={"trust_credentials"})
)
return result
@router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse)
def update_environment(
request: Request,
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)
):
module = ModuleService.get_module(db, module_id)
old_values = _get_env_snapshot(db, module_id, env_id)
result = ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="UPDATE",
entity_id=env_id,
entity_name=f"{module.module_name} / {result.slug}",
description=f"Environment '{result.slug}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"})
)
return result
@router.patch("/{module_id}/environments/{env_id}/default")
def set_default_environment(
request: Request,
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
result = ModuleEnvironmentController.set_default_environment(db, module_id, env_id)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="UPDATE",
entity_id=env_id,
entity_name=f"{module.module_name}",
description=f"Set default environment for {module.module_name}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={"is_default": True}
)
return result
@router.delete("/{module_id}/environments/{env_id}")
def delete_environment(
request: Request,
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
snapshot = _get_env_snapshot(db, module_id, env_id)
result = ModuleEnvironmentController.delete_environment(db, module_id, env_id)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="DELETE",
entity_id=env_id,
entity_name=f"{module.module_name} / {snapshot.get('slug')}",
description=f"Environment deleted from {module.module_name}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=snapshot
)
return result
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, Request
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
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
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, status_code=201)
def create_module(
request: Request,
module_data: ModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
result = ModuleController.create_module(db, module_data)
# Professional Audit Logging
AuditLogService.log(
db=db,
module_name="Modules",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.module_name,
description=f"Module '{result.module_name}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=module_data.model_dump()
)
return result
@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(
request: Request,
module_id: str,
module_data: ModuleUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Snapshot before update
existing = ModuleController.get_module(db, module_id)
old_values = {"name": existing.module_name, "status": existing.status}
result = ModuleController.update_module(db, module_id, module_data)
AuditLogService.log(
db=db,
module_name="Modules",
action_type="UPDATE",
entity_id=module_id,
entity_name=result.module_name,
description=f"Module '{result.module_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=module_data.model_dump(exclude_unset=True)
)
return result
@router.delete("/{module_id}")
def delete_module(
request: Request,
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Capture name for the log before it's deleted
existing = ModuleController.get_module(db, module_id)
module_name = existing.module_name
result = ModuleController.delete_module(db, module_id)
AuditLogService.log(
db=db,
module_name="Modules",
action_type="DELETE",
entity_id=module_id,
entity_name=module_name,
description=f"Module '{module_name}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request)
)
return result
# ── Permission Sync Routes ──────────────────────────────────────────────────
@router.get("/{module_id}/permissions")
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)
):
# Now using the Service method you just shared!
return ModulePermissionService.get_module_permissions(db, module_id)
@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:
return ModulePermissionService.sync_permissions(db, module_id)
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(
status_code=500,
detail=f"Permission sync failed: {str(e)}"
)
+96
View File
@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, status, HTTPException, Request, Query
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
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
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(
request: Request,
tenant_id: str,
assignment_data: TenantModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
result = TenantModuleController.assign_module(db, tenant_id, assignment_data)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="CREATE",
entity_id=str(result.id),
entity_name=f"Tenant {tenant_id} / Module {assignment_data.module_id}",
description=f"Module assigned to tenant {tenant_id}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=assignment_data.model_dump(mode='json')
)
return result
@router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
def update_tenant_module(
request: Request,
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)
):
result = TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="UPDATE",
entity_id=tenant_module_id,
entity_name=f"Tenant {tenant_id} / Module assignment",
description=f"Tenant module assignment updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=update_data.model_dump(exclude_unset=True)
)
return result
@router.delete("/{tenant_id}/modules/{tenant_module_id}")
def remove_module_from_tenant(
request: Request,
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)
):
result = TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="DELETE",
entity_id=tenant_module_id,
entity_name=f"Tenant {tenant_id} / Module assignment",
description=f"Tenant module assignment deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
)
return result
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException
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, User
from app.schemas.auth.module_schema import ModuleAvailableResponse
from app.controllers.auth.module_controller import ModuleController
router = APIRouter()
@router.get("/available", response_model=List[ModuleAvailableResponse])
def get_available_modules(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
List modules available to the current user (based on tenant subscription).
For platform admin (tenant_id=None), lists all active modules.
"""
return ModuleController.get_available_modules(db, current_user)
+4 -3
View File
@@ -1,3 +1,4 @@
from .auth import router
from .tenant import router
from .role import router
from .auth import router as auth_router
from .tenant import router as tenant_router
from .role import router as role_router
from .user import router as user_router
+3 -1
View File
@@ -16,4 +16,6 @@ def get_accesses(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.role.read")),
):
return AccessController.get_all_accesses(db, category)
return AccessController.get_all_accesses(
db, category, tenant_id=current_user.tenant_id
)
+47 -20
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, Response, status, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.config.settings import settings
from app.controllers.auth.auth_controller import AuthController
from app.schemas.auth.auth_schema import (
UserSignup,
@@ -14,13 +16,20 @@ from app.schemas.auth.auth_schema import (
VerifyOTPRequest,
ResetPasswordWithOTP,
)
from app.middleware.auth_middleware import get_current_user
from app.schemas.auth.language_schema import UpdateLanguageRequest
from app.middleware.auth_middleware import get_current_user, security_scheme
from app.middleware.tenant_middleware import get_tenant_from_header
from app.models.auth.user_model import User
import uuid
router = APIRouter()
def _cookie_kwargs():
kwargs = dict(httponly=True, samesite="lax", secure=settings.COOKIE_SECURE, path="/")
if settings.COOKIE_DOMAIN:
kwargs["domain"] = settings.COOKIE_DOMAIN
return kwargs
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def signup(
user_data: UserSignup,
@@ -30,12 +39,30 @@ def signup(
return AuthController.signup(db, user_data, tenant_id)
@router.post("/signin", response_model=TokenResponse)
def signin(signin_data: UserSignin, db: Session = Depends(get_db)):
return AuthController.signin(db, signin_data)
def signin(signin_data: UserSignin, response: Response, db: Session = Depends(get_db)):
result = AuthController.signin(db, signin_data)
cookie_kw = _cookie_kwargs()
access_max_age = settings.ACCESS_TOKEN_EXPIRES if signin_data.remember_me else None
refresh_max_age = settings.REFRESH_TOKEN_EXPIRES if signin_data.remember_me else None
response.set_cookie(key="access_token", value=result["access_token"], max_age=access_max_age, **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], max_age=refresh_max_age, **cookie_kw)
return result
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)):
return AuthController.refresh_token(db, token_data)
def refresh_token(
token_data: RefreshTokenRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
refresh_tok = token_data.refresh_token or request.cookies.get("refresh_token")
if not refresh_tok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
result = AuthController.refresh_token_raw(db, refresh_tok)
cookie_kw = _cookie_kwargs()
response.set_cookie(key="access_token", value=result["access_token"], **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], **cookie_kw)
return result
@router.get("/me", response_model=UserResponse)
def get_me(
@@ -56,21 +83,11 @@ def update_user(
@router.patch("/update/{user_id}/language", response_model=UserResponse)
def update_language(
user_id: uuid.UUID,
language_data: dict,
language_data: UpdateLanguageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
supported_languages = ["en", "ar"]
preferred_language = language_data.get("preferred_language")
if preferred_language not in supported_languages:
from fastapi import HTTPException
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported language. Supported languages: {', '.join(supported_languages)}"
)
user_update = UserUpdate(preferred_language=preferred_language)
user_update = UserUpdate(preferred_language=language_data.preferred_language.value)
return AuthController.update_user(db, user_id, user_update, current_user)
@router.post("/reset-password")
@@ -82,8 +99,18 @@ def reset_password(
return AuthController.reset_password(db, current_user, password_data)
@router.post("/logout")
def logout(current_user: User = Depends(get_current_user)):
return AuthController.logout(current_user)
def logout(
request: Request,
response: Response,
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
current_user: User = Depends(get_current_user),
):
token = credentials.credentials if credentials else request.cookies.get("access_token")
result = AuthController.logout(current_user, token)
cookie_kw = _cookie_kwargs()
response.delete_cookie(key="access_token", **cookie_kw)
response.delete_cookie(key="refresh_token", **cookie_kw)
return result
@router.post("/forgot-password")
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
+73 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, Query
from fastapi import APIRouter, Depends, status, Query, Request
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
@@ -13,20 +13,40 @@ from app.schemas.auth.role_schema import (
)
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role(
request: Request,
role_data: RoleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.create")),
):
return RoleController.create_role(
result = RoleController.create_role(
db, role_data, current_user.tenant_id if current_user.tenant_id else None
)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.role_name,
description=f"Role '{result.role_name}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={
"role_name": result.role_name,
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
}
)
return result
@router.get("/get", response_model=List[RoleResponse])
def get_all_roles(
db: Session = Depends(get_db),
@@ -48,32 +68,77 @@ def get_role(
@router.put("/update/{role_id}", response_model=RoleResponse)
def update_role(
request: Request,
role_id: uuid.UUID,
role_data: RoleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
existing = RoleController.get_role_by_id(db, role_id)
old_values = {"role_name": existing.role_name}
is_superadmin = current_user.tenant_id is None
return RoleController.update_role(
result = RoleController.update_role(
db, role_id, role_data, is_superadmin=is_superadmin
)
new_values = role_data.model_dump(mode='json', exclude_unset=True)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="UPDATE",
entity_id=str(role_id),
entity_name=result.role_name,
description=f"Role '{result.role_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=new_values
)
return result
@router.delete("/delete/{role_id}")
def delete_role(
request: Request,
role_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.delete")),
):
existing = RoleController.get_role_by_id(db, role_id)
role_name = existing.role_name
is_superadmin = current_user.tenant_id is None
return RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
result = RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="DELETE",
entity_id=str(role_id),
entity_name=role_name,
description=f"Role '{result.role_name}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"role_name": role_name}
)
return result
@router.get("/list", response_model=RolePaginatedResponse)
def list_roles(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in role name or ID"),
filter_role_names: Optional[List[str]] = Query(None, description="Filter by role names"),
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
sort_by: Optional[str] = Query(None, description="Sort by role name or tenant"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.read")),
@@ -84,4 +149,8 @@ def list_roles(
page=page,
page_size=page_size,
search=search,
filter_role_names=filter_role_names,
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.orm import Session
from typing import Optional
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
from app.controllers.auth.sso_controller import SSOController
public_router = APIRouter()
internal_router = APIRouter()
@public_router.post("/initiate")
def initiate_sso(
request: SSOInitiateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
User-facing endpoint to start SSO flow.
Returns a signed payload and target URL for the client to POST.
"""
return SSOController.initiate_sso(db, request, current_user)
@internal_router.post("/exchange")
def exchange_grant(
request: Request,
payload: SSOExchangeRequest,
db: Session = Depends(get_db),
x_module_signature: Optional[str] = Header(None, alias="X-Module-Signature"),
x_module_key: Optional[str] = Header(None, alias="X-Module-Key")
):
"""
Internal server-to-server endpoint for modules to exchange grant code for token.
Must be signed or authenticated via trust credentials.
"""
return SSOController.exchange_grant(
db=db,
payload=payload,
x_module_signature=x_module_signature,
x_module_key=x_module_key
)
+138
View File
@@ -0,0 +1,138 @@
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, status, Query, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.controllers.auth.subscription_plan_controller import SubscriptionPlanController
from app.schemas.auth.subscription_plan_schema import (
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
SubscriptionPlanResponse,
SubscriptionPlanDetailResponse,
SubscriptionPlanPaginatedResponse
)
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=SubscriptionPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
request: Request,
plan_data: SubscriptionPlanCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.create"))
):
result = SubscriptionPlanController.create_plan(db, plan_data)
AuditLogService.log(
db=db,
module_name="SubscriptionPlans",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.name,
description=f"Plan '{result.name}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=plan_data.model_dump(mode='json')
)
return result
@router.put("/update/{plan_id}", response_model=SubscriptionPlanResponse)
def update_plan(
request: Request,
plan_id: uuid.UUID,
plan_data: SubscriptionPlanUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.update"))
):
result = SubscriptionPlanController.update_plan(db, plan_id, plan_data)
AuditLogService.log(
db=db,
module_name="SubscriptionPlans",
action_type="UPDATE",
entity_id=str(plan_id),
entity_name=result.name,
description=f"Plan '{result.name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=plan_data.model_dump(mode='json')
)
return result
@router.get("/get/{plan_id}", response_model=SubscriptionPlanDetailResponse)
def get_plan(
plan_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.read"))
):
from app.services.auth.subscription_plan_service import SubscriptionPlanService
plan = SubscriptionPlanController.get_plan(db, plan_id)
return SubscriptionPlanService.serialize_plan_response(db, plan, detail=True)
@router.get("/list", response_model=SubscriptionPlanPaginatedResponse)
def list_plans(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
search: Optional[str] = Query(None),
filter_names: Optional[List[str]] = Query(None),
statuses: Optional[List[str]] = Query(None),
visibility: Optional[List[bool]] = Query(None, description="Filter by public/private visibility"),
sort_by: Optional[str] = Query(None, description="Sort by name, status, visibility, or price"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.read"))
):
return SubscriptionPlanController.get_paginated_plans(
db,
page,
page_size,
search,
filter_names=filter_names,
statuses=statuses,
visibility=visibility,
sort_by=sort_by,
sort_order=sort_order,
)
@router.get("/all", response_model=List[SubscriptionPlanResponse])
def all_plans(
is_public: Optional[bool] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.read"))
):
return SubscriptionPlanController.get_all_plans(db, is_public, status)
@router.delete("/delete/{plan_id}")
def delete_plan(
request: Request,
plan_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.plan.delete"))
):
result = SubscriptionPlanController.delete_plan(db, plan_id)
AuditLogService.log(
db=db,
module_name="SubscriptionPlans",
action_type="DELETE",
entity_id=str(plan_id),
entity_name=str(plan_id),
description=f"Plan '{plan_id}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request)
)
return result
+120 -21
View File
@@ -1,24 +1,65 @@
from fastapi import APIRouter, Depends, status, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, status, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.controllers.auth.tenant_controller import TenantController
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.middleware.tenant_middleware import get_tenant_id_from_user
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.services.auth.event_service import EventService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant(
request: Request,
tenant_data: TenantCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.create"))
):
return TenantController.create_tenant(db, tenant_data)
try:
# 1. Create tenant/owner/modules/events using flush
result = TenantController.create_tenant(db, tenant_data, commit=False)
# 2. Sanitize payload for audit log (never store raw passwords or hashes)
audit_values = tenant_data.model_dump(mode='json')
if "owner" in audit_values and isinstance(audit_values["owner"], dict):
audit_values["owner"]["password"] = "[PROTECTED]"
# 3. Create sanitized audit record using flush (strict=True)
AuditLogService.log(
db=db,
module_name="Tenants",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.tenant_name,
description=f"Tenant '{result.tenant_name}' onboarded",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=audit_values,
commit=False,
strict=True,
)
# 4. Perform single final commit
db.commit()
db.refresh(result)
# 5. Best-effort post-commit Redis wake-up notification (never breaks response)
pending_ids = getattr(result, "pending_event_ids", [])
if pending_ids:
EventService.enqueue_wakeups(pending_ids)
return result
except Exception:
db.rollback()
raise
@router.get("/get", response_model=List[TenantResponse])
def get_all_tenants(
@@ -26,17 +67,9 @@ def get_all_tenants(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read"))
):
# READ actions are typically not logged to avoid DB bloat
return TenantController.get_all_tenants(db)
@router.get("/get/{tenant_id}", response_model=TenantResponse)
def get_tenant(
tenant_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read"))
):
return TenantController.get_tenant_by_id(db, tenant_id)
@router.get("/me", response_model=TenantResponse)
def get_my_tenant(
db: Session = Depends(get_db),
@@ -44,30 +77,84 @@ def get_my_tenant(
):
tenant_id = get_tenant_id_from_user(current_user)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
raise HTTPException(status_code=404, detail="Tenant not found")
return TenantController.get_tenant_by_id(db, tenant_id)
@router.put("/update/{tenant_id}", response_model=TenantResponse)
def update_tenant(
request: Request,
tenant_id: uuid.UUID,
tenant_data: TenantUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.update"))
):
return TenantController.update_tenant(db, tenant_id, tenant_data)
# 1. Get snapshot BEFORE update for Audit Log
existing = TenantController.get_tenant_by_id(db, tenant_id)
old_snapshot = {
"tenant_name": existing.tenant_name,
"tenant_domain": existing.tenant_domain,
"tenant_logo_url": existing.tenant_logo_url,
"is_active": existing.is_active,
"plan_id": str(existing.plan_id) if existing.plan_id else None,
"start_date": existing.start_date.isoformat() if existing.start_date else None,
"end_date": existing.end_date.isoformat() if existing.end_date else None,
"status": existing.status,
}
# 2. Perform update
result = TenantController.update_tenant(db, tenant_id, tenant_data)
# 3. Prepare new values and indentify deltas
new_snapshot = tenant_data.model_dump(mode='json', exclude_unset=True)
old_values = {k: old_snapshot[k] for k in new_snapshot if k in old_snapshot and old_snapshot[k] != new_snapshot[k]}
new_values = {k: new_snapshot[k] for k in old_values}
# 4. Log the change
AuditLogService.log(
db=db,
module_name="Tenants",
action_type="UPDATE",
entity_id=str(tenant_id),
entity_name=result.tenant_name,
description=f"Tenant '{result.tenant_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=new_values
)
return result
@router.delete("/delete/{tenant_id}")
def delete_tenant(
request: Request,
tenant_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.delete"))
):
return TenantController.delete_tenant(db, tenant_id)
# 1. Get snapshot before deletion
existing = TenantController.get_tenant_by_id(db, tenant_id)
# 2. Perform deletion
result = TenantController.delete_tenant(db, tenant_id)
# 3. Log the deletion
AuditLogService.log(
db=db,
module_name="Tenants",
action_type="DELETE",
entity_id=str(tenant_id),
entity_name=existing.tenant_name,
description=f"Tenant '{existing.tenant_name}' removed",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"tenant_name": existing.tenant_name, "domain": existing.tenant_domain}
)
return result
@router.get("/list", response_model=TenantPaginatedResponse)
def list_tenants(
@@ -75,6 +162,12 @@ def list_tenants(
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in name, domain, or ID"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
filter_tenant_names: Optional[List[str]] = Query(None, description="Filter by tenant names"),
filter_tenant_domains: Optional[List[str]] = Query(None, description="Filter by tenant domains"),
filter_plan_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by plan IDs"),
statuses: Optional[List[bool]] = Query(None, description="Filter by tenant statuses"),
sort_by: Optional[str] = Query(None, description="Sort by name, domain, status, or plan"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read")),
@@ -85,4 +178,10 @@ def list_tenants(
page_size=page_size,
search=search,
is_active=is_active,
filter_tenant_names=filter_tenant_names,
filter_tenant_domains=filter_tenant_domains,
filter_plan_ids=filter_plan_ids,
statuses=statuses,
sort_by=sort_by,
sort_order=sort_order,
)
+81 -8
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, Query
from fastapi import APIRouter, Depends, status, Query, BackgroundTasks, Request
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
@@ -7,17 +7,38 @@ from app.controllers.auth.user_controller import UserController
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
request: Request,
user_data: UserCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.create"))
):
return UserController.create_user(db, user_data, current_user)
result = UserController.create_user(db, user_data, current_user, background_tasks)
AuditLogService.log(
db=db,
module_name="Users",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.email,
description=f"User '{result.email}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={
"email": result.email,
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
}
)
return result
@router.get("/get", response_model=List[UserResponse])
def get_all_users(
@@ -38,29 +59,75 @@ def get_user(
@router.put("/update/{user_id}", response_model=UserResponse)
def update_user(
request: Request,
user_id: uuid.UUID,
user_data: UserUpdate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.update"))
):
return UserController.update_user(db, user_id, user_data, current_user)
existing = UserController.get_user_by_id(db, user_id, current_user)
old_values = {"email": existing.email}
result = UserController.update_user(db, user_id, user_data, current_user, background_tasks)
AuditLogService.log(
db=db,
module_name="Users",
action_type="UPDATE",
entity_id=str(user_id),
entity_name=result.email,
description=f"User '{result.email}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=user_data.model_dump(mode='json', exclude_unset=True)
)
return result
@router.delete("/delete/{user_id}")
def delete_user(
request: Request,
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.delete"))
):
return UserController.delete_user(db, user_id, current_user)
existing = UserController.get_user_by_id(db, user_id, current_user)
user_email = existing.email
result = UserController.delete_user(db, user_id, current_user)
AuditLogService.log(
db=db,
module_name="Users",
action_type="DELETE",
entity_id=str(user_id),
entity_name=user_email,
description=f"User '{user_email}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"email": user_email}
)
return result
@router.get("/list", response_model=UserPaginatedResponse)
def list_users(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in email, name, phone, or ID"),
status: Optional[str] = Query(None, description="Filter by user status (e.g. active, inactive)"),
filter_names: Optional[List[str]] = Query(None, description="Filter by full names"),
filter_emails: Optional[List[str]] = Query(None, description="Filter by emails"),
statuses: Optional[List[str]] = Query(None, description="Filter by user statuses"),
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
filter_role_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by role IDs"),
sort_by: Optional[str] = Query(None, description="Sort by name, email, status, tenant, or role"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.read")),
@@ -71,5 +138,11 @@ def list_users(
page=page,
page_size=page_size,
search=search,
status=status,
)
filter_names=filter_names,
filter_emails=filter_emails,
statuses=statuses,
filter_tenant_ids=filter_tenant_ids,
filter_role_ids=filter_role_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
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, User, require_access
router = APIRouter()
@router.post("/{module_id}/permissions/sync")
def sync_module_permissions(
module_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Trigger synchronization of permissions for a specific module.
Should be restricted to platform admins or authorized roles.
"""
# TODO: Add specific permission check for "module.manage" or similar
# For now, allowing any authenticated user (or superadmin check) based on existing patterns
if current_user.tenant_id:
raise HTTPException(status_code=403, detail="Platform admin access required")
# Run sync
# We can run in background if it takes time, but synchronous gives immediate feedback
try:
result = ModulePermissionService.sync_permissions(db, module_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+1
View File
@@ -0,0 +1 @@
from .color_palette import router
+15 -10
View File
@@ -3,8 +3,12 @@ from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from app.config.database import get_db
from app.controllers.theme.palette_controller import PaletteController
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate, ColorPaletteResponse
from app.controllers.theme.color_palette_controller import PaletteController
from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
ColorPaletteResponse,
)
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
@@ -12,8 +16,7 @@ router = APIRouter()
@router.get("/get", response_model=List[ColorPaletteResponse])
def get_all_palettes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
return PaletteController.get_all_palettes(db, current_user)
@@ -21,20 +24,22 @@ def get_all_palettes(
def get_palette(
palette_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
return PaletteController.get_palette(db, palette_id)
@router.post("/create", response_model=ColorPaletteResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/create", response_model=ColorPaletteResponse, status_code=status.HTTP_201_CREATED
)
def create_palette(
data: ColorPaletteCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.create"))
_=Depends(require_access("superadmin.palette.create")),
):
return PaletteController.create_palette(db, data, current_user)
@router.put("/update/users/me/preferences", deprecated=True)
@router.put("/update/users/me/preferences", deprecated=True)
def update_user_preference():
pass
@@ -44,7 +49,7 @@ def update_palette(
data: ColorPaletteUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.update"))
_=Depends(require_access("superadmin.palette.update")),
):
return PaletteController.update_palette(db, palette_id, data)
@@ -53,6 +58,6 @@ def delete_palette(
palette_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.delete"))
_=Depends(require_access("superadmin.palette.delete")),
):
return PaletteController.delete_palette(db, palette_id)
+3 -1
View File
@@ -11,7 +11,9 @@ class AccessBase(BaseModel):
class AccessResponse(AccessBase):
id: uuid.UUID
parent_id: Optional[uuid.UUID] = None
module_id: Optional[uuid.UUID] = None
module_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
from_attributes = True
+26
View File
@@ -0,0 +1,26 @@
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
class AuditLogResponse(BaseModel):
id: UUID
module_name: str
action_type: str
entity_id: Optional[str] = None
entity_name: Optional[str] = None
description: Optional[str] = None
performed_by_email: Optional[str] = None
ip_address: Optional[str] = None
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
items: List[AuditLogResponse]
total: int
limit: int
offset: int
+7 -5
View File
@@ -16,6 +16,7 @@ class UserSignup(UserBase):
class UserSignin(BaseModel):
email: EmailStr
password: str
remember_me: bool = False
class AccessInRole(BaseModel):
id: str
@@ -24,8 +25,8 @@ class AccessInRole(BaseModel):
name: str
class RoleInUser(BaseModel):
id: uuid.UUID
role_name: str
id: Optional[uuid.UUID] = None
role_name: Optional[str] = None
accesses: List[str] = []
class UserResponse(UserBase):
@@ -33,6 +34,7 @@ class UserResponse(UserBase):
tenant_id: Optional[uuid.UUID] = None
tenant_name: Optional[str] = None
tenant_logo_url: Optional[str] = None
subscription_details: Optional[dict] = None
created_at: datetime
updated_at: datetime
role: Optional[RoleInUser] = None
@@ -55,7 +57,7 @@ class TokenResponse(BaseModel):
class RefreshTokenRequest(BaseModel):
refresh_token: str
refresh_token: Optional[str] = None
class ResetPassword(BaseModel):
old_password: str
@@ -67,10 +69,10 @@ class ForgotPasswordRequest(BaseModel):
class VerifyOTPRequest(BaseModel):
email: EmailStr
otp: str
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
class ResetPasswordWithOTP(BaseModel):
email: EmailStr
otp: str
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
new_password: str = Field(..., min_length=8)
+7 -1
View File
@@ -1,4 +1,10 @@
from pydantic import BaseModel
from enum import Enum
class LanguageEnum(str, Enum):
"""Supported languages for the application."""
EN = "en"
AR = "ar"
class UpdateLanguageRequest(BaseModel):
preferred_language: str
preferred_language: LanguageEnum
@@ -0,0 +1,50 @@
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: Optional[str] = "/sso/callback"
permission_sync_endpoint: Optional[str] = "/internal/permissions"
sso_exchange_endpoint: Optional[str]
provisioning_endpoint: Optional[str] = "/internal/tenants/provision"
trust_type: str
is_default: bool
is_active: bool
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class ModuleCreate(BaseModel):
module_id: str
module_name: str
description: Optional[str] = None
icon_url: Optional[str] = None
status: str = "active"
display_order: int = 0
class ModuleUpdate(BaseModel):
module_name: Optional[str] = None
description: Optional[str] = None
icon_url: Optional[str] = None
status: Optional[str] = None
display_order: Optional[int] = None
class ModuleResponse(BaseModel):
id: UUID
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
status: str
display_order: int
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class ModuleAvailableResponse(BaseModel):
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
display_order: int
is_active: bool
class Config:
from_attributes = True
+4 -3
View File
@@ -8,7 +8,7 @@ class RoleBase(BaseModel):
tenant_id: Optional[uuid.UUID] = None
class RoleCreate(RoleBase):
access_ids: List[uuid.UUID] = []
access_ids: List[uuid.UUID] = Field(default_factory=list)
is_default: Optional[bool] = False
class RoleUpdate(BaseModel):
@@ -19,6 +19,7 @@ class RoleUpdate(BaseModel):
class RoleResponse(RoleBase):
id: uuid.UUID
is_default: bool = False
assigned_modules: List[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -31,7 +32,7 @@ class RoleResponse(RoleBase):
from_attributes = True
class RoleWithAccessesResponse(RoleResponse):
accesses: List[dict] = []
accesses: List[dict] = Field(default_factory=list)
class Config:
from_attributes = True
@@ -41,4 +42,4 @@ class RolePaginatedResponse(BaseModel):
total: int
page: int
page_size: int
total_pages: int
total_pages: int
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class SSOInitiateRequest(BaseModel):
module_id: str
class SSOExchangeRequest(BaseModel):
grant_code: str
module_id: str
environment_slug: str
@@ -0,0 +1,73 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
import uuid
class SubscriptionPlanBase(BaseModel):
name: str = Field(..., min_length=2, max_length=100)
description: Optional[str] = None
price: Optional[float] = None
duration_days: Optional[int] = Field(None, ge=1)
max_users_allowed: Optional[int] = Field(None, ge=0)
is_public: bool = True
status: str = "active"
class SubscriptionPlanCreate(SubscriptionPlanBase):
access_ids: Optional[List[uuid.UUID]] = []
module_access_ids: Optional[List[uuid.UUID]] = []
class SubscriptionPlanUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=100)
description: Optional[str] = None
price: Optional[float] = None
duration_days: Optional[int] = Field(None, ge=1)
max_users_allowed: Optional[int] = Field(None, ge=0)
is_public: Optional[bool] = None
status: Optional[str] = None
access_ids: Optional[List[uuid.UUID]] = None
module_access_ids: Optional[List[uuid.UUID]] = None
class PlanApplicationEnvironmentSummary(BaseModel):
id: uuid.UUID
slug: str
is_default: bool = False
is_active: bool = True
frontend_base_url: str
sso_entry_path: Optional[str] = "/sso/callback"
backend_base_url: str
class Config:
from_attributes = True
class PlanApplicationSummary(BaseModel):
module_id: uuid.UUID
module_code: str
module_name: str
description: Optional[str] = None
icon_url: Optional[str] = None
display_order: int = 0
environments: List[PlanApplicationEnvironmentSummary] = []
class Config:
from_attributes = True
class SubscriptionPlanResponse(SubscriptionPlanBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
applications: List[PlanApplicationSummary] = []
class Config:
from_attributes = True
class SubscriptionPlanDetailResponse(SubscriptionPlanResponse):
access_ids: List[uuid.UUID] = []
module_access_ids: List[uuid.UUID] = []
applications: List[PlanApplicationSummary] = []
class SubscriptionPlanPaginatedResponse(BaseModel):
items: List[SubscriptionPlanResponse]
total: int
page: int
page_size: int
total_pages: int
+29
View File
@@ -0,0 +1,29 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class TenantModuleCreate(BaseModel):
module_id: str
assigned_environment_slug: Optional[str] = "prod"
is_active: bool = True
module_config: Optional[Dict[str, Any]] = None
class TenantModuleUpdate(BaseModel):
assigned_environment_slug: Optional[str] = None
is_active: Optional[bool] = None
module_config: Optional[Dict[str, Any]] = None
class TenantModuleResponse(BaseModel):
id: UUID
tenant_id: UUID
module_id: UUID
module_name: str
module_icon_url: Optional[str]
assigned_environment_slug: str
is_active: bool
module_config: Optional[Dict[str, Any]]
created_at: datetime
class Config:
from_attributes = True
+31 -3
View File
@@ -1,6 +1,6 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
from datetime import datetime
from datetime import date, datetime
import uuid
class TenantBase(BaseModel):
@@ -8,18 +8,46 @@ class TenantBase(BaseModel):
tenant_domain: str = Field(..., min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
class ModuleEnvironmentAssignment(BaseModel):
module_id: uuid.UUID
environment_slug: str
class TenantOwnerCreate(BaseModel):
first_name: str = Field(..., min_length=1, max_length=50)
last_name: str = Field(..., min_length=1, max_length=50)
email: EmailStr
password: str = Field(..., min_length=8)
phone_number: Optional[str] = None
class TenantCreate(TenantBase):
pass
plan_id: uuid.UUID
selected_module_ids: List[uuid.UUID] = Field(..., min_length=1)
module_environments: List[ModuleEnvironmentAssignment] = Field(default_factory=list)
owner: TenantOwnerCreate
start_date: Optional[date] = None
end_date: Optional[date] = None
status: Optional[str] = "ACTIVE"
class TenantUpdate(BaseModel):
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
is_active: Optional[bool] = None
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: Optional[str] = None
class TenantResponse(TenantBase):
id: uuid.UUID
tenant_id: uuid.UUID
is_active: bool
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: str
created_at: datetime
updated_at: datetime
+98 -5
View File
@@ -1,19 +1,112 @@
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from app.models.auth.access_model import Access
from typing import List
from typing import List, Any, Optional
import uuid
from app.core.redis import sync_redis_client
import json
import logging
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.module_model import Module
from app.models.auth.tenant_module_model import TenantModule
from datetime import datetime
logger = logging.getLogger(__name__)
class AccessService:
@staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[Access]:
def get_all_accesses(db: Session, category: str = None, tenant_id: Optional[uuid.UUID] = None) -> List[any]:
cache_key = f"saas:access:v4:active:{category if category else 'full'}:{str(tenant_id) if tenant_id else 'global'}"
cached_data = 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:
logger.warning(f"Access cache read error: {e}")
query = db.query(Access)
if category:
query = query.filter(Access.category == category)
return query.all()
if tenant_id:
query = query.filter(Access.category != "Superadmin")
saas_accesses = query.all()
for access in saas_accesses:
access.module_name = "SaaS (Internal)"
module_query = (
db.query(ModuleAccess)
.join(Module, ModuleAccess.module_id == Module.id)
.filter(Module.status == "active")
.options(joinedload(ModuleAccess.module))
)
if category:
module_query = module_query.filter(ModuleAccess.category == category)
if tenant_id:
active_tms = (
db.query(TenantModule.module_id)
.filter(
TenantModule.tenant_id == tenant_id,
TenantModule.is_active == True
)
.all()
)
active_module_ids = [tm[0] for tm in active_tms]
module_query = module_query.filter(ModuleAccess.module_id.in_(active_module_ids))
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_id": str(item.module_id) if getattr(item, "module_id", None) 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)
except Exception as e:
logger.warning(f"Access cache write error: {e}")
return result
@staticmethod
def get_access_categories(db: Session) -> List[str]:
categories = db.query(Access.category).distinct().all()
return [cat[0] for cat in categories]
module_categories = db.query(ModuleAccess.category).distinct().all()
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
return list(all_cats)
+240 -83
View File
@@ -1,12 +1,22 @@
from sqlalchemy import text
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.user_model import User
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
from app.config.security import security
from datetime import datetime, timedelta
import random
from datetime import datetime, timedelta, timezone
import secrets
import uuid
import logging
import jwt
from app.config.settings import settings
from app.services.auth.email_service import EmailService
from app.core.redis import sync_redis_client
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
logger = logging.getLogger(__name__)
class AuthService:
@@ -37,8 +47,20 @@ class AuthService:
@staticmethod
def signin(db: Session, signin_data: UserSignin):
# Sign-in starts before the caller's tenant is known. Use the database's
# transaction-local discovery bypass only for the globally unique email
# lookup, then immediately restore tenant isolation for the rest of the
# authentication request.
db.execute(text("SELECT set_config('app.bypass_rls', 'on', true)"))
user = db.query(User).filter(User.email == signin_data.email).first()
db.execute(text("SELECT set_config('app.bypass_rls', 'off', true)"))
if user and user.tenant_id:
db.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(user.tenant_id)},
)
if not user or not security.verify_password(
signin_data.password, user.password
):
@@ -47,12 +69,15 @@ class AuthService:
if user.status != "active":
raise HTTPException(status_code=403, detail="User inactive")
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None
if user.role:
if user.role or effective_accesses:
role_data = {
"id": str(user.role.id),
"role_name": user.role.role_name,
"accesses": [ra.access.access_code for ra in user.role.role_accesses],
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
}
return {
@@ -73,6 +98,9 @@ class AuthService:
"tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
@@ -83,6 +111,13 @@ class AuthService:
def refresh_access_token(db: Session, refresh_token: str):
payload = security.verify_refresh_token(refresh_token)
user_id = payload.get("sub")
token_tenant_id = payload.get("tenant_id")
if token_tenant_id:
db.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(token_tenant_id)},
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
@@ -102,11 +137,38 @@ class AuthService:
{"sub": str(user.id)}, tenant_id=user.tenant_id
)
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None
if user.role or effective_accesses:
role_data = {
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
}
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
"user": user,
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"status": user.status,
"tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
},
}
@staticmethod
@@ -159,20 +221,30 @@ class AuthService:
return {"message": "Password updated successfully"}
@staticmethod
def logout(current_user: User):
def logout(current_user: User, token: str):
try:
payload = jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp and sync_redis_client.client:
remaining_ttl = int(exp - datetime.now(timezone.utc).timestamp())
if remaining_ttl > 0:
sync_redis_client.client.setex(f"blacklist:{jti}", remaining_ttl, "1")
except Exception as e:
logger.warning(f"Failed to blacklist token on logout: {e}")
return {"message": "Logged out successfully"}
@staticmethod
def me(db: Session, current_user: User):
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, current_user)
)
role_data = None
if current_user.role:
if current_user.role or effective_accesses:
role_data = {
"id": str(current_user.role.id),
"role_name": current_user.role.role_name,
"accesses": [
ra.access.access_code for ra in current_user.role.role_accesses
],
"id": str(current_user.role.id) if current_user.role else None,
"role_name": current_user.role.role_name if current_user.role else "subscription",
"accesses": effective_accesses,
}
return {
@@ -189,6 +261,9 @@ class AuthService:
"tenant_logo_url": (
current_user.tenant.tenant_logo_url if current_user.tenant else None
),
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, current_user.tenant_id
),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
"role": role_data,
@@ -196,92 +271,174 @@ class AuthService:
@staticmethod
def forgot_password(db: Session, email: str):
print(f"DEBUG: Processing forgot_password for email: {email}")
"""Generate and send OTP for password reset via Redis storage."""
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"DEBUG: User not found for email: {email}")
return {"message": "If the email is registered, an OTP has been sent."}
print(f"DEBUG: User found: {user.id}")
otp_code = "".join([str(random.randint(0, 9)) for _ in range(6)])
expires_at = datetime.utcnow() + timedelta(minutes=10)
print(f"DEBUG: Generated OTP: {otp_code}, Expires: {expires_at}")
db.query(PasswordResetOTP).filter(
PasswordResetOTP.email == email, PasswordResetOTP.is_used == False
).update({"is_used": True})
otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
redis_key = f"otp:{email}"
try:
if sync_redis_client.client:
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
logger.info(f"OTP generated for email: {email}")
else:
logger.error("Redis client unavailable for OTP storage")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
except Exception as e:
logger.error(f"Redis error storing OTP: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate OTP"
)
try:
otp_entry = PasswordResetOTP(
email=email, otp=otp_code, expires_at=expires_at
)
db.add(otp_entry)
db.commit()
print("DEBUG: OTP stored in database successfully")
EmailService.send_otp(email, otp_code)
except Exception as e:
print(f"DEBUG: Database error saving OTP: {e}")
db.rollback()
raise e
# Send Email
print("DEBUG: Attempting to send email...")
email_sent = EmailService.send_otp(email, otp_code)
print(f"DEBUG: Email sending result: {email_sent}")
logger.error(f"Failed to send OTP email to {email}: {e}")
return {"message": "If the email is registered, an OTP has been sent."}
@staticmethod
def _check_otp_attempts(email: str):
"""Check if OTP attempts are exceeded. Raises 429 if locked out."""
attempts_key = f"otp_attempts:{email}"
try:
attempts = sync_redis_client.client.get(attempts_key)
if attempts and int(attempts) >= 5:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again in 15 minutes."
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Redis error checking OTP attempts: {e}")
@staticmethod
def _increment_otp_attempts(email: str):
"""Increment failed OTP attempt counter with 15-minute TTL."""
attempts_key = f"otp_attempts:{email}"
try:
pipe = sync_redis_client.client.pipeline()
pipe.incr(attempts_key)
pipe.expire(attempts_key, 900)
pipe.execute()
except Exception as e:
logger.warning(f"Redis error incrementing OTP attempts: {e}")
@staticmethod
def verify_otp(db: Session, email: str, otp: str):
otp_entry = (
db.query(PasswordResetOTP)
.filter(
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp,
PasswordResetOTP.is_used == False,
PasswordResetOTP.expires_at > datetime.utcnow(),
)
.first()
)
"""Verify OTP from Redis storage using constant-time comparison."""
redis_key = f"otp:{email}"
if not otp_entry:
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verification service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
sync_redis_client.client.setex(f"otp_verified:{email}", 300, "1")
logger.info(f"OTP verified successfully for email: {email}")
return {"message": "OTP verified successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Redis error verifying OTP: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify OTP"
)
return {"message": "OTP verified successfully"}
@staticmethod
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
otp_entry = (
db.query(PasswordResetOTP)
.filter(
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp,
PasswordResetOTP.is_used == False,
PasswordResetOTP.expires_at > datetime.utcnow(),
)
.first()
)
"""Reset password after verifying OTP and delete OTP from Redis."""
redis_key = f"otp:{email}"
verified_key = f"otp_verified:{email}"
if not otp_entry:
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
is_pre_verified = sync_redis_client.client.get(verified_key)
if not is_pre_verified:
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not security.validate_password_strength(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password too weak"
)
user.password = security.hash_password(new_password)
user.password_updated_at = datetime.now(timezone.utc)
db.commit()
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(verified_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
logger.info(f"Password reset successfully for email: {email}")
return {"message": "Password updated successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting password: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not security.validate_password_strength(new_password):
raise HTTPException(status_code=400, detail="Password too weak")
user.password = security.hash_password(new_password)
user.password_updated_at = datetime.utcnow()
otp_entry.is_used = True
db.commit()
return {"message": "Password updated successfully"}
+10 -16
View File
@@ -1,41 +1,35 @@
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os
from dotenv import load_dotenv
import logging
from app.config.settings import settings
load_dotenv()
logger = logging.getLogger(__name__)
class EmailService:
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
EMAIL_FROM = os.getenv("EMAIL_FROM")
SMTP_SECURE = os.getenv("SMTP_SECURE", "true").lower() == "true"
@staticmethod
def send_otp(to_email: str, otp: str):
try:
msg = MIMEMultipart()
msg['From'] = EmailService.EMAIL_FROM
msg['From'] = settings.EMAIL_FROM
msg['To'] = to_email
msg['Subject'] = "Password Reset OTP"
body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes."
msg.attach(MIMEText(body, 'plain'))
if EmailService.SMTP_SECURE:
server = smtplib.SMTP_SSL(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
if settings.SMTP_SECURE:
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
else:
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
server.starttls()
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD)
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
text = msg.as_string()
server.sendmail(EmailService.EMAIL_FROM, to_email, text)
server.sendmail(settings.EMAIL_FROM, to_email, text)
server.quit()
return True
except Exception as e:
print(f"Failed to send email: {e}")
logger.error(f"Failed to send email: {e}")
return False
+313
View File
@@ -0,0 +1,313 @@
import uuid
import httpx
import json
import logging
import time
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
import hmac
import hashlib
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.module_model import Module
from app.models.auth.tenant_module_model import TenantModule
from app.models.system.event_log_model import EventLog, EventStatus
from app.services.auth.trust_service import TrustService
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
class EventService:
@staticmethod
def emit_event(
db: Session,
event_type: str,
payload: Dict[str, Any],
tenant_id: Optional[uuid.UUID] = None,
follow_up_event: Optional[Dict[str, Any]] = None
) -> Optional[str]:
"""
Emits an event by writing it to the Outbox (event_logs) and flushing.
Does NOT notify Redis directly before database transaction commit.
Returns the logical event_id on success, or None if no targets exist.
"""
event_id = str(uuid.uuid4())
timestamp = datetime.now(timezone.utc).isoformat()
if "event_id" not in payload:
payload["event_id"] = event_id
final_payload = {
"event_id": event_id,
"event_type": event_type,
"timestamp": timestamp,
"data": payload
}
targets: List[ModuleEnvironment] = []
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
targets_list = payload.get("targets")
if targets_list and isinstance(targets_list, list):
target_configs = targets_list
for target in target_configs:
module_id = target.get("module_id")
env_slug = target.get("environment_slug")
if module_id and env_slug:
mod_uuid = uuid.UUID(str(module_id)) if not isinstance(module_id, uuid.UUID) else module_id
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == mod_uuid,
ModuleEnvironment.slug == env_slug
).first()
if env:
targets.append(env)
elif tenant_id:
tenant_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.is_active == True
).all()
for tm in tenant_modules:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == tm.module_id,
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod")
).first()
if not env:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == tm.module_id,
ModuleEnvironment.is_default == True
).first()
if env:
targets.append(env)
if not targets:
logger.warning(f"Event {event_type} (id={event_id}) emitted with no resolved targets.")
return None
for env in targets:
base = env.backend_base_url.rstrip('/')
if event_type in {"TENANT_PROVISION_REQUESTED", "TENANT_UPDATED", "TENANT_STATUS_CHANGED", "TENANT_DEPROVISION_REQUESTED"} and env.provisioning_endpoint:
endpoint = env.provisioning_endpoint.lstrip('/')
target_url = f"{base}/{endpoint}"
else:
target_url = f"{base}/api/internal/events"
log = EventLog(
event_id=uuid.UUID(event_id),
event_type=event_type,
payload=final_payload,
target_module_id=env.module_id,
target_environment_slug=env.slug,
target_url=target_url,
status=EventStatus.PENDING,
next_retry_at=datetime.now(timezone.utc),
follow_up_event=follow_up_event
)
db.add(log)
db.flush()
return event_id
@staticmethod
def enqueue_wakeups(event_ids: List[str]) -> None:
"""
Best-effort Redis notification to wake up workers after DB commit.
A Redis failure here must never break the main caller since durable Outbox exists.
"""
if not event_ids:
return
for eid in event_ids:
if not eid:
continue
try:
sync_redis_client.rpush("saas:events:queue", eid)
except Exception as e:
logger.warning(f"Failed to push wake-up event {eid} 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
raw_bytes = json.dumps(log.payload, separators=(',', ':')).encode("utf-8")
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
key_id = env.trust_credentials.get("key_id", "v1") if env.trust_credentials else "v1"
timestamp = str(int(time.time() * 1000))
if secret:
body_hash = hashlib.sha256(raw_bytes).hexdigest()
canonical = f"{timestamp}.{body_hash}"
signature = hmac.new(
secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-Integration-Timestamp": timestamp,
"X-Integration-Signature": signature,
"X-Integration-Key-Id": key_id,
"X-Integration-Source": "saas-core",
"X-SaaS-Timestamp": timestamp,
"X-SaaS-Signature": signature,
"X-Signature": signature,
"X-SaaS-Event-Source": "saas-core"
}
logger.info(f"Sending event {log.event_type} to {log.target_url}")
response = httpx.post(log.target_url, content=raw_bytes, headers=headers, timeout=5)
if response.status_code in range(200, 300):
log.status = EventStatus.COMPLETED
log.error_log = None
processed_count += 1
if log.follow_up_event:
follow_up = log.follow_up_event
logger.info(f"Triggering follow-up event {follow_up.get('event_type')} after {log.event_type} completed")
EventService.emit_event(
db,
event_type=follow_up["event_type"],
payload=follow_up["payload"],
tenant_id=uuid.UUID(follow_up["tenant_id"]) if follow_up.get("tenant_id") else None
)
else:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
log.error_log = f"HTTP {response.status_code}: {response.text}"
if log.retry_count > 10:
log.status = EventStatus.FAILED
except Exception as e:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
log.error_log = str(e)
db.commit()
return processed_count
@staticmethod
def process_outbox(db: Session, batch_size: int = 50):
"""
Worker method to process pending events.
"""
now = datetime.now(timezone.utc)
logs = db.query(EventLog).filter(
EventLog.status == EventStatus.PENDING,
EventLog.next_retry_at <= now
).limit(batch_size).all()
if logs:
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
for log in logs:
try:
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
raw_bytes = json.dumps(log.payload, separators=(',', ':')).encode("utf-8")
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
key_id = env.trust_credentials.get("key_id", "v1") if env.trust_credentials else "v1"
timestamp = str(int(time.time() * 1000))
if secret:
body_hash = hashlib.sha256(raw_bytes).hexdigest()
canonical = f"{timestamp}.{body_hash}"
signature = hmac.new(
secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-Integration-Timestamp": timestamp,
"X-Integration-Signature": signature,
"X-Integration-Key-Id": key_id,
"X-Integration-Source": "saas-core",
"X-SaaS-Timestamp": timestamp,
"X-SaaS-Signature": signature,
"X-Signature": signature,
"X-SaaS-Event-Source": "saas-core"
}
logger.info(f"Sending event {log.event_type} (id={log.event_id}) to {log.target_url}")
response = httpx.post(log.target_url, content=raw_bytes, headers=headers, timeout=5)
if response.status_code in range(200, 300):
log.status = EventStatus.COMPLETED
log.error_log = None
if log.follow_up_event:
follow_up = log.follow_up_event
logger.info(f"Triggering follow-up event {follow_up.get('event_type')} after {log.event_type} completed")
EventService.emit_event(
db,
event_type=follow_up["event_type"],
payload=follow_up["payload"],
tenant_id=uuid.UUID(follow_up["tenant_id"]) if follow_up.get("tenant_id") else None
)
else:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = now + timedelta(seconds=backoff)
log.error_log = f"HTTP {response.status_code}: {response.text}"
if log.retry_count > 10:
log.status = EventStatus.FAILED
except Exception as e:
log.retry_count += 1
backoff = min(60 * (2 ** log.retry_count), 86400)
log.next_retry_at = now + timedelta(seconds=backoff)
log.error_log = str(e)
db.commit()
return len(logs)
@@ -0,0 +1,115 @@
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)
try:
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 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()
@@ -0,0 +1,161 @@
import requests
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from fastapi import HTTPException
from typing import List, Dict, Any
import hmac
import hashlib
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.access_model import Access
from app.services.auth.trust_service import TrustService
from app.models.auth.module_access_model import ModuleAccess
import uuid
from app.core.redis import sync_redis_client
class ModulePermissionService:
@staticmethod
def sync_permissions(db: Session, module_id: str):
"""
Connects to the module's default environment and fetches defined permissions.
Updates the local ModuleAccess table to mirror these permissions.
:param module_id: The UUID string of the module
"""
try:
module_uuid = uuid.UUID(module_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid module UUID")
module = db.query(Module).filter(Module.id == module_uuid).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).first()
if not env:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_active == True
).first()
if not env:
raise HTTPException(status_code=400, detail="No active environment to sync from")
try:
payload_body = "{}"
secret = env.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError("Module environment is missing its HMAC trust secret")
signature = hmac.new(
secret.encode("utf-8"),
payload_body.encode("utf-8"),
hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature
}
url = f"{env.backend_base_url.rstrip('/')}/{env.permission_sync_endpoint.lstrip('/')}"
response = requests.post(url, headers=headers, data=payload_body, timeout=10)
response.raise_for_status()
data = response.json()
except Exception as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch permissions from module: {str(e)}")
permissions: List[Dict[str, Any]] = data.get("permissions", [])
synced_count = 0
timestamp = datetime.now(timezone.utc)
incoming_codes = {
perm.get("permission_code")
for perm in permissions
if perm.get("permission_code")
}
existing_accesses = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code.in_(incoming_codes),
).all() if incoming_codes else []
permission_map = {
access.access_code: access for access in existing_accesses
}
for perm in permissions:
code = perm.get("permission_code")
if not code:
continue
access = permission_map.get(code)
if not access:
access = ModuleAccess(
access_code=code,
module_id=module.id,
name=perm.get("name", code),
category=perm.get("category", "General"),
)
db.add(access)
permission_map[code] = access
else:
access.name = perm.get("name", access.name)
access.category = perm.get("category", access.category)
access.last_synced_at = timestamp
access.sync_checksum = perm.get("hash")
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 access and parent_access:
access.parent_id = parent_access.id
db.commit()
try:
if sync_redis_client.client:
sync_redis_client.client.delete("saas:access:v2:all:full")
except Exception:
pass
return {
"status": "success",
"message": "Permissions synced successfully",
"synced_count": synced_count
}
@staticmethod
def get_module_permissions(db: Session, module_id: str):
"""List permissions for a module from local DB."""
try:
module_uuid = uuid.UUID(module_id)
module = db.query(Module).filter(Module.id == module_uuid).first()
except ValueError:
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id
).all()
+100
View File
@@ -0,0 +1,100 @@
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.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
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],
role_id: Optional[uuid.UUID] = None,
) -> List[dict]:
if tenant_id:
results = db.query(Module, TenantModule.is_active).join(
TenantModule,
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == tenant_id)
).join(
ModuleAccess, ModuleAccess.module_id == Module.id
).join(
RoleModuleAccess,
(RoleModuleAccess.module_access_id == ModuleAccess.id) &
(RoleModuleAccess.role_id == role_id)
).filter(
Module.status == "active",
TenantModule.is_active == True
).distinct().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 role_can_launch_module(db: Session, role_id: Optional[uuid.UUID], module_id: uuid.UUID) -> bool:
if not role_id:
return False
return db.query(RoleModuleAccess.id).join(
ModuleAccess, ModuleAccess.id == RoleModuleAccess.module_access_id
).filter(
RoleModuleAccess.role_id == role_id,
ModuleAccess.module_id == module_id,
).first() is not None
@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()
+273 -18
View File
@@ -1,17 +1,30 @@
import uuid
import json
import logging
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.access_model import Access
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
from typing import List, Optional
import uuid
from app.services.auth.event_service import EventService
logger = logging.getLogger(__name__)
class RoleService:
@staticmethod
def create_role(db: Session, role_data: RoleCreate) -> Role:
def create_role(
db: Session,
role_data: RoleCreate,
emit_events: bool = True,
commit: bool = True,
) -> Role:
existing = (
db.query(Role)
.filter(
@@ -34,30 +47,137 @@ class RoleService:
)
db.add(role)
db.commit()
db.refresh(role)
if commit:
db.commit()
db.refresh(role)
else:
db.flush()
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, commit=commit
)
if emit_events:
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
)
if commit:
db.commit()
else:
db.flush()
return role
@staticmethod
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
def assign_accesses(
db: Session,
role_id: uuid.UUID,
access_ids: List[uuid.UUID],
commit: bool = True,
):
role = db.query(Role).filter(Role.id == role_id).first()
for access_id in access_ids:
access = db.query(Access).filter(Access.id == access_id).first()
if not access:
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
db.query(RoleModuleAccess).filter(RoleModuleAccess.role_id == role_id).delete()
if not access_ids:
return
saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
saas_ids = {a.id for a in saas_accesses}
if role and role.tenant_id:
if any(a.category == "Superadmin" for a in saas_accesses):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Access {access_id} not found",
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot assign superadmin permissions to a tenant role.",
)
role_access = RoleAccess(role_id=role_id, access_id=access_id)
db.add(role_access)
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()
if role and role.tenant_id:
from app.models.auth.tenant_module_model import TenantModule
active_tms = (
db.query(TenantModule.module_id)
.filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.is_active == True,
)
.all()
)
allowed_module_ids = {tm[0] for tm in active_tms}
for ma in module_accesses:
if ma.module_id not in allowed_module_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot assign permission '{ma.name}' for unsubscribed module to a tenant role.",
)
db.commit()
for access in module_accesses:
db.add(RoleModuleAccess(role_id=role_id, module_access_id=access.id))
if commit:
db.commit()
else:
db.flush()
@staticmethod
def get_all_roles(db: Session, tenant_id: uuid.UUID = None):
@@ -84,7 +204,25 @@ class RoleService:
) -> Role:
role = RoleService.get_role_by_id(db, role_id)
def get_module_permissions_snapshot(r_id):
snapshot_data = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == r_id)
.all()
)
snapshot_map = {}
for rma, ma in snapshot_data:
mid = str(ma.module_id)
if mid not in snapshot_map:
snapshot_map[mid] = set()
snapshot_map[mid].add(ma.access_code)
return snapshot_map
before_snapshot = get_module_permissions_snapshot(role_id)
update_dict = role_data.model_dump(exclude_unset=True)
role_name_changed = "role_name" in update_dict
if role.is_default and not is_superadmin:
raise HTTPException(
@@ -102,6 +240,61 @@ class RoleService:
db.commit()
db.refresh(role)
after_snapshot = get_module_permissions_snapshot(role_id)
all_modules = set(before_snapshot.keys()) | set(after_snapshot.keys())
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_([uuid.UUID(m) for m in all_modules])
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
diff_targets = []
for mid in all_modules:
before_set = before_snapshot.get(mid, set())
after_set = after_snapshot.get(mid, set())
added = list(after_set - before_set)
removed = list(before_set - after_set)
is_active_module = mid in after_snapshot and len(after_snapshot[mid]) > 0
if added or removed or (role_name_changed and is_active_module):
env_slug = env_map.get(mid, "prod")
diff_targets.append({
"module_id": mid,
"environment_slug": env_slug,
"added_permissions": added,
"removed_permissions": removed
})
if diff_targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"role_name": role.role_name,
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": diff_targets
}
logger.info(f"ROLE_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_UPDATED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
return role
@staticmethod
@@ -113,6 +306,49 @@ class RoleService:
status_code=status.HTTP_403_FORBIDDEN,
detail="Default roles can only be deleted by superadmins.",
)
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.distinct()
.all()
)
if active_modules:
module_ids = [m[0] for m in active_modules]
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_(module_ids)
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for m in active_modules:
mid = str(m[0])
env_slug = env_map.get(mid, "prod")
targets.append({"module_id": mid, "environment_slug": env_slug})
if targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": targets
}
logger.info(f"ROLE_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.delete(role)
db.commit()
@@ -125,6 +361,10 @@ class RoleService:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
filter_role_names: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> RolePaginatedResponse:
query = db.query(Role)
@@ -132,6 +372,12 @@ class RoleService:
if tenant_id is not None:
query = query.filter(Role.tenant_id == tenant_id)
if filter_role_names:
query = query.filter(Role.role_name.in_(filter_role_names))
if filter_tenant_ids:
query = query.filter(Role.tenant_id.in_(filter_tenant_ids))
if search and search.strip():
search_term = search.strip()
query = query.filter(
@@ -141,6 +387,15 @@ class RoleService:
)
)
sort_column_map = {
"name": Role.role_name,
"tenant": Role.tenant_id,
}
sort_column = sort_column_map.get(sort_by or "")
if sort_column is not None:
order_fn = desc if (sort_order or "").lower() == "desc" else asc
query = query.order_by(order_fn(sort_column))
total = query.count()
offset = (page - 1) * page_size
@@ -154,4 +409,4 @@ class RoleService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
+280
View File
@@ -0,0 +1,280 @@
import uuid
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from sqlalchemy import text
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.sso_grant_model import SSOGrant
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.user_model import User
from app.config.security import security
from app.services.auth.trust_service import TrustService
from app.core.redis import sync_redis_client
import json
import time
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
from app.services.auth.module_service import ModuleService
logger = logging.getLogger(__name__)
class SSOService:
@staticmethod
def generate_grant(
db: Session,
user_id: uuid.UUID,
module_id: str,
tenant_id: Optional[uuid.UUID] = None
) -> Dict[str, str]:
"""
Generates a one-time SSO grant code for the specified module.
Resolves the correct environment URL based on tenant/user config.
"""
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
if module.status != "active":
raise HTTPException(status_code=403, detail="Module is disabled")
environment_slug = "prod"
if tenant_id:
tm = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.module_id == module.id
).first()
if not tm or not tm.is_active:
raise HTTPException(status_code=403, detail="Tenant does not have access to this module")
user = db.query(User).filter(User.id == user_id).first()
if not user or not ModuleService.role_can_launch_module(db, user.role_id, module.id):
raise HTTPException(status_code=403, detail="User is not assigned to this module")
if 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.")
grant_code = str(uuid.uuid4().hex)
frontend_base_url = env.frontend_base_url.rstrip("/")
entry_path = env.sso_entry_path or "/sso/callback"
if not entry_path.startswith("/"):
entry_path = f"/{entry_path}"
redirect_url = f"{frontend_base_url}{entry_path}?grant={grant_code}"
grant_data = json.dumps({
"user_id": str(user_id),
"module_id": str(module.id),
"tenant_id": str(tenant_id) if tenant_id else None,
"environment_slug": env.slug,
"created_at": datetime.now(timezone.utc).isoformat(),
})
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSO service temporarily unavailable"
)
sync_redis_client.client.setex(f"sso_grant:{grant_code}", 60, grant_data)
return {
"grant_code": grant_code,
"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 not tm or not tm.is_active:
raise HTTPException(status_code=403, detail="Tenant does not have access to this module")
user = db.query(User).filter(User.id == user_id).first()
if not user or not ModuleService.role_can_launch_module(db, user.role_id, module.id):
raise HTTPException(status_code=403, detail="User is not assigned to this module")
if 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")
# SaaS controls identity and module entry. Operational authorization is
# deliberately owned by the destination module.
permissions = []
timestamp = int(time.time() * 1000)
subscription_details = SubscriptionEntitlementService.get_subscription_summary(
db, tenant_id
)
payload_data = {
"user_id": str(user.id),
"email": user.email,
"tenant_id": str(tenant_id) if tenant_id else None,
"permissions": permissions,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"subscription": subscription_details,
"timestamp": timestamp,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.role_name if user.role else None
}
tenant_id_str = str(tenant_id) if tenant_id else ""
canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}&timestamp={timestamp}"
try:
signature = TrustService.sign_payload(env, canonical_string)
except ValueError:
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
base_url = env.backend_base_url.rstrip('/')
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
if not path.startswith('/'):
path = '/' + path
target_url = f"{base_url}{path}"
return {
"target_url": target_url,
"payload": payload_data,
"headers": {
"X-App-Id": "saas",
"X-App-Id": module.module_id,
"X-Signature": signature
},
"redirect_url": env.frontend_base_url
}
@staticmethod
def exchange_grant(
db: Session,
grant_code: str,
module_id: str,
environment_slug: str
) -> Dict[str, Any]:
"""
Validates grant and returns a short-lived module-scoped token.
This is called by the Module Backend.
Grants are stored in Redis — atomically deleted on exchange (one-time use).
"""
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSO service temporarily unavailable"
)
redis_key = f"sso_grant:{grant_code}"
pipe = sync_redis_client.client.pipeline()
pipe.get(redis_key)
pipe.delete(redis_key)
grant_json, _ = pipe.execute()
if not grant_json:
raise HTTPException(status_code=401, detail="Invalid or expired grant code")
grant_data = json.loads(grant_json)
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or str(module.id) != grant_data["module_id"]:
raise HTTPException(status_code=401, detail="Grant invalid for this module")
if grant_data["environment_slug"] != environment_slug:
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
grant_tenant_id = grant_data.get("tenant_id")
if grant_tenant_id:
db.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(grant_tenant_id)},
)
else:
db.execute(text("SELECT set_config('app.bypass_rls', 'on', true)"))
user = db.query(User).filter(User.id == grant_data["user_id"]).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if grant_tenant_id:
if str(user.tenant_id) != grant_tenant_id:
raise HTTPException(
status_code=401,
detail="Tenant mismatch for SSO grant"
)
permissions = sorted(
SubscriptionEntitlementService.get_effective_module_access_codes(
db, user, module.id
)
)
token_payload = {
"sub": str(user.id),
"email": user.email,
"name": " ".join(
part for part in [user.first_name, user.last_name] if part
) or user.email,
"tenant_id": grant_tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"module_id": module_id,
"environment": environment_slug,
"permissions": permissions,
"role_id": str(user.role.id) if user.role else None,
"roles": [user.role.role_name] if user.role else []
}
token = security.generate_module_token(token_payload, module_id)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 900,
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name
}
}
@@ -0,0 +1,137 @@
from __future__ import annotations
import uuid
from typing import Any, Dict, Optional, Set
from sqlalchemy.orm import Session
from app.models.auth.access_model import Access
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User
class SubscriptionEntitlementService:
@staticmethod
def get_tenant(db: Session, tenant_id: Optional[uuid.UUID]) -> Optional[Tenant]:
if not tenant_id:
return None
return db.query(Tenant).filter(Tenant.id == tenant_id).first()
@staticmethod
def get_plan_access_codes(
db: Session,
tenant_id: Optional[uuid.UUID],
) -> Set[str]:
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
if not tenant or not tenant.plan_id:
return set()
rows = (
db.query(Access.access_code)
.join(PlanAccess, PlanAccess.access_id == Access.id)
.filter(PlanAccess.plan_id == tenant.plan_id)
.all()
)
return {row[0] for row in rows}
@staticmethod
def get_plan_module_access_codes(
db: Session,
tenant_id: Optional[uuid.UUID],
module_id: Optional[uuid.UUID] = None,
) -> Set[str]:
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
if not tenant or not tenant.plan_id:
return set()
query = (
db.query(ModuleAccess.access_code)
.join(
PlanModuleAccess,
PlanModuleAccess.module_access_id == ModuleAccess.id,
)
.filter(PlanModuleAccess.plan_id == tenant.plan_id)
)
if module_id:
query = query.filter(ModuleAccess.module_id == module_id)
rows = query.all()
return {row[0] for row in rows}
@staticmethod
def get_effective_module_access_codes(
db: Session,
user: User,
module_id: uuid.UUID,
) -> Set[str]:
"""Return permissions both purchased by the tenant and granted to the user role."""
if not user.role:
return set()
role_codes = {
row.module_access.access_code
for row in user.role.role_module_accesses
if row.module_access and row.module_access.module_id == module_id
}
tenant = SubscriptionEntitlementService.get_tenant(db, user.tenant_id)
if not tenant or not tenant.plan_id:
return role_codes
plan_codes = SubscriptionEntitlementService.get_plan_module_access_codes(
db, user.tenant_id, module_id
)
return role_codes & plan_codes
@staticmethod
def get_effective_access_codes(db: Session, user: User) -> Set[str]:
role_codes = set()
active_module_ids = set()
if user.tenant_id:
from app.models.auth.tenant_module_model import TenantModule
active_tms = (
db.query(TenantModule.module_id)
.filter(
TenantModule.tenant_id == user.tenant_id,
TenantModule.is_active == True,
)
.all()
)
active_module_ids = {tm[0] for tm in active_tms}
if user.role:
role_codes = {
ra.access.access_code
for ra in user.role.role_accesses
if ra.access is not None
}
for rma in user.role.role_module_accesses:
if rma.module_access is not None:
if not user.tenant_id or rma.module_access.module_id in active_module_ids:
role_codes.add(rma.module_access.access_code)
plan_codes = SubscriptionEntitlementService.get_plan_access_codes(
db, user.tenant_id
)
return role_codes | plan_codes
@staticmethod
def get_subscription_summary(
db: Session, tenant_id: Optional[uuid.UUID]
) -> Optional[Dict[str, Any]]:
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
if not tenant:
return None
plan_name = tenant.plan.name if tenant.plan else None
return {
"plan_id": str(tenant.plan_id) if tenant.plan_id else None,
"plan_name": plan_name,
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
"start_date": tenant.start_date.isoformat() if tenant.start_date else None,
"end_date": tenant.end_date.isoformat() if tenant.end_date else None,
"status": tenant.status,
"is_active": tenant.is_active,
}
@@ -0,0 +1,389 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.tenant_model import Tenant
from app.models.auth.role_model import Role
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.role_schema import RoleUpdate
from app.services.auth.role_service import RoleService
from app.services.auth.event_service import EventService
from app.models.auth.module_model import Module
from app.models.auth.module_access_model import ModuleAccess
from app.schemas.auth.subscription_plan_schema import (
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
SubscriptionPlanPaginatedResponse,
SubscriptionPlanResponse,
SubscriptionPlanDetailResponse,
PlanApplicationSummary,
PlanApplicationEnvironmentSummary,
)
ONBOARDING_MODULE_CODES = {"pim", "inventory", "fulfillment"}
class SubscriptionPlanService:
@staticmethod
def resolve_plan_applications(
db: Session,
plan: SubscriptionPlan,
onboarding_only: bool = True,
) -> List[PlanApplicationSummary]:
"""
Directly resolves active applications linked to the plan via PlanModuleAccess.
Excludes legacy internal submodules and sanitizes environment configs (omitting secrets).
"""
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
distinct_module_ids = []
if plan_module_accesses:
access_ids = [pma.module_access_id for pma in plan_module_accesses]
module_accesses = db.query(ModuleAccess).filter(ModuleAccess.id.in_(access_ids)).all()
distinct_module_ids = list({ma.module_id for ma in module_accesses})
# Check if plan has explicit onboarding suite modules (PIM, Inventory, Fulfillment)
onboarding_mods_in_plan = []
if distinct_module_ids:
onboarding_mods_in_plan = db.query(Module).filter(
Module.id.in_(distinct_module_ids),
Module.status == "active",
Module.module_id.in_(ONBOARDING_MODULE_CODES)
).all()
if not onboarding_mods_in_plan:
# Fail closed: plans without explicit module links entitle zero applications
return []
modules_query = db.query(Module).filter(
Module.id.in_([m.id for m in onboarding_mods_in_plan]),
Module.status == "active"
)
modules = modules_query.order_by(Module.display_order, Module.module_name).all()
app_summaries = []
for mod in modules:
if onboarding_only and mod.module_id not in ONBOARDING_MODULE_CODES:
continue
envs = (
db.query(ModuleEnvironment)
.filter(ModuleEnvironment.module_id == mod.id, ModuleEnvironment.is_active == True)
.order_by(ModuleEnvironment.is_default.desc(), ModuleEnvironment.slug.asc())
.all()
)
env_summaries = [
PlanApplicationEnvironmentSummary(
id=e.id,
slug=e.slug,
is_default=e.is_default,
is_active=e.is_active,
frontend_base_url=e.frontend_base_url,
sso_entry_path=e.sso_entry_path or "/sso/callback",
backend_base_url=e.backend_base_url,
)
for e in envs
]
app_summaries.append(
PlanApplicationSummary(
module_id=mod.id,
module_code=mod.module_id,
module_name=mod.module_name,
description=mod.description,
icon_url=mod.icon_url,
display_order=mod.display_order or 0,
environments=env_summaries,
)
)
return app_summaries
@staticmethod
def serialize_plan_response(
db: Session,
plan: SubscriptionPlan,
detail: bool = False,
) -> SubscriptionPlanResponse | SubscriptionPlanDetailResponse:
apps = SubscriptionPlanService.resolve_plan_applications(db, plan, onboarding_only=True)
base_data = {
"id": plan.id,
"name": plan.name,
"description": plan.description,
"price": float(plan.price) if plan.price is not None else None,
"duration_days": plan.duration_days,
"max_users_allowed": plan.max_users_allowed,
"is_public": plan.is_public,
"status": plan.status,
"created_at": plan.created_at,
"updated_at": plan.updated_at,
"applications": apps,
}
if detail:
base_data["access_ids"] = [pa.access_id for pa in plan.plan_accesses]
base_data["module_access_ids"] = [pma.module_access_id for pma in plan.plan_module_accesses]
return SubscriptionPlanDetailResponse(**base_data)
return SubscriptionPlanResponse(**base_data)
@staticmethod
def _resolve_environment_slug(
db: Session,
module_id: uuid.UUID,
assigned_environment_slug: Optional[str],
) -> Optional[str]:
if assigned_environment_slug:
return assigned_environment_slug
default_env = (
db.query(ModuleEnvironment)
.filter(
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.is_default == True,
)
.first()
)
if default_env:
return default_env.slug
any_env = (
db.query(ModuleEnvironment)
.filter(ModuleEnvironment.module_id == module_id)
.first()
)
return any_env.slug if any_env else None
@staticmethod
def _sync_tenant_default_roles_for_plan(
db: Session,
plan_id: uuid.UUID,
) -> None:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if not plan:
return
plan_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan_id).all()
plan_module_accesses = (
db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan_id).all()
)
effective_access_ids = [row.access_id for row in plan_accesses] + [
row.module_access_id for row in plan_module_accesses
]
tenants = db.query(Tenant).filter(Tenant.plan_id == plan_id).all()
for tenant in tenants:
default_role = (
db.query(Role)
.filter(Role.tenant_id == tenant.id, Role.is_default == True)
.first()
)
if default_role:
RoleService.update_role(
db,
default_role.id,
RoleUpdate(access_ids=effective_access_ids),
is_superadmin=True,
)
active_modules = (
db.query(TenantModule)
.filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True,
)
.all()
)
if not active_modules:
continue
targets = []
for tm in active_modules:
env_slug = SubscriptionPlanService._resolve_environment_slug(
db,
tm.module_id,
tm.assigned_environment_slug,
)
if env_slug:
targets.append(
{
"module_id": str(tm.module_id),
"environment_slug": env_slug,
}
)
if not targets:
continue
EventService.emit_event(
db,
event_type="TENANT_UPDATED",
payload={
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"max_users_allowed": plan.max_users_allowed,
"targets": targets,
},
tenant_id=tenant.id,
)
@staticmethod
def create_plan(db: Session, plan_data: SubscriptionPlanCreate) -> SubscriptionPlan:
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == plan_data.name).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan name already exists")
plan = SubscriptionPlan(
name=plan_data.name,
description=plan_data.description,
price=plan_data.price,
duration_days=plan_data.duration_days,
max_users_allowed=plan_data.max_users_allowed,
is_public=plan_data.is_public,
status=plan_data.status
)
db.add(plan)
db.flush()
if plan_data.access_ids:
for acc_id in plan_data.access_ids:
db.add(PlanAccess(plan_id=plan.id, access_id=acc_id))
if plan_data.module_access_ids:
for macc_id in plan_data.module_access_ids:
db.add(PlanModuleAccess(plan_id=plan.id, module_access_id=macc_id))
db.commit()
db.refresh(plan)
return plan
@staticmethod
def update_plan(db: Session, plan_id: uuid.UUID, plan_data: SubscriptionPlanUpdate) -> SubscriptionPlan:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
update_dict = plan_data.model_dump(exclude_unset=True)
if "name" in update_dict and update_dict["name"] != plan.name:
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == update_dict["name"]).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan name already exists")
if "access_ids" in update_dict:
access_ids = update_dict.pop("access_ids")
db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).delete()
if access_ids:
for acc_id in access_ids:
db.add(PlanAccess(plan_id=plan.id, access_id=acc_id))
if "module_access_ids" in update_dict:
module_access_ids = update_dict.pop("module_access_ids")
db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).delete()
if module_access_ids:
for macc_id in module_access_ids:
db.add(PlanModuleAccess(plan_id=plan.id, module_access_id=macc_id))
for key, value in update_dict.items():
setattr(plan, key, value)
db.commit()
db.refresh(plan)
SubscriptionPlanService._sync_tenant_default_roles_for_plan(db, plan.id)
db.commit()
return plan
@staticmethod
def get_plan(db: Session, plan_id: uuid.UUID) -> SubscriptionPlan:
plan = (
db.query(SubscriptionPlan)
.options(
joinedload(SubscriptionPlan.plan_accesses),
joinedload(SubscriptionPlan.plan_module_accesses)
)
.filter(SubscriptionPlan.id == plan_id)
.first()
)
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
return plan
@staticmethod
def get_all_plans(db: Session, is_public: Optional[bool] = None, status: Optional[str] = None):
query = db.query(SubscriptionPlan)
if is_public is not None:
query = query.filter(SubscriptionPlan.is_public == is_public)
if status is not None:
query = query.filter(SubscriptionPlan.status == status)
plans = query.all()
return [SubscriptionPlanService.serialize_plan_response(db, p) for p in plans]
@staticmethod
def get_paginated_plans(
db: Session,
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
filter_names: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
visibility: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> SubscriptionPlanPaginatedResponse:
query = db.query(SubscriptionPlan)
if filter_names:
query = query.filter(SubscriptionPlan.name.in_(filter_names))
if statuses:
query = query.filter(SubscriptionPlan.status.in_(statuses))
if visibility is not None and len(visibility) > 0:
query = query.filter(SubscriptionPlan.is_public.in_(visibility))
if search:
query = query.filter(
or_(
SubscriptionPlan.name.ilike(f"%{search}%"),
cast(SubscriptionPlan.id, String).ilike(f"%{search}%")
)
)
sort_column_map = {
"name": SubscriptionPlan.name,
"price": SubscriptionPlan.price,
"status": SubscriptionPlan.status,
"visibility": SubscriptionPlan.is_public,
}
sort_column = sort_column_map.get(sort_by or "")
if sort_column is not None:
order_fn = desc if (sort_order or "").lower() == "desc" else asc
query = query.order_by(order_fn(sort_column))
total = query.count()
offset = (page - 1) * page_size
plans = query.offset(offset).limit(page_size).all()
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return SubscriptionPlanPaginatedResponse(
items=[SubscriptionPlanService.serialize_plan_response(db, p) for p in plans],
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
@staticmethod
def delete_plan(db: Session, plan_id: uuid.UUID):
plan = SubscriptionPlanService.get_plan(db, plan_id)
db.delete(plan)
db.commit()
return {"message": "Plan deleted successfully"}
@@ -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()
+644 -17
View File
@@ -1,15 +1,93 @@
from datetime import date, datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.tenant_model import Tenant
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_model import Role
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.user_model import User
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate
from app.services.auth.role_service import RoleService
from app.config.security import security
import uuid
from typing import Optional
from typing import List, Optional
from app.services.auth.event_service import EventService
import logging
logger = logging.getLogger(__name__)
ONBOARDING_MODULE_CODES = {"pim", "inventory", "fulfillment"}
class TenantService:
STATUS_ACTIVE = "ACTIVE"
STATUS_INACTIVE = "INACTIVE"
STATUS_EXPIRED = "EXPIRED"
@staticmethod
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
def _today() -> date:
return datetime.now(timezone.utc).date()
@staticmethod
def _normalize_status(status_value: Optional[str], is_active: bool) -> str:
normalized = (status_value or "").strip().upper()
if normalized:
return normalized
return TenantService.STATUS_ACTIVE if is_active else TenantService.STATUS_INACTIVE
@staticmethod
def _resolve_lifecycle(
*,
start_date: Optional[date],
end_date: Optional[date],
status_value: Optional[str],
is_active: bool,
) -> tuple[str, bool]:
if start_date and end_date and end_date < start_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="end_date must be greater than or equal to start_date",
)
normalized_status = TenantService._normalize_status(status_value, is_active)
if end_date and end_date <= TenantService._today():
return TenantService.STATUS_EXPIRED, False
if normalized_status in {
TenantService.STATUS_INACTIVE,
TenantService.STATUS_EXPIRED,
}:
return normalized_status, False
return normalized_status, True
@staticmethod
def _sync_tenant_lifecycle(tenant: Tenant) -> bool:
resolved_status, resolved_is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=tenant.status,
is_active=tenant.is_active,
)
changed = (
tenant.status != resolved_status
or tenant.is_active != resolved_is_active
)
if changed:
tenant.status = resolved_status
tenant.is_active = resolved_is_active
return changed
@staticmethod
def create_tenant(db: Session, tenant_data: TenantCreate, commit: bool = True) -> Tenant:
# Pre-validation 1: Name and Domain Uniqueness
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
if existing:
raise HTTPException(
@@ -23,21 +101,327 @@ class TenantService:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant domain already exists"
)
# Pre-validation 2: Plan existence
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == tenant_data.plan_id).first()
if not plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid plan_id provided"
)
# Pre-validation 3: Owner Credentials Validation (Mandatory)
if not tenant_data.owner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant owner information is required"
)
owner_email = str(tenant_data.owner.email).strip().lower()
# Email uniqueness check across system
existing_user = db.query(User).filter(User.email == owner_email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Owner email '{owner_email}' is already registered"
)
# Password strength check
if not security.validate_password_strength(tenant_data.owner.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Owner password is too weak. Must be at least 8 characters with uppercase, lowercase, number, and special character."
)
# Pre-validation 4: Resolve Plan Modules and Validate Selection (Mandatory non-empty)
if not tenant_data.selected_module_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one application must be selected"
)
# Check duplicate module IDs
if len(tenant_data.selected_module_ids) != len(set(tenant_data.selected_module_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Duplicate module IDs in selected_module_ids"
)
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
plan_mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url
)
plan_distinct_module_ids = set()
if plan_mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(plan_mod_access_ids)).distinct().all()
plan_distinct_module_ids = {m[0] for m in modules_query}
# Check if plan has explicit onboarding suite modules (PIM, Inventory, Fulfillment)
onboarding_mods_in_plan = []
if plan_distinct_module_ids:
onboarding_mods_in_plan = db.query(Module).filter(
Module.id.in_(plan_distinct_module_ids),
Module.status == "active",
Module.module_id.in_(ONBOARDING_MODULE_CODES)
).all()
active_onboarding_modules = onboarding_mods_in_plan
allowed_module_ids = {m.id for m in active_onboarding_modules}
selected_set = set(tenant_data.selected_module_ids)
invalid_modules = selected_set - allowed_module_ids
if invalid_modules:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Selected application is not included in the selected plan or is inactive"
)
effective_selected_module_ids = [m.id for m in active_onboarding_modules if m.id in selected_set]
# Pre-validation 5: Validate Module Environments (Explicit 1-to-1 required, reject duplicates)
module_env_map = {}
seen_env_modules = set()
if tenant_data.module_environments:
for me in tenant_data.module_environments:
if me.module_id in seen_env_modules:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Duplicate environment assignment for module '{me.module_id}'"
)
seen_env_modules.add(me.module_id)
if me.module_id not in selected_set:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Environment assignment specified for module '{me.module_id}' which is not in selected applications"
)
# Verify environment exists, belongs to this module, and is active
env_exists = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == me.module_id,
ModuleEnvironment.slug == me.environment_slug,
ModuleEnvironment.is_active == True
).first()
if not env_exists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Environment '{me.environment_slug}' does not exist or is inactive for module '{me.module_id}'"
)
module_env_map[me.module_id] = me.environment_slug
# Require an explicit environment assignment for EVERY selected application
resolved_event_targets = []
for mod_id in effective_selected_module_ids:
env_slug = module_env_map.get(mod_id)
if not env_slug:
mod_obj = db.query(Module).filter(Module.id == mod_id).first()
mod_name = mod_obj.module_name if mod_obj else str(mod_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Explicit environment assignment is required for selected application '{mod_name}'"
)
resolved_event_targets.append({
"module_id": str(mod_id),
"environment_slug": env_slug
})
provisioning_id = str(uuid.uuid4())
db.add(tenant)
db.commit()
db.refresh(tenant)
return tenant
try:
# 1. Create Tenant
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url,
plan_id=tenant_data.plan_id,
start_date=tenant_data.start_date,
end_date=tenant_data.end_date,
)
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=tenant_data.status,
is_active=tenant.is_active,
)
db.add(tenant)
db.flush()
# 2. Create TenantModule assignments for selected modules only
for mod_id in effective_selected_module_ids:
env_slug = module_env_map[mod_id]
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=env_slug,
is_active=True
)
db.add(tm)
db.flush()
# 3. Create Primary Admin Role
# SaaS accesses from plan
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
saas_access_ids = [a.access_id for a in plan_saas_accesses]
# Module launch accesses ONLY for selected modules
selected_module_accesses = db.query(ModuleAccess.id).filter(
ModuleAccess.id.in_(plan_mod_access_ids),
ModuleAccess.module_id.in_(effective_selected_module_ids)
).all()
selected_mod_access_ids = [ma[0] for ma in selected_module_accesses]
role_create_data = RoleCreate(
role_name="Primary Admin",
tenant_id=tenant.id,
is_default=True,
access_ids=saas_access_ids + selected_mod_access_ids
)
role = RoleService.create_role(
db,
role_create_data,
emit_events=False,
commit=False,
)
db.flush()
# 4. Create Owner User if requested
owner_user = None
if tenant_data.owner:
owner_user = User(
email=tenant_data.owner.email.strip().lower(),
password=security.hash_password(tenant_data.owner.password),
first_name=tenant_data.owner.first_name.strip(),
last_name=tenant_data.owner.last_name.strip(),
phone_number=tenant_data.owner.phone_number,
status="active",
tenant_id=tenant.id,
role_id=role.id
)
db.add(owner_user)
db.flush()
# 5. Build Outbox Events (durable log rows)
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.all()
)
role_follow_up = None
if role_module_perms:
module_map = {}
for rma, ma in role_module_perms:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
role_targets = []
for target in resolved_event_targets:
mid = target["module_id"]
if mid in module_map:
role_targets.append({
"module_id": mid,
"environment_slug": target["environment_slug"],
"permissions": module_map[mid]
})
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"payload": {
"role_id": str(role.id),
"role_name": role.role_name,
"role_code": "TENANT_OWNER",
"is_owner": True,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
emitted_event_ids = []
if resolved_event_targets:
logger.info(f"Emitting TENANT_PROVISION_REQUESTED for tenant '{tenant.tenant_name}' across {len(resolved_event_targets)} targets.")
payload = {
"tenant_id": str(tenant.id),
"canonical_tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"max_users_allowed": plan.max_users_allowed,
"provisioning_id": provisioning_id,
"targets": resolved_event_targets
}
tenant_event_id = EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
if tenant_event_id:
emitted_event_ids.append(tenant_event_id)
# If owner user created, emit USER_PROVISION_REQUESTED outbox event with canonical_user_id
if owner_user:
user_payload = {
"user_id": str(owner_user.id),
"canonical_user_id": str(owner_user.id),
"email": owner_user.email,
"first_name": owner_user.first_name,
"last_name": owner_user.last_name,
"phone_number": owner_user.phone_number,
"tenant_id": str(tenant.id),
"canonical_tenant_id": str(tenant.id),
"role_id": str(role.id),
"role_name": role.role_name,
"role_code": "TENANT_OWNER",
"is_owner": True,
"status": owner_user.status,
"targets": role_targets if role_module_perms else []
}
user_event_id = EventService.emit_event(
db,
event_type="USER_PROVISION_REQUESTED",
payload=user_payload,
tenant_id=tenant.id
)
if user_event_id:
emitted_event_ids.append(user_event_id)
tenant.pending_event_ids = emitted_event_ids
# Controlled final commit or flush
if commit:
db.commit()
db.refresh(tenant)
# Best-effort wake-up notification to Redis after successful commit
EventService.enqueue_wakeups(emitted_event_ids)
else:
db.flush()
return tenant
except Exception as e:
db.rollback()
raise e
@staticmethod
def get_all_tenants(db: Session):
return db.query(Tenant).all()
tenants = db.query(Tenant).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
return tenants
@staticmethod
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
@@ -47,6 +431,9 @@ class TenantService:
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
if TenantService._sync_tenant_lifecycle(tenant):
db.commit()
db.refresh(tenant)
return tenant
@staticmethod
@@ -55,19 +442,195 @@ class TenantService:
update_dict = tenant_data.model_dump(exclude_unset=True)
should_emit_update = False
should_emit_status = False
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant name already exists")
should_emit_update = True
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant domain already exists")
should_emit_update = True
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
should_emit_update = True
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
new_plan_id = update_dict["plan_id"]
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
if not plan:
raise HTTPException(status_code=400, detail="Invalid plan_id")
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
new_modules_set = set()
if mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
new_modules_set = {m[0] for m in modules_query}
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
event_targets = []
provisioning_id = str(uuid.uuid4())
default_env = "prod"
for mod_id in new_modules_set:
if mod_id in current_map:
tm = current_map[mod_id]
if not tm.is_active:
tm.is_active = True
event_targets.append({
"module_id": str(mod_id),
"environment_slug": tm.assigned_environment_slug or default_env
})
else:
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=default_env,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(mod_id),
"environment_slug": default_env
})
for mod_id, tm in current_map.items():
if mod_id not in new_modules_set and tm.is_active:
tm.is_active = False
db.flush()
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first()
if default_role:
role_update_data = RoleUpdate(access_ids=all_access_ids)
RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True)
role_follow_up = None
if event_targets and default_role:
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == default_role.id)
.all()
)
module_map = {}
for rma, ma in role_module_perms:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
role_targets = []
for target in event_targets:
mid = target["module_id"]
if mid in module_map:
role_targets.append({
"module_id": mid,
"environment_slug": target["environment_slug"],
"permissions": module_map[mid]
})
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"payload": {
"role_id": str(default_role.id),
"role_name": default_role.role_name,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
if event_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"max_users_allowed": plan.max_users_allowed,
"provisioning_id": provisioning_id,
"targets": event_targets
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
for key, value in update_dict.items():
setattr(tenant, key, value)
if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")):
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=update_dict.get("status"),
is_active=tenant.is_active,
)
should_emit_status = True
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,
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_UPDATED",
payload=payload,
tenant_id=tenant.id
)
if should_emit_status:
payload = {
"tenant_id": str(tenant.id),
"is_active": tenant.is_active,
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_STATUS_CHANGED",
payload=payload,
tenant_id=tenant.id
)
db.commit()
db.refresh(tenant)
return tenant
@@ -75,6 +638,33 @@ class TenantService:
@staticmethod
def delete_tenant(db: Session, tenant_id: uuid.UUID):
tenant = TenantService.get_tenant_by_id(db, tenant_id)
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
if active_modules:
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
db.delete(tenant)
db.commit()
return {"message": "Tenant deleted successfully"}
@@ -86,10 +676,25 @@ class TenantService:
page_size: int = 10,
search: Optional[str] = None,
is_active: Optional[bool] = None,
filter_tenant_names: Optional[List[str]] = None,
filter_tenant_domains: Optional[List[str]] = None,
filter_plan_ids: Optional[List[uuid.UUID]] = None,
statuses: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> TenantPaginatedResponse:
query = db.query(Tenant)
if filter_tenant_names:
query = query.filter(Tenant.tenant_name.in_(filter_tenant_names))
if filter_tenant_domains:
query = query.filter(Tenant.tenant_domain.in_(filter_tenant_domains))
if filter_plan_ids:
query = query.filter(Tenant.plan_id.in_(filter_plan_ids))
if search and search.strip():
search_term = search.strip()
query = query.filter(
@@ -103,11 +708,33 @@ class TenantService:
if is_active is not None:
query = query.filter(Tenant.is_active == is_active)
if statuses is not None and len(statuses) > 0:
query = query.filter(Tenant.is_active.in_(statuses))
sort_column_map = {
"name": Tenant.tenant_name,
"domain": Tenant.tenant_domain,
"status": Tenant.is_active,
"plan": Tenant.plan_id,
}
sort_column = sort_column_map.get(sort_by or "")
if sort_column is not None:
order_fn = desc if (sort_order or "").lower() == "desc" else asc
query = query.order_by(order_fn(sort_column))
total = query.count()
offset = (page - 1) * page_size
tenants = query.offset(offset).limit(page_size).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return TenantPaginatedResponse(
@@ -116,4 +743,4 @@ class TenantService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
+114
View File
@@ -0,0 +1,114 @@
import hmac
import hashlib
from typing import Dict, Any, Optional
from app.models.auth.module_environment_model import ModuleEnvironment
from fastapi import HTTPException, status
class TrustService:
@staticmethod
def verify_request_signature(environment: ModuleEnvironment, signature: str, payload: str = "") -> bool:
"""
Verify the HMAC signature of an incoming request from a module.
Currently supports HMAC-SHA256.
"""
if environment.trust_type != "hmac":
if environment.trust_type == "static_key":
secret = environment.trust_credentials.get("secret_key")
return hmac.compare_digest(signature, secret)
return False
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
return False
expected_signature = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)
@staticmethod
def sign_payload(environment: ModuleEnvironment, payload: str) -> str:
"""
Signs a raw payload string using the environment's HMAC secret.
Used for direct signed POST flows.
"""
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret'")
signature = hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
@staticmethod
def sign_outbound_payload(environment: ModuleEnvironment, method: str, path: str, payload_json: str, timestamp: str) -> str:
"""
Generates HMAC-SHA256 signature for outbound requests to modules.
Signature = HMAC-SHA256(secret, method + path + timestamp + SHA256(payload))
"""
if environment.trust_type != "hmac":
pass
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret' for outbound signing")
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
string_to_sign = f"{method.upper()}{path}{timestamp}{payload_hash}"
signature = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
@staticmethod
def validate_module_trust(environment: ModuleEnvironment, request_headers: Dict[str, str], request_body: str = ""):
"""
Validates that the request comes from a trusted module environment.
Raises HTTPException if authentication fails.
"""
if not environment.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Module environment is inactive"
)
if environment.trust_type == "hmac":
signature = request_headers.get("X-Module-Signature")
if not signature:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing signature"
)
if not TrustService.verify_request_signature(environment, signature, request_body):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid signature"
)
elif environment.trust_type == "static_key":
api_key = request_headers.get("X-Module-Key")
secret = environment.trust_credentials.get("secret_key")
if not api_key or not hmac.compare_digest(api_key, secret):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API Key"
)
else:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=f"Trust type {environment.trust_type} not supported yet"
)
+251 -13
View File
@@ -1,17 +1,27 @@
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from fastapi import HTTPException, status
from sqlalchemy import or_, cast, String, asc, desc, func
from fastapi import HTTPException, status, BackgroundTasks
from datetime import datetime
import uuid
from typing import Optional
from typing import Optional, List, Dict, Any
from app.models.auth.user_model import User
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.config.security import security
from app.services.auth.event_service import EventService
import logging
import json
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.role_model import Role
from app.models.auth.tenant_model import Tenant
logger = logging.getLogger(__name__)
class UserService:
@staticmethod
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None) -> User:
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -36,8 +46,35 @@ class UserService:
)
db.add(user)
db.commit()
db.flush()
db.refresh(user)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
payload = {
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status,
"targets": targets
}
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_PROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.commit()
return user
@staticmethod
@@ -61,13 +98,31 @@ class UserService:
return user
@staticmethod
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = 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)
old_role_id = user.role_id
old_targets = []
if old_role_id:
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
update_dict = user_data.model_dump(exclude_unset=True)
if tenant_id:
update_dict.pop("tenant_id", None)
if "role_id" in update_dict and update_dict["role_id"] is not None:
role = db.query(Role).filter(Role.id == update_dict["role_id"]).first()
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role not found"
)
if tenant_id and role.tenant_id is not None and role.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot assign a role from another tenant"
)
if "email" in update_dict and update_dict["email"] != user.email:
if db.query(User).filter(User.email == update_dict["email"]).first():
raise HTTPException(
@@ -88,13 +143,88 @@ class UserService:
for key, value in update_dict.items():
setattr(user, key, value)
db.commit()
db.flush()
db.refresh(user)
new_targets = []
if user.role_id:
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
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
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
user = UserService.get_user_by_id(db, user_id, tenant_id)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
if targets:
payload = {
"user_id": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"targets": targets
}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
@@ -106,16 +236,23 @@ class UserService:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
status: Optional[str] = None,
filter_names: Optional[List[str]] = None,
filter_emails: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
filter_role_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> UserPaginatedResponse:
query = db.query(User)
query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id)
# Scope to tenant if not superadmin
if tenant_id:
query = query.filter(User.tenant_id == tenant_id)
if search and search.strip():
search_term = search.strip()
search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.filter(
or_(
User.email.ilike(f"%{search_term}%"),
@@ -126,8 +263,73 @@ class UserService:
)
)
if status is not None:
query = query.filter(User.status == status)
if filter_names:
normalized_names = [name.strip() for name in filter_names if isinstance(name, str) and name.strip()]
if normalized_names:
query = query.filter(
func.trim(
func.concat(
User.first_name,
" ",
func.coalesce(User.last_name, ""),
)
).in_(normalized_names)
)
if filter_emails:
normalized_emails = [email.strip() for email in filter_emails if isinstance(email, str) and email.strip()]
if normalized_emails:
query = query.filter(User.email.in_(normalized_emails))
if statuses:
normalized_statuses = [status for status in statuses if status]
if normalized_statuses:
query = query.filter(User.status.in_(normalized_statuses))
if filter_tenant_ids:
tenant_ids: List[uuid.UUID] = []
for tenant_value in filter_tenant_ids:
try:
tenant_ids.append(
tenant_value if isinstance(tenant_value, uuid.UUID) else uuid.UUID(str(tenant_value))
)
except (ValueError, AttributeError, TypeError):
continue
if tenant_ids:
query = query.filter(User.tenant_id.in_(tenant_ids))
if filter_role_ids:
role_ids: List[uuid.UUID] = []
for role_value in filter_role_ids:
try:
role_ids.append(
role_value if isinstance(role_value, uuid.UUID) else uuid.UUID(str(role_value))
)
except (ValueError, AttributeError, TypeError):
continue
if role_ids:
query = query.filter(User.role_id.in_(role_ids))
normalized_sort_by = (sort_by or "").strip().lower()
normalized_sort_order = (sort_order or "asc").strip().lower()
sort_fn = desc if normalized_sort_order == "desc" else asc
if normalized_sort_by == "name":
query = query.order_by(
sort_fn(func.lower(User.first_name)),
sort_fn(func.lower(func.coalesce(User.last_name, ""))),
asc(User.email),
)
elif normalized_sort_by == "email":
query = query.order_by(sort_fn(func.lower(User.email)))
elif normalized_sort_by == "status":
query = query.order_by(sort_fn(func.lower(User.status)), asc(User.email))
elif normalized_sort_by == "tenant":
query = query.order_by(sort_fn(func.lower(func.coalesce(Tenant.tenant_name, ""))), asc(User.email))
elif normalized_sort_by == "role":
query = query.order_by(sort_fn(func.lower(func.coalesce(Role.role_name, ""))), asc(User.email))
else:
query = query.order_by(desc(User.created_at))
total = query.count()
@@ -142,4 +344,40 @@ class UserService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
@staticmethod
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
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

Some files were not shown because too many files have changed in this diff Show More