39 Commits
Author SHA1 Message Date
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
103 changed files with 6218 additions and 587 deletions
+8 -12
View File
@@ -1,13 +1,13 @@
# Project Configuration # Project Configuration
PROJECT_NAME=SaaS Architecture PROJECT_NAME=SaaS Architecture
VERSION=1.0.0 VERSION=1.0.0
PORT=11001 PORT=10001
APP_ENV=development APP_ENV=development
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w" SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=* ALLOWED_HOSTS=*
HOST=127.0.0.1 HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-dev.maskantech.in FRONTEND_URL=https://saas-dev.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-dev.maskantech.in CORS_ALLOWED_ORIGINS=https://saas-dev.maskantech.in
# Security # Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5" ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration #Database Configuration
DB_SSL=False DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/ DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
# Redis Configuration # Redis Configuration
# REDIS_URL="" REDIS_HOST=106.51.105.22
# port="" REDIS_PORT=6382
# host="" REDIS_PASSWORD=8haSTisAqop8ChAs
# password=""
# Email Configuration # Email Configuration
SMTP_HOST=smtp.hostinger.com SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration # JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg" ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400 ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ" REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000 REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA" ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration # AWS S3 Configuration
AWS_ACCESS_KEY_ID="" AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY="" AWS_SECRET_ACCESS_KEY=""
+7 -11
View File
@@ -1,5 +1,5 @@
# Project Configuration # Project Configuration
PROJECT_NAME=Fulfillment And Logistics PROJECT_NAME=SaaS Architecture
VERSION=1.0.0 VERSION=1.0.0
PORT=8000 PORT=8000
APP_ENV=local APP_ENV=local
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
ALLOWED_HOSTS=* ALLOWED_HOSTS=*
HOST=127.0.0.1 HOST=127.0.0.1
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# Security # Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5" ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration #Database Configuration
DB_SSL=False DB_SSL=False
DATABASE_URL=postgresql://saas_user:nMCuFvGoG%28%23Q%40Q4%5E@106.51.104.95:5432/saas_local DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
# Redis Configuration # Redis Configuration
# REDIS_URL="" REDIS_HOST=106.51.105.22
# port="" REDIS_PORT=6381
# host="" REDIS_PASSWORD=8haSTisAqop8ChAs
# password=""
# Email Configuration # Email Configuration
SMTP_HOST=smtp.hostinger.com SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration # JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg" ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400 ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ" REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000 REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA" ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration # AWS S3 Configuration
AWS_ACCESS_KEY_ID="" AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY="" AWS_SECRET_ACCESS_KEY=""
+2 -1
View File
@@ -38,10 +38,11 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration # JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg" ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400 ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ" REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000 REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
COOKIE_SECURE=true
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA" ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook # External SaaS Webhook
+8 -12
View File
@@ -1,13 +1,13 @@
# Project Configuration # Project Configuration
PROJECT_NAME=SaaS Architecture PROJECT_NAME=SaaS Architecture
VERSION=1.0.0 VERSION=1.0.0
PORT=11002 PORT=10002
APP_ENV=testing APP_ENV=testing
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w" SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=* ALLOWED_HOSTS=*
HOST=127.0.0.1 HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-test.maskantech.in FRONTEND_URL=https://saas-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-test.maskantech.in CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in
# Security # Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5" ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration #Database Configuration
DB_SSL=False DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/ DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
# Redis Configuration # Redis Configuration
# REDIS_URL="" REDIS_HOST=106.51.105.22
# port="" REDIS_PORT=6383
# host="" REDIS_PASSWORD=8haSTisAqop8ChAs
# password=""
# Email Configuration # Email Configuration
SMTP_HOST=smtp.hostinger.com SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration # JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg" ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400 ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ" REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000 REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA" ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration # AWS S3 Configuration
AWS_ACCESS_KEY_ID="" AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY="" AWS_SECRET_ACCESS_KEY=""
+17 -15
View File
@@ -20,9 +20,10 @@ A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
## Prerequisites ## Prerequisites
- Python 3.12+
- PostgreSQL 12+
- Python 3.10+ - Python 3.10+
- PostgreSQL 12+ - PostgreSQL 12+
- Node.js (for npm scripts)
## Getting Started ## Getting Started
@@ -44,7 +45,7 @@ python -m venv venv
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
npm install # For cross-env support in npm scripts pip install -r requirements.txt
``` ```
### 4. Environment Configuration ### 4. Environment Configuration
@@ -112,11 +113,11 @@ alembic revision --autogenerate -m "Initial schema"
#### Run Migrations #### Run Migrations
```bash ```bash
# Using npm scripts (recommended - handles APP_ENV automatically) # Using manage.py (recommended - handles APP_ENV automatically)
npm run migrate:local python manage.py migrate --env local
npm run migrate:dev python manage.py migrate --env development
npm run migrate:prod python manage.py migrate --env production
npm run migrate:test python manage.py migrate --env testing
# Or using alembic directly # Or using alembic directly
$env:APP_ENV="local" # Set environment first $env:APP_ENV="local" # Set environment first
@@ -148,20 +149,20 @@ After running migrations, seed the database with initial data:
```bash ```bash
# Seed super admin user # Seed super admin user
npm run seed:superadmin:local python manage.py seed superadmin --env local
# Seed default color palettes # Seed default color palettes
npm run seed:palettes:local python manage.py seed palettes --env local
``` ```
### 7. Run the Application ### 7. Run the Application
```bash ```bash
# Using npm scripts (recommended) # Using manage.py (recommended)
npm run local # Local environment python manage.py run --env local # Local environment
npm run dev # Development environment python manage.py run --env development # Development environment
npm run prod # Production environment python manage.py run --env production # Production environment
npm run test # Testing environment python manage.py run --env testing # Testing environment
# Or using Python directly # Or using Python directly
python run.py python run.py
@@ -209,7 +210,8 @@ backend/
│ ├── seed_palettes.py │ ├── seed_palettes.py
│ └── seed_superadmin.py │ └── seed_superadmin.py
├── alembic.ini # Alembic configuration ├── alembic.ini # Alembic configuration
├── package.json # NPM scripts ├── alembic.ini # Alembic configuration
├── manage.py # Management CLI script
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
└── run.py # Application entry point └── run.py # Application entry point
``` ```
+1
View File
@@ -42,6 +42,7 @@ import app.models.auth.tenant_model
import app.models.auth.access_model import app.models.auth.access_model
import app.models.auth.role_access_model import app.models.auth.role_access_model
import app.models.theme.color_palette_model import app.models.theme.color_palette_model
import app.models.auth.subscription_plan_model
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@@ -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 ###
+127 -21
View File
@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
@@ -5,12 +6,24 @@ import logging
from sqlalchemy import text from sqlalchemy import text
from app.config.settings import settings from app.config.settings import settings
from app.config.database import engine from app.config.database import engine
from app.routes.admin import audit_logs
# Import models for Alembic
import app.models.auth.user_model import app.models.auth.user_model
import app.models.auth.role_model import app.models.auth.role_model
import app.models.auth.tenant_model import app.models.auth.tenant_model
import app.models.theme.color_palette_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 # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -21,6 +34,95 @@ logging.basicConfig(
logger = logging.getLogger(__name__) 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: def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
@@ -30,6 +132,7 @@ def create_app() -> FastAPI:
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
openapi_url="/openapi.json", openapi_url="/openapi.json",
lifespan=lifespan,
) )
# === OpenAPI Security Scheme === # === OpenAPI Security Scheme ===
@@ -84,30 +187,35 @@ def create_app() -> FastAPI:
from app.routes.auth.role import router as role_router from app.routes.auth.role import router as role_router
from app.routes.auth.access import router as access_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.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(auth_router, prefix="/api/auth", tags=["Authentication"])
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"]) 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(role_router, prefix="/api/role", tags=["Role Management"])
app.include_router(access_router, prefix="/api/access", tags=["Access 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(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) === from app.routes.theme.color_palette import router as palette_router
@app.on_event("startup") app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
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
logger.info( # === Admin Routes ===
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})" 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 === # === Basic Routes ===
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])
@@ -138,7 +246,8 @@ def create_app() -> FastAPI:
conn.execute(text("SELECT 1")) conn.execute(text("SELECT 1"))
db_status = "healthy" db_status = "healthy"
except Exception as e: except Exception as e:
db_status = f"unhealthy: {str(e)}" logger.error(f"Health check DB error: {e}")
db_status = "unhealthy"
return { return {
"status": "healthy" if db_status == "healthy" else "degraded", "status": "healthy" if db_status == "healthy" else "degraded",
@@ -148,6 +257,3 @@ def create_app() -> FastAPI:
} }
return app return app
app = create_app()
+41 -13
View File
@@ -1,18 +1,17 @@
"""
Security utilities for authentication and authorization.
"""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import bcrypt import bcrypt
import jwt import jwt
import uuid
import logging
from fastapi import HTTPException, status from fastapi import HTTPException, status
import re import re
import secrets import secrets
import string import string
from app.config.settings import settings from app.config.settings import settings
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
class SecurityUtils: class SecurityUtils:
"""Security utility class for authentication and authorization.""" """Security utility class for authentication and authorization."""
@@ -33,7 +32,7 @@ class SecurityUtils:
"""Generate JWT access token.""" """Generate JWT access token."""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES) 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 # Include tenant_id if provided
if tenant_id: if tenant_id:
@@ -50,7 +49,7 @@ class SecurityUtils:
"""Generate JWT refresh token.""" """Generate JWT refresh token."""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES) 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 # Include tenant_id if provided
if tenant_id: if tenant_id:
@@ -78,6 +77,19 @@ class SecurityUtils:
detail="Invalid token type" 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 return payload
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
raise HTTPException( raise HTTPException(
@@ -129,24 +141,42 @@ class SecurityUtils:
if len(password) < 8: if len(password) < 8:
return False return False
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password): if not re.search(r'[A-Z]', password):
return False return False
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password): if not re.search(r'[a-z]', password):
return False return False
# Check for at least one digit
if not re.search(r'\d', password): if not re.search(r'\d', password):
return False return False
# Check for at least one special character
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False return False
return True return True
@staticmethod
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
"""Generate short-lived module-scoped JWT (15 min default)."""
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()
})
if not settings.SAAS_PRIVATE_KEY:
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
return jwt.encode(
to_encode,
settings.SAAS_PRIVATE_KEY,
algorithm="RS256",
headers={"kid": settings.SAAS_KEY_ID}
)
@staticmethod @staticmethod
def validate_email(email: str) -> bool: def validate_email(email: str) -> bool:
"""Validate email format.""" """Validate email format."""
@@ -161,6 +191,4 @@ class SecurityUtils:
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
# Create instance for easy importing
security = SecurityUtils() security = SecurityUtils()
+27 -51
View File
@@ -2,32 +2,25 @@ from pydantic_settings import BaseSettings
from typing import Optional from typing import Optional
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
import os import os
# Load environment variables from .env files
app_env = os.getenv("APP_ENV", "local") app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}" env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent.parent base_path = Path(__file__).resolve().parent.parent.parent
backend_path = Path(__file__).resolve().parent.parent backend_path = Path(__file__).resolve().parent.parent
# Load specific environment file (e.g., .env.development) load_dotenv(dotenv_path=base_path / '.env')
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
load_dotenv(dotenv_path=backend_path / '.env') load_dotenv(dotenv_path=backend_path / '.env')
# Override with specific environment config
if (base_path / env_filename).exists(): if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True) load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists(): if (backend_path / env_filename).exists():
load_dotenv(dotenv_path=backend_path / env_filename, override=True) load_dotenv(dotenv_path=backend_path / env_filename, override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
# Project PROJECT_NAME: str
PROJECT_NAME: str = "SaaS Architecture" VERSION: str
VERSION: str = "1.0.0"
# FastAPI # FastAPI
PORT: int PORT: int
@@ -38,9 +31,7 @@ class Settings(BaseSettings):
# Frontend # Frontend
FRONTEND_URL: str FRONTEND_URL: str
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
CORS_ALLOWED_ORIGINS: Optional[str] = None CORS_ALLOWED_ORIGINS: Optional[str] = None
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
# Security # Security
@@ -52,14 +43,24 @@ class Settings(BaseSettings):
DB_SSL: bool = False DB_SSL: bool = False
# Redis Configuration # Redis Configuration
REDIS_URL: str = "redis://localhost:6379/0" REDIS_HOST: str
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development REDIS_PORT: int
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds REDIS_PASSWORD: Optional[str]
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool REDIS_DB: int = 0
REDIS_ENABLED: bool = True
REDIS_TIMEOUT: int = 10
REDIS_MAX_CONNECTIONS: int = 10
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Email # Email
SMTP_HOST: str SMTP_HOST: str
SMTP_PORT: int = 587 SMTP_PORT: int
SMTP_SECURE: bool = True SMTP_SECURE: bool = True
SMTP_USER: str SMTP_USER: str
SMTP_PASSWORD: str SMTP_PASSWORD: str
@@ -67,19 +68,24 @@ class Settings(BaseSettings):
# JWT settings # JWT settings
ACCESS_TOKEN_SECRET: str ACCESS_TOKEN_SECRET: str
ACCESS_TOKEN_EXPIRES: int = 86400 ACCESS_TOKEN_EXPIRES: int = 900
REFRESH_TOKEN_SECRET: str REFRESH_TOKEN_SECRET: str
REFRESH_TOKEN_EXPIRES: int = 864000 REFRESH_TOKEN_EXPIRES: int = 864000
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
# Cookie settings
COOKIE_SECURE: bool = False
COOKIE_DOMAIN: Optional[str] = None
# Super Admin Setup # Super Admin Setup
SUPER_ADMIN_EMAIL: str SUPER_ADMIN_EMAIL: str
SUPER_ADMIN_PASSWORD: str SUPER_ADMIN_PASSWORD: str
SUPER_ADMIN_FIRST_NAME: str = "Super" SUPER_ADMIN_FIRST_NAME: str = "Super"
SUPER_ADMIN_LAST_NAME: str = "Admin" SUPER_ADMIN_LAST_NAME: str = "Admin"
# External SaaS Integration # Module Integration Security (RS256)
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key" SAAS_PRIVATE_KEY: Optional[str] = None
SAAS_KEY_ID: str = "saas-key-v1"
# PayPal Integration # PayPal Integration
PAYPAL_CLIENT_ID: str PAYPAL_CLIENT_ID: str
@@ -87,40 +93,10 @@ class Settings(BaseSettings):
PAYPAL_MODE: str = "sandbox" PAYPAL_MODE: str = "sandbox"
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com" PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
# AWS S3 settings REDIS_CHAT_TTL: int = 86400
AWS_SECRET_ACCESS_KEY: Optional[str] = None
AWS_ACCESS_KEY_ID: Optional[str] = None
S3_BUCKET_NAME: Optional[str] = None
AWS_REGION: Optional[str] = "us-east-1"
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
@property
def AWS_S3_BUCKET(self) -> Optional[str]:
return self.S3_BUCKET_NAME
# S3 Dataset Processing Settings
S3_PROCESSING_WORKERS: int = 4
DOCUMENT_CHUNK_SIZE: int = 1000
DOCUMENT_CHUNK_OVERLAP: int = 200
PINECONE_BATCH_SIZE: int = 100
# Redis Chat Settings
REDIS_CHAT_TTL: int = 86400 # 24 hours
# Logging
LOG_LEVEL: str = "info" LOG_LEVEL: str = "info"
# Rate Limiting
RATE_LIMIT_REQUESTS: int = 100
RATE_LIMIT_WINDOW: int = 60
# Integration Settings (Optional for development)
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
# INTEGRATION_TIMEOUT: int = 30
# Properties for FastAPI Mail compatibility
@property @property
def MAIL_USERNAME(self) -> str: def MAIL_USERNAME(self) -> str:
return self.SMTP_USER return self.SMTP_USER
+6 -2
View File
@@ -27,6 +27,10 @@ class AuthController:
def refresh_token(db: Session, token_data: RefreshTokenRequest): def refresh_token(db: Session, token_data: RefreshTokenRequest):
return AuthService.refresh_access_token(db, token_data.refresh_token) 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 @staticmethod
def update_user( def update_user(
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
@@ -40,8 +44,8 @@ class AuthController:
) )
@staticmethod @staticmethod
def logout(current_user: User): def logout(current_user: User, token: str):
return AuthService.logout(current_user) return AuthService.logout(current_user, token)
@staticmethod @staticmethod
def me(db: Session, current_user: User): def me(db: Session, current_user: User):
+49
View File
@@ -0,0 +1,49 @@
from sqlalchemy.orm import Session
from typing import List
from app.services.auth.module_service import ModuleService
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
from app.models.auth.module_model import Module
class ModuleController:
@staticmethod
def list_modules(db: Session) -> List[ModuleResponse]:
modules = ModuleService.list_modules(db)
return [ModuleResponse.model_validate(m) for m in modules]
@staticmethod
def create_module(db: Session, module_data: ModuleCreate) -> ModuleResponse:
module = ModuleService.create_module(db, module_data)
return ModuleResponse.model_validate(module)
@staticmethod
def get_available_modules(db: Session, current_user) -> List[ModuleResponse]:
from app.schemas.auth.module_schema import ModuleAvailableResponse
results = ModuleService.get_available_modules(db, current_user.tenant_id)
response = []
for item in results:
mod = item["module"]
response.append(ModuleAvailableResponse(
module_id=mod.module_id,
module_name=mod.module_name,
description=mod.description,
icon_url=mod.icon_url,
display_order=mod.display_order or 0,
is_active=item["is_active"]
))
return response
@staticmethod
def get_module(db: Session, module_id: str) -> ModuleResponse:
module = ModuleService.get_module(db, module_id)
return ModuleResponse.model_validate(module)
@staticmethod
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> ModuleResponse:
module = ModuleService.update_module(db, module_id, module_data)
return ModuleResponse.model_validate(module)
@staticmethod
def delete_module(db: Session, module_id: str):
ModuleService.delete_module(db, module_id)
return {"message": "Module deleted successfully"}
@@ -0,0 +1,30 @@
from sqlalchemy.orm import Session
from typing import List
from app.services.auth.module_environment_service import ModuleEnvironmentService
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
class ModuleEnvironmentController:
@staticmethod
def list_environments(db: Session, module_id: str) -> List[EnvironmentResponse]:
environments = ModuleEnvironmentService.list_environments(db, module_id)
return [EnvironmentResponse.model_validate(env) for env in environments]
@staticmethod
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> EnvironmentResponse:
environment = ModuleEnvironmentService.create_environment(db, module_id, env_data)
return EnvironmentResponse.model_validate(environment)
@staticmethod
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> EnvironmentResponse:
environment = ModuleEnvironmentService.update_environment(db, module_id, env_id, env_data)
return EnvironmentResponse.model_validate(environment)
@staticmethod
def set_default_environment(db: Session, module_id: str, env_id: str):
ModuleEnvironmentService.set_default_environment(db, module_id, env_id)
return {"message": "Environment set as default"}
@staticmethod
def delete_environment(db: Session, module_id: str, env_id: str):
ModuleEnvironmentService.delete_environment(db, module_id, env_id)
return {"message": "Environment deleted successfully"}
+20
View File
@@ -61,6 +61,18 @@ class RoleController:
for ra in role.role_accesses for ra in role.role_accesses
] ]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
"access_code": rma.module_access.access_code,
"category": rma.module_access.category,
"name": rma.module_access.name,
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
}
for rma in role.role_module_accesses
])
return RoleWithAccessesResponse( return RoleWithAccessesResponse(
id=role.id, id=role.id,
role_name=role.role_name, role_name=role.role_name,
@@ -77,6 +89,10 @@ class RoleController:
page: int = 1, page: int = 1,
page_size: int = 10, page_size: int = 10,
search: Optional[str] = None, 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: ) -> RolePaginatedResponse:
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
return RoleService.get_roles_paginated( return RoleService.get_roles_paginated(
@@ -85,4 +101,8 @@ class RoleController:
page=page, page=page,
page_size=page_size, page_size=page_size,
search=search, search=search,
filter_role_names=filter_role_names,
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
) )
+73
View File
@@ -0,0 +1,73 @@
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_signed_payload(
db=db,
user_id=current_user.id,
module_id=request.module_id,
tenant_id=current_user.tenant_id
)
@staticmethod
def exchange_grant(
db: Session,
payload: SSOExchangeRequest,
x_module_signature: Optional[str] = None,
x_module_key: Optional[str] = None
):
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == payload.environment_slug
).first()
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
headers = {}
if x_module_signature:
headers["X-Module-Signature"] = x_module_signature
if x_module_key:
headers["X-Module-Key"] = x_module_key
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)
+13 -1
View File
@@ -2,7 +2,7 @@ from sqlalchemy.orm import Session
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate
from app.services.auth.tenant_service import TenantService from app.services.auth.tenant_service import TenantService
import uuid import uuid
from typing import Optional from typing import List, Optional
class TenantController: class TenantController:
@@ -33,6 +33,12 @@ class TenantController:
page_size: int, page_size: int,
search: Optional[str], search: Optional[str],
is_active: Optional[bool], 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( return TenantService.get_tenants_paginated(
db=db, db=db,
@@ -40,4 +46,10 @@ class TenantController:
page_size=page_size, page_size=page_size,
search=search, search=search,
is_active=is_active, 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 sqlalchemy.orm import Session
from fastapi import HTTPException, status from fastapi import HTTPException, status, BackgroundTasks
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from app.models.auth.user_model import User from app.models.auth.user_model import User
@@ -21,9 +21,9 @@ class UserController:
return current_user.tenant_id return current_user.tenant_id
@staticmethod @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) 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 @staticmethod
def get_all_users(db: Session, current_user: User) -> List[User]: 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) return UserService.get_user_by_id(db, user_id, tenant_id)
@staticmethod @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: 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) UserController._resolve_tenant_id(current_user, user_data.tenant_id)
tenant_id = current_user.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 @staticmethod
def delete_user(db: Session, user_id: uuid.UUID, current_user: User): def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
@@ -55,7 +55,13 @@ class UserController:
page: int = 1, page: int = 1,
page_size: int = 10, page_size: int = 10,
search: Optional[str] = None, 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 tenant_id = current_user.tenant_id
return UserService.get_users_paginated( return UserService.get_users_paginated(
@@ -64,5 +70,11 @@ class UserController:
page=page, page=page,
page_size=page_size, page_size=page_size,
search=search, 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 sqlalchemy.orm import Session
from uuid import UUID from uuid import UUID
from typing import List, Optional from typing import List
from app.models.auth.user_models import User from app.models.auth.user_model import User
from app.models.theme.color_palette_models import ColorPalette from app.models.theme.color_palette_model import ColorPalette
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate from app.schemas.theme.color_palette_schema import (
from app.services.theme.palette_service import PaletteService ColorPaletteCreate,
ColorPaletteUpdate,
)
from app.services.theme.color_palette_service import PaletteService
class PaletteController: class PaletteController:
@staticmethod @staticmethod
def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]: def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]:
return PaletteService.get_all_palettes(db)
tenant_id = current_user.tenant_id
return PaletteService.get_all_palettes(db, tenant_id)
@staticmethod @staticmethod
def get_palette(db: Session, palette_id: UUID) -> ColorPalette: def get_palette(db: Session, palette_id: UUID) -> ColorPalette:
return PaletteService.get_palette_by_id(db, palette_id) return PaletteService.get_palette_by_id(db, palette_id)
@staticmethod @staticmethod
def create_palette(db: Session, data: ColorPaletteCreate, current_user: User) -> ColorPalette: def create_palette(
return PaletteService.create_palette(db, data, tenant_id=None) db: Session, data: ColorPaletteCreate, current_user: User
) -> ColorPalette:
return PaletteService.create_palette(db, data)
@staticmethod @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) return PaletteService.update_palette(db, palette_id, data)
@staticmethod @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"
+40 -13
View File
@@ -2,9 +2,15 @@ from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from datetime import datetime, timezone
from app.config.database import get_db from app.config.database import get_db
from app.config.security import security from app.config.security import security
from app.models.auth.user_model import User 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) security_scheme = HTTPBearer(auto_error=False)
@@ -42,12 +48,35 @@ def get_current_user(
detail="User not found" detail="User not found"
) )
setattr(user, "_saas_db_session", db)
if user.status != "active": if user.status != "active":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User is inactive" 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 return user
def require_active_user(current_user: User = Depends(get_current_user)) -> User: def require_active_user(current_user: User = Depends(get_current_user)) -> User:
@@ -59,24 +88,21 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User:
return current_user return current_user
def has_access(user: User, access_code: str) -> bool: 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 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 return access_code in user_access_codes
def can_access(user: User, access_code: str, db: Session) -> bool: def can_access(user: User, access_code: str, db: Session) -> bool:
if not user.role: user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
return False db, user
)
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
if access_code in user_access_codes: if access_code in user_access_codes:
return True return True
from app.models.auth.access_models import Access
requested_access = db.query(Access).filter( requested_access = db.query(Access).filter(
Access.access_code == access_code Access.access_code == access_code
).first() ).first()
@@ -93,10 +119,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
return False return False
def get_user_accesses(user: User) -> List[str]: 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 []
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 require_access(access_code: str):
def check_permission(current_user: User = Depends(get_current_user)) -> bool: def check_permission(current_user: User = Depends(get_current_user)) -> bool:
+5
View File
@@ -3,3 +3,8 @@ from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess from app.models.auth.role_access_model import RoleAccess
from app.models.auth.tenant_model import Tenant from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User from app.models.auth.user_model import User
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
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 import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.config.database import Base from app.config.database import Base
@@ -13,6 +13,10 @@ class Access(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True) parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
parent = relationship("Access", remote_side=[id], backref="children") parent = relationship("Access", remote_side=[id], backref="children")
+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}>"
+3 -2
View File
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.config.database import Base from app.config.database import Base
class Role(Base): class Role(Base):
__tablename__ = "roles" __tablename__ = "roles"
@@ -20,12 +19,14 @@ class Role(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now() DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
) )
# Relationships
tenant = relationship("Tenant", back_populates="roles") tenant = relationship("Tenant", back_populates="roles")
users = relationship("User", back_populates="role") users = relationship("User", back_populates="role")
role_accesses = relationship( role_accesses = relationship(
"RoleAccess", back_populates="role", cascade="all, delete-orphan" "RoleAccess", back_populates="role", cascade="all, delete-orphan"
) )
role_module_accesses = relationship(
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
)
def __repr__(self): def __repr__(self):
return f"<Role {self.role_name}>" return f"<Role {self.role_name}>"
@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class RoleModuleAccess(Base):
__tablename__ = "role_module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
role = relationship("Role", back_populates="role_module_accesses")
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
__table_args__ = (
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
)
def __repr__(self):
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
+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}>"
+13 -4
View File
@@ -1,10 +1,9 @@
import uuid 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.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.config.database import Base from app.config.database import Base
class Tenant(Base): class Tenant(Base):
__tablename__ = "tenants" __tablename__ = "tenants"
@@ -13,6 +12,10 @@ class Tenant(Base):
tenant_domain = Column(String, unique=True, nullable=False, index=True) tenant_domain = Column(String, unique=True, nullable=False, index=True)
tenant_logo_url = Column(String, nullable=True) tenant_logo_url = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False) 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column( updated_at = Column(
@@ -20,8 +23,14 @@ class Tenant(Base):
) )
# Relationships # Relationships
users = relationship("User", back_populates="tenant") users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
roles = relationship("Role", back_populates="tenant") roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
plan = relationship("SubscriptionPlan", back_populates="tenants")
@property
def tenant_id(self):
return self.id
def __repr__(self): 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}>"
+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 .auth import router as auth_router
from .tenant import router from .tenant import router as tenant_router
from .role import router from .role import router as role_router
from .user import router as user_router
+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 sqlalchemy.orm import Session
from app.config.database import get_db from app.config.database import get_db
from app.config.settings import settings
from app.controllers.auth.auth_controller import AuthController from app.controllers.auth.auth_controller import AuthController
from app.schemas.auth.auth_schema import ( from app.schemas.auth.auth_schema import (
UserSignup, UserSignup,
@@ -14,13 +16,20 @@ from app.schemas.auth.auth_schema import (
VerifyOTPRequest, VerifyOTPRequest,
ResetPasswordWithOTP, 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.middleware.tenant_middleware import get_tenant_from_header
from app.models.auth.user_model import User from app.models.auth.user_model import User
import uuid import uuid
router = APIRouter() 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) @router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def signup( def signup(
user_data: UserSignup, user_data: UserSignup,
@@ -30,12 +39,30 @@ def signup(
return AuthController.signup(db, user_data, tenant_id) return AuthController.signup(db, user_data, tenant_id)
@router.post("/signin", response_model=TokenResponse) @router.post("/signin", response_model=TokenResponse)
def signin(signin_data: UserSignin, db: Session = Depends(get_db)): def signin(signin_data: UserSignin, response: Response, db: Session = Depends(get_db)):
return AuthController.signin(db, signin_data) 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) @router.post("/refresh", response_model=TokenResponse)
def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)): def refresh_token(
return AuthController.refresh_token(db, token_data) 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) @router.get("/me", response_model=UserResponse)
def get_me( def get_me(
@@ -56,21 +83,11 @@ def update_user(
@router.patch("/update/{user_id}/language", response_model=UserResponse) @router.patch("/update/{user_id}/language", response_model=UserResponse)
def update_language( def update_language(
user_id: uuid.UUID, user_id: uuid.UUID,
language_data: dict, language_data: UpdateLanguageRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
supported_languages = ["en", "ar"] user_update = UserUpdate(preferred_language=language_data.preferred_language.value)
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)
return AuthController.update_user(db, user_id, user_update, current_user) return AuthController.update_user(db, user_id, user_update, current_user)
@router.post("/reset-password") @router.post("/reset-password")
@@ -82,8 +99,18 @@ def reset_password(
return AuthController.reset_password(db, current_user, password_data) return AuthController.reset_password(db, current_user, password_data)
@router.post("/logout") @router.post("/logout")
def logout(current_user: User = Depends(get_current_user)): def logout(
return AuthController.logout(current_user) 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") @router.post("/forgot-password")
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): 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 sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import uuid 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.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User 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 = APIRouter()
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role( def create_role(
request: Request,
role_data: RoleCreate, role_data: RoleCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.create")), _=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 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]) @router.get("/get", response_model=List[RoleResponse])
def get_all_roles( def get_all_roles(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -48,32 +68,77 @@ def get_role(
@router.put("/update/{role_id}", response_model=RoleResponse) @router.put("/update/{role_id}", response_model=RoleResponse)
def update_role( def update_role(
request: Request,
role_id: uuid.UUID, role_id: uuid.UUID,
role_data: RoleUpdate, role_data: RoleUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")), _=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 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 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}") @router.delete("/delete/{role_id}")
def delete_role( def delete_role(
request: Request,
role_id: uuid.UUID, role_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.delete")), _=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 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) @router.get("/list", response_model=RolePaginatedResponse)
def list_roles( def list_roles(
page: int = Query(1, ge=1, description="Page number"), page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"), 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"), 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), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.read")), _=Depends(require_access("admin.role.read")),
@@ -84,4 +149,8 @@ def list_roles(
page=page, page=page,
page_size=page_size, page_size=page_size,
search=search, 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
)
+149
View File
@@ -0,0 +1,149 @@
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"))
):
plan = SubscriptionPlanController.get_plan(db, plan_id)
return SubscriptionPlanDetailResponse(
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,
access_ids=[pa.access_id for pa in plan.plan_accesses],
module_access_ids=[pma.module_access_id for pma in plan.plan_module_accesses]
)
@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
+96 -21
View File
@@ -1,24 +1,41 @@
from fastapi import APIRouter, Depends, status, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid 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.config.database import get_db
from app.controllers.auth.tenant_controller import TenantController from app.controllers.auth.tenant_controller import TenantController
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse 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.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.helper.helpers import get_client_ip
router = APIRouter() router = APIRouter()
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant( def create_tenant(
request: Request,
tenant_data: TenantCreate, tenant_data: TenantCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.create")) _ = Depends(require_access("superadmin.tenant.create"))
): ):
return TenantController.create_tenant(db, tenant_data) result = TenantController.create_tenant(db, tenant_data)
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=tenant_data.model_dump(mode='json')
)
return result
@router.get("/get", response_model=List[TenantResponse]) @router.get("/get", response_model=List[TenantResponse])
def get_all_tenants( def get_all_tenants(
@@ -26,17 +43,9 @@ def get_all_tenants(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read")) _ = Depends(require_access("superadmin.tenant.read"))
): ):
# READ actions are typically not logged to avoid DB bloat
return TenantController.get_all_tenants(db) 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) @router.get("/me", response_model=TenantResponse)
def get_my_tenant( def get_my_tenant(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -44,30 +53,84 @@ def get_my_tenant(
): ):
tenant_id = get_tenant_id_from_user(current_user) tenant_id = get_tenant_id_from_user(current_user)
if not tenant_id: if not tenant_id:
raise HTTPException( raise HTTPException(status_code=404, detail="Tenant not found")
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return TenantController.get_tenant_by_id(db, tenant_id) return TenantController.get_tenant_by_id(db, tenant_id)
@router.put("/update/{tenant_id}", response_model=TenantResponse) @router.put("/update/{tenant_id}", response_model=TenantResponse)
def update_tenant( def update_tenant(
request: Request,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
tenant_data: TenantUpdate, tenant_data: TenantUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.update")) _ = 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}") @router.delete("/delete/{tenant_id}")
def delete_tenant( def delete_tenant(
request: Request,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.delete")) _ = 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) @router.get("/list", response_model=TenantPaginatedResponse)
def list_tenants( def list_tenants(
@@ -75,6 +138,12 @@ def list_tenants(
page_size: int = Query(10, ge=1, le=100, description="Items per page"), 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"), search: Optional[str] = Query(None, description="Search in name, domain, or ID"),
is_active: Optional[bool] = Query(None, description="Filter by active status"), 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), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read")), _ = Depends(require_access("superadmin.tenant.read")),
@@ -85,4 +154,10 @@ def list_tenants(
page_size=page_size, page_size=page_size,
search=search, search=search,
is_active=is_active, 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,
) )
+79 -6
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 sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import uuid 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.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User 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 = APIRouter()
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user( def create_user(
request: Request,
user_data: UserCreate, user_data: UserCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.create")) _ = 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]) @router.get("/get", response_model=List[UserResponse])
def get_all_users( def get_all_users(
@@ -38,29 +59,75 @@ def get_user(
@router.put("/update/{user_id}", response_model=UserResponse) @router.put("/update/{user_id}", response_model=UserResponse)
def update_user( def update_user(
request: Request,
user_id: uuid.UUID, user_id: uuid.UUID,
user_data: UserUpdate, user_data: UserUpdate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.update")) _ = 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}") @router.delete("/delete/{user_id}")
def delete_user( def delete_user(
request: Request,
user_id: uuid.UUID, user_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.delete")) _ = 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) @router.get("/list", response_model=UserPaginatedResponse)
def list_users( def list_users(
page: int = Query(1, ge=1, description="Page number"), page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"), 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"), 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), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.read")), _ = Depends(require_access("admin.user.read")),
@@ -71,5 +138,11 @@ def list_users(
page=page, page=page,
page_size=page_size, page_size=page_size,
search=search, 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
+14 -9
View File
@@ -3,8 +3,12 @@ from sqlalchemy.orm import Session
from typing import List from typing import List
from uuid import UUID from uuid import UUID
from app.config.database import get_db from app.config.database import get_db
from app.controllers.theme.palette_controller import PaletteController from app.controllers.theme.color_palette_controller import PaletteController
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate, ColorPaletteResponse from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
ColorPaletteResponse,
)
from app.middleware.auth_middleware import get_current_user, require_access from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User from app.models.auth.user_model import User
@@ -12,8 +16,7 @@ router = APIRouter()
@router.get("/get", response_model=List[ColorPaletteResponse]) @router.get("/get", response_model=List[ColorPaletteResponse])
def get_all_palettes( def get_all_palettes(
db: Session = Depends(get_db), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user)
): ):
return PaletteController.get_all_palettes(db, current_user) return PaletteController.get_all_palettes(db, current_user)
@@ -21,16 +24,18 @@ def get_all_palettes(
def get_palette( def get_palette(
palette_id: UUID, palette_id: UUID,
db: Session = Depends(get_db), 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) 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( def create_palette(
data: ColorPaletteCreate, data: ColorPaletteCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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) return PaletteController.create_palette(db, data, current_user)
@@ -44,7 +49,7 @@ def update_palette(
data: ColorPaletteUpdate, data: ColorPaletteUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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) return PaletteController.update_palette(db, palette_id, data)
@@ -53,6 +58,6 @@ def delete_palette(
palette_id: UUID, palette_id: UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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) return PaletteController.delete_palette(db, palette_id)
+2
View File
@@ -11,6 +11,8 @@ class AccessBase(BaseModel):
class AccessResponse(AccessBase): class AccessResponse(AccessBase):
id: uuid.UUID id: uuid.UUID
parent_id: Optional[uuid.UUID] = None parent_id: Optional[uuid.UUID] = None
module_id: Optional[uuid.UUID] = None
module_name: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
+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): class UserSignin(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
remember_me: bool = False
class AccessInRole(BaseModel): class AccessInRole(BaseModel):
id: str id: str
@@ -24,8 +25,8 @@ class AccessInRole(BaseModel):
name: str name: str
class RoleInUser(BaseModel): class RoleInUser(BaseModel):
id: uuid.UUID id: Optional[uuid.UUID] = None
role_name: str role_name: Optional[str] = None
accesses: List[str] = [] accesses: List[str] = []
class UserResponse(UserBase): class UserResponse(UserBase):
@@ -33,6 +34,7 @@ class UserResponse(UserBase):
tenant_id: Optional[uuid.UUID] = None tenant_id: Optional[uuid.UUID] = None
tenant_name: Optional[str] = None tenant_name: Optional[str] = None
tenant_logo_url: Optional[str] = None tenant_logo_url: Optional[str] = None
subscription_details: Optional[dict] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
role: Optional[RoleInUser] = None role: Optional[RoleInUser] = None
@@ -55,7 +57,7 @@ class TokenResponse(BaseModel):
class RefreshTokenRequest(BaseModel): class RefreshTokenRequest(BaseModel):
refresh_token: str refresh_token: Optional[str] = None
class ResetPassword(BaseModel): class ResetPassword(BaseModel):
old_password: str old_password: str
@@ -67,10 +69,10 @@ class ForgotPasswordRequest(BaseModel):
class VerifyOTPRequest(BaseModel): class VerifyOTPRequest(BaseModel):
email: EmailStr email: EmailStr
otp: str otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
class ResetPasswordWithOTP(BaseModel): class ResetPasswordWithOTP(BaseModel):
email: EmailStr email: EmailStr
otp: str otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
new_password: str = Field(..., min_length=8) new_password: str = Field(..., min_length=8)
+7 -1
View File
@@ -1,4 +1,10 @@
from pydantic import BaseModel from pydantic import BaseModel
from enum import Enum
class LanguageEnum(str, Enum):
"""Supported languages for the application."""
EN = "en"
AR = "ar"
class UpdateLanguageRequest(BaseModel): 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
+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,47 @@
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 SubscriptionPlanResponse(SubscriptionPlanBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SubscriptionPlanDetailResponse(SubscriptionPlanResponse):
access_ids: List[uuid.UUID] = []
module_access_ids: List[uuid.UUID] = []
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
+21 -2
View File
@@ -1,6 +1,6 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import date, datetime
import uuid import uuid
class TenantBase(BaseModel): class TenantBase(BaseModel):
@@ -8,18 +8,37 @@ class TenantBase(BaseModel):
tenant_domain: str = Field(..., min_length=3, max_length=255) tenant_domain: str = Field(..., min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None tenant_logo_url: Optional[str] = None
class ModuleEnvironmentAssignment(BaseModel):
module_id: uuid.UUID
environment_slug: str
class TenantCreate(TenantBase): class TenantCreate(TenantBase):
pass plan_id: uuid.UUID
start_date: Optional[date] = None
end_date: Optional[date] = None
status: Optional[str] = "ACTIVE"
module_environments: Optional[List[ModuleEnvironmentAssignment]] = []
default_environment_slug: str = "prod"
class TenantUpdate(BaseModel): class TenantUpdate(BaseModel):
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100) tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255) tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None tenant_logo_url: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: Optional[str] = None
class TenantResponse(TenantBase): class TenantResponse(TenantBase):
id: uuid.UUID id: uuid.UUID
tenant_id: uuid.UUID
is_active: bool is_active: bool
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+76 -5
View File
@@ -1,19 +1,90 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from app.models.auth.access_model import Access from app.models.auth.access_model import Access
from typing import List from typing import List, Any
from app.core.redis import sync_redis_client
import json
import logging
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.module_model import Module
from datetime import datetime
logger = logging.getLogger(__name__)
class AccessService: class AccessService:
@staticmethod @staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[Access]: def get_all_accesses(db: Session, category: str = None) -> List[any]:
cache_key = f"saas:access:v2:all:{category if category else 'full'}"
cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None
if cached_data:
try:
data_list = json.loads(cached_data)
class SimpleAccess:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
deserialized_list = []
for item in data_list:
if "created_at" in item and item["created_at"]:
try:
item["created_at"] = datetime.fromisoformat(item["created_at"])
except ValueError:
item["created_at"] = None
deserialized_list.append(SimpleAccess(**item))
return deserialized_list
except Exception as e:
logger.warning(f"Access cache read error: {e}")
query = db.query(Access) query = db.query(Access)
if category: if category:
query = query.filter(Access.category == category) query = query.filter(Access.category == category)
return query.all() saas_accesses = query.all()
for access in saas_accesses:
access.module_name = "SaaS (Internal)"
module_query = db.query(ModuleAccess).options(joinedload(ModuleAccess.module))
if category:
module_query = module_query.filter(ModuleAccess.category == category)
module_accesses = module_query.all()
for ma in module_accesses:
if ma.module:
ma.module_name = ma.module.module_name
result = saas_accesses + module_accesses
try:
if sync_redis_client.client:
serialized = []
for item in result:
serialized.append({
"id": str(item.id),
"access_code": item.access_code,
"name": item.name,
"category": item.category,
"parent_id": str(item.parent_id) if item.parent_id else None,
"module_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 @staticmethod
def get_access_categories(db: Session) -> List[str]: def get_access_categories(db: Session) -> List[str]:
categories = db.query(Access.category).distinct().all() categories = db.query(Access.category).distinct().all()
return [cat[0] for cat in categories] module_categories = db.query(ModuleAccess.category).distinct().all()
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
return list(all_cats)
+205 -68
View File
@@ -3,10 +3,19 @@ from fastapi import HTTPException, status
from app.models.auth.user_model import User from app.models.auth.user_model import User
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
from app.config.security import security from app.config.security import security
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import random import secrets
import uuid import uuid
import logging
import jwt
from app.config.settings import settings
from app.services.auth.email_service import EmailService 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: class AuthService:
@@ -47,12 +56,15 @@ class AuthService:
if user.status != "active": if user.status != "active":
raise HTTPException(status_code=403, detail="User inactive") raise HTTPException(status_code=403, detail="User inactive")
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None role_data = None
if user.role: if user.role or effective_accesses:
role_data = { role_data = {
"id": str(user.role.id), "id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name, "role_name": user.role.role_name if user.role else "subscription",
"accesses": [ra.access.access_code for ra in user.role.role_accesses], "accesses": effective_accesses,
} }
return { return {
@@ -73,6 +85,9 @@ class AuthService:
"tenant_id": user.tenant_id, "tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None, "tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url 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, "created_at": user.created_at,
"updated_at": user.updated_at, "updated_at": user.updated_at,
"role": role_data, "role": role_data,
@@ -102,11 +117,38 @@ class AuthService:
{"sub": str(user.id)}, tenant_id=user.tenant_id {"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 { return {
"access_token": new_access_token, "access_token": new_access_token,
"refresh_token": new_refresh_token, "refresh_token": new_refresh_token,
"token_type": "bearer", "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 @staticmethod
@@ -159,20 +201,30 @@ class AuthService:
return {"message": "Password updated successfully"} return {"message": "Password updated successfully"}
@staticmethod @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"} return {"message": "Logged out successfully"}
@staticmethod @staticmethod
def me(db: Session, current_user: User): def me(db: Session, current_user: User):
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, current_user)
)
role_data = None role_data = None
if current_user.role or effective_accesses:
if current_user.role:
role_data = { role_data = {
"id": str(current_user.role.id), "id": str(current_user.role.id) if current_user.role else None,
"role_name": current_user.role.role_name, "role_name": current_user.role.role_name if current_user.role else "subscription",
"accesses": [ "accesses": effective_accesses,
ra.access.access_code for ra in current_user.role.role_accesses
],
} }
return { return {
@@ -189,6 +241,9 @@ class AuthService:
"tenant_logo_url": ( "tenant_logo_url": (
current_user.tenant.tenant_logo_url if current_user.tenant else None 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, "created_at": current_user.created_at,
"updated_at": current_user.updated_at, "updated_at": current_user.updated_at,
"role": role_data, "role": role_data,
@@ -196,92 +251,174 @@ class AuthService:
@staticmethod @staticmethod
def forgot_password(db: Session, email: str): 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() user = db.query(User).filter(User.email == email).first()
if not user: if not user:
print(f"DEBUG: User not found for email: {email}")
return {"message": "If the email is registered, an OTP has been sent."} return {"message": "If the email is registered, an OTP has been sent."}
print(f"DEBUG: User found: {user.id}") otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
otp_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) redis_key = f"otp:{email}"
expires_at = datetime.utcnow() + timedelta(minutes=10) try:
print(f"DEBUG: Generated OTP: {otp_code}, Expires: {expires_at}") if sync_redis_client.client:
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
db.query(PasswordResetOTP).filter( logger.info(f"OTP generated for email: {email}")
PasswordResetOTP.email == email, PasswordResetOTP.is_used == False else:
).update({"is_used": True}) 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: try:
otp_entry = PasswordResetOTP( EmailService.send_otp(email, otp_code)
email=email, otp=otp_code, expires_at=expires_at
)
db.add(otp_entry)
db.commit()
print("DEBUG: OTP stored in database successfully")
except Exception as e: except Exception as e:
print(f"DEBUG: Database error saving OTP: {e}") logger.error(f"Failed to send OTP email to {email}: {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}")
return {"message": "If the email is registered, an OTP has been sent."} 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 @staticmethod
def verify_otp(db: Session, email: str, otp: str): def verify_otp(db: Session, email: str, otp: str):
otp_entry = ( """Verify OTP from Redis storage using constant-time comparison."""
db.query(PasswordResetOTP) redis_key = f"otp:{email}"
.filter(
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp,
PasswordResetOTP.is_used == False,
PasswordResetOTP.expires_at > datetime.utcnow(),
)
.first()
)
if not otp_entry: try:
if not sync_redis_client.client:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP" 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"} 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_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify OTP"
)
@staticmethod @staticmethod
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str): def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
otp_entry = ( """Reset password after verifying OTP and delete OTP from Redis."""
db.query(PasswordResetOTP) redis_key = f"otp:{email}"
.filter( verified_key = f"otp_verified:{email}"
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp, try:
PasswordResetOTP.is_used == False, if not sync_redis_client.client:
PasswordResetOTP.expires_at > datetime.utcnow(), raise HTTPException(
) status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
.first() detail="Password reset service temporarily unavailable"
) )
if not otp_entry: 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP" 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() user = db.query(User).filter(User.email == email).first()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
) )
if not security.validate_password_strength(new_password): if not security.validate_password_strength(new_password):
raise HTTPException(status_code=400, detail="Password too weak") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password too weak"
)
user.password = security.hash_password(new_password) user.password = security.hash_password(new_password)
user.password_updated_at = datetime.utcnow() user.password_updated_at = datetime.now(timezone.utc)
otp_entry.is_used = True
db.commit() 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"} 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_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
+10 -16
View File
@@ -1,41 +1,35 @@
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
import os import logging
from dotenv import load_dotenv from app.config.settings import settings
load_dotenv() logger = logging.getLogger(__name__)
class EmailService: 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 @staticmethod
def send_otp(to_email: str, otp: str): def send_otp(to_email: str, otp: str):
try: try:
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = EmailService.EMAIL_FROM msg['From'] = settings.EMAIL_FROM
msg['To'] = to_email msg['To'] = to_email
msg['Subject'] = "Password Reset OTP" msg['Subject'] = "Password Reset OTP"
body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes." body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes."
msg.attach(MIMEText(body, 'plain')) msg.attach(MIMEText(body, 'plain'))
if EmailService.SMTP_SECURE: if settings.SMTP_SECURE:
server = smtplib.SMTP_SSL(EmailService.SMTP_HOST, EmailService.SMTP_PORT) server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
else: else:
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT) server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
server.starttls() server.starttls()
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD) server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
text = msg.as_string() text = msg.as_string()
server.sendmail(EmailService.EMAIL_FROM, to_email, text) server.sendmail(settings.EMAIL_FROM, to_email, text)
server.quit() server.quit()
return True return True
except Exception as e: except Exception as e:
print(f"Failed to send email: {e}") logger.error(f"Failed to send email: {e}")
return False return False
+280
View File
@@ -0,0 +1,280 @@
import uuid
import httpx
import json
import logging
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
):
"""
Emits an event by writing it to the Outbox (event_logs).
Scopes delivery to relevant modules based on tenant_id.
"""
event_id = str(uuid.uuid4())
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:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.slug == env_slug
).first()
if env:
targets.append(env)
elif tenant_id:
tenant_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.is_active == True
).all()
for tm in tenant_modules:
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} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
return
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('/')
logger.info(f"Trace: base='{base}', endpoint='{endpoint}'")
target_url = f"{base}/{endpoint}"
logger.info(f"Trace: Calculated target_url='{target_url}'")
else:
logger.info(f"Using default event stream for env '{env.slug}'. ProvEndpoint: '{env.provisioning_endpoint}'")
target_url = f"{base}/api/internal/events"
log = EventLog(
event_id=uuid.UUID(event_id),
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()
try:
sync_redis_client.rpush("saas:events:queue", event_id)
except Exception as e:
logger.error(f"Failed to push event to Redis queue: {e}")
@staticmethod
def process_queue_item(db: Session, event_id: str):
"""
Process all pending EventLogs associated with the given logical event_id.
"""
logs = db.query(EventLog).filter(
EventLog.event_id == uuid.UUID(event_id),
EventLog.status == EventStatus.PENDING
).all()
if not logs:
return 0
processed_count = 0
for log in logs:
try:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == log.target_module_id,
ModuleEnvironment.slug == log.target_environment_slug
).first()
if not env:
log.status = EventStatus.FAILED
log.error_log = "Target environment config missing"
continue
payload_json = json.dumps(log.payload)
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_json.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature,
"X-SaaS-Event-Source": "saas-core"
}
logger.info(f"Sending event {log.event_type} to {log.target_url}")
response = httpx.post(log.target_url, content=payload_json, headers=headers, timeout=5)
if response.status_code in range(200, 300):
log.status = EventStatus.COMPLETED
log.error_log = None
processed_count += 1
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
payload_json = json.dumps(log.payload)
secret = env.trust_credentials.get("hmac_secret")
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_json.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature,
"X-SaaS-Event-Source": "saas-core"
}
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
response = httpx.post(log.target_url, content=payload_json, 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,111 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List
import uuid
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate
class ModuleEnvironmentService:
@staticmethod
def list_environments(db: Session, module_id: str) -> List[ModuleEnvironment]:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id
).order_by(ModuleEnvironment.is_default.desc(), ModuleEnvironment.slug).all()
@staticmethod
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> ModuleEnvironment:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
if env_data.is_default:
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).update({"is_default": False})
try:
environment = ModuleEnvironment(
module_id=module.id,
slug=env_data.slug,
frontend_base_url=env_data.frontend_base_url,
backend_base_url=env_data.backend_base_url,
sso_entry_path=env_data.sso_entry_path,
permission_sync_endpoint=env_data.permission_sync_endpoint,
sso_exchange_endpoint=env_data.sso_exchange_endpoint,
trust_type=env_data.trust_type,
trust_credentials=env_data.trust_credentials,
is_default=env_data.is_default,
is_active=env_data.is_active
)
db.add(environment)
db.commit()
db.refresh(environment)
return environment
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Environment slug already exists for this module")
@staticmethod
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> ModuleEnvironment:
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
if env_data.is_default and env_data.is_default != environment.is_default:
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == environment.module_id,
ModuleEnvironment.id != environment.id,
ModuleEnvironment.is_default == True
).update({"is_default": False})
update_data = env_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(environment, key, value)
db.commit()
db.refresh(environment)
return environment
@staticmethod
def set_default_environment(db: Session, module_id: str, env_id: str):
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == environment.module_id
).update({"is_default": False})
environment.is_default = True
db.commit()
@staticmethod
def delete_environment(db: Session, module_id: str, env_id: str):
environment = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid.UUID(env_id),
ModuleEnvironment.module_id == uuid.UUID(module_id)
).first()
if not environment:
raise HTTPException(status_code=404, detail="Environment not found")
if environment.is_default:
raise HTTPException(status_code=400, detail="Cannot delete default environment. Set another environment as default first.")
db.delete(environment)
db.commit()
@@ -0,0 +1,162 @@
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:
pass
if secret:
signature = hmac.new(
secret.encode("utf-8"),
payload_body.encode("utf-8"),
hashlib.sha256
).hexdigest()
else:
signature = ""
headers = {
"Content-Type": "application/json",
"X-SaaS-Signature": signature
}
url = f"{env.backend_base_url}{env.permission_sync_endpoint}"
response = requests.post(url, headers=headers, data=payload_body, timeout=10)
response.raise_for_status()
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)
permission_map = {}
for perm in permissions:
code = perm.get("permission_code")
if not code:
continue
access = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code == code
).first()
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)
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")
permission_map[code] = access
synced_count += 1
db.flush()
for perm in permissions:
code = perm.get("permission_code")
parent_code = perm.get("parent_code")
if not code or not parent_code:
continue
access = permission_map.get(code)
parent_access = permission_map.get(parent_code)
if not parent_access:
parent_access = db.query(ModuleAccess).filter(
ModuleAccess.module_id == module.id,
ModuleAccess.access_code == parent_code
).first()
if access and parent_access:
access.parent_id = parent_access.id
db.commit()
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()
+77
View File
@@ -0,0 +1,77 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List, Optional
import uuid
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.module_model import Module
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate
class ModuleService:
@staticmethod
def list_modules(db: Session) -> List[Module]:
return db.query(Module).order_by(Module.display_order, Module.module_name).all()
@staticmethod
def create_module(db: Session, module_data: ModuleCreate) -> Module:
try:
module = Module(
module_id=module_data.module_id,
module_name=module_data.module_name,
description=module_data.description,
icon_url=module_data.icon_url,
status=module_data.status,
display_order=module_data.display_order
)
db.add(module)
db.commit()
db.refresh(module)
return module
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Module ID already exists")
@staticmethod
def get_module(db: Session, module_id: str) -> Module:
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return module
@staticmethod
def get_available_modules(db: Session, tenant_id: Optional[uuid.UUID]) -> List[dict]:
if tenant_id:
results = db.query(Module, TenantModule.is_active).join(
TenantModule,
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == tenant_id)
).filter(
Module.status == "active",
TenantModule.is_active == True
).order_by(Module.display_order).all()
return [{"module": mod, "is_active": is_active} for mod, is_active in results]
else:
modules = db.query(Module).filter(Module.status == "active").order_by(Module.display_order).all()
return [{"module": mod, "is_active": True} for mod in modules]
@staticmethod
def get_module_by_module_id(db: Session, module_id_str: str) -> Optional[Module]:
return db.query(Module).filter(Module.module_id == module_id_str).first()
@staticmethod
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> Module:
module = ModuleService.get_module(db, module_id)
update_data = module_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(module, key, value)
db.commit()
db.refresh(module)
return module
@staticmethod
def delete_module(db: Session, module_id: str):
module = ModuleService.get_module(db, module_id)
db.delete(module)
db.commit()
+220 -13
View File
@@ -1,17 +1,25 @@
import uuid
import json
import logging
from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.models.auth.role_model import Role from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess from app.models.auth.role_access_model import RoleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.access_model import Access from app.models.auth.access_model import Access
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
from typing import List, Optional from app.services.auth.event_service import EventService
import uuid
logger = logging.getLogger(__name__)
class RoleService: class RoleService:
@staticmethod @staticmethod
def create_role(db: Session, role_data: RoleCreate) -> Role: def create_role(db: Session, role_data: RoleCreate, emit_events: bool = True) -> Role:
existing = ( existing = (
db.query(Role) db.query(Role)
.filter( .filter(
@@ -40,22 +48,86 @@ class RoleService:
if role_data.access_ids: if role_data.access_ids:
RoleService.assign_accesses(db, role.id, role_data.access_ids) RoleService.assign_accesses(db, role.id, role_data.access_ids)
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
)
db.commit()
return role return role
@staticmethod @staticmethod
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]): def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete() db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
db.query(RoleModuleAccess).filter(RoleModuleAccess.role_id == role_id).delete()
for access_id in access_ids: if not access_ids:
access = db.query(Access).filter(Access.id == access_id).first() return
if not access:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Access {access_id} not found",
)
role_access = RoleAccess(role_id=role_id, access_id=access_id) saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
db.add(role_access) saas_ids = {a.id for a in saas_accesses}
for access in saas_accesses:
db.add(RoleAccess(role_id=role_id, access_id=access.id))
remaining_ids = set(access_ids) - saas_ids
if remaining_ids:
module_accesses = db.query(ModuleAccess).filter(ModuleAccess.id.in_(remaining_ids)).all()
for access in module_accesses:
db.add(RoleModuleAccess(role_id=role_id, module_access_id=access.id))
db.commit() db.commit()
@@ -84,7 +156,25 @@ class RoleService:
) -> Role: ) -> Role:
role = RoleService.get_role_by_id(db, role_id) role = RoleService.get_role_by_id(db, role_id)
def get_module_permissions_snapshot(r_id):
snapshot_data = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == r_id)
.all()
)
snapshot_map = {}
for rma, ma in snapshot_data:
mid = str(ma.module_id)
if mid not in snapshot_map:
snapshot_map[mid] = set()
snapshot_map[mid].add(ma.access_code)
return snapshot_map
before_snapshot = get_module_permissions_snapshot(role_id)
update_dict = role_data.model_dump(exclude_unset=True) update_dict = role_data.model_dump(exclude_unset=True)
role_name_changed = "role_name" in update_dict
if role.is_default and not is_superadmin: if role.is_default and not is_superadmin:
raise HTTPException( raise HTTPException(
@@ -102,6 +192,61 @@ class RoleService:
db.commit() db.commit()
db.refresh(role) db.refresh(role)
after_snapshot = get_module_permissions_snapshot(role_id)
all_modules = set(before_snapshot.keys()) | set(after_snapshot.keys())
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_([uuid.UUID(m) for m in all_modules])
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
diff_targets = []
for mid in all_modules:
before_set = before_snapshot.get(mid, set())
after_set = after_snapshot.get(mid, set())
added = list(after_set - before_set)
removed = list(before_set - after_set)
is_active_module = mid in after_snapshot and len(after_snapshot[mid]) > 0
if added or removed or (role_name_changed and is_active_module):
env_slug = env_map.get(mid, "prod")
diff_targets.append({
"module_id": mid,
"environment_slug": env_slug,
"added_permissions": added,
"removed_permissions": removed
})
if diff_targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"role_name": role.role_name,
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": diff_targets
}
logger.info(f"ROLE_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_UPDATED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
return role return role
@staticmethod @staticmethod
@@ -114,6 +259,49 @@ class RoleService:
detail="Default roles can only be deleted by superadmins.", detail="Default roles can only be deleted by superadmins.",
) )
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.distinct()
.all()
)
if active_modules:
module_ids = [m[0] for m in active_modules]
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_(module_ids)
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for m in active_modules:
mid = str(m[0])
env_slug = env_map.get(mid, "prod")
targets.append({"module_id": mid, "environment_slug": env_slug})
if targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": targets
}
logger.info(f"ROLE_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.delete(role) db.delete(role)
db.commit() db.commit()
return {"message": "Role deleted successfully"} return {"message": "Role deleted successfully"}
@@ -125,6 +313,10 @@ class RoleService:
page: int = 1, page: int = 1,
page_size: int = 10, page_size: int = 10,
search: Optional[str] = None, 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: ) -> RolePaginatedResponse:
query = db.query(Role) query = db.query(Role)
@@ -132,6 +324,12 @@ class RoleService:
if tenant_id is not None: if tenant_id is not None:
query = query.filter(Role.tenant_id == tenant_id) 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(): if search and search.strip():
search_term = search.strip() search_term = search.strip()
query = query.filter( query = query.filter(
@@ -141,6 +339,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() total = query.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
+272
View File
@@ -0,0 +1,272 @@
import uuid
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.sso_grant_model import SSOGrant
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
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,
)
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")
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)
redirect_url = f"{env.frontend_base_url}{env.sso_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 tm and tm.is_active and tm.assigned_environment_slug:
environment_slug = tm.assigned_environment_slug
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first()
if not env:
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first()
if not env:
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
permissions = sorted(
SubscriptionEntitlementService.get_plan_module_access_codes(
db, tenant_id, module.id
)
)
if not permissions and user.role and user.role.role_module_accesses:
permissions = sorted(
{
rma.module_access.access_code
for rma in user.role.role_module_accesses
if rma.module_access and rma.module_access.module_id == module.id
}
)
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")
user = db.query(User).filter(User.id == grant_data["user_id"]).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
grant_tenant_id = grant_data.get("tenant_id")
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_plan_module_access_codes(
db,
uuid.UUID(grant_tenant_id) if grant_tenant_id else None,
module.id,
)
)
if not permissions and user.role and user.role.role_module_accesses:
permissions = sorted(
{
rma.module_access.access_code
for rma in user.role.role_module_accesses
if rma.module_access and rma.module_access.module_id == module.id
}
)
token_payload = {
"sub": str(user.id),
"email": user.email,
"tenant_id": grant_tenant_id,
"module_id": module_id,
"environment": environment_slug,
"permissions": permissions,
"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,96 @@
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_access_codes(db: Session, user: User) -> Set[str]:
role_codes = set()
if user.role:
role_codes = {
ra.access.access_code
for ra in user.role.role_accesses
if ra.access is not None
}
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,278 @@
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.schemas.auth.subscription_plan_schema import (
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
SubscriptionPlanPaginatedResponse,
SubscriptionPlanResponse
)
class SubscriptionPlanService:
@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)
return query.all()
@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=[SubscriptionPlanResponse.from_orm(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()
+455 -5
View File
@@ -1,12 +1,82 @@
from datetime import date, datetime, timezone
from sqlalchemy.orm import Session 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 fastapi import HTTPException, status
from app.models.auth.tenant_model import Tenant from app.models.auth.tenant_model import Tenant
from app.models.auth.tenant_module_model import TenantModule
from app.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.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse 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
import uuid 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__)
class TenantService: class TenantService:
STATUS_ACTIVE = "ACTIVE"
STATUS_INACTIVE = "INACTIVE"
STATUS_EXPIRED = "EXPIRED"
@staticmethod
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 @staticmethod
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant: def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
@@ -24,20 +94,157 @@ class TenantService:
detail="Tenant domain already exists" detail="Tenant domain already exists"
) )
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"
)
provisioning_id = str(uuid.uuid4())
try:
# 1. Create Tenant
tenant = Tenant( tenant = Tenant(
tenant_name=tenant_data.tenant_name, tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain, tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url 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()
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]
distinct_module_ids = []
if mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
distinct_module_ids = [m[0] for m in modules_query]
module_env_map = {}
if tenant_data.module_environments:
for me in tenant_data.module_environments:
module_env_map[me.module_id] = me.environment_slug
event_targets = []
for mod_id in distinct_module_ids:
env_slug = module_env_map.get(mod_id, tenant_data.default_environment_slug)
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=env_slug,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(mod_id),
"environment_slug": env_slug
})
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
role_create_data = RoleCreate(
role_name="Primary Admin",
tenant_id=tenant.id,
is_default=True,
access_ids=all_access_ids
)
role = RoleService.create_role(db, role_create_data, emit_events=False)
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.all()
) )
db.add(tenant) 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 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,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
if event_targets:
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"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
)
logger.info(f"Event TENANT_PROVISION_REQUESTED emitted to outbox{' (with ROLE_PROVISION_REQUESTED follow-up)' if role_follow_up else ''}.")
db.commit() db.commit()
db.refresh(tenant) db.refresh(tenant)
return tenant return tenant
except Exception as e:
db.rollback()
raise e
@staticmethod @staticmethod
def get_all_tenants(db: Session): def get_all_tenants(db: Session):
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 @staticmethod
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant: def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
@@ -47,6 +254,9 @@ class TenantService:
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found" detail="Tenant not found"
) )
if TenantService._sync_tenant_lifecycle(tenant):
db.commit()
db.refresh(tenant)
return tenant return tenant
@staticmethod @staticmethod
@@ -55,19 +265,195 @@ class TenantService:
update_dict = tenant_data.model_dump(exclude_unset=True) update_dict = tenant_data.model_dump(exclude_unset=True)
should_emit_update = False
should_emit_status = False
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name: if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first() existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="Tenant name already exists") raise HTTPException(status_code=400, detail="Tenant name already exists")
should_emit_update = True
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain: if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first() existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="Tenant domain already exists") raise HTTPException(status_code=400, detail="Tenant domain already exists")
should_emit_update = True
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
should_emit_update = True
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "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(): for key, value in update_dict.items():
setattr(tenant, key, value) 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.commit()
db.refresh(tenant) db.refresh(tenant)
return tenant return tenant
@@ -75,6 +461,33 @@ class TenantService:
@staticmethod @staticmethod
def delete_tenant(db: Session, tenant_id: uuid.UUID): def delete_tenant(db: Session, tenant_id: uuid.UUID):
tenant = TenantService.get_tenant_by_id(db, tenant_id) tenant = TenantService.get_tenant_by_id(db, tenant_id)
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
if active_modules:
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
db.delete(tenant) db.delete(tenant)
db.commit() db.commit()
return {"message": "Tenant deleted successfully"} return {"message": "Tenant deleted successfully"}
@@ -86,10 +499,25 @@ class TenantService:
page_size: int = 10, page_size: int = 10,
search: Optional[str] = None, search: Optional[str] = None,
is_active: Optional[bool] = 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: ) -> TenantPaginatedResponse:
query = db.query(Tenant) 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(): if search and search.strip():
search_term = search.strip() search_term = search.strip()
query = query.filter( query = query.filter(
@@ -103,11 +531,33 @@ class TenantService:
if is_active is not None: if is_active is not None:
query = query.filter(Tenant.is_active == is_active) 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() total = query.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
tenants = query.offset(offset).limit(page_size).all() 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 total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return TenantPaginatedResponse( return TenantPaginatedResponse(
+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"
)
+250 -12
View File
@@ -1,17 +1,27 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String from sqlalchemy import or_, cast, String, asc, desc, func
from fastapi import HTTPException, status from fastapi import HTTPException, status, BackgroundTasks
from datetime import datetime from datetime import datetime
import uuid import uuid
from typing import Optional from typing import Optional, List, Dict, Any
from app.models.auth.user_model import User from app.models.auth.user_model import User
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.config.security import security from app.config.security import security
from app.services.auth.event_service import EventService
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: class UserService:
@staticmethod @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(): if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -36,8 +46,35 @@ class UserService:
) )
db.add(user) db.add(user)
db.commit() db.flush()
db.refresh(user) 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 return user
@staticmethod @staticmethod
@@ -61,13 +98,31 @@ class UserService:
return user return user
@staticmethod @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) user = UserService.get_user_by_id(db, user_id, tenant_id)
old_role_id = user.role_id
old_targets = []
if old_role_id:
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
update_dict = user_data.model_dump(exclude_unset=True) update_dict = user_data.model_dump(exclude_unset=True)
if tenant_id: if tenant_id:
update_dict.pop("tenant_id", None) update_dict.pop("tenant_id", None)
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 "email" in update_dict and update_dict["email"] != user.email:
if db.query(User).filter(User.email == update_dict["email"]).first(): if db.query(User).filter(User.email == update_dict["email"]).first():
raise HTTPException( raise HTTPException(
@@ -88,13 +143,88 @@ class UserService:
for key, value in update_dict.items(): for key, value in update_dict.items():
setattr(user, key, value) setattr(user, key, value)
db.commit() db.flush()
db.refresh(user) 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 return user
@staticmethod @staticmethod
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None): def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
user = UserService.get_user_by_id(db, user_id, tenant_id) user = UserService.get_user_by_id(db, user_id, tenant_id)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
if targets:
payload = {
"user_id": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"targets": targets
}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.delete(user) db.delete(user)
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
@@ -106,16 +236,23 @@ class UserService:
page: int = 1, page: int = 1,
page_size: int = 10, page_size: int = 10,
search: Optional[str] = None, 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: ) -> UserPaginatedResponse:
query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id)
query = db.query(User) # Scope to tenant if not superadmin
if tenant_id: if tenant_id:
query = query.filter(User.tenant_id == tenant_id) query = query.filter(User.tenant_id == tenant_id)
if search and search.strip(): if search and search.strip():
search_term = search.strip() search_term = search.strip()
search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.filter( query = query.filter(
or_( or_(
User.email.ilike(f"%{search_term}%"), User.email.ilike(f"%{search_term}%"),
@@ -126,8 +263,73 @@ class UserService:
) )
) )
if status is not None: if filter_names:
query = query.filter(User.status == status) 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() total = query.count()
@@ -143,3 +345,39 @@ class UserService:
page_size=page_size, page_size=page_size,
total_pages=total_pages, total_pages=total_pages,
) )
@staticmethod
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
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
+52
View File
@@ -0,0 +1,52 @@
import logging
import uuid
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.models.system.audit_log import AuditLog
logger = logging.getLogger(__name__)
class AuditLogService:
@staticmethod
def log(
db: Session,
module_name: str,
action_type: str,
description: str,
entity_id: Optional[str] = None,
entity_name: Optional[str] = None,
performed_by_id: 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,
) -> None:
"""
Write an audit log entry.
Failures are silently swallowed and logged — they must NEVER break
the main operation that triggered them.
"""
try:
entry = AuditLog(
module_name=module_name,
action_type=action_type,
entity_id=entity_id,
entity_name=entity_name,
description=description,
performed_by_id=(
uuid.UUID(performed_by_id) if performed_by_id else None
),
performed_by_email=performed_by_email,
ip_address=ip_address,
old_values=old_values,
new_values=new_values,
)
db.add(entry)
db.commit()
except Exception as exc:
db.rollback()
logger.error("AuditLogService.log failed: %s", exc, exc_info=True)
+16 -22
View File
@@ -1,19 +1,17 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from uuid import UUID from uuid import UUID
from typing import List, Optional from typing import List
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.models.theme.color_palette_models import ColorPalette from app.models.theme.color_palette_model import ColorPalette
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
)
class PaletteService: class PaletteService:
@staticmethod @staticmethod
def get_all_palettes(db: Session, tenant_id: Optional[UUID] = None) -> List[ColorPalette]: def get_all_palettes(db: Session) -> List[ColorPalette]:
query = db.query(ColorPalette) return db.query(ColorPalette).all()
if tenant_id:
query = query.filter((ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id))
else:
pass
return query.all()
@staticmethod @staticmethod
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette: def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
@@ -23,19 +21,15 @@ class PaletteService:
return palette return palette
@staticmethod @staticmethod
def create_palette(db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None) -> ColorPalette: def create_palette(db: Session, data: ColorPaletteCreate) -> ColorPalette:
if data.is_default: if data.is_default:
if tenant_id: db.query(ColorPalette).update({"is_default": False})
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id).update({"is_default": False})
else:
db.query(ColorPalette).filter(ColorPalette.tenant_id == None).update({"is_default": False})
db_palette = ColorPalette( db_palette = ColorPalette(
name=data.name, name=data.name,
description=data.description, description=data.description,
colors=data.colors.model_dump(), colors=data.colors.model_dump(),
is_default=data.is_default, is_default=data.is_default,
tenant_id=tenant_id
) )
db.add(db_palette) db.add(db_palette)
db.commit() db.commit()
@@ -43,7 +37,9 @@ class PaletteService:
return db_palette return db_palette
@staticmethod @staticmethod
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette: def update_palette(
db: Session, palette_id: UUID, data: ColorPaletteUpdate
) -> ColorPalette:
palette = PaletteService.get_palette_by_id(db, palette_id) palette = PaletteService.get_palette_by_id(db, palette_id)
if data.name is not None: if data.name is not None:
@@ -54,11 +50,9 @@ class PaletteService:
palette.colors = data.colors.model_dump() palette.colors = data.colors.model_dump()
if data.is_default is not None: if data.is_default is not None:
if data.is_default: if data.is_default:
tenant_id = palette.tenant_id db.query(ColorPalette).filter(
if tenant_id: ColorPalette.id != palette_id
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id, ColorPalette.id != palette_id).update({"is_default": False}) ).update({"is_default": False})
else:
db.query(ColorPalette).filter(ColorPalette.tenant_id == None, ColorPalette.id != palette_id).update({"is_default": False})
palette.is_default = data.is_default palette.is_default = data.is_default
+58
View File
@@ -0,0 +1,58 @@
import os
import subprocess
import sys
import typer
from typing import Optional
app = typer.Typer(help="Management script for SaaS Backend")
seed_app = typer.Typer(help="Seeding commands")
app.add_typer(seed_app, name="seed")
def run_command(args: list[str], env_name: str):
"""
Helper to run commands with a specific APP_ENV.
Replaces "python" with sys.executable to ensure the same interpreter is used.
"""
env = os.environ.copy()
env["APP_ENV"] = env_name
if args[0] == "python":
args[0] = sys.executable
try:
subprocess.run(args, env=env, check=True)
except subprocess.CalledProcessError as e:
sys.exit(e.returncode)
except KeyboardInterrupt:
sys.exit(0)
@app.command()
def run(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
):
"""Start the FastAPI server."""
run_command(["python", "run.py"], env)
@app.command()
def migrate(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
):
"""Run Alembic migrations (upgrade head)."""
run_command(["python", "-m", "alembic", "upgrade", "head"], env)
@seed_app.command("palettes")
def seed_palettes(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
):
"""Seed palettes database."""
run_command(["python", "scripts/seed_palettes.py"], env)
@seed_app.command("superadmin")
def seed_superadmin(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
):
"""Seed superadmin user."""
run_command(["python", "scripts/seed_superadmin.py"], env)
if __name__ == "__main__":
app()
-105
View File
@@ -1,105 +0,0 @@
{
"name": "saas-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "saas-backend",
"version": "1.0.0",
"devDependencies": {
"cross-env": "^7.0.3"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
}
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"name": "saas-backend",
"version": "1.0.0",
"description": "Backend for SaaS architecture",
"main": "run.py",
"scripts": {
"start": "python run.py",
"local": "cross-env APP_ENV=local python run.py",
"dev": "cross-env APP_ENV=development python run.py",
"prod": "cross-env APP_ENV=production python run.py",
"test": "cross-env APP_ENV=testing python run.py",
"migrate:local": "cross-env APP_ENV=local python -m alembic upgrade head",
"migrate:dev": "cross-env APP_ENV=development python -m alembic upgrade head",
"migrate:prod": "cross-env APP_ENV=production python -m alembic upgrade head",
"migrate:test": "cross-env APP_ENV=testing python -m alembic upgrade head",
"seed:palettes:local": "cross-env APP_ENV=local python scripts/seed_palettes.py",
"seed:palettes:dev": "cross-env APP_ENV=development python scripts/seed_palettes.py",
"seed:palettes:prod": "cross-env APP_ENV=production python scripts/seed_palettes.py",
"seed:palettes:test": "cross-env APP_ENV=testing python scripts/seed_palettes.py",
"seed:superadmin:local": "cross-env APP_ENV=local python scripts/seed_superadmin.py",
"seed:superadmin:dev": "cross-env APP_ENV=development python scripts/seed_superadmin.py",
"seed:superadmin:prod": "cross-env APP_ENV=production python scripts/seed_superadmin.py",
"seed:superadmin:test": "cross-env APP_ENV=testing python scripts/seed_superadmin.py"
},
"dependencies": {},
"devDependencies": {
"cross-env": "^7.0.3"
}
}
+4
View File
@@ -8,3 +8,7 @@ pydantic-settings==2.12.0
bcrypt>=4.0.1 bcrypt>=4.0.1
pyjwt>=2.8.0 pyjwt>=2.8.0
email-validator>=2.1.0 email-validator>=2.1.0
redis==7.1.0
requests==2.32.5
typer==0.21.1
httpx==0.28.1
+1 -1
View File
@@ -9,5 +9,5 @@ if __name__ == "__main__":
"run:app", "run:app",
host=settings.HOST, host=settings.HOST,
port=settings.PORT, port=settings.PORT,
reload=settings.APP_ENV == "development" reload=settings.APP_ENV == "development",
) )

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