10 Commits
25 changed files with 801 additions and 77 deletions
+5 -5
View File
@@ -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:5173,http://localhost:5174
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -28,12 +28,12 @@ REDIS_PORT=6381
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
SMTP_USER=info.maskantech@gmail.com
SMTP_PASSWORD=tuthpljtkrwchgxd
EMAIL_FROM=info.maskantech@gmail.com
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
+5 -5
View File
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
ALLOWED_HOSTS=*
HOST=127.0.0.1
FRONTEND_URL=https://saas-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in,https://docqube.com,https://docqubeapp-test.maskantech.in
# Security
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
@@ -28,12 +28,12 @@ REDIS_PORT=6383
REDIS_PASSWORD=8haSTisAqop8ChAs
# Email Configuration
SMTP_HOST=smtp.hostinger.com
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=info@maskantech.in
SMTP_PASSWORD=Infomaskan@123
EMAIL_FROM=info@maskantech.in
SMTP_USER=info.maskantech@gmail.com
SMTP_PASSWORD=tuthpljtkrwchgxd
EMAIL_FROM=info.maskantech@gmail.com
# JWT Configuration
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
+17
View File
@@ -302,6 +302,23 @@ The RBAC system supports:
- Flexible role-to-permission mapping
- Tenant-scoped roles
## DocQube ➔ SaaS Data Migration
To migrate tenants, roles, and users from DocQube DB to SaaS DB:
```bash
# 1. Preview changes (Dry Run - No changes committed):
python scripts/migrate_docqube_to_saas.py --dry-run
# 2. Execute and commit to SaaS DB:
python scripts/migrate_docqube_to_saas.py --execute
```
### Password Verification & Automatic Upgrade
- Users migrated from DocQube with `pbkdf2_sha256` password hashes can log in seamlessly.
- Upon first successful login, their password hash is automatically and transparently upgraded to standard SaaS `bcrypt`.
## Troubleshooting
### Database Connection Issues
@@ -34,30 +34,50 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import sqlalchemy as sa
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_email_trgm "
"ON users USING gin (email gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_first_name_trgm "
"ON users USING gin (first_name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_last_name_trgm "
"ON users USING gin (last_name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_audit_entity_name_trgm "
"ON audit_logs USING gin (entity_name gin_trgm_ops)"
)
conn = op.get_bind()
try:
with conn.begin_nested():
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_users_email_trgm "
"ON users USING gin (email gin_trgm_ops)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_users_first_name_trgm "
"ON users USING gin (first_name gin_trgm_ops)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_users_last_name_trgm "
"ON users USING gin (last_name gin_trgm_ops)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_audit_entity_name_trgm "
"ON audit_logs USING gin (entity_name gin_trgm_ops)"
)
)
except Exception as e:
print(f"Skipping pg_trgm extension and indexes due to permissions: {e}")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_audit_entity_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_last_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_first_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_email_trgm")
conn = op.get_bind()
try:
with conn.begin_nested():
conn.execute(sa.text("DROP INDEX IF EXISTS ix_audit_entity_name_trgm"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_last_name_trgm"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_first_name_trgm"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_email_trgm"))
except Exception as e:
print(f"Skipping pg_trgm index drop: {e}")
+2 -1
View File
@@ -84,7 +84,8 @@ async def lifespan(app: FastAPI):
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
if result:
_, event_id = result
_, event_id_bytes = result
event_id = event_id_bytes.decode("utf-8") if isinstance(event_id_bytes, bytes) else event_id_bytes
try:
with SessionLocal() as db:
await run_in_threadpool(EventService.process_queue_item, db, event_id)
+19 -13
View File
@@ -24,26 +24,32 @@ class SecurityUtils:
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash.
A stored value that is not a bcrypt hash means "this account has no
password" — which is now a real state, because an account provisioned
through an identity provider has no password to store and holds a
placeholder instead.
`bcrypt.checkpw` raises `ValueError: Invalid salt` on such a value, and
an unhandled exception on the sign-in path is a 500 where the answer is
plainly "no". Returning False says the same thing without telling the
caller which kind of account they just probed.
"""
"""Verify a password against its hash (supports bcrypt and PBKDF2)."""
if not plain_password or not hashed_password:
return False
# Support migrated PBKDF2 hashes from DocQube
if hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$"):
try:
from passlib.hash import pbkdf2_sha256
return pbkdf2_sha256.verify(plain_password, hashed_password)
except Exception as e:
logger.warning(f"PBKDF2 verification failed: {e}")
return False
try:
return bcrypt.checkpw(
plain_password.encode('utf-8'), hashed_password.encode('utf-8')
)
except ValueError:
except (ValueError, TypeError):
return False
@staticmethod
def is_legacy_hash(hashed_password: str) -> bool:
"""Check if hash is in legacy format (e.g. PBKDF2) needing upgrade to bcrypt."""
if not hashed_password:
return False
return hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$")
@staticmethod
def generate_access_token(
+3 -3
View File
@@ -1,9 +1,9 @@
from sqlalchemy.orm import Session
from typing import List
from typing import List, Any
from app.services.auth.access_service import AccessService
from app.schemas.auth.access_schema import AccessResponse
class AccessController:
@staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
return AccessService.get_all_accesses(db, category)
def get_all_accesses(db: Session, category: str = None, tenant_id: Any = None) -> List[AccessResponse]:
return AccessService.get_all_accesses(db, category, tenant_id)
+5
View File
@@ -91,8 +91,10 @@ class RoleController:
"category": ra.access.category,
"name": ra.access.name,
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
"module_name": "SaaS (Internal)",
}
for ra in role.role_accesses
if ra.access
]
accesses.extend([
@@ -102,8 +104,11 @@ class RoleController:
"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,
"module_id": str(rma.module_access.module_id) if rma.module_access.module_id else None,
"module_name": rma.module_access.module.module_name if (rma.module_access and rma.module_access.module) else "DocQube",
}
for rma in role.role_module_accesses
if rma.module_access
])
return RoleWithAccessesResponse(
+5
View File
@@ -43,10 +43,15 @@ def get_client_ip(request: Request) -> str:
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
def _consume(bucket: str, limit: int, window_seconds: int) -> Optional[int]:
"""Increment the bucket. Returns seconds-to-wait if over limit, else None."""
from app.config.settings import settings
if settings.APP_ENV in ["local", "localdev"]:
return None
client = getattr(sync_redis_client, "client", None)
if client is None:
return None
+6 -1
View File
@@ -7,13 +7,18 @@ from app.schemas.auth.access_schema import AccessResponse
from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import User
from uuid import UUID
router = APIRouter()
@router.get("/get", response_model=List[AccessResponse])
def get_accesses(
category: Optional[str] = None,
tenant_id: Optional[UUID] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.role.read")),
):
return AccessController.get_all_accesses(db, category)
# If the user is a tenant admin (non-superadmin), always enforce their own tenant_id
effective_tenant_id = current_user.tenant_id if getattr(current_user, "tenant_id", None) else tenant_id
return AccessController.get_all_accesses(db, category, effective_tenant_id)
+2 -2
View File
@@ -97,8 +97,8 @@ def refresh_token(
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)
response.set_cookie(key="access_token", value=result["access_token"], max_age=settings.ACCESS_TOKEN_EXPIRES, **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], max_age=settings.REFRESH_TOKEN_EXPIRES, **cookie_kw)
return result
@router.get("/me", response_model=UserResponse)
+35 -2
View File
@@ -14,8 +14,8 @@ class AccessService:
CACHE_PREFIX = "saas:access:v2:all:"
@staticmethod
def get_all_accesses(db: Session, category: str = None) -> List[any]:
cache_key = f"{AccessService.CACHE_PREFIX}{category if category else 'full'}"
def get_all_accesses(db: Session, category: str = None, tenant_id: Any = None) -> List[any]:
cache_key = f"{AccessService.CACHE_PREFIX}{category if category else 'full'}:{str(tenant_id) if tenant_id else 'all'}"
cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None
if cached_data:
@@ -59,6 +59,39 @@ class AccessService:
if ma.module:
ma.module_name = ma.module.module_name
# If tenant_id is provided, filter module accesses and internal accesses based on tenant's plan
if tenant_id:
try:
import uuid as uuid_lib
from app.models.auth.tenant_model import Tenant
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.tenant_module_model import TenantModule
tid = uuid_lib.UUID(str(tenant_id)) if not isinstance(tenant_id, uuid_lib.UUID) else tenant_id
tenant = db.query(Tenant).filter(Tenant.id == tid).first()
if tenant:
if tenant.plan_id:
plan_mas = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == tenant.plan_id).all()
allowed_ma_ids = {pma.module_access_id for pma in plan_mas}
plan_as = db.query(PlanAccess).filter(PlanAccess.plan_id == tenant.plan_id).all()
allowed_internal_ids = {pa.access_id for pa in plan_as}
module_accesses = [ma for ma in module_accesses if ma.id in allowed_ma_ids]
saas_accesses = [a for a in saas_accesses if a.id in allowed_internal_ids]
else:
# Tenant has no plan attached, check assigned TenantModules
tms = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
allowed_m_ids = {tm.module_id for tm in tms}
if allowed_m_ids:
module_accesses = [ma for ma in module_accesses if ma.module_id in allowed_m_ids]
else:
module_accesses = []
saas_accesses = []
except Exception as e:
logger.error(f"Error filtering accesses for tenant {tenant_id}: {e}", exc_info=True)
result = saas_accesses + module_accesses
try:
+5
View File
@@ -109,6 +109,11 @@ class AuthService:
lockout_service.record_success(db, user)
# Upgrade legacy PBKDF2 hash to bcrypt on successful sign-in
if security.is_legacy_hash(user.password):
user.password = security.hash_password(signin_data.password)
db.commit()
if user.status != "active":
raise HTTPException(status_code=403, detail="User inactive")
+29 -3
View File
@@ -64,6 +64,13 @@ class EventService:
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.slug == env_slug
).first()
if not env:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.is_default == True
).first()
if env:
targets.append(env)
@@ -89,6 +96,7 @@ class EventService:
targets.append(env)
if not targets:
scoping_type = 'Explicit' if targets_list else 'Implicit'
severity = (
logger.error
if event_type in EventService.MUST_REACH_SOMEONE
@@ -96,15 +104,33 @@ class EventService:
)
severity(
f"Event {event_type} reached no modules "
f"(tenant={tenant_id}). Nothing downstream will hear about it."
f"(tenant={tenant_id}). Nothing downstream will hear about it. Payload scoping: {scoping_type}"
)
return 0
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:
target_url = f"{base}/{env.provisioning_endpoint.lstrip('/')}"
provision_event_types = {
"TENANT_PROVISION_REQUESTED",
"TENANT_UPDATED",
"TENANT_STATUS_CHANGED",
"TENANT_DEPROVISION_REQUESTED",
"ROLE_PROVISION_REQUESTED",
"ROLE_UPDATED",
"ROLE_DEPROVISION_REQUESTED",
"USER_PROVISION_REQUESTED",
"USER_UPDATED",
"USER_DEPROVISION_REQUESTED",
"PLAN_PROVISION_REQUESTED",
"PLAN_UPDATED",
"PLAN_DEPROVISION_REQUESTED",
}
if event_type in provision_event_types 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:
target_url = f"{base}/api/internal/events"
@@ -176,6 +176,7 @@ class ModulePermissionService:
ModuleAccess.access_code == parent_code
).first()
if parent_access:
permission_map[code].parent_id = parent_access.id
+6 -1
View File
@@ -173,7 +173,12 @@ class RoleService:
equality `get_all_roles` and the paginated list already apply, so global
(tenant-less) roles stay invisible to tenants.
"""
query = db.query(Role).filter(Role.id == role_id)
query = db.query(Role).options(
joinedload(Role.role_accesses).joinedload(RoleAccess.access),
joinedload(Role.role_module_accesses)
.joinedload(RoleModuleAccess.module_access)
.joinedload(ModuleAccess.module),
).filter(Role.id == role_id)
if not actor_is_superadmin:
if actor_tenant_id is None:
+4 -2
View File
@@ -165,6 +165,8 @@ class SSOService:
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.role_name if user.role else None,
"role_id": str(user.role_id) if user.role_id else None,
"is_superadmin": bool(user.is_superadmin),
"module_id": module.module_id,
"environment": env.slug,
"nonce": uuid.uuid4().hex,
@@ -178,8 +180,8 @@ class SSOService:
try:
signature = TrustService.sign_payload(env, canonical_string)
except ValueError:
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
except ValueError as e:
raise HTTPException(status_code=500, detail=f"Module trust configuration error: {str(e)}")
base_url = env.backend_base_url.rstrip('/')
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
+111 -1
View File
@@ -1,9 +1,13 @@
import uuid
import re
import logging
from typing import List, Optional
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
logger = logging.getLogger(__name__)
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
@@ -128,7 +132,84 @@ class SubscriptionPlanService:
tenant_id=tenant.id,
)
@staticmethod
def _slugify(name: str) -> str:
"""Convert a plan name to a stable lowercase code, e.g. 'Professional''professional'."""
return re.sub(r'[^a-z0-9]+', '_', name.lower()).strip('_')
@staticmethod
def _emit_plan_event(
db: Session,
plan: "SubscriptionPlan",
event_type: str,
) -> None:
"""
Fire a plan webhook event to every module that is included in this plan.
We deliberately scope delivery to only the modules whose access codes are
in this plan's PlanModuleAccess rows. A plan that has no DocQube access
codes never notifies DocQube; a plan that has both DocQube and Inventory
codes notifies both independently.
"""
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.module_model import Module
# Find every distinct module that this plan grants access to.
module_ids = (
db.query(ModuleAccess.module_id)
.join(PlanModuleAccess, PlanModuleAccess.module_access_id == ModuleAccess.id)
.filter(PlanModuleAccess.plan_id == plan.id)
.distinct()
.all()
)
if not module_ids:
return # plan has no module access; nothing to notify
targets = []
for (module_id,) in module_ids:
env = (
db.query(ModuleEnvironment)
.filter(
ModuleEnvironment.module_id == module_id,
ModuleEnvironment.is_default == True,
ModuleEnvironment.is_active == True,
)
.first()
)
if env:
targets.append(
{
"module_id": str(module_id),
"environment_slug": env.slug,
}
)
if not targets:
return
payload = {
"saas_plan_id": str(plan.id),
"code": SubscriptionPlanService._slugify(plan.name),
"name": plan.name,
"description": plan.description,
"price_amount": float(plan.price) if plan.price is not None else None,
"currency": "USD",
"interval": "monthly" if plan.duration_days and plan.duration_days >= 28 else "one_time",
"is_public": bool(plan.is_public),
"grace_period_days": int(plan.grace_period_days or 0),
"targets": targets,
}
EventService.emit_event(
db,
event_type=event_type,
payload=payload,
)
db.commit()
logger.info(f"Emitted {event_type} for plan '{plan.name}' to {len(targets)} target(s)")
@staticmethod
def create_plan(db: Session, plan_data: SubscriptionPlanCreate) -> SubscriptionPlan:
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == plan_data.name).first()
@@ -158,6 +239,15 @@ class SubscriptionPlanService:
db.commit()
db.refresh(plan)
try:
from app.services.auth.access_service import AccessService
AccessService.invalidate_cache()
except Exception:
pass
try:
SubscriptionPlanService._emit_plan_event(db, plan, "PLAN_PROVISION_REQUESTED")
except Exception:
logger.error("Plan event emission failed for create_plan", exc_info=True)
return plan
@staticmethod
@@ -194,6 +284,16 @@ class SubscriptionPlanService:
db.refresh(plan)
SubscriptionPlanService._sync_tenant_default_roles_for_plan(db, plan.id)
db.commit()
try:
from app.services.auth.access_service import AccessService
AccessService.invalidate_cache()
except Exception:
pass
try:
SubscriptionPlanService._emit_plan_event(db, plan, "PLAN_UPDATED")
except Exception:
import logging
logging.getLogger(__name__).warning("Plan update event emission failed", exc_info=True)
return plan
@staticmethod
@@ -306,6 +406,16 @@ class SubscriptionPlanService:
),
)
try:
SubscriptionPlanService._emit_plan_event(db, plan, "PLAN_DEPROVISION_REQUESTED")
except Exception:
import logging
logging.getLogger(__name__).warning("Plan deprovision event emission failed", exc_info=True)
db.delete(plan)
db.commit()
try:
from app.services.auth.access_service import AccessService
AccessService.invalidate_cache()
except Exception:
pass
return {"message": "Plan deleted successfully"}
+12
View File
@@ -177,6 +177,12 @@ class UserService:
if tenant_id:
update_dict.pop("tenant_id", None)
if str(user_id) == str(user.id) and update_dict.get("status") in ["inactive", "disabled"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot deactivate your own account.",
)
if update_dict.get("status") == "active" and user.status != "active":
SeatService.assert_seat_available(db, user.tenant_id)
@@ -292,6 +298,12 @@ class UserService:
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None,
actor_id: uuid.UUID = None):
if actor_id and str(actor_id) == str(user_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account.",
)
user = UserService.get_user_by_id(db, user_id, tenant_id)
targets = []
+16
View File
@@ -54,5 +54,21 @@ def seed_superadmin(
"""Seed superadmin user."""
run_command(["python", "scripts/seed_superadmin.py"], env)
@seed_app.command("docqube")
def seed_docqube(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
):
"""Seed DocQube module and sync permissions."""
run_command(["python", "scripts/seed_docqube_module.py", "--env", env], env)
@seed_app.command("migrate-docqube")
def migrate_docqube(
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview migration without committing"),
):
"""Migrate DocQube tenants, roles, and users to Central SaaS."""
mode_flag = "--dry-run" if dry_run else "--execute"
run_command(["python", "scripts/migrate_docqube_to_saas.py", "--env", env, mode_flag], env)
if __name__ == "__main__":
app()
+1
View File
@@ -19,3 +19,4 @@ pyotp==2.10.0
# Multipart form parsing, for document uploads. FastAPI refuses to build a
# route with an UploadFile parameter without it, at import time.
python-multipart==0.0.32
passlib==1.7.4
+1 -1
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 in ["development", "local", "localdev"],
)
+322
View File
@@ -0,0 +1,322 @@
"""
DocQube to Central SaaS Zero-Data-Loss Migration Script
======================================================
Extracts Tenants, Roles, and Users from DocQube database and safely
loads them into the Central SaaS database.
Features:
- Idempotent upsert (safe to run multiple times without duplicates)
- Intelligent ID & Name reconciliation for Tenants and Roles
- Dual-hash preservation (PBKDF2 passwords preserved and verified)
- Auto-provisions DocQube Module subscription for all migrated tenants
- Supports --env (testing, local, development, production)
- Supports --dry-run to preview counts without writing changes
Usage:
python scripts/migrate_docqube_to_saas.py --env testing --dry-run
python scripts/migrate_docqube_to_saas.py --env testing --execute
"""
import sys
import os
import argparse
import logging
import uuid
from typing import Dict, Any, List
# Pre-parse --env before loading config
parser = argparse.ArgumentParser(description="Migrate DocQube Users and Tenants to Central SaaS", add_help=False)
parser.add_argument("--env", default=os.getenv("APP_ENV", "testing"), help="Environment (testing, local, development, production)")
env_args, _ = parser.parse_known_args()
os.environ["APP_ENV"] = env_args.env
# Add parent directory to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.config.settings import settings
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("migration")
ENV_DOCQUBE_DB_DEFAULTS = {
"testing": "postgresql://docqube_user:d20kMfypQE8Z@106.51.105.22:5432/docqube_test",
"local": "postgresql://docqube_user:d20kMfypQE8Z@106.51.105.22:5432/docqube_local",
"development": "postgresql://docqube_user:d20kMfypQE8Z@106.51.105.22:5432/docqube_local",
"production": "postgresql://docqube_user:d20kMfypQE8Z@106.51.105.22:5432/docqube_prod",
}
def parse_args():
full_parser = argparse.ArgumentParser(description="Migrate DocQube Users and Tenants to Central SaaS")
full_parser.add_argument("--env", default=os.getenv("APP_ENV", "testing"), help="Environment (testing, local, development, production)")
default_dq_db = ENV_DOCQUBE_DB_DEFAULTS.get(env_args.env, ENV_DOCQUBE_DB_DEFAULTS["testing"])
full_parser.add_argument("--docqube-db", default=os.getenv("DOCQUBE_DATABASE_URL", default_dq_db), help="DocQube Source Database URL")
full_parser.add_argument("--saas-db", default=settings.DATABASE_URL, help="SaaS Target Database URL")
full_parser.add_argument("--dry-run", action="store_true", help="Preview migration without committing changes")
full_parser.add_argument("--execute", action="store_true", help="Execute the migration and commit to SaaS DB")
return full_parser.parse_args()
def split_full_name(full_name: str) -> tuple[str, str]:
if not full_name:
return ("User", "")
parts = full_name.strip().split(" ", 1)
if len(parts) == 1:
return (parts[0], "")
return (parts[0], parts[1])
def run_migration():
args = parse_args()
if not args.dry_run and not args.execute:
logger.error("Please specify either --dry-run or --execute")
sys.exit(1)
is_dry_run = args.dry_run
logger.info(f"Starting DocQube -> SaaS Migration [{'DRY RUN' if is_dry_run else 'EXECUTE'}]")
logger.info(f"Environment: {args.env}")
logger.info(f"Source DocQube DB: {args.docqube_db.split('@')[-1] if '@' in args.docqube_db else args.docqube_db}")
logger.info(f"Target SaaS DB: {args.saas_db.split('@')[-1] if '@' in args.saas_db else args.saas_db}")
docqube_engine = create_engine(args.docqube_db)
saas_engine = create_engine(args.saas_db)
DocQubeSession = sessionmaker(bind=docqube_engine)
SaaSSession = sessionmaker(bind=saas_engine)
dq_db = DocQubeSession()
saas_db = SaaSSession()
# Enable RLS bypass for cross-tenant migration
saas_db.execute(text("SELECT set_config('app.bypass_rls', 'on', false);"))
try:
# 1. Fetch or ensure 'docqube' module exists in SaaS
module_row = saas_db.execute(text("SELECT id FROM modules WHERE module_id = 'docqube'")).fetchone()
if not module_row:
logger.info("[INFO] Module 'docqube' not found in SaaS modules table. Registering it...")
if not is_dry_run:
saas_db.execute(
text("""
INSERT INTO modules (id, module_id, module_name, description, status)
VALUES (:id, 'docqube', 'DocQube E-Sign & DMS', 'Document management and e-signing platform', 'active')
"""),
{"id": uuid.uuid4()}
)
module_row = saas_db.execute(text("SELECT id FROM modules WHERE module_id = 'docqube'")).fetchone()
docqube_module_id = module_row[0]
else:
docqube_module_id = uuid.uuid4()
else:
docqube_module_id = module_row[0]
logger.info(f"[INFO] DocQube Module ID in SaaS: {docqube_module_id}")
# 2. Extract Tenants from DocQube
dq_tenants = dq_db.execute(text("""
SELECT id, name, is_active, created_at
FROM tenants
""")).fetchall()
logger.info(f"[INFO] Found {len(dq_tenants)} tenants in DocQube.")
# 3. Extract Roles from DocQube
dq_roles = dq_db.execute(text("""
SELECT id, tenant_id, name, description, created_at
FROM roles
""")).fetchall()
logger.info(f"[INFO] Found {len(dq_roles)} roles in DocQube.")
# 4. Extract Users from DocQube
dq_users = dq_db.execute(text("""
SELECT id, tenant_id, role_id, name, email, password_hash, is_active, preferred_language, created_at
FROM users
WHERE is_deleted IS NOT TRUE
""")).fetchall()
logger.info(f"[INFO] Found {len(dq_users)} active users in DocQube.")
# 5. Reconcile & Migrate Tenants into SaaS
tenant_id_map: dict[Any, Any] = {}
tenants_migrated = 0
for t in dq_tenants:
t_id = t[0]
t_name = t[1] or "Default Workspace"
t_is_active = t[2] if t[2] is not None else True
t_domain = f"{t_name.lower().replace(' ', '-')[:25]}-{str(t_id)[:6]}.docqube.local"
# Check if tenant exists by ID or by name
existing_tenant = saas_db.execute(
text("SELECT id FROM tenants WHERE id = :id OR tenant_name = :name"),
{"id": t_id, "name": t_name}
).fetchone()
if existing_tenant:
target_tenant_id = existing_tenant[0]
tenant_id_map[t_id] = target_tenant_id
if not is_dry_run:
saas_db.execute(
text("""
UPDATE tenants
SET tenant_name = :name, is_active = :is_active
WHERE id = :id
"""),
{"id": target_tenant_id, "name": t_name, "is_active": t_is_active}
)
else:
target_tenant_id = t_id
tenant_id_map[t_id] = target_tenant_id
if not is_dry_run:
saas_db.execute(
text("""
INSERT INTO tenants (id, tenant_name, tenant_domain, is_active, status)
VALUES (:id, :name, :domain, :is_active, 'ACTIVE')
"""),
{"id": target_tenant_id, "name": t_name, "domain": t_domain, "is_active": t_is_active}
)
if not is_dry_run:
# Grant DocQube Module subscription
saas_db.execute(
text("""
INSERT INTO tenant_modules (id, tenant_id, module_id, is_active)
VALUES (:id, :tenant_id, :module_id, true)
ON CONFLICT (tenant_id, module_id) DO UPDATE
SET is_active = true
"""),
{"id": uuid.uuid4(), "tenant_id": target_tenant_id, "module_id": docqube_module_id}
)
tenants_migrated += 1
logger.info(f"[OK] Processed {tenants_migrated} Tenants & Tenant Modules.")
# 6. Reconcile & Migrate Roles into SaaS
role_id_map: dict[Any, Any] = {}
roles_migrated = 0
for r in dq_roles:
r_id = r[0]
r_tenant_id = r[1]
r_name = r[2]
target_tenant_id = tenant_id_map.get(r_tenant_id, r_tenant_id)
existing_role = saas_db.execute(
text("SELECT id FROM roles WHERE id = :id OR (tenant_id = :tenant_id AND role_name = :name)"),
{"id": r_id, "tenant_id": target_tenant_id, "name": r_name}
).fetchone()
if existing_role:
target_role_id = existing_role[0]
role_id_map[r_id] = target_role_id
if not is_dry_run:
saas_db.execute(
text("""
UPDATE roles
SET role_name = :name
WHERE id = :id
"""),
{"id": target_role_id, "name": r_name}
)
else:
target_role_id = r_id
role_id_map[r_id] = target_role_id
if not is_dry_run:
saas_db.execute(
text("""
INSERT INTO roles (id, tenant_id, role_name, is_default)
VALUES (:id, :tenant_id, :name, false)
"""),
{"id": target_role_id, "tenant_id": target_tenant_id, "name": r_name}
)
roles_migrated += 1
logger.info(f"[OK] Processed {roles_migrated} Roles.")
# 7. Migrate Users into SaaS
users_migrated = 0
for u in dq_users:
u_tenant_id = u[1]
u_role_id = u[2]
u_name = u[3]
u_email = u[4].strip().lower()
u_password = u[5] # PBKDF2 hash string preserved
u_is_active = u[6] if u[6] is not None else True
u_pref_lang = u[7] or "en"
target_tenant_id = tenant_id_map.get(u_tenant_id, u_tenant_id)
target_role_id = role_id_map.get(u_role_id, u_role_id)
first_name, last_name = split_full_name(u_name)
user_status = "active" if u_is_active else "inactive"
is_superadmin_user = (target_tenant_id is None)
if not is_dry_run:
saas_db.execute(
text("""
INSERT INTO users (
id, email, password, first_name, last_name,
tenant_id, role_id, is_superadmin, preferred_language, status
)
VALUES (
:id, :email, :password, :first_name, :last_name,
:tenant_id, :role_id, :is_superadmin, :pref_lang, :status
)
ON CONFLICT (email) DO UPDATE
SET first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
password = EXCLUDED.password,
tenant_id = EXCLUDED.tenant_id,
role_id = EXCLUDED.role_id,
is_superadmin = (users.is_superadmin OR EXCLUDED.is_superadmin),
preferred_language = EXCLUDED.preferred_language,
status = EXCLUDED.status
"""),
{
"id": uuid.uuid4(),
"email": u_email,
"password": u_password,
"first_name": first_name,
"last_name": last_name,
"tenant_id": target_tenant_id,
"role_id": target_role_id,
"is_superadmin": is_superadmin_user,
"pref_lang": u_pref_lang,
"status": user_status
}
)
users_migrated += 1
logger.info(f"[OK] Processed {users_migrated} Users.")
if not is_dry_run:
saas_db.commit()
logger.info("[SUCCESS] Migration successfully COMMITTED to SaaS Database!")
else:
saas_db.rollback()
logger.info("[INFO] DRY-RUN complete. No changes were committed.")
print("\n" + "="*50)
print("MIGRATION SUMMARY")
print("="*50)
print(f"Environment: {args.env}")
print(f"Mode: {'DRY-RUN (Preview)' if is_dry_run else 'EXECUTED & COMMITTED'}")
print(f"Tenants Migrated: {tenants_migrated}")
print(f"Roles Migrated: {roles_migrated}")
print(f"Users Migrated: {users_migrated}")
print("="*50 + "\n")
except Exception as e:
saas_db.rollback()
logger.error(f"[ERROR] Migration failed with error: {e}", exc_info=True)
sys.exit(1)
finally:
dq_db.close()
saas_db.close()
if __name__ == "__main__":
run_migration()
+132
View File
@@ -0,0 +1,132 @@
import os
import sys
import argparse
# Handle --env before importing settings/database
parser = argparse.ArgumentParser(description="Seed DocQube Module into SaaS DB")
parser.add_argument("--env", default=os.getenv("APP_ENV", "testing"), help="Environment to seed (testing, local, development, production)")
args, unknown = parser.parse_known_args()
os.environ["APP_ENV"] = args.env
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.config.settings import settings
from app.config.database import SessionLocal
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
def seed_docqube_module():
print("==================================================")
print("Seeding DocQube Module into SaaS Database")
print(f"Environment: {settings.APP_ENV}")
db_target = settings.DATABASE_URL.split('@')[-1] if '@' in settings.DATABASE_URL else settings.DATABASE_URL
print(f"Database: {db_target}")
print("==================================================")
db = SessionLocal()
try:
docqube_module = db.query(Module).filter(Module.module_id == "docqube").first()
if not docqube_module:
print("[INFO] Creating DocQube Module in SaaS DB...")
docqube_module = Module(
module_id="docqube",
module_name="DocQube",
description="DocQube Document Management System",
status="active"
)
db.add(docqube_module)
db.commit()
db.refresh(docqube_module)
print(f"[OK] Created Module: {docqube_module.module_name} (ID: {docqube_module.id})")
else:
print(f"[INFO] DocQube Module already exists (ID: {docqube_module.id}).")
# Determine slug and URLs based on environment
env_config = {
"testing": {
"slug": "testing",
"frontend_base_url": "https://docqubeapp-test.maskantech.in/dashboard",
"backend_base_url": "https://docqubeapi-test.maskantech.in",
"hmac_secret": "docqube_trust_secret_2026",
},
"local": {
"slug": "localdev",
"frontend_base_url": "http://localhost:5173/dashboard",
"backend_base_url": "http://localhost:8000",
"hmac_secret": "docqube_trust_secret_2026",
},
"development": {
"slug": "localdev",
"frontend_base_url": "http://localhost:5173/dashboard",
"backend_base_url": "http://localhost:8000",
"hmac_secret": "docqube_trust_secret_2026",
},
"production": {
"slug": "production",
"frontend_base_url": "https://docqubeapp.maskantech.in/dashboard",
"backend_base_url": "https://docqubeapi.maskantech.in",
"hmac_secret": "docqube_trust_secret_2026",
}
}
current_env = args.env if args.env in env_config else "testing"
target = env_config[current_env]
slug = target["slug"]
docqube_env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == docqube_module.id,
ModuleEnvironment.slug == slug
).first()
if not docqube_env:
print(f"[INFO] Creating DocQube '{slug}' environment...")
docqube_env = ModuleEnvironment(
module_id=docqube_module.id,
slug=slug,
frontend_base_url=target["frontend_base_url"],
backend_base_url=target["backend_base_url"],
sso_entry_path="/api/sso/login",
permission_sync_endpoint="/api/sso/sync-permissions",
provisioning_endpoint="/api/sso/provision",
trust_type="HMAC",
is_default=True,
is_active=True
)
docqube_env.credentials = {"hmac_secret": target["hmac_secret"]}
db.add(docqube_env)
db.commit()
print(f"[OK] Successfully created DocQube '{slug}' environment.")
else:
print(f"[INFO] Updating existing DocQube '{slug}' environment...")
docqube_env.frontend_base_url = target["frontend_base_url"]
docqube_env.backend_base_url = target["backend_base_url"]
docqube_env.sso_entry_path = "/api/sso/login"
docqube_env.provisioning_endpoint = "/api/sso/provision"
docqube_env.permission_sync_endpoint = "/api/sso/sync-permissions"
docqube_env.trust_type = "HMAC"
docqube_env.credentials = {"hmac_secret": target["hmac_secret"]}
docqube_env.is_active = True
docqube_env.is_default = True
db.commit()
print(f"[OK] Successfully updated DocQube '{slug}' environment.")
# Automatically sync permissions from the module
print("[INFO] Syncing DocQube permissions into SaaS database...")
try:
from app.services.auth.module_permission_service import ModulePermissionService
sync_res = ModulePermissionService.sync_permissions(db, str(docqube_module.id))
print(f"[OK] Synced {sync_res.get('synced_count', 0)} permissions from DocQube.")
except Exception as sync_err:
print(f"[WARN] Could not sync permissions live from module endpoint: {sync_err}")
print("==================================================")
print("[SUCCESS] Seeding completed successfully!")
print("==================================================")
finally:
db.close()
if __name__ == "__main__":
seed_docqube_module()
+14 -14
View File
@@ -92,7 +92,7 @@ def seed_accesses(db: Session):
db.commit()
print(
f" Created {created_count} new accesses (total: {len(PREDEFINED_ACCESSES)})"
f" [OK] Created {created_count} new accesses (total: {len(PREDEFINED_ACCESSES)})"
)
parent_count = 0
@@ -107,13 +107,13 @@ def seed_accesses(db: Session):
parent_count += 1
elif child and not parent:
print(
f" ⚠ Warning: Parent '{parent_code}' not found for '{access_code}'"
f" [WARN] Parent '{parent_code}' not found for '{access_code}'"
)
db.commit()
if parent_count > 0:
print(f" Set {parent_count} parent relationships")
print(f" [OK] Set {parent_count} parent relationships")
def seed_superadmin_role(db: Session) -> Role:
@@ -131,9 +131,9 @@ def seed_superadmin_role(db: Session) -> Role:
db.add(role)
db.commit()
db.refresh(role)
print(" Superadmin role created")
print(" [OK] Superadmin role created")
else:
print(" Superadmin role already exists")
print(" [OK] Superadmin role already exists")
all_accesses = db.query(Access).all()
existing_access_ids = {ra.access_id for ra in role.role_accesses}
@@ -148,9 +148,9 @@ def seed_superadmin_role(db: Session) -> Role:
db.commit()
if new_accesses_count > 0:
print(f" Added {new_accesses_count} accesses to superadmin role")
print(f" [OK] Added {new_accesses_count} accesses to superadmin role")
print(f" Superadmin role has {len(all_accesses)} total accesses")
print(f" [OK] Superadmin role has {len(all_accesses)} total accesses")
return role
@@ -163,7 +163,7 @@ def create_superadmin_user(db: Session, role: Role) -> bool:
)
if existing_superadmin:
print(f" Superadmin already exists")
print(f" [OK] Superadmin already exists")
print(f" Email: {existing_superadmin.email}")
print(
f" Role: {existing_superadmin.role.role_name if existing_superadmin.role else 'None'}"
@@ -176,12 +176,12 @@ def create_superadmin_user(db: Session, role: Role) -> bool:
if not existing_superadmin.role_id or existing_superadmin.role_id != role.id:
existing_superadmin.role_id = role.id
changed = True
print(" Updated superadmin role")
print(" [OK] Updated superadmin role")
if not existing_superadmin.is_superadmin:
existing_superadmin.is_superadmin = True
changed = True
print(" Set explicit is_superadmin flag")
print(" [OK] Set explicit is_superadmin flag")
if changed:
db.commit()
@@ -189,7 +189,7 @@ def create_superadmin_user(db: Session, role: Role) -> bool:
return False
if not security.validate_password_strength(settings.SUPER_ADMIN_PASSWORD):
print(" ✗ Error: Superadmin password does not meet strength requirements")
print(" [ERROR] Superadmin password does not meet strength requirements")
print(
" Password must be 8+ characters with uppercase, lowercase, digit, and special character"
)
@@ -212,7 +212,7 @@ def create_superadmin_user(db: Session, role: Role) -> bool:
db.commit()
db.refresh(superadmin)
print(f" Superadmin created successfully!")
print(f" [OK] Superadmin created successfully!")
print(f" Email: {superadmin.email}")
print(f" Name: {superadmin.first_name} {superadmin.last_name}")
print(f" Role: {superadmin.role.role_name}")
@@ -243,7 +243,7 @@ def main():
missing_vars.append(var)
if missing_vars:
print("✗ Error: Missing required environment variables:")
print("[ERROR] Missing required environment variables:")
for var in missing_vars:
print(f" - {var}")
print()
@@ -276,7 +276,7 @@ def main():
db.rollback()
print()
print("=" * 60)
print(f" Failed to seed data: {str(e)}")
print(f"[ERROR] Failed to seed data: {str(e)}")
print("=" * 60)
import traceback