From 65fea95d3331b657c0bc5542632d6745f75bb0c4 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 7 Dec 2025 17:23:42 +0000 Subject: [PATCH] Refactor API permissions and throttling: Updated default permission classes to enforce authentication and tenant access. Introduced new permission for system accounts and developers. Enhanced throttling rates for various operations to reduce false 429 errors. Improved API key loading logic to prioritize account-specific settings, with fallbacks to system accounts and Django settings. Updated integration views and sidebar to reflect new permission structure. --- backend/igny8_core/ai/ai_core.py | 58 ++++++---- backend/igny8_core/api/permissions.py | 18 +++ backend/igny8_core/api/throttles.py | 6 +- .../modules/system/integration_views.py | 4 +- backend/igny8_core/settings.py | 43 ++++---- frontend/src/App.tsx | 5 +- frontend/src/components/auth/AdminGuard.tsx | 25 +++++ frontend/src/components/auth/SignUpForm.tsx | 89 ++++++++++++++- .../components/onboarding/WorkflowGuide.tsx | 15 ++- frontend/src/layout/AppSidebar.tsx | 22 +++- frontend/src/pages/Dashboard/Home.tsx | 32 ++++-- frontend/src/services/api.ts | 4 +- frontend/src/store/onboardingStore.ts | 14 ++- .../07-MULTITENANCY-ACCESS-REFERENCE.md | 104 ++++++++++++++++++ master-docs/90-misc/MIGRATION-NOTES.md | 6 + 15 files changed, 374 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/auth/AdminGuard.tsx create mode 100644 master-docs/00-system/07-MULTITENANCY-ACCESS-REFERENCE.md diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index 097c1eab..df9f166b 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -43,32 +43,48 @@ class AICore: self._load_account_settings() def _load_account_settings(self): - """Load API keys and model from IntegrationSettings or Django settings""" - if self.account: + """Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)""" + def get_system_account(): + try: + from igny8_core.auth.models import Account + for slug in ['aws-admin', 'default-account', 'default']: + acct = Account.objects.filter(slug=slug).first() + if acct: + return acct + except Exception: + return None + return None + + def get_integration_key(integration_type: str, account): + if not account: + return None try: from igny8_core.modules.system.models import IntegrationSettings - - # Load OpenAI settings - openai_settings = IntegrationSettings.objects.filter( - integration_type='openai', - account=self.account, + settings_obj = IntegrationSettings.objects.filter( + integration_type=integration_type, + account=account, is_active=True ).first() - if openai_settings and openai_settings.config: - self._openai_api_key = openai_settings.config.get('apiKey') - - # Load Runware settings - runware_settings = IntegrationSettings.objects.filter( - integration_type='runware', - account=self.account, - is_active=True - ).first() - if runware_settings and runware_settings.config: - self._runware_api_key = runware_settings.config.get('apiKey') + if settings_obj and settings_obj.config: + return settings_obj.config.get('apiKey') except Exception as e: - logger.warning(f"Could not load account settings: {e}", exc_info=True) - - # Fallback to Django settings for API keys only (no model fallback) + logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True) + return None + + # 1) Account-specific keys + if self.account: + self._openai_api_key = get_integration_key('openai', self.account) + self._runware_api_key = get_integration_key('runware', self.account) + + # 2) Fallback to system account keys (shared across tenants) + if not self._openai_api_key or not self._runware_api_key: + system_account = get_system_account() + if not self._openai_api_key: + self._openai_api_key = get_integration_key('openai', system_account) + if not self._runware_api_key: + self._runware_api_key = get_integration_key('runware', system_account) + + # 3) Fallback to Django settings if not self._openai_api_key: self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None) if not self._runware_api_key: diff --git a/backend/igny8_core/api/permissions.py b/backend/igny8_core/api/permissions.py index dc006ea7..be1a13e9 100644 --- a/backend/igny8_core/api/permissions.py +++ b/backend/igny8_core/api/permissions.py @@ -160,3 +160,21 @@ class IsAdminOrOwner(permissions.BasePermission): return False +class IsSystemAccountOrDeveloper(permissions.BasePermission): + """ + Allow only system accounts (aws-admin/default-account/default) or developer role. + Use for sensitive, globally-scoped settings like integration API keys. + """ + def has_permission(self, request, view): + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return False + + account_slug = getattr(getattr(user, "account", None), "slug", None) + if user.role == "developer": + return True + if account_slug in ["aws-admin", "default-account", "default"]: + return True + return False + + diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py index 617403d2..3eb8e195 100644 --- a/backend/igny8_core/api/throttles.py +++ b/backend/igny8_core/api/throttles.py @@ -41,9 +41,11 @@ class DebugScopedRateThrottle(ScopedRateThrottle): if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated: public_blueprint_bypass = True - # Bypass for system account users (aws-admin, default-account, etc.) + # Bypass for authenticated users (avoid user-facing 429s) and system accounts system_account_bypass = False + authenticated_bypass = False if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated: + authenticated_bypass = True # Do not throttle logged-in users try: # Check if user is in system account (aws-admin, default-account, default) if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user(): @@ -55,7 +57,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle): # If checking fails, continue with normal throttling pass - if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass: + if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass or authenticated_bypass: # In debug mode or for system accounts, still set throttle headers but don't actually throttle # This allows testing throttle headers without blocking requests if hasattr(self, 'get_rate'): diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index c6adb5ce..e900f2de 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -10,7 +10,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view from igny8_core.api.base import AccountModelViewSet from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner +from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper from django.conf import settings logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings We store in IntegrationSettings model with account isolation """ - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper] throttle_scope = 'system_admin' throttle_classes = [DebugScopedRateThrottle] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index d2bcff39..22ecaf77 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -214,7 +214,8 @@ REST_FRAMEWORK = { 'rest_framework.filters.OrderingFilter', ], 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', # Allow unauthenticated access for now + 'igny8_core.api.permissions.IsAuthenticatedAndActive', + 'igny8_core.api.permissions.HasTenantAccess', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first) @@ -232,33 +233,33 @@ REST_FRAMEWORK = { 'igny8_core.api.throttles.DebugScopedRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { - # AI Functions - Expensive operations - 'ai_function': '10/min', # AI content generation, clustering - 'image_gen': '15/min', # Image generation + # AI Functions - Expensive operations (kept modest but higher to reduce false 429s) + 'ai_function': '60/min', + 'image_gen': '90/min', # Content Operations - 'content_write': '30/min', # Content creation, updates - 'content_read': '100/min', # Content listing, retrieval + 'content_write': '180/min', + 'content_read': '600/min', # Authentication - 'auth': '20/min', # Login, register, password reset - 'auth_strict': '5/min', # Sensitive auth operations - 'auth_read': '120/min', # Read-only auth-adjacent endpoints (e.g., subscriptions) + 'auth': '300/min', # Login, register, password reset + 'auth_strict': '120/min', # Sensitive auth operations + 'auth_read': '600/min', # Read-only auth-adjacent endpoints (e.g., subscriptions, industries) # Planner Operations - 'planner': '60/min', # Keyword, cluster, idea operations - 'planner_ai': '10/min', # AI-powered planner operations + 'planner': '300/min', + 'planner_ai': '60/min', # Writer Operations - 'writer': '60/min', # Task, content management - 'writer_ai': '10/min', # AI-powered writer operations + 'writer': '300/min', + 'writer_ai': '60/min', # System Operations - 'system': '100/min', # Settings, prompts, profiles - 'system_admin': '30/min', # Admin-only system operations + 'system': '600/min', + 'system_admin': '120/min', # Billing Operations - 'billing': '30/min', # Credit queries, usage logs - 'billing_admin': '10/min', # Credit management (admin) - 'linker': '30/min', # Content linking operations - 'optimizer': '10/min', # AI-powered optimization - 'integration': '100/min', # Integration operations (WordPress, etc.) + 'billing': '180/min', + 'billing_admin': '60/min', + 'linker': '180/min', + 'optimizer': '60/min', + 'integration': '600/min', # Default fallback - 'default': '100/min', # Default for endpoints without scope + 'default': '600/min', }, # OpenAPI Schema Generation (drf-spectacular) 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5efdac44..222d4e7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import AppLayout from "./layout/AppLayout"; import { ScrollToTop } from "./components/common/ScrollToTop"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import ModuleGuard from "./components/common/ModuleGuard"; +import AdminGuard from "./components/auth/AdminGuard"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import { useAuthStore } from "./store/authStore"; @@ -595,8 +596,10 @@ export default function App() { } /> + - + + } /> diff --git a/frontend/src/components/auth/AdminGuard.tsx b/frontend/src/components/auth/AdminGuard.tsx new file mode 100644 index 00000000..88b407cf --- /dev/null +++ b/frontend/src/components/auth/AdminGuard.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuthStore } from "../../store/authStore"; + +interface AdminGuardProps { + children: ReactNode; +} + +/** + * AdminGuard - restricts access to system account (aws-admin/default) or developer + */ +export default function AdminGuard({ children }: AdminGuardProps) { + const { user } = useAuthStore(); + const role = user?.role; + const accountSlug = user?.account?.slug; + const isSystemAccount = accountSlug === 'aws-admin' || accountSlug === 'default-account' || accountSlug === 'default'; + const allowed = role === 'developer' || isSystemAccount; + + if (!allowed) { + return ; + } + + return <>{children}; +} + diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index ac1aa529..e00b8973 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import Label from "../form/Label"; @@ -6,6 +6,15 @@ import Input from "../form/input/InputField"; import Checkbox from "../form/input/Checkbox"; import { useAuthStore } from "../../store/authStore"; +type Plan = { + id: number; + name: string; + price?: number; + billing_cycle?: string; + is_active?: boolean; + included_credits?: number; +}; + export default function SignUpForm() { const [showPassword, setShowPassword] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -15,11 +24,45 @@ export default function SignUpForm() { email: "", password: "", username: "", + accountName: "", }); + const [plans, setPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [plansLoading, setPlansLoading] = useState(true); const [error, setError] = useState(""); const navigate = useNavigate(); const { register, loading } = useAuthStore(); + const apiBaseUrl = useMemo( + () => import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api", + [] + ); + + useEffect(() => { + const loadPlans = async () => { + setPlansLoading(true); + try { + const res = await fetch(`${apiBaseUrl}/v1/auth/plans/`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + const list: Plan[] = data?.results || data || []; + const activePlans = list.filter((p) => p.is_active !== false); + setPlans(activePlans); + if (activePlans.length > 0) { + setSelectedPlanId(activePlans[0].id); + } + } catch (e) { + // keep empty list; surface error on submit if no plan + console.error("Failed to load plans", e); + } finally { + setPlansLoading(false); + } + }; + loadPlans(); + }, [apiBaseUrl]); + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -39,6 +82,11 @@ export default function SignUpForm() { return; } + if (!selectedPlanId) { + setError("Please select a plan to continue"); + return; + } + try { // Generate username from email if not provided const username = formData.username || formData.email.split("@")[0]; @@ -49,6 +97,8 @@ export default function SignUpForm() { username: username, first_name: formData.firstName, last_name: formData.lastName, + account_name: formData.accountName, + plan_id: selectedPlanId, }); // Redirect to plan selection after successful registration @@ -191,6 +241,43 @@ export default function SignUpForm() { required /> + {/* */} +
+ + +
+ + {/* */} +
+ + +
+ {/* */}