Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781df9d109 | ||
|
|
af4dae50e4 | ||
|
|
235d391d4f | ||
|
|
d353fabf11 | ||
|
|
08edde726b | ||
|
|
cce404b6a3 | ||
|
|
899cd275f9 | ||
|
|
cb7de68b3c | ||
|
|
28d3bd1525 | ||
|
|
a36d91fe90 |
+5
-5
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user