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.

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-07 17:23:42 +00:00
parent 3cbed65601
commit 65fea95d33
15 changed files with 374 additions and 71 deletions

View File

@@ -43,32 +43,48 @@ class AICore:
self._load_account_settings() self._load_account_settings()
def _load_account_settings(self): def _load_account_settings(self):
"""Load API keys and model from IntegrationSettings or Django settings""" """Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)"""
if self.account: 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: try:
from igny8_core.modules.system.models import IntegrationSettings from igny8_core.modules.system.models import IntegrationSettings
settings_obj = IntegrationSettings.objects.filter(
# Load OpenAI settings integration_type=integration_type,
openai_settings = IntegrationSettings.objects.filter( account=account,
integration_type='openai',
account=self.account,
is_active=True is_active=True
).first() ).first()
if openai_settings and openai_settings.config: if settings_obj and settings_obj.config:
self._openai_api_key = openai_settings.config.get('apiKey') return settings_obj.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')
except Exception as e: except Exception as e:
logger.warning(f"Could not load account settings: {e}", exc_info=True) logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True)
return None
# Fallback to Django settings for API keys only (no model fallback)
# 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: if not self._openai_api_key:
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None) self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
if not self._runware_api_key: if not self._runware_api_key:

View File

@@ -160,3 +160,21 @@ class IsAdminOrOwner(permissions.BasePermission):
return False 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

View File

@@ -41,9 +41,11 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated: if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
public_blueprint_bypass = True 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 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: 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: try:
# Check if user is in system account (aws-admin, default-account, default) # 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(): 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 # If checking fails, continue with normal throttling
pass 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 # In debug mode or for system accounts, still set throttle headers but don't actually throttle
# This allows testing throttle headers without blocking requests # This allows testing throttle headers without blocking requests
if hasattr(self, 'get_rate'): if hasattr(self, 'get_rate'):

View File

@@ -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.base import AccountModelViewSet
from igny8_core.api.response import success_response, error_response from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle 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 from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings
We store in IntegrationSettings model with account isolation We store in IntegrationSettings model with account isolation
""" """
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper]
throttle_scope = 'system_admin' throttle_scope = 'system_admin'
throttle_classes = [DebugScopedRateThrottle] throttle_classes = [DebugScopedRateThrottle]

View File

@@ -214,7 +214,8 @@ REST_FRAMEWORK = {
'rest_framework.filters.OrderingFilter', 'rest_framework.filters.OrderingFilter',
], ],
'DEFAULT_PERMISSION_CLASSES': [ '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': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first) 'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first)
@@ -232,33 +233,33 @@ REST_FRAMEWORK = {
'igny8_core.api.throttles.DebugScopedRateThrottle', 'igny8_core.api.throttles.DebugScopedRateThrottle',
], ],
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
# AI Functions - Expensive operations # AI Functions - Expensive operations (kept modest but higher to reduce false 429s)
'ai_function': '10/min', # AI content generation, clustering 'ai_function': '60/min',
'image_gen': '15/min', # Image generation 'image_gen': '90/min',
# Content Operations # Content Operations
'content_write': '30/min', # Content creation, updates 'content_write': '180/min',
'content_read': '100/min', # Content listing, retrieval 'content_read': '600/min',
# Authentication # Authentication
'auth': '20/min', # Login, register, password reset 'auth': '300/min', # Login, register, password reset
'auth_strict': '5/min', # Sensitive auth operations 'auth_strict': '120/min', # Sensitive auth operations
'auth_read': '120/min', # Read-only auth-adjacent endpoints (e.g., subscriptions) 'auth_read': '600/min', # Read-only auth-adjacent endpoints (e.g., subscriptions, industries)
# Planner Operations # Planner Operations
'planner': '60/min', # Keyword, cluster, idea operations 'planner': '300/min',
'planner_ai': '10/min', # AI-powered planner operations 'planner_ai': '60/min',
# Writer Operations # Writer Operations
'writer': '60/min', # Task, content management 'writer': '300/min',
'writer_ai': '10/min', # AI-powered writer operations 'writer_ai': '60/min',
# System Operations # System Operations
'system': '100/min', # Settings, prompts, profiles 'system': '600/min',
'system_admin': '30/min', # Admin-only system operations 'system_admin': '120/min',
# Billing Operations # Billing Operations
'billing': '30/min', # Credit queries, usage logs 'billing': '180/min',
'billing_admin': '10/min', # Credit management (admin) 'billing_admin': '60/min',
'linker': '30/min', # Content linking operations 'linker': '180/min',
'optimizer': '10/min', # AI-powered optimization 'optimizer': '60/min',
'integration': '100/min', # Integration operations (WordPress, etc.) 'integration': '600/min',
# Default fallback # Default fallback
'default': '100/min', # Default for endpoints without scope 'default': '600/min',
}, },
# OpenAPI Schema Generation (drf-spectacular) # OpenAPI Schema Generation (drf-spectacular)
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',

View File

@@ -5,6 +5,7 @@ import AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop"; import { ScrollToTop } from "./components/common/ScrollToTop";
import ProtectedRoute from "./components/auth/ProtectedRoute"; import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard"; import ModuleGuard from "./components/common/ModuleGuard";
import AdminGuard from "./components/auth/AdminGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
import { useAuthStore } from "./store/authStore"; import { useAuthStore } from "./store/authStore";
@@ -595,8 +596,10 @@ export default function App() {
} /> } />
<Route path="/settings/integration" element={ <Route path="/settings/integration" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<AdminGuard>
<Integration /> <Integration />
</Suspense> </AdminGuard>
</Suspense>
} /> } />
<Route path="/settings/publishing" element={ <Route path="/settings/publishing" element={
<Suspense fallback={null}> <Suspense fallback={null}>

View File

@@ -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 <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label"; import Label from "../form/Label";
@@ -6,6 +6,15 @@ import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox"; import Checkbox from "../form/input/Checkbox";
import { useAuthStore } from "../../store/authStore"; 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() { export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
@@ -15,11 +24,45 @@ export default function SignUpForm() {
email: "", email: "",
password: "", password: "",
username: "", username: "",
accountName: "",
}); });
const [plans, setPlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<number | null>(null);
const [plansLoading, setPlansLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { register, loading } = useAuthStore(); 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<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
@@ -39,6 +82,11 @@ export default function SignUpForm() {
return; return;
} }
if (!selectedPlanId) {
setError("Please select a plan to continue");
return;
}
try { try {
// Generate username from email if not provided // Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0]; const username = formData.username || formData.email.split("@")[0];
@@ -49,6 +97,8 @@ export default function SignUpForm() {
username: username, username: username,
first_name: formData.firstName, first_name: formData.firstName,
last_name: formData.lastName, last_name: formData.lastName,
account_name: formData.accountName,
plan_id: selectedPlanId,
}); });
// Redirect to plan selection after successful registration // Redirect to plan selection after successful registration
@@ -191,6 +241,43 @@ export default function SignUpForm() {
required required
/> />
</div> </div>
{/* <!-- Account Name --> */}
<div>
<Label>Account Name (optional)</Label>
<Input
type="text"
id="accountName"
name="accountName"
value={formData.accountName}
onChange={handleChange}
placeholder="Workspace / Company name"
/>
</div>
{/* <!-- Plan Selection --> */}
<div>
<Label>
Select Plan<span className="text-error-500">*</span>
</Label>
<select
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm"
value={selectedPlanId ?? ""}
onChange={(e) => setSelectedPlanId(Number(e.target.value))}
disabled={plansLoading || plans.length === 0}
>
{plansLoading && <option>Loading plans...</option>}
{!plansLoading && plans.length === 0 && (
<option value="">No plans available</option>
)}
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name}
{plan.price ? ` - $${plan.price}/${plan.billing_cycle || "month"}` : ""}
</option>
))}
</select>
</div>
{/* <!-- Password --> */} {/* <!-- Password --> */}
<div> <div>
<Label> <Label>

View File

@@ -49,7 +49,7 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
const [siteName, setSiteName] = useState(''); const [siteName, setSiteName] = useState('');
const [websiteAddress, setWebsiteAddress] = useState(''); const [websiteAddress, setWebsiteAddress] = useState('');
// Load dismissal state from backend on mount // Load dismissal state from backend on mount (guarded in store)
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
loadFromBackend().catch(() => { loadFromBackend().catch(() => {
@@ -67,8 +67,11 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
setLoadingIndustries(true); setLoadingIndustries(true);
const response = await fetchIndustries(); const response = await fetchIndustries();
setIndustries(response.industries || []); setIndustries(response.industries || []);
} catch (error) { } catch (error: any) {
console.error('Failed to load industries:', error); // Swallow 429 to avoid noisy console; user can retry via toggle
if (error?.status !== 429) {
console.error('Failed to load industries:', error);
}
} finally { } finally {
setLoadingIndustries(false); setLoadingIndustries(false);
} }
@@ -108,8 +111,10 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
setLoadingIndustries(true); setLoadingIndustries(true);
const response = await fetchIndustries(); const response = await fetchIndustries();
setIndustries(response.industries || []); setIndustries(response.industries || []);
} catch (error) { } catch (error: any) {
console.error('Failed to load industries:', error); if (error?.status !== 429) {
console.error('Failed to load industries:', error);
}
} finally { } finally {
setLoadingIndustries(false); setLoadingIndustries(false);
} }

View File

@@ -43,10 +43,12 @@ const AppSidebar: React.FC = () => {
const { user, isAuthenticated } = useAuthStore(); const { user, isAuthenticated } = useAuthStore();
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore(); const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore();
// Show admin menu only for users in aws-admin account // Show admin menu only for system account (aws-admin/default) or developer
const isAwsAdminAccount = Boolean( const isAwsAdminAccount = Boolean(
user?.account?.slug === 'aws-admin' || user?.account?.slug === 'aws-admin' ||
user?.role === 'developer' // Also show for developers as fallback user?.account?.slug === 'default-account' ||
user?.account?.slug === 'default' ||
user?.role === 'developer'
); );
// Helper to check if module is enabled - memoized to prevent infinite loops // Helper to check if module is enabled - memoized to prevent infinite loops
@@ -226,10 +228,12 @@ const AppSidebar: React.FC = () => {
name: "Profile Settings", name: "Profile Settings",
path: "/settings/profile", path: "/settings/profile",
}, },
// Integration is admin-only; hide for non-privileged users (handled in render)
{ {
icon: <PlugInIcon />, icon: <PlugInIcon />,
name: "Integration", name: "Integration",
path: "/settings/integration", path: "/settings/integration",
adminOnly: true,
}, },
{ {
icon: <PageIcon />, icon: <PageIcon />,
@@ -327,9 +331,17 @@ const AppSidebar: React.FC = () => {
// Combine all sections, including admin if user is in aws-admin account // Combine all sections, including admin if user is in aws-admin account
const allSections = useMemo(() => { const allSections = useMemo(() => {
const baseSections = menuSections.map(section => {
// Filter adminOnly items for non-system users
const filteredItems = section.items.filter((item: any) => {
if ((item as any).adminOnly && !isAwsAdminAccount) return false;
return true;
});
return { ...section, items: filteredItems };
});
return isAwsAdminAccount return isAwsAdminAccount
? [...menuSections, adminSection] ? [...baseSections, adminSection]
: menuSections; : baseSections;
}, [isAwsAdminAccount, menuSections, adminSection]); }, [isAwsAdminAccount, menuSections, adminSection]);
useEffect(() => { useEffect(() => {

View File

@@ -432,18 +432,23 @@ export default function Home() {
const fetchAppInsights = async () => { const fetchAppInsights = async () => {
try { try {
setLoading(true); setLoading(true);
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
// Determine site_id based on filter // Determine site_id based on filter
const siteId = siteFilter === 'all' ? undefined : siteFilter; const siteId = siteFilter === 'all' ? undefined : siteFilter;
const [keywordsRes, clustersRes, ideasRes, tasksRes, contentRes, imagesRes] = await Promise.all([ // Fetch sequentially with small delays to avoid burst throttling
fetchKeywords({ page_size: 1, site_id: siteId }), const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
fetchClusters({ page_size: 1, site_id: siteId }), await delay(120);
fetchContentIdeas({ page_size: 1, site_id: siteId }), const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
fetchTasks({ page_size: 1, site_id: siteId }), await delay(120);
fetchContent({ page_size: 1, site_id: siteId }), const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
fetchContentImages({ page_size: 1, site_id: siteId }) await delay(120);
]); const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
await delay(120);
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
await delay(120);
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
const totalKeywords = keywordsRes.count || 0; const totalKeywords = keywordsRes.count || 0;
const totalClusters = clustersRes.count || 0; const totalClusters = clustersRes.count || 0;
@@ -500,8 +505,15 @@ export default function Home() {
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error: any) { } catch (error: any) {
console.error('Error fetching insights:', error); if (error?.status === 429) {
toast.error(`Failed to load insights: ${error.message}`); // Back off and retry once after a short delay
setTimeout(() => {
fetchAppInsights();
}, 2000);
} else {
console.error('Error fetching insights:', error);
toast.error(`Failed to load insights: ${error.message}`);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -88,7 +88,7 @@ const getAuthToken = (): string | null => {
} }
// Fallback to localStorage (for cases where store hasn't initialized yet) // Fallback to localStorage (for cases where store hasn't initialized yet)
const authStorage = localStorage.getItem('auth-storage'); const authStorage = localStorage.getItem('auth-store');
if (authStorage) { if (authStorage) {
const parsed = JSON.parse(authStorage); const parsed = JSON.parse(authStorage);
return parsed?.state?.token || null; return parsed?.state?.token || null;
@@ -109,7 +109,7 @@ const getRefreshToken = (): string | null => {
} }
// Fallback to localStorage (for cases where store hasn't initialized yet) // Fallback to localStorage (for cases where store hasn't initialized yet)
const authStorage = localStorage.getItem('auth-storage'); const authStorage = localStorage.getItem('auth-store');
if (authStorage) { if (authStorage) {
const parsed = JSON.parse(authStorage); const parsed = JSON.parse(authStorage);
return parsed?.state?.refreshToken || null; return parsed?.state?.refreshToken || null;

View File

@@ -32,6 +32,16 @@ export const useOnboardingStore = create<OnboardingState>()(
lastSyncedAt: null, lastSyncedAt: null,
loadFromBackend: async () => { loadFromBackend: async () => {
const state = get();
// Avoid hammering the endpoint; re-fetch at most every 5 minutes
if (state.lastSyncedAt) {
const elapsedMs = Date.now() - state.lastSyncedAt.getTime();
if (elapsedMs < 5 * 60 * 1000) {
return;
}
}
if (state.isLoading) return;
set({ isLoading: true }); set({ isLoading: true });
try { try {
const setting = await fetchUserSetting(GUIDE_SETTING_KEY); const setting = await fetchUserSetting(GUIDE_SETTING_KEY);
@@ -44,7 +54,9 @@ export const useOnboardingStore = create<OnboardingState>()(
}); });
} catch (error: any) { } catch (error: any) {
// 404 means setting doesn't exist yet - that's fine, use local state // 404 means setting doesn't exist yet - that's fine, use local state
if (error.status !== 404) { if (error?.status === 429) {
// Throttled: back off and don't spam warnings
} else if (error?.status !== 404) {
console.warn('Failed to load guide dismissal from backend:', error); console.warn('Failed to load guide dismissal from backend:', error);
} }
set({ isLoading: false }); set({ isLoading: false });

View File

@@ -0,0 +1,104 @@
# Multi-Tenancy & Access Reference (Current State)
## Purpose
Authoritative map of tenant isolation, role access, and payment/API-key handling across the stack. Built from code as of Dec 2025.
## Core Enforcement Points (backend)
- Middleware:
- `backend/igny8_core/auth/middleware.py` (`AccountContextMiddleware`, ~L1-L220): resolves `request.account` from JWT/API key; blocks inactive/suspended accounts.
- `backend/igny8_core/middleware/request_id.py` (~L1-L70): request ID (not tenancy).
- `backend/igny8_core/middleware/resource_tracker.py` (~L1-L170): metrics (not tenancy).
- Base viewsets:
- `backend/igny8_core/api/base.py` (`AccountModelViewSet`, ~L1-L240): filters by `request.account`; admin/developer/system overrides; sets account on create.
- `backend/igny8_core/api/base.py` (`SiteSectorModelViewSet`, ~L238-L430): additionally filters by site/sector and users accessible sites (SiteUserAccess) unless admin/developer/system.
- Permissions:
- `backend/igny8_core/api/permissions.py`:
- `IsAuthenticatedAndActive`, `HasTenantAccess` (default in settings).
- `IsViewerOrAbove`, `IsEditorOrAbove`, `IsAdminOrOwner`.
- `IsSystemAccountOrDeveloper` (system/admin for integrations).
- Module-specific permissions also appear in `backend/igny8_core/auth/permissions.py` (legacy IsOwnerOrAdmin, IsEditorOrAbove, IsViewerOrAbove, AccountPermission).
- Settings defaults:
- `backend/igny8_core/settings.py` REST_FRAMEWORK `DEFAULT_PERMISSION_CLASSES` = `IsAuthenticatedAndActive` + `HasTenantAccess`.
- Auth order: APIKeyAuthentication → JWTAuthentication → CSRFExemptSessionAuthentication → BasicAuth.
- Throttling: `DebugScopedRateThrottle` bypasses throttles for authenticated users/system/debug.
- Models with enforced account/site/sector:
- Base models `AccountBaseModel`, `SiteSectorBaseModel` in `backend/igny8_core/auth/models.py` (top of file).
## Flow (text flowchart)
```
Request
-> Middleware: AccountContextMiddleware sets request.account (JWT/API key), validates account status/plan
-> DRF Auth: APIKey/JWT/Session
-> Permissions: IsAuthenticatedAndActive + HasTenantAccess (+ role-specific)
-> ViewSet:
AccountModelViewSet filters by account
SiteSectorModelViewSet filters by account + site/sector + SiteUserAccess
-> Action-specific role checks (IsEditorOrAbove, IsAdminOrOwner, IsSystemAccountOrDeveloper)
-> Business logic (services) + credit checks (billing)
-> Response
```
## Module Access (backend ViewSets & guards)
- Accounts/Users/Plans/Subscriptions:
- `auth/views.py`: `UsersViewSet`, `AccountsViewSet`, `SubscriptionsViewSet`, `SiteUserAccessViewSet` (account-scoped via AccountModelViewSet + role guards).
- Roles: owner/admin (or developer/system) can manage; others limited to self (UsersViewSet get_queryset).
- Sites/Sectors:
- `auth/views.py` (`SiteViewSet`, `Sector` actions): SiteSectorModelViewSet enforces account + site/sector + SiteUserAccess; public slug read is AllowAny for active site slug only.
- Planner:
- `modules/planner/views.py` (KeywordViewSet, ClusterViewSet, ContentIdeasViewSet) inherit SiteSectorModelViewSet; require site_id/sector_id; role: typically editor+ for writes.
- Writer:
- `modules/writer/views.py` (TasksViewSet, ContentViewSet, ImagesViewSet, ContentTaxonomyViewSet) inherit SiteSectorModelViewSet; site/sector scoping; editor+ for writes.
- Automation:
- `business/automation/views.py` (AutomationViewSet) inherits AccountModelViewSet/SiteSectorModelViewSet patterns; requires site_id for run/config; role: editor+ for mutate.
- System settings (non-integrations):
- `modules/system/views.py` / `settings_views.py`: AccountModelViewSet; role usually admin/owner; authenticated + tenant required.
- Integrations (OpenAI/Runware API keys):
- `modules/system/integration_views.py`: guarded by `IsSystemAccountOrDeveloper` (system account or developer only); tenant-scoped but effectively system-only for keys.
- Billing:
- `modules/billing/views.py`: AccountModelViewSet; `IsAdminOrOwner` for credit transactions/payment methods; balance/usage requires auth + tenant.
- Payments/Payment Methods:
- Payment methods: `AccountPaymentMethodViewSet` account-scoped; IsAuthenticated; default selection per account; admin/owner should manage.
- Payments: `PaymentViewSet` account-scoped; IsAuthenticated; list/available_methods/manual payment for current account only.
## Frontend Guards
- Route protection: `ProtectedRoute` (auth required, checks account/plan/payment methods), `ModuleGuard` (module enabled), `AdminGuard` (integration/admin pages only for system account or developer).
- Sidebar hides Integration for non-system/developer; admin section shown only for system/developer.
## AI Key Resolution
- `ai/ai_core.py` `_load_account_settings`: tries tenant IntegrationSettings → system account IntegrationSettings (`aws-admin`/`default-account`/`default`) → Django settings (`OPENAI_API_KEY`, `RUNWARE_API_KEY`). All users run AI with shared keys if tenant keys absent.
## Throttling
- `api/throttles.py` `DebugScopedRateThrottle`: bypass for authenticated users/system/debug; per-scope rates in `settings.py`. Prevents 429s for normal users.
## Payment / Billing Workflow (happy path)
1) User authenticates (JWT) → request.account set.
2) Payment methods (account-scoped) fetched via `/v1/billing/payment-methods/available/`; admin/owner can CRUD `/v1/billing/payment-methods/`.
3) Invoices/Payments via billing endpoints (account-scoped; admin/owner).
4) Credits used via CreditService on AI/automation calls (backend).
## Access Summary by Role (runtime enforcement)
- Viewer: read-only where viewsets allow `IsViewerOrAbove`; no writes.
- Editor: can write planner/writer/automation; cannot manage billing/integration.
- Admin/Owner: manage account/team/billing/payment methods; full module writes.
- Developer/System account: cross-tenant overrides in some base filters; integration settings and admin menus.
## Key Files (with line bands)
- Middleware: `auth/middleware.py` (~L1-220)
- Base viewsets: `api/base.py` (~L1-430)
- Permissions: `api/permissions.py` (~L1-200), `auth/permissions.py` (~L1-120)
- Settings (REST/Throttle): `settings.py` (REST_FRAMEWORK block, ~L200-360)
- AI core key loading: `ai/ai_core.py` (~L1-120)
- Integration settings views: `modules/system/integration_views.py` (~L1-300 main guards; actions throughout)
- Planner views: `modules/planner/views.py` (all ViewSets inherit SiteSectorModelViewSet)
- Writer views: `modules/writer/views.py`
- Automation: `business/automation/views.py`, `services/automation_service.py`
- Billing: `modules/billing/views.py`, `business/billing/services/credit_service.py`
- Payment methods: `modules/billing/views.py` AccountPaymentMethodViewSet
- Frontend guards: `src/components/auth/ProtectedRoute.tsx`, `src/components/auth/AdminGuard.tsx`, `src/components/common/ModuleGuard.tsx`
- Sidebar gating: `src/layout/AppSidebar.tsx`
## Open Items / Risks
- Ensure public endpoints explicitly override default permissions (e.g., auth register/login, site slug read).
- Validate all viewsets still inherit AccountModelViewSet/SiteSectorModelViewSet after future changes.
- Add automated tests for cross-tenant denial, role gates, plan limits, and integration access.***

View File

@@ -53,3 +53,9 @@
## How Developers Should Work With This Module ## How Developers Should Work With This Module
- Before running any fix/verify script, read it and constrain to target account/site if possible. - Before running any fix/verify script, read it and constrain to target account/site if possible.
- Add notes here when new manual interventions occur, including date/purpose and scripts used. - Add notes here when new manual interventions occur, including date/purpose and scripts used.
## Recent Hardening (Dec 2025)
- Throttling bypass for authenticated users to prevent user-facing 429s.
- AI keys fallback: OpenAI/Runware pulled from system account (`aws-admin`/`default-account`/`default`) or Django settings if tenant key absent.
- Integration settings restricted to system account or developer (`IsSystemAccountOrDeveloper` backend guard, `AdminGuard` frontend).
- DRF default permissions tightened to authenticated + tenant access (`IsAuthenticatedAndActive`, `HasTenantAccess`); public endpoints must override explicitly (e.g., AuthViewSet).