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_NAME=SaaS Architecture
VERSION=1.0.0
PORT=11001
PORT=10001
APP_ENV=development
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-dev.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-dev.maskantech.in
FRONTEND_URL=https://saas-dev.maskantech.in
CORS_ALLOWED_ORIGINS=https://saas-dev.maskantech.in
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6382
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
+8 -12
View File
@@ -1,5 +1,5 @@
# Project Configuration
PROJECT_NAME=Fulfillment And Logistics
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=8000
APP_ENV=local
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=http://localhost:3000
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://saas_user:nMCuFvGoG%28%23Q%40Q4%5E@106.51.104.95:5432/saas_local
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6381
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
+2 -1
View File
@@ -38,10 +38,11 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
COOKIE_SECURE=true
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
+8 -12
View File
@@ -1,13 +1,13 @@
# Project Configuration
PROJECT_NAME=SaaS Architecture
VERSION=1.0.0
PORT=11002
PORT=10002
APP_ENV=testing
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=https://fulfilment-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://fulfilment-test.maskantech.in
FRONTEND_URL=https://saas-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
#Database Configuration
DB_SSL=False
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
# Redis Configuration
# REDIS_URL=""
# port=""
# host=""
# password=""
REDIS_HOST=106.51.105.22
REDIS_PORT=6383
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
ACCESS_TOKEN_EXPIRES=86400
ACCESS_TOKEN_EXPIRES=900
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
REFRESH_TOKEN_EXPIRES=864000
JWT_ALGORITHM=HS256
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
# AWS S3 Configuration
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
+17 -15
View File
@@ -20,9 +20,10 @@ A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
## Prerequisites
- Python 3.12+
- PostgreSQL 12+
- Python 3.10+
- PostgreSQL 12+
- Node.js (for npm scripts)
## Getting Started
@@ -44,7 +45,7 @@ python -m venv venv
```bash
pip install -r requirements.txt
npm install # For cross-env support in npm scripts
pip install -r requirements.txt
```
### 4. Environment Configuration
@@ -112,11 +113,11 @@ alembic revision --autogenerate -m "Initial schema"
#### Run Migrations
```bash
# Using npm scripts (recommended - handles APP_ENV automatically)
npm run migrate:local
npm run migrate:dev
npm run migrate:prod
npm run migrate:test
# Using manage.py (recommended - handles APP_ENV automatically)
python manage.py migrate --env local
python manage.py migrate --env development
python manage.py migrate --env production
python manage.py migrate --env testing
# Or using alembic directly
$env:APP_ENV="local" # Set environment first
@@ -148,20 +149,20 @@ After running migrations, seed the database with initial data:
```bash
# Seed super admin user
npm run seed:superadmin:local
python manage.py seed superadmin --env local
# Seed default color palettes
npm run seed:palettes:local
python manage.py seed palettes --env local
```
### 7. Run the Application
```bash
# Using npm scripts (recommended)
npm run local # Local environment
npm run dev # Development environment
npm run prod # Production environment
npm run test # Testing environment
# Using manage.py (recommended)
python manage.py run --env local # Local environment
python manage.py run --env development # Development environment
python manage.py run --env production # Production environment
python manage.py run --env testing # Testing environment
# Or using Python directly
python run.py
@@ -209,7 +210,8 @@ backend/
│ ├── seed_palettes.py
│ └── seed_superadmin.py
├── alembic.ini # Alembic configuration
├── package.json # NPM scripts
├── alembic.ini # Alembic configuration
├── manage.py # Management CLI script
├── requirements.txt # Python dependencies
└── run.py # Application entry point
```
+1
View File
@@ -42,6 +42,7 @@ import app.models.auth.tenant_model
import app.models.auth.access_model
import app.models.auth.role_access_model
import app.models.theme.color_palette_model
import app.models.auth.subscription_plan_model
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -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 ###
+129 -23
View File
@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
@@ -5,12 +6,24 @@ import logging
from sqlalchemy import text
from app.config.settings import settings
from app.config.database import engine
# Import models for Alembic
from app.routes.admin import audit_logs
import app.models.auth.user_model
import app.models.auth.role_model
import app.models.auth.tenant_model
import app.models.theme.color_palette_model
import app.models.auth.module_model
import app.models.auth.module_environment_model
import app.models.auth.tenant_module_model
import app.models.auth.sso_grant_model
import app.models.auth.access_model
import app.models.auth.subscription_plan_model
import app.models.system.event_log_model
import app.models.system.audit_log
import asyncio
from app.services.auth.event_service import EventService
from app.config.database import SessionLocal
from app.core.redis import redis_client, sync_redis_client
from fastapi.concurrency import run_in_threadpool
# Configure logging
logging.basicConfig(
@@ -21,6 +34,95 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
async def wait_for_db(retries: int = 10, base_delay: float = 3.0, max_delay: float = 30.0):
"""Wait for the database to become available with exponential backoff."""
for attempt in range(1, retries + 1):
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
logger.info("Database connection successful!")
return
except Exception as e:
if attempt == retries:
logger.error(f"Database unreachable after {retries} attempts: {e}")
raise
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
logger.warning(
f"Database not ready (attempt {attempt}/{retries}): {e}. "
f"Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting SaaS application...")
await wait_for_db()
app.state.redis_available = False
try:
await redis_client.connect()
await run_in_threadpool(sync_redis_client.connect)
app.state.redis_available = True
logger.info("Redis connected successfully.")
except Exception as e:
logger.error(f"Redis unavailable at startup: {e}")
logger.warning("Continuing without Redis — event queue disabled.")
logger.info(
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
)
async def redis_event_consumer():
logger.info("Redis Event Consumer STARTED")
while True:
try:
if not app.state.redis_available or not redis_client.client:
await asyncio.sleep(5)
continue
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
if result:
_, event_id = result
try:
with SessionLocal() as db:
await run_in_threadpool(EventService.process_queue_item, db, event_id)
except Exception as e:
logger.error(f"Error processing event {event_id}: {e}")
except Exception:
await asyncio.sleep(1)
async def fallback_poller():
logger.info("Fallback Event Poller STARTED")
while True:
try:
with SessionLocal() as db:
await run_in_threadpool(EventService.process_outbox, db)
except Exception as e:
logger.error(f"Fallback poller error: {e}")
await asyncio.sleep(60)
background_tasks = [
asyncio.create_task(redis_event_consumer()),
asyncio.create_task(fallback_poller()),
]
yield
logger.info("Shutting down...")
for task in background_tasks:
task.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
if app.state.redis_available:
try:
await redis_client.close()
await run_in_threadpool(sync_redis_client.close)
except Exception:
pass
def create_app() -> FastAPI:
app = FastAPI(
@@ -30,6 +132,7 @@ def create_app() -> FastAPI:
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
# === OpenAPI Security Scheme ===
@@ -73,7 +176,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -84,30 +187,35 @@ def create_app() -> FastAPI:
from app.routes.auth.role import router as role_router
from app.routes.auth.access import router as access_router
from app.routes.auth.user import router as user_router
from app.routes.auth.sso import public_router as sso_public_router, internal_router as sso_internal_router
from app.routes.api.module import router as module_router
from app.routes.auth.subscription_plan import router as subscription_plan_router
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
app.include_router(role_router, prefix="/api/role", tags=["Role Management"])
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
from app.routes.internal.module import router as internal_module_router
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
@app.on_event("startup")
def startup_event():
logger.info("Testing database connection...")
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
conn.commit()
logger.info("Database connection successful!")
except Exception as e:
logger.error(f"Database connection failed: {e}")
raise
from app.routes.theme.color_palette import router as palette_router
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
logger.info(
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
)
# === Admin Routes ===
from app.routes.admin.modules import router as admin_modules_router
from app.routes.admin.module_environments import router as admin_module_env_router
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
# === Basic Routes ===
@app.get("/", tags=["Root"])
@@ -138,7 +246,8 @@ def create_app() -> FastAPI:
conn.execute(text("SELECT 1"))
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
logger.error(f"Health check DB error: {e}")
db_status = "unhealthy"
return {
"status": "healthy" if db_status == "healthy" else "degraded",
@@ -147,7 +256,4 @@ def create_app() -> FastAPI:
"version": settings.VERSION,
}
return app
app = create_app()
return app
+48 -20
View File
@@ -1,18 +1,17 @@
"""
Security utilities for authentication and authorization.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import bcrypt
import jwt
import uuid
import logging
from fastapi import HTTPException, status
import re
import secrets
import string
from app.config.settings import settings
from app.core.redis import sync_redis_client
logger = logging.getLogger(__name__)
class SecurityUtils:
"""Security utility class for authentication and authorization."""
@@ -33,24 +32,24 @@ class SecurityUtils:
"""Generate JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "access"})
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
return jwt.encode(
to_encode,
settings.ACCESS_TOKEN_SECRET,
algorithm="HS256"
)
@staticmethod
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
"""Generate JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "refresh"})
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
# Include tenant_id if provided
if tenant_id:
@@ -77,7 +76,20 @@ class SecurityUtils:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
jti = payload.get("jti")
if jti and sync_redis_client.client:
try:
if sync_redis_client.client.get(f"blacklist:{jti}"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked"
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Redis blacklist check failed: {e}")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
@@ -129,24 +141,42 @@ class SecurityUtils:
if len(password) < 8:
return False
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password):
return False
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password):
return False
# Check for at least one digit
if not re.search(r'\d', password):
return False
# Check for at least one special character
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False
return True
@staticmethod
def 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
def validate_email(email: str) -> bool:
"""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
# Create instance for easy importing
security = SecurityUtils()
+29 -53
View File
@@ -2,32 +2,25 @@ from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
from dotenv import load_dotenv
import os
# Load environment variables from .env files
app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent.parent
backend_path = Path(__file__).resolve().parent.parent
# Load specific environment file (e.g., .env.development)
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
load_dotenv(dotenv_path=base_path / '.env')
load_dotenv(dotenv_path=backend_path / '.env')
# Override with specific environment config
if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists():
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
class Settings(BaseSettings):
# Project
PROJECT_NAME: str = "SaaS Architecture"
VERSION: str = "1.0.0"
PROJECT_NAME: str
VERSION: str
# FastAPI
PORT: int
@@ -38,9 +31,7 @@ class Settings(BaseSettings):
# Frontend
FRONTEND_URL: str
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
CORS_ALLOWED_ORIGINS: Optional[str] = None
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
# Security
@@ -52,14 +43,24 @@ class Settings(BaseSettings):
DB_SSL: bool = False
# Redis Configuration
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
REDIS_HOST: str
REDIS_PORT: int
REDIS_PASSWORD: Optional[str]
REDIS_DB: int = 0
REDIS_ENABLED: bool = True
REDIS_TIMEOUT: int = 10
REDIS_MAX_CONNECTIONS: int = 10
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Email
SMTP_HOST: str
SMTP_PORT: int = 587
SMTP_PORT: int
SMTP_SECURE: bool = True
SMTP_USER: str
SMTP_PASSWORD: str
@@ -67,60 +68,35 @@ class Settings(BaseSettings):
# JWT settings
ACCESS_TOKEN_SECRET: str
ACCESS_TOKEN_EXPIRES: int = 86400
ACCESS_TOKEN_EXPIRES: int = 900
REFRESH_TOKEN_SECRET: str
REFRESH_TOKEN_EXPIRES: int = 864000
JWT_ALGORITHM: str = "HS256"
# Cookie settings
COOKIE_SECURE: bool = False
COOKIE_DOMAIN: Optional[str] = None
# Super Admin Setup
SUPER_ADMIN_EMAIL: str
SUPER_ADMIN_PASSWORD: str
SUPER_ADMIN_FIRST_NAME: str = "Super"
SUPER_ADMIN_LAST_NAME: str = "Admin"
# External SaaS Integration
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
# Module Integration Security (RS256)
SAAS_PRIVATE_KEY: Optional[str] = None
SAAS_KEY_ID: str = "saas-key-v1"
# PayPal Integration
PAYPAL_CLIENT_ID: str
PAYPAL_CLIENT_SECRET: str
PAYPAL_MODE: str = "sandbox"
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
# AWS S3 settings
AWS_SECRET_ACCESS_KEY: Optional[str] = None
AWS_ACCESS_KEY_ID: Optional[str] = None
S3_BUCKET_NAME: Optional[str] = None
AWS_REGION: Optional[str] = "us-east-1"
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
@property
def AWS_S3_BUCKET(self) -> Optional[str]:
return self.S3_BUCKET_NAME
# S3 Dataset Processing Settings
S3_PROCESSING_WORKERS: int = 4
DOCUMENT_CHUNK_SIZE: int = 1000
DOCUMENT_CHUNK_OVERLAP: int = 200
PINECONE_BATCH_SIZE: int = 100
# Redis Chat Settings
REDIS_CHAT_TTL: int = 86400 # 24 hours
REDIS_CHAT_TTL: int = 86400
# Logging
LOG_LEVEL: str = "info"
# Rate Limiting
RATE_LIMIT_REQUESTS: int = 100
RATE_LIMIT_WINDOW: int = 60
# Integration Settings (Optional for development)
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
# INTEGRATION_TIMEOUT: int = 30
# Properties for FastAPI Mail compatibility
@property
def MAIL_USERNAME(self) -> str:
return self.SMTP_USER
+6 -2
View File
@@ -27,6 +27,10 @@ class AuthController:
def refresh_token(db: Session, token_data: RefreshTokenRequest):
return AuthService.refresh_access_token(db, token_data.refresh_token)
@staticmethod
def refresh_token_raw(db: Session, refresh_token: str):
return AuthService.refresh_access_token(db, refresh_token)
@staticmethod
def update_user(
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
@@ -40,8 +44,8 @@ class AuthController:
)
@staticmethod
def logout(current_user: User):
return AuthService.logout(current_user)
def logout(current_user: User, token: str):
return AuthService.logout(current_user, token)
@staticmethod
def me(db: Session, current_user: User):
+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
@@ -60,6 +60,18 @@ class RoleController:
}
for ra in role.role_accesses
]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
"access_code": rma.module_access.access_code,
"category": rma.module_access.category,
"name": rma.module_access.name,
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
}
for rma in role.role_module_accesses
])
return RoleWithAccessesResponse(
id=role.id,
@@ -77,6 +89,10 @@ class RoleController:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
filter_role_names: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> RolePaginatedResponse:
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
return RoleService.get_roles_paginated(
@@ -85,4 +101,8 @@ class RoleController:
page=page,
page_size=page_size,
search=search,
filter_role_names=filter_role_names,
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+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.services.auth.tenant_service import TenantService
import uuid
from typing import Optional
from typing import List, Optional
class TenantController:
@@ -33,6 +33,12 @@ class TenantController:
page_size: int,
search: Optional[str],
is_active: Optional[bool],
filter_tenant_names: Optional[List[str]] = None,
filter_tenant_domains: Optional[List[str]] = None,
filter_plan_ids: Optional[List[uuid.UUID]] = None,
statuses: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
):
return TenantService.get_tenants_paginated(
db=db,
@@ -40,4 +46,10 @@ class TenantController:
page_size=page_size,
search=search,
is_active=is_active,
filter_tenant_names=filter_tenant_names,
filter_tenant_domains=filter_tenant_domains,
filter_plan_ids=filter_plan_ids,
statuses=statuses,
sort_by=sort_by,
sort_order=sort_order,
)
@@ -0,0 +1,67 @@
from sqlalchemy.orm import Session
from typing import List
from app.models.auth.module_model import Module
import uuid
from app.services.auth.tenant_module_service import TenantModuleService
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
class TenantModuleController:
@staticmethod
def list_tenant_modules(db: Session, tenant_id: str) -> List[TenantModuleResponse]:
results = TenantModuleService.list_tenant_modules(db, tenant_id)
response_list = []
for tm, mod in results:
response_list.append(TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=mod.module_name,
module_icon_url=mod.icon_url,
assigned_environment_slug=tm.assigned_environment_slug or "prod",
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
))
return response_list
@staticmethod
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModuleResponse:
tm = TenantModuleService.assign_module(db, tenant_id, assignment_data)
module = db.query(Module).filter(Module.id == tm.module_id).first()
return TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=module.module_name if module else "Unknown",
module_icon_url=module.icon_url if module else None,
assigned_environment_slug=tm.assigned_environment_slug,
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
)
@staticmethod
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModuleResponse:
tm = TenantModuleService.update_assignment(db, tenant_id, tenant_module_id, update_data)
module = db.query(Module).filter(Module.id == tm.module_id).first()
return TenantModuleResponse(
id=str(tm.id),
tenant_id=str(tm.tenant_id),
module_id=str(tm.module_id),
module_name=module.module_name if module else "Unknown",
module_icon_url=module.icon_url if module else None,
assigned_environment_slug=tm.assigned_environment_slug,
is_active=tm.is_active,
module_config=tm.module_config,
created_at=tm.created_at
)
@staticmethod
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
TenantModuleService.remove_assignment(db, tenant_id, tenant_module_id)
return {"message": "Module removed from tenant successfully"}
+19 -7
View File
@@ -1,5 +1,5 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from fastapi import HTTPException, status, BackgroundTasks
from typing import List, Optional
import uuid
from app.models.auth.user_model import User
@@ -21,9 +21,9 @@ class UserController:
return current_user.tenant_id
@staticmethod
def create_user(db: Session, user_data: UserCreate, current_user: User) -> User:
def create_user(db: Session, user_data: UserCreate, current_user: User, background_tasks: BackgroundTasks) -> User:
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
return UserService.create_user(db, user_data, tenant_id)
return UserService.create_user(db, user_data, tenant_id, background_tasks)
@staticmethod
def get_all_users(db: Session, current_user: User) -> List[User]:
@@ -36,12 +36,12 @@ class UserController:
return UserService.get_user_by_id(db, user_id, tenant_id)
@staticmethod
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User) -> User:
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> User:
if current_user.tenant_id is not None and user_data.tenant_id is not None:
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
tenant_id = current_user.tenant_id
return UserService.update_user(db, user_id, user_data, tenant_id)
return UserService.update_user(db, user_id, user_data, tenant_id, background_tasks)
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
@@ -55,7 +55,13 @@ class UserController:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
status: Optional[str] = None,
filter_names: Optional[List[str]] = None,
filter_emails: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
filter_role_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
):
tenant_id = current_user.tenant_id
return UserService.get_users_paginated(
@@ -64,5 +70,11 @@ class UserController:
page=page,
page_size=page_size,
search=search,
status=status,
filter_names=filter_names,
filter_emails=filter_emails,
statuses=statuses,
filter_tenant_ids=filter_tenant_ids,
filter_role_ids=filter_role_ids,
sort_by=sort_by,
sort_order=sort_order,
)
@@ -1,28 +1,33 @@
from sqlalchemy.orm import Session
from uuid import UUID
from typing import List, Optional
from app.models.auth.user_models import User
from app.models.theme.color_palette_models import ColorPalette
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
from app.services.theme.palette_service import PaletteService
from typing import List
from app.models.auth.user_model import User
from app.models.theme.color_palette_model import ColorPalette
from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
)
from app.services.theme.color_palette_service import PaletteService
class PaletteController:
@staticmethod
def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]:
tenant_id = current_user.tenant_id
return PaletteService.get_all_palettes(db, tenant_id)
return PaletteService.get_all_palettes(db)
@staticmethod
def get_palette(db: Session, palette_id: UUID) -> ColorPalette:
return PaletteService.get_palette_by_id(db, palette_id)
@staticmethod
def create_palette(db: Session, data: ColorPaletteCreate, current_user: User) -> ColorPalette:
return PaletteService.create_palette(db, data, tenant_id=None)
def create_palette(
db: Session, data: ColorPaletteCreate, current_user: User
) -> ColorPalette:
return PaletteService.create_palette(db, data)
@staticmethod
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
def update_palette(
db: Session, palette_id: UUID, data: ColorPaletteUpdate
) -> ColorPalette:
return PaletteService.update_palette(db, palette_id, data)
@staticmethod
+133
View File
@@ -0,0 +1,133 @@
import logging
from typing import Optional
from redis.asyncio import Redis, from_url as async_from_url
from app.config.settings import settings
logger = logging.getLogger(__name__)
class RedisClient:
def __init__(self):
self._redis: Optional[Redis] = None
async def connect(self):
"""
Initializes the Redis connection pool (Async).
"""
if settings.REDIS_ENABLED:
try:
self._redis = async_from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
ping = await self._redis.ping()
if ping:
logger.info("Connected to Redis (Async)")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
self._redis = None
else:
logger.info("Redis is disabled in settings")
async def close(self):
"""
Closes the Redis connection.
"""
if self._redis:
await self._redis.close()
logger.info("Redis connection closed")
async def get(self, key: str) -> Optional[str]:
"""
Get a value by key.
"""
if not self._redis:
return None
try:
return await self._redis.get(key)
except Exception as e:
logger.error(f"Redis GET error for key {key}: {e}")
return None
async def set(self, key: str, value: str, expire: int = None) -> bool:
"""
Set a value by key with optional expiration time in seconds.
"""
if not self._redis:
return False
try:
return await self._redis.set(key, value, ex=expire)
except Exception as e:
logger.error(f"Redis SET error for key {key}: {e}")
return False
async def delete(self, key: str) -> bool:
"""
Delete a value by key.
"""
if not self._redis:
return False
try:
return await self._redis.delete(key) > 0
except Exception as e:
logger.error(f"Redis DELETE error for key {key}: {e}")
return False
@property
def client(self) -> Optional[Redis]:
"""
Expose the raw Redis client if needed for advanced operations.
"""
return self._redis
class SyncRedisClient:
def __init__(self):
self._redis = None
def connect(self):
"""
Initializes the Redis connection pool (Sync).
"""
if settings.REDIS_ENABLED:
try:
import redis
self._redis = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=settings.REDIS_MAX_CONNECTIONS,
socket_timeout=settings.REDIS_TIMEOUT
)
if self._redis.ping():
logger.info("Connected to Redis (Sync)")
except Exception as e:
logger.error(f"Failed to connect to Redis (Sync): {e}")
self._redis = None
def close(self):
if self._redis:
self._redis.close()
logger.info("Redis connection closed (Sync)")
def rpush(self, key: str, *values) -> int:
if not self._redis:
try:
self.connect()
except Exception:
pass
if not self._redis:
return 0
try:
return self._redis.rpush(key, *values)
except Exception as e:
logger.error(f"Redis RPUSH error: {e}")
return 0
@property
def client(self):
return self._redis
redis_client = RedisClient()
sync_redis_client = SyncRedisClient()
+1
View File
@@ -0,0 +1 @@
from .helpers import get_client_ip
+7
View File
@@ -0,0 +1,7 @@
from fastapi import Request
def get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+44 -17
View File
@@ -2,9 +2,15 @@ from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timezone
from app.config.database import get_db
from app.config.security import security
from app.models.auth.user_model import User
from app.models.auth.access_model import Access
from app.models.auth.tenant_model import Tenant
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
security_scheme = HTTPBearer(auto_error=False)
@@ -41,6 +47,8 @@ def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
setattr(user, "_saas_db_session", db)
if user.status != "active":
raise HTTPException(
@@ -48,6 +56,27 @@ def get_current_user(
detail="User is inactive"
)
if user.tenant_id is not None:
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
today = datetime.now(timezone.utc).date()
if tenant.end_date and tenant.end_date <= today and tenant.status != "EXPIRED":
tenant.status = "EXPIRED"
tenant.is_active = False
db.commit()
db.refresh(tenant)
if not tenant.is_active or tenant.status in {"INACTIVE", "EXPIRED"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant is inactive"
)
return user
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
@@ -59,24 +88,21 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User:
return current_user
def has_access(user: User, access_code: str) -> bool:
if not user.role:
db = getattr(user, "_saas_db_session", None)
if db is None:
return False
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
return access_code in user_access_codes
def can_access(user: User, access_code: str, db: Session) -> bool:
if not user.role:
return False
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
if access_code in user_access_codes:
return True
from app.models.auth.access_models import Access
return True
requested_access = db.query(Access).filter(
Access.access_code == access_code
).first()
@@ -93,10 +119,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
return False
def get_user_accesses(user: User) -> List[str]:
if not user.role:
db = getattr(user, "_saas_db_session", None)
if db is None:
return []
return [ra.access.access_code for ra in user.role.role_accesses]
return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user))
def require_access(access_code: str):
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
@@ -121,4 +148,4 @@ def require_access_hierarchical(access_code: str):
)
return True
return check_permission
return check_permission
+6 -1
View File
@@ -2,4 +2,9 @@ from app.models.auth.access_model import Access
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User
from app.models.auth.user_model import User
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
+5 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
@@ -12,6 +12,10 @@ class Access(Base):
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
sync_checksum = Column(String, nullable=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
+32
View File
@@ -0,0 +1,32 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class ModuleAccess(Base):
__tablename__ = "module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
access_code = Column(String, nullable=False, index=True)
category = Column(String, nullable=False, index=True)
name = Column(String, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
sync_checksum = Column(String, nullable=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
module = relationship("Module", back_populates="module_accesses")
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
__table_args__ = (
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
)
def __repr__(self):
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
@@ -0,0 +1,37 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class ModuleEnvironment(Base):
__tablename__ = "module_environments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
slug = Column(String, nullable=False, index=True)
frontend_base_url = Column(String, nullable=False)
sso_entry_path = Column(String, default="/sso/start")
backend_base_url = Column(String, nullable=False)
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
trust_type = Column(String, nullable=False)
trust_credentials = Column(JSON, nullable=False)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
module = relationship("Module", back_populates="environments")
__table_args__ = (
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
)
def __repr__(self):
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
+25
View File
@@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Column, String, Integer, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Module(Base):
__tablename__ = "modules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
module_id = Column(String, unique=True, nullable=False, index=True)
module_name = Column(String, nullable=False)
description = Column(String, nullable=True)
status = Column(String, default="active")
icon_url = Column(String, nullable=True)
display_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
def __repr__(self):
return f"<Module {self.module_id}>"
+24
View File
@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class PlanAccess(Base):
__tablename__ = "plan_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
access_id = Column(UUID(as_uuid=True), ForeignKey("accesses.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
plan = relationship("SubscriptionPlan", back_populates="plan_accesses")
access = relationship("Access")
__table_args__ = (
UniqueConstraint('plan_id', 'access_id', name='uq_plan_access'),
)
def __repr__(self):
return f"<PlanAccess plan={self.plan_id} access={self.access_id}>"
@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class PlanModuleAccess(Base):
__tablename__ = "plan_module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
plan = relationship("SubscriptionPlan", back_populates="plan_module_accesses")
module_access = relationship("ModuleAccess")
__table_args__ = (
UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access'),
)
def __repr__(self):
return f"<PlanModuleAccess plan={self.plan_id} module_access={self.module_access_id}>"
+4 -3
View File
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Role(Base):
__tablename__ = "roles"
@@ -20,12 +19,14 @@ class Role(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenant = relationship("Tenant", back_populates="roles")
users = relationship("User", back_populates="role")
role_accesses = relationship(
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
)
role_module_accesses = relationship(
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Role {self.role_name}>"
return f"<Role {self.role_name}>"
@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class RoleModuleAccess(Base):
__tablename__ = "role_module_accesses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
role = relationship("Role", back_populates="role_module_accesses")
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
__table_args__ = (
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
)
def __repr__(self):
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
+31
View File
@@ -0,0 +1,31 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class SSOGrant(Base):
__tablename__ = "sso_grants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
grant_code = Column(String, unique=True, nullable=False, index=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
environment_slug = Column(String, nullable=False)
# redirect_url removed - stateless grants
is_used = Column(Boolean, default=False)
used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=False) # 60 seconds
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User")
module = relationship("Module")
tenant = relationship("Tenant")
def __repr__(self):
return f"<SSOGrant {self.grant_code} for user {self.user_id}>"
@@ -0,0 +1,30 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, Integer, func, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class SubscriptionPlan(Base):
__tablename__ = "subscription_plans"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String, nullable=True)
price = Column(Numeric(10, 2), nullable=True)
duration_days = Column(Integer, nullable=True)
max_users_allowed = Column(Integer, nullable=True)
is_public = Column(Boolean, default=True)
status = Column(String, default="active")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenants = relationship("Tenant", back_populates="plan")
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
def __repr__(self):
return f"<SubscriptionPlan {self.name}>"
+14 -5
View File
@@ -1,10 +1,9 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
from sqlalchemy import Column, String, Boolean, Date, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class Tenant(Base):
__tablename__ = "tenants"
@@ -13,6 +12,10 @@ class Tenant(Base):
tenant_domain = Column(String, unique=True, nullable=False, index=True)
tenant_logo_url = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True, index=True)
start_date = Column(Date, nullable=True)
end_date = Column(Date, nullable=True)
status = Column(String, nullable=False, default="ACTIVE", index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
@@ -20,8 +23,14 @@ class Tenant(Base):
)
# Relationships
users = relationship("User", back_populates="tenant")
roles = relationship("Role", back_populates="tenant")
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
plan = relationship("SubscriptionPlan", back_populates="tenants")
@property
def tenant_id(self):
return self.id
def __repr__(self):
return f"<Tenant {self.tenant_name}>"
return f"<Tenant {self.tenant_name}>"
+33
View File
@@ -0,0 +1,33 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class TenantModule(Base):
__tablename__ = "tenant_modules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True)
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
assigned_environment_slug = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
deactivated_at = Column(DateTime(timezone=True), nullable=True)
plan_tier = Column(String, nullable=True)
module_config = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
tenant = relationship("Tenant", back_populates="tenant_modules")
module = relationship("Module", back_populates="tenant_modules")
__table_args__ = (
UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module'),
)
def __repr__(self):
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
+38
View File
@@ -0,0 +1,38 @@
import uuid
from sqlalchemy import Column, String, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from app.config.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
module_name = Column(String(100), nullable=False, index=True)
action_type = Column(String(20), nullable=False, index=True)
description = Column(Text, nullable=True)
entity_id = Column(String(255), nullable=True)
entity_name = Column(String(255), nullable=True)
performed_by_id = Column(UUID(as_uuid=True), nullable=True)
performed_by_email = Column(String(255), nullable=True, index=True)
ip_address = Column(String(45), nullable=True)
old_values = Column(JSONB, nullable=True)
new_values = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def __repr__(self):
return (
f"<AuditLog {self.action_type} on {self.module_name}"
f" by {self.performed_by_email}"
)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, String, DateTime, func, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.config.database import Base
import enum
class EventStatus(str, enum.Enum):
PENDING = "PENDING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
class EventLog(Base):
__tablename__ = "event_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Idempotency Key
event_type = Column(String, nullable=False)
payload = Column(JSONB, nullable=False)
target_module_id = Column(UUID(as_uuid=True), nullable=False)
target_environment_slug = Column(String, nullable=False)
target_url = Column(String, nullable=False)
status = Column(String, default=EventStatus.PENDING, index=True)
retry_count = Column(Integer, default=0)
next_retry_at = Column(DateTime(timezone=True), default=func.now(), index=True)
error_log = Column(Text, nullable=True)
follow_up_event = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<EventLog {self.event_type} -> {self.target_module_id} ({self.status})>"
+85
View File
@@ -0,0 +1,85 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc, or_, cast, String
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.models.system.audit_log import AuditLog
from app.schemas.auth.audit_schema import AuditLogListResponse
router = APIRouter()
@router.get("/", response_model=AuditLogListResponse)
def get_audit_logs(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
module_name: Optional[str] = Query(None, description="Filter by module name"),
action_type: Optional[str] = Query(None, description="CREATE | UPDATE | DELETE"),
performed_by_email: Optional[str] = Query(None, description="Filter by actor email"),
search: Optional[str] = Query(None, description="Search audit logs"),
module_names: Optional[List[str]] = Query(None, description="Filter by module names"),
action_types: Optional[List[str]] = Query(None, description="Filter by action types"),
performed_by_emails: Optional[List[str]] = Query(None, description="Filter by actor emails"),
sort_by: Optional[str] = Query("created_at", description="Sort by created_at, module_name, action_type, or performed_by_email"),
sort_order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""
Fetch audit logs with optional filters.
Only accessible to authenticated users (super-admin in practice).
"""
query = db.query(AuditLog)
if module_name:
query = query.filter(AuditLog.module_name == module_name)
if module_names:
query = query.filter(AuditLog.module_name.in_(module_names))
if action_type:
query = query.filter(AuditLog.action_type == action_type.upper())
if action_types:
normalized_action_types = [item.upper() for item in action_types]
query = query.filter(AuditLog.action_type.in_(normalized_action_types))
if performed_by_email:
query = query.filter(
AuditLog.performed_by_email.ilike(f"%{performed_by_email}%")
)
if performed_by_emails:
query = query.filter(AuditLog.performed_by_email.in_(performed_by_emails))
if search:
search_term = search.strip()
query = query.filter(
or_(
AuditLog.module_name.ilike(f"%{search_term}%"),
AuditLog.action_type.ilike(f"%{search_term}%"),
AuditLog.entity_name.ilike(f"%{search_term}%"),
AuditLog.description.ilike(f"%{search_term}%"),
AuditLog.performed_by_email.ilike(f"%{search_term}%"),
AuditLog.ip_address.ilike(f"%{search_term}%"),
cast(AuditLog.entity_id, String).ilike(f"%{search_term}%"),
)
)
total = query.count()
sort_column_map = {
"created_at": AuditLog.created_at,
"module_name": AuditLog.module_name,
"action_type": AuditLog.action_type,
"performed_by_email": AuditLog.performed_by_email,
}
sort_column = sort_column_map.get(sort_by or "created_at", AuditLog.created_at)
order_fn = asc if (sort_order or "").lower() == "asc" else desc
items = (
query.order_by(order_fn(sort_column))
.offset(offset)
.limit(limit)
.all()
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
+150
View File
@@ -0,0 +1,150 @@
import uuid as uuid_module
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.controllers.auth.module_environment_controller import ModuleEnvironmentController
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentResponse, EnvironmentUpdate
from app.services.auth.module_service import ModuleService
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
def _get_env_snapshot(db: Session, module_id: str, env_id: str) -> dict:
"""Helper to capture state before changes for the Audit Log."""
try:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.id == uuid_module.UUID(env_id),
ModuleEnvironment.module_id == uuid_module.UUID(module_id)
).first()
if not env: return {}
return {
"slug": env.slug,
"backend_base_url": env.backend_base_url,
"is_default": env.is_default,
"is_active": env.is_active
}
except: return {}
@router.get("/{module_id}/environments", response_model=List[EnvironmentResponse])
def list_environments(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleEnvironmentController.list_environments(db, module_id)
@router.post("/{module_id}/environments", response_model=EnvironmentResponse, status_code=201)
def create_environment(
request: Request,
module_id: str,
env_data: EnvironmentCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
result = ModuleEnvironmentController.create_environment(db, module_id, env_data)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="CREATE",
entity_id=str(result.id),
entity_name=f"{module.module_name} / {result.slug}",
description=f"Environment '{result.slug}' created for '{module.module_name}'",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=env_data.model_dump(exclude={"trust_credentials"})
)
return result
@router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse)
def update_environment(
request: Request,
module_id: str,
env_id: str,
env_data: EnvironmentUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
old_values = _get_env_snapshot(db, module_id, env_id)
result = ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="UPDATE",
entity_id=env_id,
entity_name=f"{module.module_name} / {result.slug}",
description=f"Environment '{result.slug}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"})
)
return result
@router.patch("/{module_id}/environments/{env_id}/default")
def set_default_environment(
request: Request,
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
result = ModuleEnvironmentController.set_default_environment(db, module_id, env_id)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="UPDATE",
entity_id=env_id,
entity_name=f"{module.module_name}",
description=f"Set default environment for {module.module_name}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={"is_default": True}
)
return result
@router.delete("/{module_id}/environments/{env_id}")
def delete_environment(
request: Request,
module_id: str,
env_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
module = ModuleService.get_module(db, module_id)
snapshot = _get_env_snapshot(db, module_id, env_id)
result = ModuleEnvironmentController.delete_environment(db, module_id, env_id)
AuditLogService.log(
db=db,
module_name="Module Environments",
action_type="DELETE",
entity_id=env_id,
entity_name=f"{module.module_name} / {snapshot.get('slug')}",
description=f"Environment deleted from {module.module_name}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=snapshot
)
return result
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
from app.models.auth.module_model import Module
from app.models.auth.access_model import Access
from app.models.auth.module_access_model import ModuleAccess
import uuid
from app.config.database import get_db
from app.services.auth.module_permission_service import ModulePermissionService
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
from app.controllers.auth.module_controller import ModuleController
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.get("/", response_model=List[ModuleResponse])
def list_modules(
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleController.list_modules(db)
@router.post("/", response_model=ModuleResponse, status_code=201)
def create_module(
request: Request,
module_data: ModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
result = ModuleController.create_module(db, module_data)
# Professional Audit Logging
AuditLogService.log(
db=db,
module_name="Modules",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.module_name,
description=f"Module '{result.module_name}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=module_data.model_dump()
)
return result
@router.get("/{module_id}", response_model=ModuleResponse)
def get_module(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
return ModuleController.get_module(db, module_id)
@router.put("/{module_id}", response_model=ModuleResponse)
def update_module(
request: Request,
module_id: str,
module_data: ModuleUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Snapshot before update
existing = ModuleController.get_module(db, module_id)
old_values = {"name": existing.module_name, "status": existing.status}
result = ModuleController.update_module(db, module_id, module_data)
AuditLogService.log(
db=db,
module_name="Modules",
action_type="UPDATE",
entity_id=module_id,
entity_name=result.module_name,
description=f"Module '{result.module_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=module_data.model_dump(exclude_unset=True)
)
return result
@router.delete("/{module_id}")
def delete_module(
request: Request,
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Capture name for the log before it's deleted
existing = ModuleController.get_module(db, module_id)
module_name = existing.module_name
result = ModuleController.delete_module(db, module_id)
AuditLogService.log(
db=db,
module_name="Modules",
action_type="DELETE",
entity_id=module_id,
entity_name=module_name,
description=f"Module '{module_name}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request)
)
return result
# ── Permission Sync Routes ──────────────────────────────────────────────────
@router.get("/{module_id}/permissions")
def get_module_permissions(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
# Now using the Service method you just shared!
return ModulePermissionService.get_module_permissions(db, module_id)
@router.post("/{module_id}/permissions/sync")
def sync_module_permissions(
module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
try:
return ModulePermissionService.sync_permissions(db, module_id)
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(
status_code=500,
detail=f"Permission sync failed: {str(e)}"
)
+96
View File
@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, status, HTTPException, Request, Query
from sqlalchemy.orm import Session
from typing import List
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
from app.controllers.auth.tenant_module_controller import TenantModuleController
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse])
def list_tenant_modules(
tenant_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
return TenantModuleController.list_tenant_modules(db, tenant_id)
@router.post("/{tenant_id}/modules", response_model=TenantModuleResponse)
def assign_module_to_tenant(
request: Request,
tenant_id: str,
assignment_data: TenantModuleCreate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
result = TenantModuleController.assign_module(db, tenant_id, assignment_data)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="CREATE",
entity_id=str(result.id),
entity_name=f"Tenant {tenant_id} / Module {assignment_data.module_id}",
description=f"Module assigned to tenant {tenant_id}",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=assignment_data.model_dump(mode='json')
)
return result
@router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
def update_tenant_module(
request: Request,
tenant_id: str,
tenant_module_id: str,
update_data: TenantModuleUpdate,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
result = TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="UPDATE",
entity_id=tenant_module_id,
entity_name=f"Tenant {tenant_id} / Module assignment",
description=f"Tenant module assignment updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values=update_data.model_dump(exclude_unset=True)
)
return result
@router.delete("/{tenant_id}/modules/{tenant_module_id}")
def remove_module_from_tenant(
request: Request,
tenant_id: str,
tenant_module_id: str,
current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db)
):
result = TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id)
AuditLogService.log(
db=db,
module_name="Tenant Modules",
action_type="DELETE",
entity_id=tenant_module_id,
entity_name=f"Tenant {tenant_id} / Module assignment",
description=f"Tenant module assignment deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
)
return result
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.schemas.auth.module_schema import ModuleAvailableResponse
from app.controllers.auth.module_controller import ModuleController
router = APIRouter()
@router.get("/available", response_model=List[ModuleAvailableResponse])
def get_available_modules(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
List modules available to the current user (based on tenant subscription).
For platform admin (tenant_id=None), lists all active modules.
"""
return ModuleController.get_available_modules(db, current_user)
+4 -3
View File
@@ -1,3 +1,4 @@
from .auth import router
from .tenant import router
from .role import router
from .auth import router as auth_router
from .tenant import router as tenant_router
from .role import router as role_router
from .user import router as user_router
+47 -20
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, Response, status, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.config.settings import settings
from app.controllers.auth.auth_controller import AuthController
from app.schemas.auth.auth_schema import (
UserSignup,
@@ -14,13 +16,20 @@ from app.schemas.auth.auth_schema import (
VerifyOTPRequest,
ResetPasswordWithOTP,
)
from app.middleware.auth_middleware import get_current_user
from app.schemas.auth.language_schema import UpdateLanguageRequest
from app.middleware.auth_middleware import get_current_user, security_scheme
from app.middleware.tenant_middleware import get_tenant_from_header
from app.models.auth.user_model import User
import uuid
router = APIRouter()
def _cookie_kwargs():
kwargs = dict(httponly=True, samesite="lax", secure=settings.COOKIE_SECURE, path="/")
if settings.COOKIE_DOMAIN:
kwargs["domain"] = settings.COOKIE_DOMAIN
return kwargs
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def signup(
user_data: UserSignup,
@@ -30,12 +39,30 @@ def signup(
return AuthController.signup(db, user_data, tenant_id)
@router.post("/signin", response_model=TokenResponse)
def signin(signin_data: UserSignin, db: Session = Depends(get_db)):
return AuthController.signin(db, signin_data)
def signin(signin_data: UserSignin, response: Response, db: Session = Depends(get_db)):
result = AuthController.signin(db, signin_data)
cookie_kw = _cookie_kwargs()
access_max_age = settings.ACCESS_TOKEN_EXPIRES if signin_data.remember_me else None
refresh_max_age = settings.REFRESH_TOKEN_EXPIRES if signin_data.remember_me else None
response.set_cookie(key="access_token", value=result["access_token"], max_age=access_max_age, **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], max_age=refresh_max_age, **cookie_kw)
return result
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)):
return AuthController.refresh_token(db, token_data)
def refresh_token(
token_data: RefreshTokenRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
refresh_tok = token_data.refresh_token or request.cookies.get("refresh_token")
if not refresh_tok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
result = AuthController.refresh_token_raw(db, refresh_tok)
cookie_kw = _cookie_kwargs()
response.set_cookie(key="access_token", value=result["access_token"], **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], **cookie_kw)
return result
@router.get("/me", response_model=UserResponse)
def get_me(
@@ -56,21 +83,11 @@ def update_user(
@router.patch("/update/{user_id}/language", response_model=UserResponse)
def update_language(
user_id: uuid.UUID,
language_data: dict,
language_data: UpdateLanguageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
supported_languages = ["en", "ar"]
preferred_language = language_data.get("preferred_language")
if preferred_language not in supported_languages:
from fastapi import HTTPException
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported language. Supported languages: {', '.join(supported_languages)}"
)
user_update = UserUpdate(preferred_language=preferred_language)
user_update = UserUpdate(preferred_language=language_data.preferred_language.value)
return AuthController.update_user(db, user_id, user_update, current_user)
@router.post("/reset-password")
@@ -82,8 +99,18 @@ def reset_password(
return AuthController.reset_password(db, current_user, password_data)
@router.post("/logout")
def logout(current_user: User = Depends(get_current_user)):
return AuthController.logout(current_user)
def logout(
request: Request,
response: Response,
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
current_user: User = Depends(get_current_user),
):
token = credentials.credentials if credentials else request.cookies.get("access_token")
result = AuthController.logout(current_user, token)
cookie_kw = _cookie_kwargs()
response.delete_cookie(key="access_token", **cookie_kw)
response.delete_cookie(key="refresh_token", **cookie_kw)
return result
@router.post("/forgot-password")
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
+73 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, Query
from fastapi import APIRouter, Depends, status, Query, Request
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
@@ -13,20 +13,40 @@ from app.schemas.auth.role_schema import (
)
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role(
request: Request,
role_data: RoleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.create")),
):
return RoleController.create_role(
result = RoleController.create_role(
db, role_data, current_user.tenant_id if current_user.tenant_id else None
)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.role_name,
description=f"Role '{result.role_name}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={
"role_name": result.role_name,
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
}
)
return result
@router.get("/get", response_model=List[RoleResponse])
def get_all_roles(
db: Session = Depends(get_db),
@@ -48,32 +68,77 @@ def get_role(
@router.put("/update/{role_id}", response_model=RoleResponse)
def update_role(
request: Request,
role_id: uuid.UUID,
role_data: RoleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
existing = RoleController.get_role_by_id(db, role_id)
old_values = {"role_name": existing.role_name}
is_superadmin = current_user.tenant_id is None
return RoleController.update_role(
result = RoleController.update_role(
db, role_id, role_data, is_superadmin=is_superadmin
)
new_values = role_data.model_dump(mode='json', exclude_unset=True)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="UPDATE",
entity_id=str(role_id),
entity_name=result.role_name,
description=f"Role '{result.role_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=new_values
)
return result
@router.delete("/delete/{role_id}")
def delete_role(
request: Request,
role_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.delete")),
):
existing = RoleController.get_role_by_id(db, role_id)
role_name = existing.role_name
is_superadmin = current_user.tenant_id is None
return RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
result = RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
AuditLogService.log(
db=db,
module_name="Roles",
action_type="DELETE",
entity_id=str(role_id),
entity_name=role_name,
description=f"Role '{result.role_name}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"role_name": role_name}
)
return result
@router.get("/list", response_model=RolePaginatedResponse)
def list_roles(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in role name or ID"),
filter_role_names: Optional[List[str]] = Query(None, description="Filter by role names"),
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
sort_by: Optional[str] = Query(None, description="Sort by role name or tenant"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.read")),
@@ -84,4 +149,8 @@ def list_roles(
page=page,
page_size=page_size,
search=search,
filter_role_names=filter_role_names,
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.orm import Session
from typing import Optional
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
from app.controllers.auth.sso_controller import SSOController
public_router = APIRouter()
internal_router = APIRouter()
@public_router.post("/initiate")
def initiate_sso(
request: SSOInitiateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
User-facing endpoint to start SSO flow.
Returns a signed payload and target URL for the client to POST.
"""
return SSOController.initiate_sso(db, request, current_user)
@internal_router.post("/exchange")
def exchange_grant(
request: Request,
payload: SSOExchangeRequest,
db: Session = Depends(get_db),
x_module_signature: Optional[str] = Header(None, alias="X-Module-Signature"),
x_module_key: Optional[str] = Header(None, alias="X-Module-Key")
):
"""
Internal server-to-server endpoint for modules to exchange grant code for token.
Must be signed or authenticated via trust credentials.
"""
return SSOController.exchange_grant(
db=db,
payload=payload,
x_module_signature=x_module_signature,
x_module_key=x_module_key
)
+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
from typing import List, Optional
from fastapi import APIRouter, Depends, status, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.controllers.auth.tenant_controller import TenantController
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.middleware.tenant_middleware import get_tenant_id_from_user
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant(
request: Request,
tenant_data: TenantCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.create"))
):
return TenantController.create_tenant(db, tenant_data)
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])
def get_all_tenants(
@@ -26,17 +43,9 @@ def get_all_tenants(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read"))
):
# READ actions are typically not logged to avoid DB bloat
return TenantController.get_all_tenants(db)
@router.get("/get/{tenant_id}", response_model=TenantResponse)
def get_tenant(
tenant_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read"))
):
return TenantController.get_tenant_by_id(db, tenant_id)
@router.get("/me", response_model=TenantResponse)
def get_my_tenant(
db: Session = Depends(get_db),
@@ -44,30 +53,84 @@ def get_my_tenant(
):
tenant_id = get_tenant_id_from_user(current_user)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
raise HTTPException(status_code=404, detail="Tenant not found")
return TenantController.get_tenant_by_id(db, tenant_id)
@router.put("/update/{tenant_id}", response_model=TenantResponse)
def update_tenant(
request: Request,
tenant_id: uuid.UUID,
tenant_data: TenantUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.update"))
):
return TenantController.update_tenant(db, tenant_id, tenant_data)
# 1. Get snapshot BEFORE update for Audit Log
existing = TenantController.get_tenant_by_id(db, tenant_id)
old_snapshot = {
"tenant_name": existing.tenant_name,
"tenant_domain": existing.tenant_domain,
"tenant_logo_url": existing.tenant_logo_url,
"is_active": existing.is_active,
"plan_id": str(existing.plan_id) if existing.plan_id else None,
"start_date": existing.start_date.isoformat() if existing.start_date else None,
"end_date": existing.end_date.isoformat() if existing.end_date else None,
"status": existing.status,
}
# 2. Perform update
result = TenantController.update_tenant(db, tenant_id, tenant_data)
# 3. Prepare new values and indentify deltas
new_snapshot = tenant_data.model_dump(mode='json', exclude_unset=True)
old_values = {k: old_snapshot[k] for k in new_snapshot if k in old_snapshot and old_snapshot[k] != new_snapshot[k]}
new_values = {k: new_snapshot[k] for k in old_values}
# 4. Log the change
AuditLogService.log(
db=db,
module_name="Tenants",
action_type="UPDATE",
entity_id=str(tenant_id),
entity_name=result.tenant_name,
description=f"Tenant '{result.tenant_name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=new_values
)
return result
@router.delete("/delete/{tenant_id}")
def delete_tenant(
request: Request,
tenant_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.delete"))
):
return TenantController.delete_tenant(db, tenant_id)
# 1. Get snapshot before deletion
existing = TenantController.get_tenant_by_id(db, tenant_id)
# 2. Perform deletion
result = TenantController.delete_tenant(db, tenant_id)
# 3. Log the deletion
AuditLogService.log(
db=db,
module_name="Tenants",
action_type="DELETE",
entity_id=str(tenant_id),
entity_name=existing.tenant_name,
description=f"Tenant '{existing.tenant_name}' removed",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"tenant_name": existing.tenant_name, "domain": existing.tenant_domain}
)
return result
@router.get("/list", response_model=TenantPaginatedResponse)
def list_tenants(
@@ -75,6 +138,12 @@ def list_tenants(
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in name, domain, or ID"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
filter_tenant_names: Optional[List[str]] = Query(None, description="Filter by tenant names"),
filter_tenant_domains: Optional[List[str]] = Query(None, description="Filter by tenant domains"),
filter_plan_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by plan IDs"),
statuses: Optional[List[bool]] = Query(None, description="Filter by tenant statuses"),
sort_by: Optional[str] = Query(None, description="Sort by name, domain, status, or plan"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read")),
@@ -85,4 +154,10 @@ def list_tenants(
page_size=page_size,
search=search,
is_active=is_active,
filter_tenant_names=filter_tenant_names,
filter_tenant_domains=filter_tenant_domains,
filter_plan_ids=filter_plan_ids,
statuses=statuses,
sort_by=sort_by,
sort_order=sort_order,
)
+81 -8
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, Query
from fastapi import APIRouter, Depends, status, Query, BackgroundTasks, Request
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
@@ -7,17 +7,38 @@ from app.controllers.auth.user_controller import UserController
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
request: Request,
user_data: UserCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.create"))
):
return UserController.create_user(db, user_data, current_user)
result = UserController.create_user(db, user_data, current_user, background_tasks)
AuditLogService.log(
db=db,
module_name="Users",
action_type="CREATE",
entity_id=str(result.id),
entity_name=result.email,
description=f"User '{result.email}' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
new_values={
"email": result.email,
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
}
)
return result
@router.get("/get", response_model=List[UserResponse])
def get_all_users(
@@ -38,29 +59,75 @@ def get_user(
@router.put("/update/{user_id}", response_model=UserResponse)
def update_user(
request: Request,
user_id: uuid.UUID,
user_data: UserUpdate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.update"))
):
return UserController.update_user(db, user_id, user_data, current_user)
existing = UserController.get_user_by_id(db, user_id, current_user)
old_values = {"email": existing.email}
result = UserController.update_user(db, user_id, user_data, current_user, background_tasks)
AuditLogService.log(
db=db,
module_name="Users",
action_type="UPDATE",
entity_id=str(user_id),
entity_name=result.email,
description=f"User '{result.email}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values=old_values,
new_values=user_data.model_dump(mode='json', exclude_unset=True)
)
return result
@router.delete("/delete/{user_id}")
def delete_user(
request: Request,
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.delete"))
):
return UserController.delete_user(db, user_id, current_user)
existing = UserController.get_user_by_id(db, user_id, current_user)
user_email = existing.email
result = UserController.delete_user(db, user_id, current_user)
AuditLogService.log(
db=db,
module_name="Users",
action_type="DELETE",
entity_id=str(user_id),
entity_name=user_email,
description=f"User '{user_email}' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
old_values={"email": user_email}
)
return result
@router.get("/list", response_model=UserPaginatedResponse)
def list_users(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search in email, name, phone, or ID"),
status: Optional[str] = Query(None, description="Filter by user status (e.g. active, inactive)"),
filter_names: Optional[List[str]] = Query(None, description="Filter by full names"),
filter_emails: Optional[List[str]] = Query(None, description="Filter by emails"),
statuses: Optional[List[str]] = Query(None, description="Filter by user statuses"),
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
filter_role_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by role IDs"),
sort_by: Optional[str] = Query(None, description="Sort by name, email, status, tenant, or role"),
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.read")),
@@ -71,5 +138,11 @@ def list_users(
page=page,
page_size=page_size,
search=search,
status=status,
)
filter_names=filter_names,
filter_emails=filter_emails,
statuses=statuses,
filter_tenant_ids=filter_tenant_ids,
filter_role_ids=filter_role_ids,
sort_by=sort_by,
sort_order=sort_order,
)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.services.auth.module_permission_service import ModulePermissionService
from app.middleware.auth_middleware import get_current_user, User, require_access
router = APIRouter()
@router.post("/{module_id}/permissions/sync")
def sync_module_permissions(
module_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Trigger synchronization of permissions for a specific module.
Should be restricted to platform admins or authorized roles.
"""
# TODO: Add specific permission check for "module.manage" or similar
# For now, allowing any authenticated user (or superadmin check) based on existing patterns
if current_user.tenant_id:
raise HTTPException(status_code=403, detail="Platform admin access required")
# Run sync
# We can run in background if it takes time, but synchronous gives immediate feedback
try:
result = ModulePermissionService.sync_permissions(db, module_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+1
View File
@@ -0,0 +1 @@
from .color_palette import router
+15 -10
View File
@@ -3,8 +3,12 @@ from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from app.config.database import get_db
from app.controllers.theme.palette_controller import PaletteController
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate, ColorPaletteResponse
from app.controllers.theme.color_palette_controller import PaletteController
from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
ColorPaletteResponse,
)
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
@@ -12,8 +16,7 @@ router = APIRouter()
@router.get("/get", response_model=List[ColorPaletteResponse])
def get_all_palettes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
return PaletteController.get_all_palettes(db, current_user)
@@ -21,20 +24,22 @@ def get_all_palettes(
def get_palette(
palette_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
return PaletteController.get_palette(db, palette_id)
@router.post("/create", response_model=ColorPaletteResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/create", response_model=ColorPaletteResponse, status_code=status.HTTP_201_CREATED
)
def create_palette(
data: ColorPaletteCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.create"))
_=Depends(require_access("superadmin.palette.create")),
):
return PaletteController.create_palette(db, data, current_user)
@router.put("/update/users/me/preferences", deprecated=True)
@router.put("/update/users/me/preferences", deprecated=True)
def update_user_preference():
pass
@@ -44,7 +49,7 @@ def update_palette(
data: ColorPaletteUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.update"))
_=Depends(require_access("superadmin.palette.update")),
):
return PaletteController.update_palette(db, palette_id, data)
@@ -53,6 +58,6 @@ def delete_palette(
palette_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.palette.delete"))
_=Depends(require_access("superadmin.palette.delete")),
):
return PaletteController.delete_palette(db, palette_id)
+3 -1
View File
@@ -11,7 +11,9 @@ class AccessBase(BaseModel):
class AccessResponse(AccessBase):
id: uuid.UUID
parent_id: Optional[uuid.UUID] = None
module_id: Optional[uuid.UUID] = None
module_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
from_attributes = True
+26
View File
@@ -0,0 +1,26 @@
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
class AuditLogResponse(BaseModel):
id: UUID
module_name: str
action_type: str
entity_id: Optional[str] = None
entity_name: Optional[str] = None
description: Optional[str] = None
performed_by_email: Optional[str] = None
ip_address: Optional[str] = None
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
items: List[AuditLogResponse]
total: int
limit: int
offset: int
+7 -5
View File
@@ -16,6 +16,7 @@ class UserSignup(UserBase):
class UserSignin(BaseModel):
email: EmailStr
password: str
remember_me: bool = False
class AccessInRole(BaseModel):
id: str
@@ -24,8 +25,8 @@ class AccessInRole(BaseModel):
name: str
class RoleInUser(BaseModel):
id: uuid.UUID
role_name: str
id: Optional[uuid.UUID] = None
role_name: Optional[str] = None
accesses: List[str] = []
class UserResponse(UserBase):
@@ -33,6 +34,7 @@ class UserResponse(UserBase):
tenant_id: Optional[uuid.UUID] = None
tenant_name: Optional[str] = None
tenant_logo_url: Optional[str] = None
subscription_details: Optional[dict] = None
created_at: datetime
updated_at: datetime
role: Optional[RoleInUser] = None
@@ -55,7 +57,7 @@ class TokenResponse(BaseModel):
class RefreshTokenRequest(BaseModel):
refresh_token: str
refresh_token: Optional[str] = None
class ResetPassword(BaseModel):
old_password: str
@@ -67,10 +69,10 @@ class ForgotPasswordRequest(BaseModel):
class VerifyOTPRequest(BaseModel):
email: EmailStr
otp: str
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
class ResetPasswordWithOTP(BaseModel):
email: EmailStr
otp: str
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
new_password: str = Field(..., min_length=8)
+7 -1
View File
@@ -1,4 +1,10 @@
from pydantic import BaseModel
from enum import Enum
class LanguageEnum(str, Enum):
"""Supported languages for the application."""
EN = "en"
AR = "ar"
class UpdateLanguageRequest(BaseModel):
preferred_language: str
preferred_language: LanguageEnum
@@ -0,0 +1,50 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class EnvironmentCreate(BaseModel):
slug: str
frontend_base_url: str
backend_base_url: str
sso_entry_path: str = "/sso/callback"
permission_sync_endpoint: str = "/internal/permissions"
sso_exchange_endpoint: Optional[str] = "/internal/sso/exchange"
provisioning_endpoint: str = "/internal/tenants/provision"
trust_type: str = "hmac"
trust_credentials: Dict[str, Any]
is_default: bool = False
is_active: bool = True
class EnvironmentUpdate(BaseModel):
slug: Optional[str] = None
frontend_base_url: Optional[str] = None
backend_base_url: Optional[str] = None
sso_entry_path: Optional[str] = None
permission_sync_endpoint: Optional[str] = None
sso_exchange_endpoint: Optional[str] = None
provisioning_endpoint: Optional[str] = None
trust_type: Optional[str] = None
trust_credentials: Optional[Dict[str, Any]] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class EnvironmentResponse(BaseModel):
id: UUID
module_id: UUID
slug: str
frontend_base_url: str
backend_base_url: str
sso_entry_path: Optional[str] = "/sso/callback"
permission_sync_endpoint: Optional[str] = "/internal/permissions"
sso_exchange_endpoint: Optional[str]
provisioning_endpoint: Optional[str] = "/internal/tenants/provision"
trust_type: str
is_default: bool
is_active: bool
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
class ModuleCreate(BaseModel):
module_id: str
module_name: str
description: Optional[str] = None
icon_url: Optional[str] = None
status: str = "active"
display_order: int = 0
class ModuleUpdate(BaseModel):
module_name: Optional[str] = None
description: Optional[str] = None
icon_url: Optional[str] = None
status: Optional[str] = None
display_order: Optional[int] = None
class ModuleResponse(BaseModel):
id: UUID
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
status: str
display_order: int
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class ModuleAvailableResponse(BaseModel):
module_id: str
module_name: str
description: Optional[str]
icon_url: Optional[str]
display_order: int
is_active: bool
class Config:
from_attributes = True
+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 typing import Optional, List
from datetime import datetime
from datetime import date, datetime
import uuid
class TenantBase(BaseModel):
@@ -8,18 +8,37 @@ class TenantBase(BaseModel):
tenant_domain: str = Field(..., min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
class ModuleEnvironmentAssignment(BaseModel):
module_id: uuid.UUID
environment_slug: str
class 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):
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
is_active: Optional[bool] = None
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: Optional[str] = None
class TenantResponse(TenantBase):
id: uuid.UUID
tenant_id: uuid.UUID
is_active: bool
plan_id: Optional[uuid.UUID] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
status: str
created_at: datetime
updated_at: datetime
+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 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:
@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)
if category:
query = query.filter(Access.category == category)
return query.all()
saas_accesses = query.all()
for access in saas_accesses:
access.module_name = "SaaS (Internal)"
module_query = db.query(ModuleAccess).options(joinedload(ModuleAccess.module))
if category:
module_query = module_query.filter(ModuleAccess.category == category)
module_accesses = module_query.all()
for ma in module_accesses:
if ma.module:
ma.module_name = ma.module.module_name
result = saas_accesses + module_accesses
try:
if sync_redis_client.client:
serialized = []
for item in result:
serialized.append({
"id": str(item.id),
"access_code": item.access_code,
"name": item.name,
"category": item.category,
"parent_id": str(item.parent_id) if item.parent_id else None,
"module_id": str(item.module_id) if getattr(item, "module_id", None) else None,
"module_name": getattr(item, "module_name", None),
"created_at": item.created_at.isoformat() if item.created_at else None,
})
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600)
except Exception as e:
logger.warning(f"Access cache write error: {e}")
return result
@staticmethod
def get_access_categories(db: Session) -> List[str]:
categories = db.query(Access.category).distinct().all()
return [cat[0] for cat in categories]
module_categories = db.query(ModuleAccess.category).distinct().all()
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
return list(all_cats)
+220 -83
View File
@@ -3,10 +3,19 @@ from fastapi import HTTPException, status
from app.models.auth.user_model import User
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
from app.config.security import security
from datetime import datetime, timedelta
import random
from datetime import datetime, timedelta, timezone
import secrets
import uuid
import logging
import jwt
from app.config.settings import settings
from app.services.auth.email_service import EmailService
from app.core.redis import sync_redis_client
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
logger = logging.getLogger(__name__)
class AuthService:
@@ -47,12 +56,15 @@ class AuthService:
if user.status != "active":
raise HTTPException(status_code=403, detail="User inactive")
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None
if user.role:
if user.role or effective_accesses:
role_data = {
"id": str(user.role.id),
"role_name": user.role.role_name,
"accesses": [ra.access.access_code for ra in user.role.role_accesses],
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
}
return {
@@ -73,6 +85,9 @@ class AuthService:
"tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
@@ -102,11 +117,38 @@ class AuthService:
{"sub": str(user.id)}, tenant_id=user.tenant_id
)
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, user)
)
role_data = None
if user.role or effective_accesses:
role_data = {
"id": str(user.role.id) if user.role else None,
"role_name": user.role.role_name if user.role else "subscription",
"accesses": effective_accesses,
}
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
"user": user,
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"status": user.status,
"tenant_id": user.tenant_id,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, user.tenant_id
),
"created_at": user.created_at,
"updated_at": user.updated_at,
"role": role_data,
},
}
@staticmethod
@@ -159,20 +201,30 @@ class AuthService:
return {"message": "Password updated successfully"}
@staticmethod
def logout(current_user: User):
def logout(current_user: User, token: str):
try:
payload = jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp and sync_redis_client.client:
remaining_ttl = int(exp - datetime.now(timezone.utc).timestamp())
if remaining_ttl > 0:
sync_redis_client.client.setex(f"blacklist:{jti}", remaining_ttl, "1")
except Exception as e:
logger.warning(f"Failed to blacklist token on logout: {e}")
return {"message": "Logged out successfully"}
@staticmethod
def me(db: Session, current_user: User):
effective_accesses = sorted(
SubscriptionEntitlementService.get_effective_access_codes(db, current_user)
)
role_data = None
if current_user.role:
if current_user.role or effective_accesses:
role_data = {
"id": str(current_user.role.id),
"role_name": current_user.role.role_name,
"accesses": [
ra.access.access_code for ra in current_user.role.role_accesses
],
"id": str(current_user.role.id) if current_user.role else None,
"role_name": current_user.role.role_name if current_user.role else "subscription",
"accesses": effective_accesses,
}
return {
@@ -189,6 +241,9 @@ class AuthService:
"tenant_logo_url": (
current_user.tenant.tenant_logo_url if current_user.tenant else None
),
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
db, current_user.tenant_id
),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
"role": role_data,
@@ -196,92 +251,174 @@ class AuthService:
@staticmethod
def forgot_password(db: Session, email: str):
print(f"DEBUG: Processing forgot_password for email: {email}")
"""Generate and send OTP for password reset via Redis storage."""
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"DEBUG: User not found for email: {email}")
return {"message": "If the email is registered, an OTP has been sent."}
print(f"DEBUG: User found: {user.id}")
otp_code = "".join([str(random.randint(0, 9)) for _ in range(6)])
expires_at = datetime.utcnow() + timedelta(minutes=10)
print(f"DEBUG: Generated OTP: {otp_code}, Expires: {expires_at}")
db.query(PasswordResetOTP).filter(
PasswordResetOTP.email == email, PasswordResetOTP.is_used == False
).update({"is_used": True})
otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
redis_key = f"otp:{email}"
try:
if sync_redis_client.client:
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
logger.info(f"OTP generated for email: {email}")
else:
logger.error("Redis client unavailable for OTP storage")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
except Exception as e:
logger.error(f"Redis error storing OTP: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate OTP"
)
try:
otp_entry = PasswordResetOTP(
email=email, otp=otp_code, expires_at=expires_at
)
db.add(otp_entry)
db.commit()
print("DEBUG: OTP stored in database successfully")
EmailService.send_otp(email, otp_code)
except Exception as e:
print(f"DEBUG: Database error saving OTP: {e}")
db.rollback()
raise e
# Send Email
print("DEBUG: Attempting to send email...")
email_sent = EmailService.send_otp(email, otp_code)
print(f"DEBUG: Email sending result: {email_sent}")
logger.error(f"Failed to send OTP email to {email}: {e}")
return {"message": "If the email is registered, an OTP has been sent."}
@staticmethod
def _check_otp_attempts(email: str):
"""Check if OTP attempts are exceeded. Raises 429 if locked out."""
attempts_key = f"otp_attempts:{email}"
try:
attempts = sync_redis_client.client.get(attempts_key)
if attempts and int(attempts) >= 5:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again in 15 minutes."
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Redis error checking OTP attempts: {e}")
@staticmethod
def _increment_otp_attempts(email: str):
"""Increment failed OTP attempt counter with 15-minute TTL."""
attempts_key = f"otp_attempts:{email}"
try:
pipe = sync_redis_client.client.pipeline()
pipe.incr(attempts_key)
pipe.expire(attempts_key, 900)
pipe.execute()
except Exception as e:
logger.warning(f"Redis error incrementing OTP attempts: {e}")
@staticmethod
def verify_otp(db: Session, email: str, otp: str):
otp_entry = (
db.query(PasswordResetOTP)
.filter(
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp,
PasswordResetOTP.is_used == False,
PasswordResetOTP.expires_at > datetime.utcnow(),
)
.first()
)
"""Verify OTP from Redis storage using constant-time comparison."""
redis_key = f"otp:{email}"
if not otp_entry:
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verification service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
sync_redis_client.client.setex(f"otp_verified:{email}", 300, "1")
logger.info(f"OTP verified successfully for email: {email}")
return {"message": "OTP verified successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Redis error verifying OTP: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify OTP"
)
return {"message": "OTP verified successfully"}
@staticmethod
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
otp_entry = (
db.query(PasswordResetOTP)
.filter(
PasswordResetOTP.email == email,
PasswordResetOTP.otp == otp,
PasswordResetOTP.is_used == False,
PasswordResetOTP.expires_at > datetime.utcnow(),
)
.first()
)
"""Reset password after verifying OTP and delete OTP from Redis."""
redis_key = f"otp:{email}"
verified_key = f"otp_verified:{email}"
if not otp_entry:
try:
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Password reset service temporarily unavailable"
)
AuthService._check_otp_attempts(email)
is_pre_verified = sync_redis_client.client.get(verified_key)
if not is_pre_verified:
stored_otp = sync_redis_client.client.get(redis_key)
if not stored_otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
if not secrets.compare_digest(otp, stored_otp):
AuthService._increment_otp_attempts(email)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OTP"
)
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not security.validate_password_strength(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password too weak"
)
user.password = security.hash_password(new_password)
user.password_updated_at = datetime.now(timezone.utc)
db.commit()
sync_redis_client.client.delete(redis_key)
sync_redis_client.client.delete(verified_key)
sync_redis_client.client.delete(f"otp_attempts:{email}")
logger.info(f"Password reset successfully for email: {email}")
return {"message": "Password updated successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting password: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not security.validate_password_strength(new_password):
raise HTTPException(status_code=400, detail="Password too weak")
user.password = security.hash_password(new_password)
user.password_updated_at = datetime.utcnow()
otp_entry.is_used = True
db.commit()
return {"message": "Password updated successfully"}
+10 -16
View File
@@ -1,41 +1,35 @@
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os
from dotenv import load_dotenv
import logging
from app.config.settings import settings
load_dotenv()
logger = logging.getLogger(__name__)
class EmailService:
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
EMAIL_FROM = os.getenv("EMAIL_FROM")
SMTP_SECURE = os.getenv("SMTP_SECURE", "true").lower() == "true"
@staticmethod
def send_otp(to_email: str, otp: str):
try:
msg = MIMEMultipart()
msg['From'] = EmailService.EMAIL_FROM
msg['From'] = settings.EMAIL_FROM
msg['To'] = to_email
msg['Subject'] = "Password Reset OTP"
body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes."
msg.attach(MIMEText(body, 'plain'))
if EmailService.SMTP_SECURE:
server = smtplib.SMTP_SSL(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
if settings.SMTP_SECURE:
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
else:
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
server.starttls()
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD)
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
text = msg.as_string()
server.sendmail(EmailService.EMAIL_FROM, to_email, text)
server.sendmail(settings.EMAIL_FROM, to_email, text)
server.quit()
return True
except Exception as e:
print(f"Failed to send email: {e}")
logger.error(f"Failed to send email: {e}")
return False
+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 import or_, cast, String
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.role_model import Role
from app.models.auth.role_access_model import RoleAccess
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.access_model import Access
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
from typing import List, Optional
import uuid
from app.services.auth.event_service import EventService
logger = logging.getLogger(__name__)
class RoleService:
@staticmethod
def create_role(db: Session, role_data: RoleCreate) -> Role:
def create_role(db: Session, role_data: RoleCreate, emit_events: bool = True) -> Role:
existing = (
db.query(Role)
.filter(
@@ -39,23 +47,87 @@ class RoleService:
if 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
@staticmethod
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
db.query(RoleModuleAccess).filter(RoleModuleAccess.role_id == role_id).delete()
for access_id in access_ids:
access = db.query(Access).filter(Access.id == access_id).first()
if not access:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Access {access_id} not found",
)
if not access_ids:
return
role_access = RoleAccess(role_id=role_id, access_id=access_id)
db.add(role_access)
saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
saas_ids = {a.id for a in saas_accesses}
for access in saas_accesses:
db.add(RoleAccess(role_id=role_id, access_id=access.id))
remaining_ids = set(access_ids) - saas_ids
if remaining_ids:
module_accesses = db.query(ModuleAccess).filter(ModuleAccess.id.in_(remaining_ids)).all()
for access in module_accesses:
db.add(RoleModuleAccess(role_id=role_id, module_access_id=access.id))
db.commit()
@@ -84,7 +156,25 @@ class RoleService:
) -> Role:
role = RoleService.get_role_by_id(db, role_id)
def get_module_permissions_snapshot(r_id):
snapshot_data = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == r_id)
.all()
)
snapshot_map = {}
for rma, ma in snapshot_data:
mid = str(ma.module_id)
if mid not in snapshot_map:
snapshot_map[mid] = set()
snapshot_map[mid].add(ma.access_code)
return snapshot_map
before_snapshot = get_module_permissions_snapshot(role_id)
update_dict = role_data.model_dump(exclude_unset=True)
role_name_changed = "role_name" in update_dict
if role.is_default and not is_superadmin:
raise HTTPException(
@@ -102,6 +192,61 @@ class RoleService:
db.commit()
db.refresh(role)
after_snapshot = get_module_permissions_snapshot(role_id)
all_modules = set(before_snapshot.keys()) | set(after_snapshot.keys())
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_([uuid.UUID(m) for m in all_modules])
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
diff_targets = []
for mid in all_modules:
before_set = before_snapshot.get(mid, set())
after_set = after_snapshot.get(mid, set())
added = list(after_set - before_set)
removed = list(before_set - after_set)
is_active_module = mid in after_snapshot and len(after_snapshot[mid]) > 0
if added or removed or (role_name_changed and is_active_module):
env_slug = env_map.get(mid, "prod")
diff_targets.append({
"module_id": mid,
"environment_slug": env_slug,
"added_permissions": added,
"removed_permissions": removed
})
if diff_targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"role_name": role.role_name,
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": diff_targets
}
logger.info(f"ROLE_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_UPDATED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
return role
@staticmethod
@@ -113,6 +258,49 @@ class RoleService:
status_code=status.HTTP_403_FORBIDDEN,
detail="Default roles can only be deleted by superadmins.",
)
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.distinct()
.all()
)
if active_modules:
module_ids = [m[0] for m in active_modules]
env_map = {}
if role.tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == role.tenant_id,
TenantModule.module_id.in_(module_ids)
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for m in active_modules:
mid = str(m[0])
env_slug = env_map.get(mid, "prod")
targets.append({"module_id": mid, "environment_slug": env_slug})
if targets:
provisioning_id = str(uuid.uuid4())
payload = {
"role_id": str(role.id),
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
"provisioning_id": provisioning_id,
"targets": targets
}
logger.info(f"ROLE_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db,
event_type="ROLE_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.delete(role)
db.commit()
@@ -125,6 +313,10 @@ class RoleService:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
filter_role_names: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> RolePaginatedResponse:
query = db.query(Role)
@@ -132,6 +324,12 @@ class RoleService:
if tenant_id is not None:
query = query.filter(Role.tenant_id == tenant_id)
if filter_role_names:
query = query.filter(Role.role_name.in_(filter_role_names))
if filter_tenant_ids:
query = query.filter(Role.tenant_id.in_(filter_tenant_ids))
if search and search.strip():
search_term = search.strip()
query = query.filter(
@@ -141,6 +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()
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()
+466 -16
View File
@@ -1,13 +1,83 @@
from datetime import date, datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.tenant_model import Tenant
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_model import Role
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.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
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:
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
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
@@ -23,21 +93,158 @@ class TenantService:
status_code=status.HTTP_400_BAD_REQUEST,
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"
)
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url
)
provisioning_id = str(uuid.uuid4())
db.add(tenant)
db.commit()
db.refresh(tenant)
return tenant
try:
# 1. Create Tenant
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url,
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()
)
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.refresh(tenant)
return tenant
except Exception as e:
db.rollback()
raise e
@staticmethod
def get_all_tenants(db: Session):
return db.query(Tenant).all()
tenants = db.query(Tenant).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
return tenants
@staticmethod
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
@@ -47,6 +254,9 @@ class TenantService:
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
if TenantService._sync_tenant_lifecycle(tenant):
db.commit()
db.refresh(tenant)
return tenant
@staticmethod
@@ -55,19 +265,195 @@ class TenantService:
update_dict = tenant_data.model_dump(exclude_unset=True)
should_emit_update = False
should_emit_status = False
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant name already exists")
should_emit_update = True
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant domain already exists")
should_emit_update = True
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
should_emit_update = True
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
new_plan_id = update_dict["plan_id"]
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
if not plan:
raise HTTPException(status_code=400, detail="Invalid plan_id")
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
new_modules_set = set()
if mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
new_modules_set = {m[0] for m in modules_query}
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
event_targets = []
provisioning_id = str(uuid.uuid4())
default_env = "prod"
for mod_id in new_modules_set:
if mod_id in current_map:
tm = current_map[mod_id]
if not tm.is_active:
tm.is_active = True
event_targets.append({
"module_id": str(mod_id),
"environment_slug": tm.assigned_environment_slug or default_env
})
else:
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=default_env,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(mod_id),
"environment_slug": default_env
})
for mod_id, tm in current_map.items():
if mod_id not in new_modules_set and tm.is_active:
tm.is_active = False
db.flush()
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first()
if default_role:
role_update_data = RoleUpdate(access_ids=all_access_ids)
RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True)
role_follow_up = None
if event_targets and default_role:
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == default_role.id)
.all()
)
module_map = {}
for rma, ma in role_module_perms:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
role_targets = []
for target in event_targets:
mid = target["module_id"]
if mid in module_map:
role_targets.append({
"module_id": mid,
"environment_slug": target["environment_slug"],
"permissions": module_map[mid]
})
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"payload": {
"role_id": str(default_role.id),
"role_name": default_role.role_name,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
if event_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"max_users_allowed": plan.max_users_allowed,
"provisioning_id": provisioning_id,
"targets": event_targets
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
for key, value in update_dict.items():
setattr(tenant, key, value)
if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")):
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=update_dict.get("status"),
is_active=tenant.is_active,
)
should_emit_status = True
if should_emit_update or should_emit_status:
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
if should_emit_update:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_UPDATED",
payload=payload,
tenant_id=tenant.id
)
if should_emit_status:
payload = {
"tenant_id": str(tenant.id),
"is_active": tenant.is_active,
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_STATUS_CHANGED",
payload=payload,
tenant_id=tenant.id
)
db.commit()
db.refresh(tenant)
return tenant
@@ -75,6 +461,33 @@ class TenantService:
@staticmethod
def delete_tenant(db: Session, tenant_id: uuid.UUID):
tenant = TenantService.get_tenant_by_id(db, tenant_id)
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
if active_modules:
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
db.delete(tenant)
db.commit()
return {"message": "Tenant deleted successfully"}
@@ -86,10 +499,25 @@ class TenantService:
page_size: int = 10,
search: Optional[str] = None,
is_active: Optional[bool] = None,
filter_tenant_names: Optional[List[str]] = None,
filter_tenant_domains: Optional[List[str]] = None,
filter_plan_ids: Optional[List[uuid.UUID]] = None,
statuses: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> TenantPaginatedResponse:
query = db.query(Tenant)
if filter_tenant_names:
query = query.filter(Tenant.tenant_name.in_(filter_tenant_names))
if filter_tenant_domains:
query = query.filter(Tenant.tenant_domain.in_(filter_tenant_domains))
if filter_plan_ids:
query = query.filter(Tenant.plan_id.in_(filter_plan_ids))
if search and search.strip():
search_term = search.strip()
query = query.filter(
@@ -103,11 +531,33 @@ class TenantService:
if is_active is not None:
query = query.filter(Tenant.is_active == is_active)
if statuses is not None and len(statuses) > 0:
query = query.filter(Tenant.is_active.in_(statuses))
sort_column_map = {
"name": Tenant.tenant_name,
"domain": Tenant.tenant_domain,
"status": Tenant.is_active,
"plan": Tenant.plan_id,
}
sort_column = sort_column_map.get(sort_by or "")
if sort_column is not None:
order_fn = desc if (sort_order or "").lower() == "desc" else asc
query = query.order_by(order_fn(sort_column))
total = query.count()
offset = (page - 1) * page_size
tenants = query.offset(offset).limit(page_size).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return TenantPaginatedResponse(
@@ -116,4 +566,4 @@ class TenantService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
+114
View File
@@ -0,0 +1,114 @@
import hmac
import hashlib
from typing import Dict, Any, Optional
from app.models.auth.module_environment_model import ModuleEnvironment
from fastapi import HTTPException, status
class TrustService:
@staticmethod
def verify_request_signature(environment: ModuleEnvironment, signature: str, payload: str = "") -> bool:
"""
Verify the HMAC signature of an incoming request from a module.
Currently supports HMAC-SHA256.
"""
if environment.trust_type != "hmac":
if environment.trust_type == "static_key":
secret = environment.trust_credentials.get("secret_key")
return hmac.compare_digest(signature, secret)
return False
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
return False
expected_signature = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)
@staticmethod
def sign_payload(environment: ModuleEnvironment, payload: str) -> str:
"""
Signs a raw payload string using the environment's HMAC secret.
Used for direct signed POST flows.
"""
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret'")
signature = hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
@staticmethod
def sign_outbound_payload(environment: ModuleEnvironment, method: str, path: str, payload_json: str, timestamp: str) -> str:
"""
Generates HMAC-SHA256 signature for outbound requests to modules.
Signature = HMAC-SHA256(secret, method + path + timestamp + SHA256(payload))
"""
if environment.trust_type != "hmac":
pass
secret = environment.trust_credentials.get("hmac_secret")
if not secret:
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret' for outbound signing")
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
string_to_sign = f"{method.upper()}{path}{timestamp}{payload_hash}"
signature = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
@staticmethod
def validate_module_trust(environment: ModuleEnvironment, request_headers: Dict[str, str], request_body: str = ""):
"""
Validates that the request comes from a trusted module environment.
Raises HTTPException if authentication fails.
"""
if not environment.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Module environment is inactive"
)
if environment.trust_type == "hmac":
signature = request_headers.get("X-Module-Signature")
if not signature:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing signature"
)
if not TrustService.verify_request_signature(environment, signature, request_body):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid signature"
)
elif environment.trust_type == "static_key":
api_key = request_headers.get("X-Module-Key")
secret = environment.trust_credentials.get("secret_key")
if not api_key or not hmac.compare_digest(api_key, secret):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API Key"
)
else:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=f"Trust type {environment.trust_type} not supported yet"
)
+251 -13
View File
@@ -1,17 +1,27 @@
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String
from fastapi import HTTPException, status
from sqlalchemy import or_, cast, String, asc, desc, func
from fastapi import HTTPException, status, BackgroundTasks
from datetime import datetime
import uuid
from typing import Optional
from typing import Optional, List, Dict, Any
from app.models.auth.user_model import User
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.config.security import security
from app.services.auth.event_service import EventService
import logging
import json
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.role_model import Role
from app.models.auth.tenant_model import Tenant
logger = logging.getLogger(__name__)
class UserService:
@staticmethod
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None) -> User:
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -36,8 +46,35 @@ class UserService:
)
db.add(user)
db.commit()
db.flush()
db.refresh(user)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
payload = {
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status,
"targets": targets
}
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_PROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.commit()
return user
@staticmethod
@@ -61,13 +98,31 @@ class UserService:
return user
@staticmethod
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None) -> User:
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
user = UserService.get_user_by_id(db, user_id, tenant_id)
old_role_id = user.role_id
old_targets = []
if old_role_id:
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
update_dict = user_data.model_dump(exclude_unset=True)
if tenant_id:
update_dict.pop("tenant_id", None)
if "role_id" in update_dict and update_dict["role_id"] is not None:
role = db.query(Role).filter(Role.id == update_dict["role_id"]).first()
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role not found"
)
if tenant_id and role.tenant_id is not None and role.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot assign a role from another tenant"
)
if "email" in update_dict and update_dict["email"] != user.email:
if db.query(User).filter(User.email == update_dict["email"]).first():
raise HTTPException(
@@ -88,13 +143,88 @@ class UserService:
for key, value in update_dict.items():
setattr(user, key, value)
db.commit()
db.flush()
db.refresh(user)
new_targets = []
if user.role_id:
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
role_changed = (old_role_id != user.role_id)
base_payload = {
"user_id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone_number": user.phone_number,
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"role_id": str(user.role_id) if user.role_id else None,
"status": user.status
}
if role_changed:
old_mids = {t["module_id"] for t in old_targets}
new_mids = {t["module_id"] for t in new_targets}
removed_mids = old_mids - new_mids
deprovision_targets = [t for t in old_targets if t["module_id"] in removed_mids]
if deprovision_targets:
payload = {**base_payload, "targets": deprovision_targets}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
if new_targets:
payload = {**base_payload, "targets": new_targets}
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_PROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
else:
if new_targets:
payload = {**base_payload, "targets": new_targets}
logger.info(f"USER_UPDATED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_UPDATED",
payload=payload,
tenant_id=user.tenant_id
)
db.commit()
return user
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
user = UserService.get_user_by_id(db, user_id, tenant_id)
targets = []
if user.role_id:
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
if targets:
payload = {
"user_id": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"targets": targets
}
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
EventService.emit_event(
db=db,
event_type="USER_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=user.tenant_id
)
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
@@ -106,16 +236,23 @@ class UserService:
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
status: Optional[str] = None,
filter_names: Optional[List[str]] = None,
filter_emails: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
filter_role_ids: Optional[List[uuid.UUID]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> UserPaginatedResponse:
query = db.query(User)
query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id)
# Scope to tenant if not superadmin
if tenant_id:
query = query.filter(User.tenant_id == tenant_id)
if search and search.strip():
search_term = search.strip()
search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.filter(
or_(
User.email.ilike(f"%{search_term}%"),
@@ -126,8 +263,73 @@ class UserService:
)
)
if status is not None:
query = query.filter(User.status == status)
if filter_names:
normalized_names = [name.strip() for name in filter_names if isinstance(name, str) and name.strip()]
if normalized_names:
query = query.filter(
func.trim(
func.concat(
User.first_name,
" ",
func.coalesce(User.last_name, ""),
)
).in_(normalized_names)
)
if filter_emails:
normalized_emails = [email.strip() for email in filter_emails if isinstance(email, str) and email.strip()]
if normalized_emails:
query = query.filter(User.email.in_(normalized_emails))
if statuses:
normalized_statuses = [status for status in statuses if status]
if normalized_statuses:
query = query.filter(User.status.in_(normalized_statuses))
if filter_tenant_ids:
tenant_ids: List[uuid.UUID] = []
for tenant_value in filter_tenant_ids:
try:
tenant_ids.append(
tenant_value if isinstance(tenant_value, uuid.UUID) else uuid.UUID(str(tenant_value))
)
except (ValueError, AttributeError, TypeError):
continue
if tenant_ids:
query = query.filter(User.tenant_id.in_(tenant_ids))
if filter_role_ids:
role_ids: List[uuid.UUID] = []
for role_value in filter_role_ids:
try:
role_ids.append(
role_value if isinstance(role_value, uuid.UUID) else uuid.UUID(str(role_value))
)
except (ValueError, AttributeError, TypeError):
continue
if role_ids:
query = query.filter(User.role_id.in_(role_ids))
normalized_sort_by = (sort_by or "").strip().lower()
normalized_sort_order = (sort_order or "asc").strip().lower()
sort_fn = desc if normalized_sort_order == "desc" else asc
if normalized_sort_by == "name":
query = query.order_by(
sort_fn(func.lower(User.first_name)),
sort_fn(func.lower(func.coalesce(User.last_name, ""))),
asc(User.email),
)
elif normalized_sort_by == "email":
query = query.order_by(sort_fn(func.lower(User.email)))
elif normalized_sort_by == "status":
query = query.order_by(sort_fn(func.lower(User.status)), asc(User.email))
elif normalized_sort_by == "tenant":
query = query.order_by(sort_fn(func.lower(func.coalesce(Tenant.tenant_name, ""))), asc(User.email))
elif normalized_sort_by == "role":
query = query.order_by(sort_fn(func.lower(func.coalesce(Role.role_name, ""))), asc(User.email))
else:
query = query.order_by(desc(User.created_at))
total = query.count()
@@ -142,4 +344,40 @@ class UserService:
page=page,
page_size=page_size,
total_pages=total_pages,
)
)
@staticmethod
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
active_modules = (
db.query(ModuleAccess.module_id)
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role_id)
.distinct()
.all()
)
if not active_modules:
return []
module_ids = [m[0] for m in active_modules]
env_map = {}
if tenant_id:
tm_assignments = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.module_id.in_(module_ids),
TenantModule.is_active == True
).all()
for tm in tm_assignments:
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
targets = []
for mid_uuid in module_ids:
mid = str(mid_uuid)
env_slug = env_map.get(mid, "prod")
targets.append({
"module_id": mid,
"environment_slug": env_slug
})
return targets
+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)
+21 -27
View File
@@ -1,19 +1,17 @@
from sqlalchemy.orm import Session
from uuid import UUID
from typing import List, Optional
from typing import List
from fastapi import HTTPException, status
from app.models.theme.color_palette_models import ColorPalette
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
from app.models.theme.color_palette_model import ColorPalette
from app.schemas.theme.color_palette_schema import (
ColorPaletteCreate,
ColorPaletteUpdate,
)
class PaletteService:
@staticmethod
def get_all_palettes(db: Session, tenant_id: Optional[UUID] = None) -> List[ColorPalette]:
query = db.query(ColorPalette)
if tenant_id:
query = query.filter((ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id))
else:
pass
return query.all()
def get_all_palettes(db: Session) -> List[ColorPalette]:
return db.query(ColorPalette).all()
@staticmethod
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
@@ -23,19 +21,15 @@ class PaletteService:
return palette
@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 tenant_id:
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id).update({"is_default": False})
else:
db.query(ColorPalette).filter(ColorPalette.tenant_id == None).update({"is_default": False})
db.query(ColorPalette).update({"is_default": False})
db_palette = ColorPalette(
name=data.name,
description=data.description,
colors=data.colors.model_dump(),
is_default=data.is_default,
tenant_id=tenant_id
)
db.add(db_palette)
db.commit()
@@ -43,9 +37,11 @@ class PaletteService:
return db_palette
@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)
if data.name is not None:
palette.name = data.name
if data.description is not None:
@@ -54,14 +50,12 @@ class PaletteService:
palette.colors = data.colors.model_dump()
if data.is_default is not None:
if data.is_default:
tenant_id = palette.tenant_id
if tenant_id:
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id, ColorPalette.id != palette_id).update({"is_default": False})
else:
db.query(ColorPalette).filter(ColorPalette.tenant_id == None, ColorPalette.id != palette_id).update({"is_default": False})
db.query(ColorPalette).filter(
ColorPalette.id != palette_id
).update({"is_default": False})
palette.is_default = data.is_default
db.commit()
db.refresh(palette)
return palette
@@ -69,7 +63,7 @@ class PaletteService:
@staticmethod
def delete_palette(db: Session, palette_id: UUID):
palette = PaletteService.get_palette_by_id(db, palette_id)
db.delete(palette)
db.commit()
return True
+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"
}
}
+5 -1
View File
@@ -7,4 +7,8 @@ alembic==1.17.2
pydantic-settings==2.12.0
bcrypt>=4.0.1
pyjwt>=2.8.0
email-validator>=2.1.0
email-validator>=2.1.0
redis==7.1.0
requests==2.32.5
typer==0.21.1
httpx==0.28.1
+2 -2
View File
@@ -9,5 +9,5 @@ if __name__ == "__main__":
"run:app",
host=settings.HOST,
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