feat(multi-tenancy): implement critical fixes for orphaned users and permissions
- Simplified HasTenantAccess permission logic to ensure every authenticated user has an account. - Added fallback to system account for OpenAI settings in AI configuration. - Allowed any authenticated user to check task progress in IntegrationSettingsViewSet. - Created a script to identify and fix orphaned users without accounts. - Updated error response handling in business endpoints for clarity.
This commit is contained in:
147
backend/fix_orphaned_users.py
Normal file
147
backend/fix_orphaned_users.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to identify and fix orphaned users (users without accounts).
|
||||
|
||||
This script will:
|
||||
1. Find all users with account = NULL
|
||||
2. For each user, either:
|
||||
- Assign them to an existing account if possible
|
||||
- Create a new account for them
|
||||
- Delete them if they're test/invalid users
|
||||
3. Report the results
|
||||
|
||||
Run this from backend directory:
|
||||
python3 fix_orphaned_users.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
from django.db import transaction
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("ORPHANED USERS FIX SCRIPT")
|
||||
print("=" * 80)
|
||||
|
||||
# Find users without accounts
|
||||
orphaned_users = User.objects.filter(account__isnull=True)
|
||||
count = orphaned_users.count()
|
||||
|
||||
print(f"\nFound {count} user(s) without accounts:\n")
|
||||
|
||||
if count == 0:
|
||||
print("✅ No orphaned users found. System is healthy!")
|
||||
return
|
||||
|
||||
# List them
|
||||
for i, user in enumerate(orphaned_users, 1):
|
||||
print(f"{i}. ID: {user.id}")
|
||||
print(f" Email: {user.email}")
|
||||
print(f" Username: {user.username}")
|
||||
print(f" Role: {user.role}")
|
||||
print(f" Active: {user.is_active}")
|
||||
print(f" Superuser: {user.is_superuser}")
|
||||
print(f" Created: {user.created_at}")
|
||||
print()
|
||||
|
||||
# Ask what to do
|
||||
print("\nOptions:")
|
||||
print("1. Auto-fix: Create accounts for all orphaned users")
|
||||
print("2. Delete all orphaned users")
|
||||
print("3. Exit without changes")
|
||||
|
||||
choice = input("\nEnter choice (1-3): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
auto_fix_users(orphaned_users)
|
||||
elif choice == '2':
|
||||
delete_users(orphaned_users)
|
||||
else:
|
||||
print("\n❌ No changes made. Exiting.")
|
||||
|
||||
def auto_fix_users(users):
|
||||
"""Create accounts for orphaned users"""
|
||||
print("\n" + "=" * 80)
|
||||
print("AUTO-FIXING ORPHANED USERS")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# Get or create free plan
|
||||
try:
|
||||
free_plan = Plan.objects.get(slug='free', is_active=True)
|
||||
except Plan.DoesNotExist:
|
||||
print("❌ ERROR: Free plan not found. Cannot create accounts.")
|
||||
print(" Please create a 'free' plan first or assign users manually.")
|
||||
return
|
||||
|
||||
fixed_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for user in users:
|
||||
try:
|
||||
# Generate account name
|
||||
if user.first_name or user.last_name:
|
||||
account_name = f"{user.first_name} {user.last_name}".strip()
|
||||
else:
|
||||
account_name = user.email.split('@')[0]
|
||||
|
||||
# Generate unique slug
|
||||
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while Account.objects.filter(slug=slug).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create account
|
||||
account = Account.objects.create(
|
||||
name=account_name,
|
||||
slug=slug,
|
||||
owner=user,
|
||||
plan=free_plan,
|
||||
credits=free_plan.get_effective_credits_per_month(),
|
||||
status='trial',
|
||||
billing_email=user.email,
|
||||
)
|
||||
|
||||
# Assign account to user
|
||||
user.account = account
|
||||
user.save()
|
||||
|
||||
print(f"✅ Fixed user: {user.email}")
|
||||
print(f" Created account: {account.name} (ID: {account.id})")
|
||||
print(f" Credits: {account.credits}")
|
||||
print()
|
||||
|
||||
fixed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR fixing user {user.email}: {e}")
|
||||
print()
|
||||
|
||||
print("=" * 80)
|
||||
print(f"✅ Successfully fixed {fixed_count} user(s)")
|
||||
print("=" * 80)
|
||||
|
||||
def delete_users(users):
|
||||
"""Delete orphaned users"""
|
||||
print("\n⚠️ WARNING: This will permanently delete the selected users!")
|
||||
confirm = input("Type 'DELETE' to confirm: ").strip()
|
||||
|
||||
if confirm != 'DELETE':
|
||||
print("\n❌ Deletion cancelled.")
|
||||
return
|
||||
|
||||
count = users.count()
|
||||
users.delete()
|
||||
|
||||
print(f"\n✅ Deleted {count} user(s)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -19,8 +19,8 @@ FUNCTION_ALIASES = {
|
||||
|
||||
def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
"""
|
||||
Get model configuration from IntegrationSettings only.
|
||||
No fallbacks - account must have IntegrationSettings configured.
|
||||
Get model configuration from IntegrationSettings.
|
||||
Falls back to system account (aws-admin) if user account doesn't have settings.
|
||||
|
||||
Args:
|
||||
function_name: Name of the AI function
|
||||
@@ -38,17 +38,42 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
# Resolve function alias
|
||||
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
||||
|
||||
# Get IntegrationSettings for OpenAI
|
||||
# Get IntegrationSettings for OpenAI - try user account first
|
||||
integration_settings = None
|
||||
try:
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_settings = IntegrationSettings.objects.filter(
|
||||
integration_type='openai',
|
||||
account=account,
|
||||
is_active=True
|
||||
)
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||
|
||||
# Fallback to system account (aws-admin, default-account, or default)
|
||||
if not integration_settings:
|
||||
logger.info(f"No OpenAI settings for account {account.id}, falling back to system account")
|
||||
try:
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
for slug in ['aws-admin', 'default-account', 'default']:
|
||||
system_account = Account.objects.filter(slug=slug).first()
|
||||
if system_account:
|
||||
integration_settings = IntegrationSettings.objects.filter(
|
||||
integration_type='openai',
|
||||
account=system_account,
|
||||
is_active=True
|
||||
).first()
|
||||
if integration_settings:
|
||||
logger.info(f"Using OpenAI settings from system account: {slug}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load system account OpenAI settings: {e}")
|
||||
|
||||
# If still no settings found, raise error
|
||||
if not integration_settings:
|
||||
raise ValueError(
|
||||
f"OpenAI IntegrationSettings not configured for account {account.id}. "
|
||||
f"OpenAI IntegrationSettings not configured for account {account.id} or system account. "
|
||||
f"Please configure OpenAI settings in the integration page."
|
||||
)
|
||||
|
||||
|
||||
@@ -12,13 +12,23 @@ class IsAuthenticatedAndActive(permissions.BasePermission):
|
||||
Base permission for most endpoints
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
logger.warning(f"[IsAuthenticatedAndActive] DENIED: User not authenticated")
|
||||
return False
|
||||
|
||||
# Check if user is active
|
||||
if hasattr(request.user, 'is_active'):
|
||||
return request.user.is_active
|
||||
is_active = request.user.is_active
|
||||
if is_active:
|
||||
logger.info(f"[IsAuthenticatedAndActive] ALLOWED: User {request.user.email} is active")
|
||||
else:
|
||||
logger.warning(f"[IsAuthenticatedAndActive] DENIED: User {request.user.email} is inactive")
|
||||
return is_active
|
||||
|
||||
logger.info(f"[IsAuthenticatedAndActive] ALLOWED: User {request.user.email} (no is_active check)")
|
||||
return True
|
||||
|
||||
|
||||
@@ -27,47 +37,58 @@ class HasTenantAccess(permissions.BasePermission):
|
||||
Permission class that requires user to belong to the tenant/account
|
||||
Ensures tenant isolation
|
||||
Superusers, developers, and system account users bypass this check.
|
||||
|
||||
CRITICAL: Every authenticated user MUST have an account.
|
||||
The middleware sets request.account from request.user.account.
|
||||
If a user doesn't have an account, it's a data integrity issue.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
logger.warning(f"[HasTenantAccess] DENIED: User not authenticated")
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is superuser")
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is developer")
|
||||
return True
|
||||
|
||||
# Bypass for system account users
|
||||
try:
|
||||
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is system account user")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get account from request (set by middleware)
|
||||
account = getattr(request, 'account', None)
|
||||
# SIMPLIFIED LOGIC: Every authenticated user MUST have an account
|
||||
# Middleware already set request.account from request.user.account
|
||||
# Just verify it exists
|
||||
if not hasattr(request.user, 'account'):
|
||||
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} has no account attribute")
|
||||
return False
|
||||
|
||||
# If no account in request, try to get from user
|
||||
if not account and hasattr(request.user, 'account'):
|
||||
try:
|
||||
account = request.user.account
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
|
||||
# Regular users must have account access
|
||||
if account:
|
||||
# Check if user belongs to this account
|
||||
if hasattr(request.user, 'account'):
|
||||
try:
|
||||
user_account = request.user.account
|
||||
return user_account == account or user_account.id == account.id
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
|
||||
return False
|
||||
try:
|
||||
# Access the account to trigger any lazy loading
|
||||
user_account = request.user.account
|
||||
if not user_account:
|
||||
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} has NULL account")
|
||||
return False
|
||||
|
||||
# Success - user has a valid account
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} has account {user_account.name} (ID: {user_account.id})")
|
||||
return True
|
||||
except (AttributeError, Exception) as e:
|
||||
# User doesn't have account relationship - data integrity issue
|
||||
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} account access failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class IsViewerOrAbove(permissions.BasePermission):
|
||||
@@ -77,24 +98,36 @@ class IsViewerOrAbove(permissions.BasePermission):
|
||||
Superusers and developers bypass this check.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
logger.warning(f"[IsViewerOrAbove] DENIED: User not authenticated")
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is superuser")
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is developer")
|
||||
return True
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# viewer, editor, admin, owner all have access
|
||||
return role in ['viewer', 'editor', 'admin', 'owner']
|
||||
allowed = role in ['viewer', 'editor', 'admin', 'owner']
|
||||
if allowed:
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} has role {role}")
|
||||
else:
|
||||
logger.warning(f"[IsViewerOrAbove] DENIED: User {request.user.email} has invalid role {role}")
|
||||
return allowed
|
||||
|
||||
# If no role system, allow authenticated users
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} (no role system)")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||
# Explicitly specify required fields for clarity
|
||||
extra_kwargs = {
|
||||
'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}},
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Allow partial updates for PATCH requests."""
|
||||
@@ -87,10 +91,12 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
# Make slug optional - it will be auto-generated from name if not provided
|
||||
if 'slug' in self.fields:
|
||||
self.fields['slug'].required = False
|
||||
# For partial updates (PATCH), make name optional
|
||||
# For partial updates (PATCH), make name and industry optional
|
||||
if self.partial:
|
||||
if 'name' in self.fields:
|
||||
self.fields['name'].required = False
|
||||
if 'industry' in self.fields:
|
||||
self.fields['industry'].required = False
|
||||
|
||||
def validate_domain(self, value):
|
||||
"""Ensure domain has https:// protocol.
|
||||
|
||||
@@ -5,6 +5,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.db import transaction
|
||||
from django.db.models import Max, Count, Sum, Q
|
||||
from django.http import HttpResponse
|
||||
from django.conf import settings
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
@@ -655,10 +656,10 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
error=validation['error'],
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request,
|
||||
extra_data={
|
||||
debug_info={
|
||||
'count': validation.get('count'),
|
||||
'required': validation.get('required')
|
||||
}
|
||||
} if settings.DEBUG else None
|
||||
)
|
||||
|
||||
# Validation passed - proceed with clustering
|
||||
|
||||
@@ -29,6 +29,9 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
ViewSet for managing integration settings (OpenAI, Runware, GSC)
|
||||
Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings
|
||||
We store in IntegrationSettings model with account isolation
|
||||
|
||||
IMPORTANT: Integration settings are system-wide (configured by super users/developers)
|
||||
Normal users don't configure their own API keys - they use the system account settings via fallback
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper]
|
||||
|
||||
@@ -897,11 +900,14 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='task_progress/(?P<task_id>[^/.]+)', url_name='task-progress')
|
||||
@action(detail=False, methods=['get'], url_path='task_progress/(?P<task_id>[^/.]+)', url_name='task-progress',
|
||||
permission_classes=[IsAuthenticatedAndActive]) # Allow any authenticated user to check task progress
|
||||
def task_progress(self, request, task_id=None):
|
||||
"""
|
||||
Get Celery task progress status
|
||||
GET /api/v1/system/settings/task_progress/<task_id>/
|
||||
|
||||
Permission: Any authenticated user can check task progress (not restricted to system accounts)
|
||||
"""
|
||||
if not task_id:
|
||||
return error_response(
|
||||
|
||||
Reference in New Issue
Block a user