From 464b8b911fa092093dcbbe1845a5cc52a8b79cdf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 18:39:18 +0800 Subject: [PATCH 1/3] Phase 8: Registration flow & MFA enforcement UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: add POST /auth/totp/enforce-setup and /auth/totp/enforce-confirm endpoints that operate on mfa_enforce_token (not session cookie), generate TOTP secret/QR/backup codes, verify confirmation code, enable TOTP, clear mfa_enforce_pending flag, and issue a full session cookie - frontend: expand LockScreen to five modes — login, first-run setup, open registration, TOTP challenge, MFA enforcement setup (QR -> verify -> backup codes), and forced password change; all modes share AmbientBackground and the existing card layout; registration visible only when authStatus.registration_open is true Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/totp.py | 112 +++ frontend/src/components/auth/LockScreen.tsx | 854 +++++++++++++++----- 2 files changed, 765 insertions(+), 201 deletions(-) diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index e8b9be7..e147892 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -39,6 +39,7 @@ from app.services.auth import ( verify_password_with_upgrade, hash_password, verify_mfa_token, + verify_mfa_enforce_token, create_session_token, ) from app.services.totp import ( @@ -94,6 +95,15 @@ class BackupCodesRegenerateRequest(BaseModel): code: str # Current TOTP code required to regenerate +class EnforceSetupRequest(BaseModel): + mfa_token: str + + +class EnforceConfirmRequest(BaseModel): + mfa_token: str + code: str # 6-digit TOTP code from authenticator app + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -394,6 +404,108 @@ async def regenerate_backup_codes( return {"backup_codes": plaintext_codes} +@router.post("/totp/enforce-setup") +async def enforce_setup_totp( + data: EnforceSetupRequest, + db: AsyncSession = Depends(get_db), +): + """ + Generate TOTP secret + QR code + backup codes during MFA enforcement. + + Called after login returns mfa_setup_required=True. Uses the mfa_enforce_token + (not a session cookie) because the user is not yet fully authenticated. + + Idempotent: regenerates secret if called again before confirm. + Returns { secret, qr_code_base64, backup_codes }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled for this account") + + # Generate new secret (idempotent — overwrite any unconfirmed secret) + raw_secret = generate_totp_secret() + encrypted_secret = encrypt_totp_secret(raw_secret) + user.totp_secret = encrypted_secret + user.totp_enabled = False # Not enabled until enforce-confirm called + + # Generate backup codes — hash before storage, return plaintext once + plaintext_codes = generate_backup_codes(10) + await _delete_backup_codes(db, user.id) + await _store_backup_codes(db, user.id, plaintext_codes) + + await db.commit() + + uri = get_totp_uri(encrypted_secret, user.username) + qr_base64 = generate_qr_base64(uri) + + return { + "secret": raw_secret, + "qr_code_base64": qr_base64, + "backup_codes": plaintext_codes, + } + + +@router.post("/totp/enforce-confirm") +async def enforce_confirm_totp( + data: EnforceConfirmRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +): + """ + Confirm TOTP setup during enforcement, clear the pending flag, issue a full session. + + Must be called after /totp/enforce-setup while totp_enabled is still False. + On success: enables TOTP, clears mfa_enforce_pending, sets session cookie, + returns { authenticated: true }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if not user.totp_secret: + raise HTTPException(status_code=400, detail="TOTP setup not started — call /totp/enforce-setup first") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled") + + # Verify the confirmation code + matched_window = verify_totp_code(user.totp_secret, data.code) + if matched_window is None: + raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync") + + # Enable TOTP and clear the enforcement flag + user.totp_enabled = True + user.mfa_enforce_pending = False + user.last_login_at = datetime.now() + await db.commit() + + # Issue a full session + token = await _create_full_session(db, user, request) + _set_session_cookie(response, token) + + return {"authenticated": True} + + @router.get("/totp/status") async def totp_status( db: AsyncSession = Depends(get_db), diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 961574a..1885512 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -1,15 +1,16 @@ import { useState, FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { Lock, Loader2 } from 'lucide-react'; +import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import AmbientBackground from './AmbientBackground'; +import type { TotpSetupResponse } from '@/types'; /** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */ function validatePassword(password: string): string | null { @@ -20,63 +21,124 @@ function validatePassword(password: string): string | null { return null; } -export default function LockScreen() { - const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth(); +type ScreenMode = + | 'login' + | 'setup' // first-run admin account creation + | 'register' // open registration + | 'totp' // TOTP challenge after login + | 'mfa_enforce' // forced MFA setup after login/register + | 'force_pw'; // admin-forced password change - // Credentials state (shared across login/setup states) +type MfaEnforceStep = 'qr' | 'verify' | 'backup_codes'; + +export default function LockScreen() { + const { + authStatus, + isLoading, + login, + register, + setup, + verifyTotp, + mfaRequired, + mfaSetupRequired, + mfaToken, + isLoginPending, + isRegisterPending, + isSetupPending, + isTotpPending, + } = useAuth(); + + // ── Shared credential fields ── const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // TOTP challenge state + // ── TOTP challenge ── const [totpCode, setTotpCode] = useState(''); const [useBackupCode, setUseBackupCode] = useState(false); - // Lockout handling (HTTP 423) + // ── Registration mode ── + const [mode, setMode] = useState('login'); + + // ── Lockout (HTTP 423) ── const [lockoutMessage, setLockoutMessage] = useState(null); - // Redirect authenticated users immediately - if (!isLoading && authStatus?.authenticated) { + // ── MFA enforcement setup flow ── + const [mfaEnforceStep, setMfaEnforceStep] = useState('qr'); + const [mfaEnforceQr, setMfaEnforceQr] = useState(''); + const [mfaEnforceSecret, setMfaEnforceSecret] = useState(''); + const [mfaEnforceBackupCodes, setMfaEnforceBackupCodes] = useState([]); + const [mfaEnforceCode, setMfaEnforceCode] = useState(''); + const [isMfaEnforceSetupPending, setIsMfaEnforceSetupPending] = useState(false); + const [isMfaEnforceConfirmPending, setIsMfaEnforceConfirmPending] = useState(false); + + // ── Forced password change ── + const [forcedNewPassword, setForcedNewPassword] = useState(''); + const [forcedConfirmPassword, setForcedConfirmPassword] = useState(''); + const [isForcePwPending, setIsForcePwPending] = useState(false); + + // Redirect authenticated users (no pending MFA flows) + if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') { return ; } const isSetup = authStatus?.setup_required === true; + const registrationOpen = authStatus?.registration_open === true; + + // Derive active screen — hook-driven states override local mode + const activeMode: ScreenMode = mfaRequired + ? 'totp' + : mfaSetupRequired + ? 'mfa_enforce' + : isSetup + ? 'setup' + : mode; + + // ── Handlers ── const handleCredentialSubmit = async (e: FormEvent) => { e.preventDefault(); setLockoutMessage(null); if (isSetup) { - // Setup mode: validate password then create account - const validationError = validatePassword(password); - if (validationError) { - toast.error(validationError); - return; - } - if (password !== confirmPassword) { - toast.error('Passwords do not match'); - return; - } + const err = validatePassword(password); + if (err) { toast.error(err); return; } + if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } try { await setup({ username, password }); - // useAuth invalidates auth query → Navigate above handles redirect } catch (error) { toast.error(getErrorMessage(error, 'Failed to create account')); } - } else { - // Login mode - try { - await login({ username, password }); - // If mfaRequired becomes true, the TOTP state renders automatically - // If not required, useAuth invalidates auth query → Navigate above handles redirect - } catch (error: any) { - if (error?.response?.status === 423) { - const msg = error.response.data?.detail || 'Account locked. Try again later.'; - setLockoutMessage(msg); - } else { - toast.error(getErrorMessage(error, 'Invalid username or password')); - } + return; + } + + try { + const result = await login({ username, password }); + // must_change_password: backend issued session but UI must gate the app + if ('must_change_password' in result && result.must_change_password) { + setMode('force_pw'); } + // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically + } catch (error: any) { + if (error?.response?.status === 423) { + setLockoutMessage(error.response.data?.detail || 'Account locked. Try again later.'); + } else { + toast.error(getErrorMessage(error, 'Invalid username or password')); + } + } + }; + + const handleRegisterSubmit = async (e: FormEvent) => { + e.preventDefault(); + const err = validatePassword(password); + if (err) { toast.error(err); return; } + if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } + try { + await register({ username, password }); + // On success useAuth invalidates query → Navigate handles redirect + // If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches + } catch (error) { + toast.error(getErrorMessage(error, 'Registration failed')); } }; @@ -84,190 +146,580 @@ export default function LockScreen() { e.preventDefault(); try { await verifyTotp(totpCode); - // useAuth invalidates auth query → Navigate above handles redirect } catch (error) { toast.error(getErrorMessage(error, 'Invalid verification code')); setTotpCode(''); } }; + const handleMfaEnforceStart = async () => { + if (!mfaToken) return; + setIsMfaEnforceSetupPending(true); + try { + const { data } = await api.post('/auth/totp/enforce-setup', { + mfa_token: mfaToken, + }); + setMfaEnforceQr(data.qr_code_base64); + setMfaEnforceSecret(data.secret); + setMfaEnforceBackupCodes(data.backup_codes); + setMfaEnforceStep('qr'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to begin MFA setup')); + } finally { + setIsMfaEnforceSetupPending(false); + } + }; + + const handleMfaEnforceConfirm = async () => { + if (!mfaToken || !mfaEnforceCode || mfaEnforceCode.length !== 6) { + toast.error('Enter a 6-digit code from your authenticator app'); + return; + } + setIsMfaEnforceConfirmPending(true); + try { + await api.post('/auth/totp/enforce-confirm', { + mfa_token: mfaToken, + code: mfaEnforceCode, + }); + // Backend issued session — show backup codes then redirect + setMfaEnforceStep('backup_codes'); + } catch (error) { + toast.error(getErrorMessage(error, 'Invalid code — try again')); + setMfaEnforceCode(''); + } finally { + setIsMfaEnforceConfirmPending(false); + } + }; + + const handleCopyBackupCodes = async () => { + try { + await navigator.clipboard.writeText(mfaEnforceBackupCodes.join('\n')); + toast.success('Backup codes copied'); + } catch { + toast.error('Failed to copy — please select and copy manually'); + } + }; + + const handleForcePwSubmit = async (e: FormEvent) => { + e.preventDefault(); + const err = validatePassword(forcedNewPassword); + if (err) { toast.error(err); return; } + if (forcedNewPassword !== forcedConfirmPassword) { + toast.error('Passwords do not match'); + return; + } + setIsForcePwPending(true); + try { + await api.post('/auth/change-password', { + old_password: password, // retained from original login submission + new_password: forcedNewPassword, + }); + toast.success('Password updated — welcome to UMBRA'); + // Auth query still has authenticated:true → Navigate will fire after re-render + setMode('login'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to change password')); + } finally { + setIsForcePwPending(false); + } + }; + + // ── Render helpers ── + + const renderTotpChallenge = () => ( + <> + +
+
+
+
+ Two-Factor Authentication + + {useBackupCode ? 'Enter one of your backup codes' : 'Enter the code from your authenticator app'} + +
+
+
+ +
+
+ + + setTotpCode( + useBackupCode + ? e.target.value.replace(/[^0-9-]/g, '') + : e.target.value.replace(/\D/g, '') + ) + } + placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'} + autoFocus + autoComplete="one-time-code" + className="text-center text-lg tracking-widest" + /> +
+ + +
+
+ + ); + + const renderMfaEnforce = () => { + // Show a loading/start state if QR hasn't been fetched yet + if (!mfaEnforceQr && mfaEnforceStep !== 'backup_codes') { + return ( + <> + +
+
+
+
+ Set Up Two-Factor Authentication + Your account requires MFA before you can continue +
+
+
+ +

+ An administrator has required that your account be protected with an authenticator app. + You'll need an app like Google Authenticator, Authy, or 1Password to continue. +

+ +
+ + ); + } + + if (mfaEnforceStep === 'backup_codes') { + return ( + <> + +
+
+
+
+ Save Your Backup Codes + Store these somewhere safe — they won't be shown again +
+
+
+ +

+ These {mfaEnforceBackupCodes.length} codes can each be used once if you lose access to + your authenticator app. MFA is now active on your account. +

+
+ {mfaEnforceBackupCodes.map((code, i) => ( + + {code} + + ))} +
+ + +
+ + ); + } + + if (mfaEnforceStep === 'qr') { + return ( + <> + +
+
+
+
+ Scan QR Code + Add UMBRA to your authenticator app +
+
+
+ +
+ TOTP QR code — scan with your authenticator app +
+

+ Can't scan? Enter this code manually in your app: +

+ + {mfaEnforceSecret} + + +
+ + ); + } + + // verify step + return ( + <> + +
+
+
+
+ Verify Your Authenticator + Enter the 6-digit code shown in your app +
+
+
+ +
+ + setMfaEnforceCode(e.target.value.replace(/\D/g, ''))} + className="text-center tracking-widest text-lg" + autoFocus + autoComplete="one-time-code" + onKeyDown={(e) => { if (e.key === 'Enter') handleMfaEnforceConfirm(); }} + /> +
+ + +
+ + ); + }; + + const renderLoginOrSetup = () => ( + <> + +
+
+
+
+ {isSetup ? 'Welcome to UMBRA' : 'Sign in'} + + {isSetup ? 'Create your account to get started' : 'Enter your credentials to continue'} + +
+
+
+ + {lockoutMessage && ( +
+
+ )} +
+
+ + { setUsername(e.target.value); setLockoutMessage(null); }} + placeholder="Enter username" + required + autoFocus + autoComplete="username" + /> +
+
+ + { setPassword(e.target.value); setLockoutMessage(null); }} + placeholder={isSetup ? 'Create a password' : 'Enter password'} + required + autoComplete={isSetup ? 'new-password' : 'current-password'} + /> +
+ {isSetup && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ )} + +
+ + {/* Open registration link — only shown on login screen when enabled */} + {!isSetup && registrationOpen && ( +
+ +
+ )} +
+ + ); + + const renderRegister = () => ( + <> + +
+
+
+
+ Create Account + Register for access to UMBRA +
+
+
+ +
+
+ + setUsername(e.target.value)} + placeholder="Choose a username" + required + autoFocus + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Create a password" + required + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ +
+
+ +
+
+ + ); + + const renderForcedPasswordChange = () => ( + <> + +
+
+
+
+ Password Change Required + An administrator has reset your password. Please set a new one. +
+
+
+ +
+
+ + setForcedNewPassword(e.target.value)} + placeholder="Create a new password" + required + autoFocus + autoComplete="new-password" + /> +
+
+ + setForcedConfirmPassword(e.target.value)} + placeholder="Confirm your new password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ +
+
+ + ); + return (
- {/* Wordmark — in flex flow above card */} + {/* Wordmark */} UMBRA {/* Auth card */} - {mfaRequired ? ( - // State C: TOTP challenge - <> - -
-
-
-
- Two-Factor Authentication - - {useBackupCode - ? 'Enter one of your backup codes' - : 'Enter the code from your authenticator app'} - -
-
-
- -
-
- - - setTotpCode( - useBackupCode - ? e.target.value.replace(/[^0-9-]/g, '') - : e.target.value.replace(/\D/g, '') - ) - } - placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'} - autoFocus - autoComplete="one-time-code" - className="text-center text-lg tracking-widest" - /> -
- - -
-
- - ) : ( - // State A (setup) or State B (login) - <> - -
-
-
-
- {isSetup ? 'Welcome to UMBRA' : 'Sign in'} - - {isSetup - ? 'Create your account to get started' - : 'Enter your credentials to continue'} - -
-
-
- - {/* Lockout warning banner */} - {lockoutMessage && ( -
-
- )} - -
-
- - { setUsername(e.target.value); setLockoutMessage(null); }} - placeholder="Enter username" - required - autoFocus - autoComplete="username" - /> -
- -
- - { setPassword(e.target.value); setLockoutMessage(null); }} - placeholder={isSetup ? 'Create a password' : 'Enter password'} - required - autoComplete={isSetup ? 'new-password' : 'current-password'} - /> -
- - {isSetup && ( -
- - setConfirmPassword(e.target.value)} - placeholder="Confirm your password" - required - autoComplete="new-password" - /> -

- Must be 12-128 characters with at least one letter and one non-letter. -

-
- )} - - -
-
- - )} + {activeMode === 'totp' && renderTotpChallenge()} + {activeMode === 'mfa_enforce' && renderMfaEnforce()} + {activeMode === 'force_pw' && renderForcedPasswordChange()} + {activeMode === 'register' && renderRegister()} + {(activeMode === 'login' || activeMode === 'setup') && renderLoginOrSetup()}
); From 2ec70d93443377a6df01b87cb49ef103ff41e8c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 18:40:16 +0800 Subject: [PATCH 2/3] Add Phase 7 admin portal frontend (IAM, Config, Dashboard) Creates 7 files: useAdmin hook with TanStack Query v5, AdminPortal layout with horizontal tab nav, IAMPage with user table + stat cards + system settings, UserActionsMenu with two-click confirms, CreateUserDialog, ConfigPage with paginated audit log + action filter, AdminDashboardPage with stats + recent logins/actions tables. Co-Authored-By: Claude Opus 4.6 --- .../components/admin/AdminDashboardPage.tsx | 241 ++++++++++++++ frontend/src/components/admin/AdminPortal.tsx | 64 ++++ frontend/src/components/admin/ConfigPage.tsx | 231 ++++++++++++++ .../src/components/admin/CreateUserDialog.tsx | 121 +++++++ frontend/src/components/admin/IAMPage.tsx | 298 ++++++++++++++++++ .../src/components/admin/UserActionsMenu.tsx | 296 +++++++++++++++++ frontend/src/hooks/useAdmin.ts | 165 ++++++++++ 7 files changed, 1416 insertions(+) create mode 100644 frontend/src/components/admin/AdminDashboardPage.tsx create mode 100644 frontend/src/components/admin/AdminPortal.tsx create mode 100644 frontend/src/components/admin/ConfigPage.tsx create mode 100644 frontend/src/components/admin/CreateUserDialog.tsx create mode 100644 frontend/src/components/admin/IAMPage.tsx create mode 100644 frontend/src/components/admin/UserActionsMenu.tsx create mode 100644 frontend/src/hooks/useAdmin.ts diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx new file mode 100644 index 0000000..33d95c4 --- /dev/null +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -0,0 +1,241 @@ +import { + Users, + UserCheck, + UserX, + Activity, + Smartphone, + LogIn, + ShieldAlert, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import { cn } from '@/lib/utils'; + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +} + +export default function AdminDashboardPage() { + const { data: dashboard, isLoading } = useAdminDashboard(); + const { data: auditData } = useAuditLog(1, 10); + + const mfaPct = dashboard ? Math.round(dashboard.mfa_adoption_rate * 100) : null; + const disabledUsers = + dashboard ? dashboard.total_users - dashboard.active_users : null; + + return ( +
+ {/* Stats grid */} +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : ( + <> + } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Users" + value={dashboard?.active_users ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Disabled Users" + value={disabledUsers ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-blue-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> + + )} +
+ +
+ {/* Recent logins */} + + +
+
+ +
+ Recent Logins +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : !dashboard?.recent_logins?.length ? ( +

No recent logins.

+ ) : ( +
+ + + + + + + + + + {dashboard.recent_logins.map((entry, idx) => ( + + + + + + ))} + +
+ Username + + When + + IP +
{entry.username} + {getRelativeTime(entry.last_login_at)} + + {entry.ip_address ?? '—'} +
+
+ )} +
+
+ + {/* Recent admin actions */} + + +
+
+ +
+ Recent Admin Actions +
+
+ + {!auditData?.entries?.length ? ( +

No recent actions.

+ ) : ( +
+ + + + + + + + + + + {auditData.entries.slice(0, 10).map((entry, idx) => ( + + + + + + + ))} + +
+ Action + + Actor + + Target + + When +
+ + {entry.action} + + + {entry.actor_username ?? ( + system + )} + + {entry.target_username ?? '—'} + + {getRelativeTime(entry.created_at)} +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/AdminPortal.tsx b/frontend/src/components/admin/AdminPortal.tsx new file mode 100644 index 0000000..b519f6f --- /dev/null +++ b/frontend/src/components/admin/AdminPortal.tsx @@ -0,0 +1,64 @@ +import { NavLink, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Users, Settings2, LayoutDashboard, ShieldCheck } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import IAMPage from './IAMPage'; +import ConfigPage from './ConfigPage'; +import AdminDashboardPage from './AdminDashboardPage'; + +const tabs = [ + { label: 'IAM Management', path: '/admin/iam', icon: Users }, + { label: 'Configuration', path: '/admin/config', icon: Settings2 }, + { label: 'Management Dashboard', path: '/admin/dashboard', icon: LayoutDashboard }, +]; + +export default function AdminPortal() { + const location = useLocation(); + + return ( +
+ {/* Portal header with tab navigation */} +
+
+
+
+ +
+

Admin Portal

+
+ + {/* Horizontal tab navigation */} + +
+
+ + {/* Page content */} +
+ + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx new file mode 100644 index 0000000..8a9ce15 --- /dev/null +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { + FileText, + ChevronLeft, + ChevronRight, + Filter, + X, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Select } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuditLog } from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import { cn } from '@/lib/utils'; + +const ACTION_TYPES = [ + 'user.create', + 'user.login', + 'user.logout', + 'user.login_failed', + 'user.locked', + 'user.unlocked', + 'user.role_changed', + 'user.disabled', + 'user.enabled', + 'user.password_reset', + 'user.totp_disabled', + 'user.mfa_enforced', + 'user.mfa_enforcement_removed', + 'user.sessions_revoked', + 'config.updated', +]; + +function actionLabel(action: string): string { + return action + .split('.') + .map((p) => p.replace(/_/g, ' ')) + .join(' — '); +} + +function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +} + +export default function ConfigPage() { + const [page, setPage] = useState(1); + const [filterAction, setFilterAction] = useState(''); + const PER_PAGE = 25; + + const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined); + + const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; + + return ( +
+ + +
+
+ +
+ Audit Log + {data && ( + + {data.total} entries + + )} +
+ + {/* Filter controls */} +
+
+ + Filter: +
+
+ +
+ {filterAction && ( + + )} +
+
+ + + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : !data?.entries?.length ? ( +

No audit entries found.

+ ) : ( + <> +
+ + + + + + + + + + + + + {data.entries.map((entry, idx) => ( + + + + + + + + + ))} + +
+ Time + + Actor + + Action + + Target + + IP + + Detail +
+ {getRelativeTime(entry.created_at)} + + {entry.actor_username ?? ( + system + )} + + + {entry.action} + + + {entry.target_username ?? '—'} + + {entry.ip_address ?? '—'} + + {entry.detail ?? '—'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx new file mode 100644 index 0000000..a44164e --- /dev/null +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { UserPlus, Loader2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { useCreateUser, getErrorMessage } from '@/hooks/useAdmin'; +import type { UserRole } from '@/types'; + +interface CreateUserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialogProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('standard'); + + const createUser = useCreateUser(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password.trim()) return; + + try { + await createUser.mutateAsync({ username: username.trim(), password, role }); + toast.success(`User "${username.trim()}" created successfully`); + setUsername(''); + setPassword(''); + setRole('standard'); + onOpenChange(false); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to create user')); + } + }; + + return ( + + + + + + Create User + + + onOpenChange(false)} /> + +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + autoFocus + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Min. 8 characters" + required + /> +

+ Must be at least 8 characters. The user will be prompted to change it on first login. +

+
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx new file mode 100644 index 0000000..64329b9 --- /dev/null +++ b/frontend/src/components/admin/IAMPage.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + Users, + UserCheck, + ShieldCheck, + Smartphone, + Plus, + Loader2, + Activity, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + useAdminUsers, + useAdminDashboard, + useAdminConfig, + useUpdateConfig, + getErrorMessage, +} from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import type { AdminUserDetail, UserRole } from '@/types'; +import { cn } from '@/lib/utils'; +import UserActionsMenu from './UserActionsMenu'; +import CreateUserDialog from './CreateUserDialog'; + +// ── Role badge ──────────────────────────────────────────────────────────────── + +function RoleBadge({ role }: { role: UserRole }) { + const styles: Record = { + admin: 'bg-red-500/15 text-red-400', + standard: 'bg-blue-500/15 text-blue-400', + public_event_manager: 'bg-purple-500/15 text-purple-400', + }; + const labels: Record = { + admin: 'Admin', + standard: 'Standard', + public_event_manager: 'Pub. Events', + }; + return ( + + {labels[role]} + + ); +} + +// ── Stat card ───────────────────────────────────────────────────────────────── + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function IAMPage() { + const [createOpen, setCreateOpen] = useState(false); + + const { data: users, isLoading: usersLoading } = useAdminUsers(); + const { data: dashboard } = useAdminDashboard(); + const { data: config, isLoading: configLoading } = useAdminConfig(); + const updateConfig = useUpdateConfig(); + + const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => { + try { + await updateConfig.mutateAsync({ [key]: value }); + toast.success('System settings updated'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to update settings')); + } + }; + + const mfaPct = dashboard + ? Math.round(dashboard.mfa_adoption_rate * 100) + : null; + + return ( +
+ {/* Stats row */} +
+ } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Admins" + value={dashboard?.admin_count ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> +
+ + {/* User table */} + + +
+
+ +
+ Users +
+ +
+ + {usersLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : !users?.length ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + + + {users.map((user: AdminUserDetail, idx) => ( + + + + + + + + + + + ))} + +
+ Username + + Role + + Status + + Last Login + + MFA + + Sessions + + Created + + Actions +
{user.username} + + + + {user.is_active ? 'Active' : 'Disabled'} + + + {user.last_login_at ? getRelativeTime(user.last_login_at) : '—'} + + {user.totp_enabled ? ( + + On + + ) : user.mfa_enforce_pending ? ( + + Pending + + ) : ( + + )} + + {user.active_sessions} + + {getRelativeTime(user.created_at)} + + +
+
+ )} +
+
+ + {/* System settings */} + + +
+
+ +
+ System Settings +
+
+ + {configLoading ? ( +
+ + +
+ ) : ( + <> +
+
+ +

+ When enabled, the /register page accepts new sign-ups. +

+
+ handleConfigToggle('allow_registration', v)} + disabled={updateConfig.isPending} + /> +
+ +
+
+ +

+ Newly registered users will be required to set up TOTP before accessing the app. +

+
+ handleConfigToggle('enforce_mfa_new_users', v)} + disabled={updateConfig.isPending} + /> +
+ + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx new file mode 100644 index 0000000..3163b2f --- /dev/null +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -0,0 +1,296 @@ +import { useState, useRef, useEffect } from 'react'; +import { toast } from 'sonner'; +import { + MoreHorizontal, + ShieldCheck, + ShieldOff, + KeyRound, + UserX, + UserCheck, + LogOut, + Smartphone, + SmartphoneOff, + ChevronRight, + Loader2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { + useUpdateRole, + useResetPassword, + useDisableMfa, + useEnforceMfa, + useRemoveMfaEnforcement, + useToggleUserActive, + useRevokeSessions, + getErrorMessage, +} from '@/hooks/useAdmin'; +import type { AdminUserDetail, UserRole } from '@/types'; +import { cn } from '@/lib/utils'; + +interface UserActionsMenuProps { + user: AdminUserDetail; +} + +const ROLES: { value: UserRole; label: string }[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'standard', label: 'Standard' }, + { value: 'public_event_manager', label: 'Public Event Manager' }, +]; + +export default function UserActionsMenu({ user }: UserActionsMenuProps) { + const [open, setOpen] = useState(false); + const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false); + const [showResetPassword, setShowResetPassword] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const menuRef = useRef(null); + + const updateRole = useUpdateRole(); + const resetPassword = useResetPassword(); + const disableMfa = useDisableMfa(); + const enforceMfa = useEnforceMfa(); + const removeMfaEnforcement = useRemoveMfaEnforcement(); + const toggleActive = useToggleUserActive(); + const revokeSessions = useRevokeSessions(); + + // Close on outside click + useEffect(() => { + const handleOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + setRoleSubmenuOpen(false); + } + }; + if (open) document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [open]); + + const handleAction = async (fn: () => Promise, successMsg: string) => { + try { + await fn(); + toast.success(successMsg); + setOpen(false); + } catch (err) { + toast.error(getErrorMessage(err, 'Action failed')); + } + }; + + // Two-click confirms + const disableMfaConfirm = useConfirmAction(() => { + handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled'); + }); + + const toggleActiveConfirm = useConfirmAction(() => { + handleAction( + () => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }), + user.is_active ? 'Account disabled' : 'Account enabled' + ); + }); + + const revokeSessionsConfirm = useConfirmAction(() => { + handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked'); + }); + + const isLoading = + updateRole.isPending || + resetPassword.isPending || + disableMfa.isPending || + enforceMfa.isPending || + removeMfaEnforcement.isPending || + toggleActive.isPending || + revokeSessions.isPending; + + return ( +
+ + + {open && ( +
+ {/* Edit Role */} +
+ + + {roleSubmenuOpen && ( +
setRoleSubmenuOpen(true)} + onMouseLeave={() => setRoleSubmenuOpen(false)} + > + {ROLES.map(({ value, label }) => ( + + ))} +
+ )} +
+ + {/* Reset Password */} + {!showResetPassword ? ( + + ) : ( +
+ setNewPassword(e.target.value)} + autoFocus + /> +
+ + +
+
+ )} + +
+ + {/* MFA actions */} + {user.mfa_enforce_pending ? ( + + ) : ( + + )} + + {user.totp_enabled && ( + + )} + +
+ + {/* Disable / Enable Account */} + + + {/* Revoke Sessions */} + +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts new file mode 100644 index 0000000..3d9d484 --- /dev/null +++ b/frontend/src/hooks/useAdmin.ts @@ -0,0 +1,165 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api, { getErrorMessage } from '@/lib/api'; +import type { + AdminUser, + AdminUserDetail, + AdminDashboardData, + SystemConfig, + AuditLogEntry, + UserRole, +} from '@/types'; + +interface AuditLogResponse { + entries: AuditLogEntry[]; + total: number; + page: number; + per_page: number; +} + +interface CreateUserPayload { + username: string; + password: string; + role: UserRole; +} + +interface UpdateRolePayload { + userId: number; + role: UserRole; +} + +interface ResetPasswordPayload { + userId: number; + new_password: string; +} + +// ── Queries ────────────────────────────────────────────────────────────────── + +export function useAdminUsers() { + return useQuery({ + queryKey: ['admin', 'users'], + queryFn: async () => { + const { data } = await api.get('/admin/users'); + return data; + }, + }); +} + +export function useAdminDashboard() { + return useQuery({ + queryKey: ['admin', 'dashboard'], + queryFn: async () => { + const { data } = await api.get('/admin/dashboard'); + return data; + }, + }); +} + +export function useAdminConfig() { + return useQuery({ + queryKey: ['admin', 'config'], + queryFn: async () => { + const { data } = await api.get('/admin/config'); + return data; + }, + }); +} + +export function useAuditLog( + page: number, + perPage: number, + action?: string, + targetUserId?: number +) { + return useQuery({ + queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId], + queryFn: async () => { + const params: Record = { page, per_page: perPage }; + if (action) params.action = action; + if (targetUserId) params.target_user_id = targetUserId; + const { data } = await api.get('/admin/audit-log', { params }); + return data; + }, + }); +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +function useAdminMutation( + mutationFn: (vars: TVariables) => Promise, + onSuccess?: () => void +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin'] }); + onSuccess?.(); + }, + }); +} + +export function useCreateUser() { + return useAdminMutation(async (payload: CreateUserPayload) => { + const { data } = await api.post('/admin/users', payload); + return data; + }); +} + +export function useUpdateRole() { + return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => { + const { data } = await api.patch(`/admin/users/${userId}/role`, { role }); + return data; + }); +} + +export function useResetPassword() { + return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => { + const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password }); + return data; + }); +} + +export function useDisableMfa() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/totp`); + return data; + }); +} + +export function useEnforceMfa() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`); + return data; + }); +} + +export function useRemoveMfaEnforcement() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`); + return data; + }); +} + +export function useToggleUserActive() { + return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => { + const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active }); + return data; + }); +} + +export function useRevokeSessions() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/sessions`); + return data; + }); +} + +export function useUpdateConfig() { + return useAdminMutation(async (config: Partial) => { + const { data } = await api.patch('/admin/config', config); + return data; + }); +} + +// Re-export getErrorMessage for convenience in admin components +export { getErrorMessage }; From d8bdae8ec39116b8138d375a38f9de113f799018 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 19:06:25 +0800 Subject: [PATCH 3/3] Implement multi-user RBAC: database, auth, routing, admin API (Phases 1-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Add role, mfa_enforce_pending, must_change_password to users table. Create system_config (singleton) and audit_log tables. Migration 026. Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects, calendars, people, locations, event_templates, ntfy_sent) with 4-step nullable→backfill→FK→NOT NULL pattern. Migrations 027-034. Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add MFA enforcement token serializer with distinct salt, rewrite auth router with require_role() factory and registration endpoint. Phase 4: Scope all 12 routers by user_id, fix dependency type bugs, bound weather cache (SEC-15), multi-user ntfy dispatch. Phase 5: Create admin router (14 endpoints), admin schemas, audit service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With. Phase 6: Update frontend types, useAuth hook (role/isAdmin/register), App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header). Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15. Co-Authored-By: Claude Opus 4.6 --- .../026_add_user_role_and_system_config.py | 101 +++ .../versions/027_add_user_id_to_todos.py | 38 + .../versions/028_add_user_id_to_reminders.py | 36 + .../versions/029_add_user_id_to_projects.py | 36 + .../versions/030_add_user_id_to_calendars.py | 36 + .../versions/031_add_user_id_to_people.py | 36 + .../versions/032_add_user_id_to_locations.py | 34 + .../033_add_user_id_to_event_templates.py | 34 + .../versions/034_add_user_id_to_ntfy_sent.py | 46 ++ backend/app/jobs/notifications.py | 69 +- backend/app/main.py | 5 +- backend/app/models/__init__.py | 4 + backend/app/models/audit_log.py | 27 + backend/app/models/calendar.py | 5 +- backend/app/models/event_template.py | 3 + backend/app/models/location.py | 5 +- backend/app/models/ntfy_sent.py | 12 +- backend/app/models/person.py | 5 +- backend/app/models/project.py | 5 +- backend/app/models/reminder.py | 5 +- backend/app/models/system_config.py | 27 + backend/app/models/todo.py | 3 + backend/app/models/user.py | 16 + backend/app/routers/admin.py | 687 ++++++++++++++++++ backend/app/routers/auth.py | 242 +++++- backend/app/routers/calendars.py | 24 +- backend/app/routers/dashboard.py | 46 +- backend/app/routers/event_templates.py | 18 +- backend/app/routers/events.py | 74 +- backend/app/routers/locations.py | 21 +- backend/app/routers/people.py | 16 +- backend/app/routers/projects.py | 67 +- backend/app/routers/reminders.py | 40 +- backend/app/routers/todos.py | 45 +- backend/app/routers/weather.py | 56 +- backend/app/schemas/admin.py | 133 ++++ backend/app/schemas/auth.py | 41 +- backend/app/services/audit.py | 22 + backend/app/services/auth.py | 29 + frontend/nginx.conf | 18 + frontend/src/App.tsx | 31 + frontend/src/components/layout/Sidebar.tsx | 13 +- frontend/src/hooks/useAuth.ts | 39 +- frontend/src/lib/api.ts | 1 + frontend/src/types/index.ts | 64 +- 45 files changed, 2148 insertions(+), 167 deletions(-) create mode 100644 backend/alembic/versions/026_add_user_role_and_system_config.py create mode 100644 backend/alembic/versions/027_add_user_id_to_todos.py create mode 100644 backend/alembic/versions/028_add_user_id_to_reminders.py create mode 100644 backend/alembic/versions/029_add_user_id_to_projects.py create mode 100644 backend/alembic/versions/030_add_user_id_to_calendars.py create mode 100644 backend/alembic/versions/031_add_user_id_to_people.py create mode 100644 backend/alembic/versions/032_add_user_id_to_locations.py create mode 100644 backend/alembic/versions/033_add_user_id_to_event_templates.py create mode 100644 backend/alembic/versions/034_add_user_id_to_ntfy_sent.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/system_config.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/services/audit.py diff --git a/backend/alembic/versions/026_add_user_role_and_system_config.py b/backend/alembic/versions/026_add_user_role_and_system_config.py new file mode 100644 index 0000000..9bb863a --- /dev/null +++ b/backend/alembic/versions/026_add_user_role_and_system_config.py @@ -0,0 +1,101 @@ +"""Add role, mfa_enforce_pending, must_change_password to users; create system_config table. + +Revision ID: 026 +Revises: 025 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add role column with server_default for existing rows + op.add_column("users", sa.Column( + "role", sa.String(30), nullable=False, server_default="standard" + )) + + # 2. Add MFA enforcement pending flag + op.add_column("users", sa.Column( + "mfa_enforce_pending", sa.Boolean(), nullable=False, server_default="false" + )) + + # 3. Add forced password change flag (SEC-12) + op.add_column("users", sa.Column( + "must_change_password", sa.Boolean(), nullable=False, server_default="false" + )) + + # 4. Add last_password_change_at audit column + op.add_column("users", sa.Column( + "last_password_change_at", sa.DateTime(), nullable=True + )) + + # 5. Add CHECK constraint on role values (SEC-16) + op.create_check_constraint( + "ck_users_role", + "users", + "role IN ('admin', 'standard', 'public_event_manager')" + ) + + # 6. Promote the first (existing) user to admin + op.execute( + "UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)" + ) + + # 7. Create system_config table (singleton pattern -- always id=1) + op.create_table( + "system_config", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("allow_registration", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("enforce_mfa_new_users", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + # SEC-09: Enforce singleton row + sa.CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + # 8. Seed the singleton row + op.execute( + "INSERT INTO system_config (id, allow_registration, enforce_mfa_new_users) " + "VALUES (1, false, false)" + ) + + # 9. Create audit_log table + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("target_user_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(100), nullable=False), + sa.Column("detail", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]), + sa.ForeignKeyConstraint( + ["target_user_id"], ["users.id"], ondelete="SET NULL" + ), + ) + op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"]) + op.create_index("ix_audit_log_target_user_id", "audit_log", ["target_user_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_log_created_at", table_name="audit_log") + op.drop_index("ix_audit_log_action", table_name="audit_log") + op.drop_index("ix_audit_log_target_user_id", table_name="audit_log") + op.drop_index("ix_audit_log_actor_user_id", table_name="audit_log") + op.drop_table("audit_log") + op.drop_table("system_config") + op.drop_constraint("ck_users_role", "users", type_="check") + op.drop_column("users", "last_password_change_at") + op.drop_column("users", "must_change_password") + op.drop_column("users", "mfa_enforce_pending") + op.drop_column("users", "role") diff --git a/backend/alembic/versions/027_add_user_id_to_todos.py b/backend/alembic/versions/027_add_user_id_to_todos.py new file mode 100644 index 0000000..4a60855 --- /dev/null +++ b/backend/alembic/versions/027_add_user_id_to_todos.py @@ -0,0 +1,38 @@ +"""Add user_id FK to todos table. + +Revision ID: 027 +Revises: 026 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "027" +down_revision = "026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("todos", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE todos SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_todos_user_id", "todos", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("todos", "user_id", nullable=False) + op.create_index("ix_todos_user_id", "todos", ["user_id"]) + op.create_index("ix_todos_user_completed", "todos", ["user_id", "completed"]) + op.create_index("ix_todos_user_due_date", "todos", ["user_id", "due_date"]) + + +def downgrade() -> None: + op.drop_index("ix_todos_user_due_date", table_name="todos") + op.drop_index("ix_todos_user_completed", table_name="todos") + op.drop_index("ix_todos_user_id", table_name="todos") + op.drop_constraint("fk_todos_user_id", "todos", type_="foreignkey") + op.drop_column("todos", "user_id") diff --git a/backend/alembic/versions/028_add_user_id_to_reminders.py b/backend/alembic/versions/028_add_user_id_to_reminders.py new file mode 100644 index 0000000..75a2dbb --- /dev/null +++ b/backend/alembic/versions/028_add_user_id_to_reminders.py @@ -0,0 +1,36 @@ +"""Add user_id FK to reminders table. + +Revision ID: 028 +Revises: 027 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "028" +down_revision = "027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("reminders", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE reminders SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_reminders_user_id", "reminders", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("reminders", "user_id", nullable=False) + op.create_index("ix_reminders_user_id", "reminders", ["user_id"]) + op.create_index("ix_reminders_user_remind_at", "reminders", ["user_id", "remind_at"]) + + +def downgrade() -> None: + op.drop_index("ix_reminders_user_remind_at", table_name="reminders") + op.drop_index("ix_reminders_user_id", table_name="reminders") + op.drop_constraint("fk_reminders_user_id", "reminders", type_="foreignkey") + op.drop_column("reminders", "user_id") diff --git a/backend/alembic/versions/029_add_user_id_to_projects.py b/backend/alembic/versions/029_add_user_id_to_projects.py new file mode 100644 index 0000000..f5f9031 --- /dev/null +++ b/backend/alembic/versions/029_add_user_id_to_projects.py @@ -0,0 +1,36 @@ +"""Add user_id FK to projects table. + +Revision ID: 029 +Revises: 028 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "029" +down_revision = "028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("projects", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE projects SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_projects_user_id", "projects", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("projects", "user_id", nullable=False) + op.create_index("ix_projects_user_id", "projects", ["user_id"]) + op.create_index("ix_projects_user_status", "projects", ["user_id", "status"]) + + +def downgrade() -> None: + op.drop_index("ix_projects_user_status", table_name="projects") + op.drop_index("ix_projects_user_id", table_name="projects") + op.drop_constraint("fk_projects_user_id", "projects", type_="foreignkey") + op.drop_column("projects", "user_id") diff --git a/backend/alembic/versions/030_add_user_id_to_calendars.py b/backend/alembic/versions/030_add_user_id_to_calendars.py new file mode 100644 index 0000000..ef1b621 --- /dev/null +++ b/backend/alembic/versions/030_add_user_id_to_calendars.py @@ -0,0 +1,36 @@ +"""Add user_id FK to calendars table. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE calendars SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_calendars_user_id", "calendars", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("calendars", "user_id", nullable=False) + op.create_index("ix_calendars_user_id", "calendars", ["user_id"]) + op.create_index("ix_calendars_user_default", "calendars", ["user_id", "is_default"]) + + +def downgrade() -> None: + op.drop_index("ix_calendars_user_default", table_name="calendars") + op.drop_index("ix_calendars_user_id", table_name="calendars") + op.drop_constraint("fk_calendars_user_id", "calendars", type_="foreignkey") + op.drop_column("calendars", "user_id") diff --git a/backend/alembic/versions/031_add_user_id_to_people.py b/backend/alembic/versions/031_add_user_id_to_people.py new file mode 100644 index 0000000..60ee2bc --- /dev/null +++ b/backend/alembic/versions/031_add_user_id_to_people.py @@ -0,0 +1,36 @@ +"""Add user_id FK to people table. + +Revision ID: 031 +Revises: 030 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "031" +down_revision = "030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("people", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE people SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_people_user_id", "people", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("people", "user_id", nullable=False) + op.create_index("ix_people_user_id", "people", ["user_id"]) + op.create_index("ix_people_user_name", "people", ["user_id", "name"]) + + +def downgrade() -> None: + op.drop_index("ix_people_user_name", table_name="people") + op.drop_index("ix_people_user_id", table_name="people") + op.drop_constraint("fk_people_user_id", "people", type_="foreignkey") + op.drop_column("people", "user_id") diff --git a/backend/alembic/versions/032_add_user_id_to_locations.py b/backend/alembic/versions/032_add_user_id_to_locations.py new file mode 100644 index 0000000..996a207 --- /dev/null +++ b/backend/alembic/versions/032_add_user_id_to_locations.py @@ -0,0 +1,34 @@ +"""Add user_id FK to locations table. + +Revision ID: 032 +Revises: 031 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "032" +down_revision = "031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("locations", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE locations SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_locations_user_id", "locations", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("locations", "user_id", nullable=False) + op.create_index("ix_locations_user_id", "locations", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_locations_user_id", table_name="locations") + op.drop_constraint("fk_locations_user_id", "locations", type_="foreignkey") + op.drop_column("locations", "user_id") diff --git a/backend/alembic/versions/033_add_user_id_to_event_templates.py b/backend/alembic/versions/033_add_user_id_to_event_templates.py new file mode 100644 index 0000000..5ca90a4 --- /dev/null +++ b/backend/alembic/versions/033_add_user_id_to_event_templates.py @@ -0,0 +1,34 @@ +"""Add user_id FK to event_templates table. + +Revision ID: 033 +Revises: 032 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "033" +down_revision = "032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("event_templates", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE event_templates SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_event_templates_user_id", "event_templates", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("event_templates", "user_id", nullable=False) + op.create_index("ix_event_templates_user_id", "event_templates", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_event_templates_user_id", table_name="event_templates") + op.drop_constraint("fk_event_templates_user_id", "event_templates", type_="foreignkey") + op.drop_column("event_templates", "user_id") diff --git a/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py new file mode 100644 index 0000000..81b3a3d --- /dev/null +++ b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py @@ -0,0 +1,46 @@ +"""Add user_id FK to ntfy_sent table, rebuild unique constraint as composite. + +Revision ID: 034 +Revises: 033 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "034" +down_revision = "033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ntfy_sent", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE ntfy_sent SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_ntfy_sent_user_id", "ntfy_sent", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("ntfy_sent", "user_id", nullable=False) + + # Drop old unique constraint on notification_key alone + op.drop_constraint("ntfy_sent_notification_key_key", "ntfy_sent", type_="unique") + + # Create composite unique constraint (per-user dedup) + op.create_unique_constraint( + "uq_ntfy_sent_user_key", "ntfy_sent", ["user_id", "notification_key"] + ) + op.create_index("ix_ntfy_sent_user_id", "ntfy_sent", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent") + op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique") + op.create_unique_constraint( + "ntfy_sent_notification_key_key", "ntfy_sent", ["notification_key"] + ) + op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey") + op.drop_column("ntfy_sent", "user_id") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index cd3099e..bcd0961 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -19,6 +19,7 @@ from app.database import AsyncSessionLocal from app.models.settings import Settings from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.todo import Todo from app.models.project import Project from app.models.ntfy_sent import NtfySent @@ -55,10 +56,11 @@ async def _mark_sent(db: AsyncSession, key: str) -> None: async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: """Send notifications for reminders that are currently due and not dismissed/snoozed.""" - # Mirror the filter from /api/reminders/due + # Mirror the filter from /api/reminders/due, scoped to this user result = await db.execute( select(Reminder).where( and_( + Reminder.user_id == settings.user_id, Reminder.remind_at <= now, Reminder.is_dismissed == False, # noqa: E712 Reminder.is_active == True, # noqa: E712 @@ -72,8 +74,8 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim if reminder.snoozed_until and reminder.snoozed_until > now: continue # respect snooze - # Key ties notification to the specific day to handle re-fires after midnight - key = f"reminder:{reminder.id}:{reminder.remind_at.date()}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" if await _already_sent(db, key): continue @@ -98,9 +100,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) # Window: events starting between now and (now + lead_minutes) window_end = now + timedelta(minutes=lead_minutes) + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == settings.user_id) + result = await db.execute( select(CalendarEvent).where( and_( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= now, CalendarEvent.start_datetime <= window_end, # Exclude recurring parent templates — they duplicate the child instance rows. @@ -116,8 +122,8 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) today = now.date() for event in events: - # Key includes the minute-precision start to avoid re-firing during the window - key = f"event:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" if await _already_sent(db, key): continue @@ -141,13 +147,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: """Send notifications for incomplete todos due within the configured lead days.""" - from datetime import date as date_type lead_days = settings.ntfy_todo_lead_days cutoff = today + timedelta(days=lead_days) result = await db.execute( select(Todo).where( and_( + Todo.user_id == settings.user_id, Todo.completed == False, # noqa: E712 Todo.due_date != None, # noqa: E711 Todo.due_date <= cutoff, @@ -157,7 +163,8 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: todos = result.scalars().all() for todo in todos: - key = f"todo:{todo.id}:{today}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"todo:{settings.user_id}:{todo.id}:{today}" if await _already_sent(db, key): continue @@ -185,6 +192,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non result = await db.execute( select(Project).where( and_( + Project.user_id == settings.user_id, Project.due_date != None, # noqa: E711 Project.due_date <= cutoff, Project.status != "completed", @@ -194,7 +202,8 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non projects = result.scalars().all() for project in projects: - key = f"project:{project.id}:{today}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"project:{settings.user_id}:{project.id}:{today}" if await _already_sent(db, key): continue @@ -213,6 +222,18 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non await _mark_sent(db, key) +async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: + """Run all notification dispatches for a single user's settings.""" + if settings.ntfy_reminders_enabled: + await _dispatch_reminders(db, settings, now) + if settings.ntfy_events_enabled: + await _dispatch_events(db, settings, now) + if settings.ntfy_todos_enabled: + await _dispatch_todos(db, settings, now.date()) + if settings.ntfy_projects_enabled: + await _dispatch_projects(db, settings, now.date()) + + async def _purge_old_sent_records(db: AsyncSession) -> None: """Remove ntfy_sent entries older than 7 days to keep the table lean.""" # See DATETIME NOTE at top of file re: naive datetime usage @@ -240,29 +261,35 @@ async def run_notification_dispatch() -> None: """ Main dispatch function called by APScheduler every 60 seconds. Uses AsyncSessionLocal directly — not the get_db() request-scoped dependency. + + Iterates over ALL users with ntfy enabled. Per-user errors are caught and + logged individually so one user's failure does not prevent others from + receiving notifications. """ try: async with AsyncSessionLocal() as db: - result = await db.execute(select(Settings)) - settings = result.scalar_one_or_none() + # Fetch all Settings rows that have ntfy enabled + result = await db.execute( + select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712 + ) + all_settings = result.scalars().all() - if not settings or not settings.ntfy_enabled: + if not all_settings: return # See DATETIME NOTE at top of file re: naive datetime usage now = datetime.now() - today = now.date() - if settings.ntfy_reminders_enabled: - await _dispatch_reminders(db, settings, now) - if settings.ntfy_events_enabled: - await _dispatch_events(db, settings, now) - if settings.ntfy_todos_enabled: - await _dispatch_todos(db, settings, today) - if settings.ntfy_projects_enabled: - await _dispatch_projects(db, settings, today) + for user_settings in all_settings: + try: + await _dispatch_for_user(db, user_settings, now) + except Exception: + # Isolate per-user failures — log and continue to next user + logger.exception( + "ntfy dispatch failed for user_id=%s", user_settings.user_id + ) - # Daily housekeeping: purge stale dedup records + # Daily housekeeping: purge stale dedup records (shared across all users) await _purge_old_sent_records(db) # Security housekeeping runs every cycle regardless of ntfy_enabled diff --git a/backend/app/main.py b/backend/app/main.py index c3a7e9a..624a61f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp +from app.routers import totp, admin from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -15,6 +15,8 @@ from app.models import user as _user_model # noqa: F401 from app.models import session as _session_model # noqa: F401 from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401 +from app.models import system_config as _system_config_model # noqa: F401 +from app.models import audit_log as _audit_log_model # noqa: F401 @asynccontextmanager @@ -68,6 +70,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cca74b0..d9d7a34 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,8 @@ from app.models.session import UserSession from app.models.ntfy_sent import NtfySent from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode +from app.models.system_config import SystemConfig +from app.models.audit_log import AuditLog __all__ = [ "Settings", @@ -30,4 +32,6 @@ __all__ = [ "NtfySent", "TOTPUsage", "BackupCode", + "SystemConfig", + "AuditLog", ] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..a16f8a6 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,27 @@ +from sqlalchemy import String, Text, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class AuditLog(Base): + """ + Append-only audit trail for admin actions and auth events. + No DELETE endpoint — this table is immutable once written. + """ + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True, index=True + ) + target_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=func.now(), server_default=func.now(), index=True + ) diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 60de5f7..43ab782 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, func +from sqlalchemy import String, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import List @@ -9,6 +9,9 @@ class Calendar(Base): __tablename__ = "calendars" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(100), nullable=False) color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") diff --git a/backend/app/models/event_template.py b/backend/app/models/event_template.py index 47004de..78f8a2d 100644 --- a/backend/app/models/event_template.py +++ b/backend/app/models/event_template.py @@ -9,6 +9,9 @@ class EventTemplate(Base): __tablename__ = "event_templates" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/location.py b/backend/app/models/location.py index 7dbea4a..c78adf8 100644 --- a/backend/app/models/location.py +++ b/backend/app/models/location.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func, text +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import Optional, List @@ -9,6 +9,9 @@ class Location(Base): __tablename__ = "locations" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(100), default="other") diff --git a/backend/app/models/ntfy_sent.py b/backend/app/models/ntfy_sent.py index ad3868a..a819741 100644 --- a/backend/app/models/ntfy_sent.py +++ b/backend/app/models/ntfy_sent.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, func +from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from app.database import Base @@ -8,7 +8,7 @@ class NtfySent(Base): """ Deduplication table for ntfy notifications. Prevents the background job from re-sending the same notification - within a given time window. + within a given time window. Scoped per-user. Key format: "{type}:{entity_id}:{date_window}" Examples: @@ -18,7 +18,13 @@ class NtfySent(Base): "project:3:2026-02-25" """ __tablename__ = "ntfy_sent" + __table_args__ = ( + UniqueConstraint("user_id", "notification_key", name="uq_ntfy_sent_user_key"), + ) id: Mapped[int] = mapped_column(primary_key=True) - notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + notification_key: Mapped[str] = mapped_column(String(255), index=True) sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index fbf7984..66c3acd 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Date, Boolean, func, text +from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -9,6 +9,9 @@ class Person(Base): __tablename__ = "people" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 57b0969..faefbe5 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,5 +1,5 @@ import sqlalchemy as sa -from sqlalchemy import Boolean, String, Text, Date, func +from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -10,6 +10,9 @@ class Project(Base): __tablename__ = "projects" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), default="not_started") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index 8259d21..261a89b 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -9,6 +9,9 @@ class Reminder(Base): __tablename__ = "reminders" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..3e801b2 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,27 @@ +from sqlalchemy import Boolean, CheckConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class SystemConfig(Base): + """ + Singleton system configuration table (always id=1). + Stores global toggles for registration, MFA enforcement, etc. + """ + __tablename__ = "system_config" + __table_args__ = ( + CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + allow_registration: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + enforce_mfa_new_users: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), onupdate=func.now(), server_default=func.now() + ) diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py index cf38b3f..23850ed 100644 --- a/backend/app/models/todo.py +++ b/backend/app/models/todo.py @@ -9,6 +9,9 @@ class Todo(Base): __tablename__ = "todos" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) priority: Mapped[str] = mapped_column(String(20), default="medium") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8de4311..efe582f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -23,7 +23,23 @@ class User(Base): # Account state is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # RBAC + role: Mapped[str] = mapped_column( + String(30), nullable=False, default="standard", server_default="standard" + ) + + # MFA enforcement (admin can toggle; checked at login) + mfa_enforce_pending: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + + # Forced password change (set after admin reset) + must_change_password: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + # Audit created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) + last_password_change_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..7c80b1a --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,687 @@ +""" +Admin router — full user management, system config, and audit log. + +Security measures implemented: + SEC-02: Session revocation on role change + SEC-05: Block admin self-actions (own role/password/MFA/active status) + SEC-08: X-Requested-With header check (verify_xhr) on all state-mutating requests + SEC-13: Session revocation + ntfy alert on MFA disable + +All routes require the `require_admin` dependency (which chains through +get_current_user, so the session cookie is always validated). +""" +import secrets +from datetime import datetime +from typing import Optional + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.audit_log import AuditLog +from app.models.backup_code import BackupCode +from app.models.session import UserSession +from app.models.system_config import SystemConfig +from app.models.user import User +from app.routers.auth import ( + _create_user_defaults, + get_current_user, + require_admin, +) +from app.schemas.admin import ( + AdminDashboardResponse, + AuditLogEntry, + AuditLogResponse, + CreateUserRequest, + ResetPasswordResponse, + SystemConfigResponse, + SystemConfigUpdate, + ToggleActiveRequest, + ToggleMfaEnforceRequest, + UpdateUserRoleRequest, + UserDetailResponse, + UserListItem, + UserListResponse, +) +from app.services.audit import log_audit_event +from app.services.auth import hash_password + +# --------------------------------------------------------------------------- +# CSRF guard — SEC-08 +# --------------------------------------------------------------------------- + +async def verify_xhr(request: Request) -> None: + """ + Lightweight CSRF mitigation: require X-Requested-With on state-mutating + requests. Browsers never send this header cross-origin without CORS + pre-flight, which our CORS policy blocks. + """ + if request.method not in ("GET", "HEAD", "OPTIONS"): + if request.headers.get("X-Requested-With") != "XMLHttpRequest": + raise HTTPException(status_code=403, detail="Invalid request origin") + + +# --------------------------------------------------------------------------- +# Router — all endpoints inherit require_admin + verify_xhr +# --------------------------------------------------------------------------- + +router = APIRouter( + dependencies=[Depends(require_admin), Depends(verify_xhr)], +) + + +# --------------------------------------------------------------------------- +# Session revocation helper (used in multiple endpoints) +# --------------------------------------------------------------------------- + +async def _revoke_all_sessions(db: AsyncSession, user_id: int) -> int: + """Mark every active session for user_id as revoked. Returns count revoked.""" + result = await db.execute( + sa.update(UserSession) + .where(UserSession.user_id == user_id, UserSession.revoked == False) + .values(revoked=True) + .returning(UserSession.id) + ) + return len(result.fetchall()) + + +# --------------------------------------------------------------------------- +# Self-action guard — SEC-05 +# --------------------------------------------------------------------------- + +def _guard_self_action(actor: User, target_id: int, action: str) -> None: + """Raise 403 if an admin attempts a privileged action against their own account.""" + if actor.id == target_id: + raise HTTPException( + status_code=403, + detail=f"Admins cannot {action} their own account", + ) + + +# --------------------------------------------------------------------------- +# GET /users +# --------------------------------------------------------------------------- + +@router.get("/users", response_model=UserListResponse) +async def list_users( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return all users with basic stats.""" + result = await db.execute(sa.select(User).order_by(User.created_at)) + users = result.scalars().all() + return UserListResponse( + users=[UserListItem.model_validate(u) for u in users], + total=len(users), + ) + + +# --------------------------------------------------------------------------- +# GET /users/{user_id} +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: int, + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return a single user with their active session count.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + session_result = await db.execute( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + active_sessions = session_result.scalar_one() + + return UserDetailResponse( + **UserListItem.model_validate(user).model_dump(), + active_sessions=active_sessions, + ) + + +# --------------------------------------------------------------------------- +# POST /users +# --------------------------------------------------------------------------- + +@router.post("/users", response_model=UserDetailResponse, status_code=201) +async def create_user( + data: CreateUserRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Admin-create a user with Settings and default calendars.""" + existing = await db.execute(sa.select(User).where(User.username == data.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Username already taken") + + new_user = User( + username=data.username, + password_hash=hash_password(data.password), + role=data.role, + last_password_change_at=datetime.now(), + # Force password change so the user sets their own credential + must_change_password=True, + ) + db.add(new_user) + await db.flush() # populate new_user.id + + await _create_user_defaults(db, new_user.id) + await db.commit() + + await log_audit_event( + db, + action="admin.user_created", + actor_id=actor.id, + target_id=new_user.id, + detail={"username": new_user.username, "role": new_user.role}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return UserDetailResponse( + **UserListItem.model_validate(new_user).model_dump(), + active_sessions=0, + ) + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/role — SEC-02, SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/role") +async def update_user_role( + user_id: int, + data: UpdateUserRoleRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Change a user's role. + Blocks demotion of the last admin (SEC-05 variant). + Revokes all sessions after role change (SEC-02). + """ + _guard_self_action(actor, user_id, "change role of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent demoting the last admin + if user.role == "admin" and data.role != "admin": + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + if admin_count <= 1: + raise HTTPException( + status_code=409, + detail="Cannot demote the last admin account", + ) + + old_role = user.role + user.role = data.role + + # SEC-02: revoke sessions so the new role takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.role_changed", + actor_id=actor.id, + target_id=user_id, + detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"Role updated to '{data.role}'. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/reset-password — SEC-05 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse) +async def reset_user_password( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Generate a temporary password, revoke all sessions, and mark must_change_password. + The admin is shown the plaintext temp password once — it is not stored. + """ + _guard_self_action(actor, user_id, "reset the password of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + temp_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(temp_password) + user.must_change_password = True + user.last_password_change_at = datetime.now() + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.password_reset", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return ResetPasswordResponse( + message=f"Password reset. {revoked} session(s) revoked. User must change password on next login.", + temporary_password=temp_password, + ) + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/disable-mfa — SEC-05, SEC-13 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/disable-mfa") +async def disable_user_mfa( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Clear TOTP secret + backup codes and revoke all sessions (SEC-13). + """ + _guard_self_action(actor, user_id, "disable MFA for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.totp_enabled: + raise HTTPException(status_code=409, detail="MFA is not enabled for this user") + + # Clear TOTP data + user.totp_secret = None + user.totp_enabled = False + user.mfa_enforce_pending = False + + # Remove all backup codes + await db.execute( + sa.delete(BackupCode).where(BackupCode.user_id == user_id) + ) + + # SEC-13: revoke sessions so the MFA downgrade takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.mfa_disabled", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA disabled. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/enforce-mfa — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/enforce-mfa") +async def toggle_mfa_enforce( + user_id: int, + data: ToggleMfaEnforceRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Toggle the mfa_enforce_pending flag. Next login will prompt MFA setup.""" + _guard_self_action(actor, user_id, "toggle MFA enforcement for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.mfa_enforce_pending = data.enforce + + await log_audit_event( + db, + action="admin.mfa_enforce_toggled", + actor_id=actor.id, + target_id=user_id, + detail={"enforce": data.enforce}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA enforcement {'enabled' if data.enforce else 'disabled'} for user."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/active — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/active") +async def toggle_user_active( + user_id: int, + data: ToggleActiveRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Enable or disable a user account. + Revoking an account also revokes all active sessions immediately. + """ + _guard_self_action(actor, user_id, "change active status of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.is_active = data.is_active + revoked = 0 + + if not data.is_active: + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.user_deactivated" if not data.is_active else "admin.user_activated", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + state = "activated" if data.is_active else f"deactivated ({revoked} session(s) revoked)" + return {"message": f"User {state}."} + + +# --------------------------------------------------------------------------- +# DELETE /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.delete("/users/{user_id}/sessions") +async def revoke_user_sessions( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Forcibly revoke all active sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.sessions_revoked", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"{revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# GET /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}/sessions") +async def list_user_sessions( + user_id: int, + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """List all active (non-revoked, non-expired) sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + sessions_result = await db.execute( + sa.select(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ).order_by(UserSession.created_at.desc()) + ) + sessions = sessions_result.scalars().all() + + return { + "sessions": [ + { + "id": s.id, + "created_at": s.created_at, + "expires_at": s.expires_at, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + } + for s in sessions + ], + "total": len(sessions), + } + + +# --------------------------------------------------------------------------- +# GET /config +# --------------------------------------------------------------------------- + +@router.get("/config", response_model=SystemConfigResponse) +async def get_system_config( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Fetch the singleton system configuration row.""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + # Bootstrap the singleton if it doesn't exist yet + config = SystemConfig(id=1) + db.add(config) + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# PUT /config +# --------------------------------------------------------------------------- + +@router.put("/config", response_model=SystemConfigResponse) +async def update_system_config( + data: SystemConfigUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Update one or more system config fields (partial update).""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + config = SystemConfig(id=1) + db.add(config) + await db.flush() + + changes: dict = {} + if data.allow_registration is not None: + changes["allow_registration"] = data.allow_registration + config.allow_registration = data.allow_registration + if data.enforce_mfa_new_users is not None: + changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users + config.enforce_mfa_new_users = data.enforce_mfa_new_users + + if changes: + await log_audit_event( + db, + action="admin.config_updated", + actor_id=actor.id, + detail=changes, + ip=request.client.host if request.client else None, + ) + + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# GET /dashboard +# --------------------------------------------------------------------------- + +@router.get("/dashboard", response_model=AdminDashboardResponse) +async def admin_dashboard( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Aggregate stats for the admin portal dashboard.""" + total_users = await db.scalar( + sa.select(sa.func.count()).select_from(User) + ) + active_users = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.is_active == True) + ) + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + totp_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True) + ) + active_sessions = await db.scalar( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + + mfa_adoption = (totp_count / total_users) if total_users else 0.0 + + # 10 most recent logins — join to get username + actor_alias = sa.alias(User.__table__, name="actor") + recent_logins_result = await db.execute( + sa.select(User.username, User.last_login_at) + .where(User.last_login_at != None) + .order_by(User.last_login_at.desc()) + .limit(10) + ) + recent_logins = [ + {"username": row.username, "last_login_at": row.last_login_at} + for row in recent_logins_result + ] + + # 10 most recent audit entries + recent_audit_result = await db.execute( + sa.select(AuditLog).order_by(AuditLog.created_at.desc()).limit(10) + ) + recent_audit_entries = [ + { + "id": e.id, + "action": e.action, + "actor_user_id": e.actor_user_id, + "target_user_id": e.target_user_id, + "detail": e.detail, + "created_at": e.created_at, + } + for e in recent_audit_result.scalars() + ] + + return AdminDashboardResponse( + total_users=total_users or 0, + active_users=active_users or 0, + admin_count=admin_count or 0, + active_sessions=active_sessions or 0, + mfa_adoption_rate=round(mfa_adoption, 4), + recent_logins=recent_logins, + recent_audit_entries=recent_audit_entries, + ) + + +# --------------------------------------------------------------------------- +# GET /audit-log +# --------------------------------------------------------------------------- + +@router.get("/audit-log", response_model=AuditLogResponse) +async def get_audit_log( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), + action: Optional[str] = Query(None, description="Filter by action string (prefix match)"), + target_user_id: Optional[int] = Query(None, description="Filter by target user ID"), + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + per_page: int = Query(50, ge=1, le=200, description="Results per page"), +): + """ + Paginated audit log with optional filters. + Resolves actor and target user IDs to usernames via a JOIN. + """ + # Aliases for the two user joins + actor_user = sa.orm.aliased(User, name="actor_user") + target_user = sa.orm.aliased(User, name="target_user") + + # Base query — left outer join so entries with NULL actor/target still appear + base_q = ( + sa.select( + AuditLog, + actor_user.username.label("actor_username"), + target_user.username.label("target_username"), + ) + .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) + .outerjoin(target_user, AuditLog.target_user_id == target_user.id) + ) + + if action: + base_q = base_q.where(AuditLog.action.like(f"{action}%")) + if target_user_id is not None: + base_q = base_q.where(AuditLog.target_user_id == target_user_id) + + # Count before pagination + count_q = sa.select(sa.func.count()).select_from( + base_q.subquery() + ) + total = await db.scalar(count_q) or 0 + + # Paginate + offset = (page - 1) * per_page + rows_result = await db.execute( + base_q.order_by(AuditLog.created_at.desc()).offset(offset).limit(per_page) + ) + + entries = [ + AuditLogEntry( + id=row.AuditLog.id, + actor_username=row.actor_username, + target_username=row.target_username, + action=row.AuditLog.action, + detail=row.AuditLog.detail, + ip_address=row.AuditLog.ip_address, + created_at=row.AuditLog.created_at, + ) + for row in rows_result + ] + + return AuditLogResponse(entries=entries, total=total) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e0fb04b..5dbeac1 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,18 +1,20 @@ """ -Authentication router — username/password with DB-backed sessions and account lockout. +Authentication router — username/password with DB-backed sessions, account lockout, +role-based access control, and multi-user registration. Session flow: - POST /setup → create User + Settings row → issue session cookie - POST /login → verify credentials → check lockout → insert UserSession → issue cookie - → if TOTP enabled: return mfa_token instead of full session + POST /setup → create admin User + Settings + calendars → issue session cookie + POST /login → verify credentials → check lockout → MFA/enforce checks → issue session + POST /register → create standard user (when registration enabled) POST /logout → mark session revoked in DB → delete cookie - GET /status → verify user exists + session valid + GET /status → verify user exists + session valid + role + registration_open Security layers: - 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints - 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard + 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints + 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) 3. Session revocation stored in DB (survives container restarts) - 4. bcrypt→Argon2id transparent upgrade on first login with migrated hash + 4. bcrypt→Argon2id transparent upgrade on first login + 5. Role-based authorization via require_role() dependency factory """ import uuid from datetime import datetime, timedelta @@ -26,14 +28,21 @@ from app.database import get_db from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings -from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest +from app.models.system_config import SystemConfig +from app.models.calendar import Calendar +from app.schemas.auth import ( + SetupRequest, LoginRequest, RegisterRequest, + ChangePasswordRequest, VerifyPasswordRequest, +) from app.services.auth import ( hash_password, verify_password_with_upgrade, create_session_token, verify_session_token, create_mfa_token, + create_mfa_enforce_token, ) +from app.services.audit import log_audit_event from app.config import settings as app_settings router = APIRouter() @@ -64,8 +73,6 @@ async def get_current_user( ) -> User: """ Dependency that verifies the session cookie and returns the authenticated User. - Replaces the old get_current_session (which returned Settings). - Any router that hasn't been updated will get a compile-time type error. """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") @@ -119,6 +126,24 @@ async def get_current_settings( return settings_obj +# --------------------------------------------------------------------------- +# Role-based authorization dependencies +# --------------------------------------------------------------------------- + +def require_role(*allowed_roles: str): + """Factory: returns a dependency that enforces role membership.""" + async def _check( + current_user: User = Depends(get_current_user), + ) -> User: + if current_user.role not in allowed_roles: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return current_user + return _check + +# Convenience aliases +require_admin = require_role("admin") + + # --------------------------------------------------------------------------- # Account lockout helpers # --------------------------------------------------------------------------- @@ -166,7 +191,7 @@ async def _create_db_session( id=session_id, user_id=user.id, expires_at=expires_at, - ip_address=ip[:45] if ip else None, # clamp to column width + ip_address=ip[:45] if ip else None, user_agent=(user_agent or "")[:255] if user_agent else None, ) db.add(db_session) @@ -175,6 +200,25 @@ async def _create_db_session( return session_id, token +# --------------------------------------------------------------------------- +# User bootstrapping helper (Settings + default calendars) +# --------------------------------------------------------------------------- + +async def _create_user_defaults(db: AsyncSession, user_id: int) -> None: + """Create Settings row and default calendars for a new user.""" + db.add(Settings(user_id=user_id)) + db.add(Calendar( + name="Personal", color="#3b82f6", + is_default=True, is_system=False, is_visible=True, + user_id=user_id, + )) + db.add(Calendar( + name="Birthdays", color="#f59e0b", + is_default=False, is_system=True, is_visible=True, + user_id=user_id, + )) + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -187,7 +231,7 @@ async def setup( db: AsyncSession = Depends(get_db), ): """ - First-time setup: create the User record and a linked Settings row. + First-time setup: create the admin User + Settings + default calendars. Only works when no users exist (i.e., fresh install). """ existing = await db.execute(select(User)) @@ -195,13 +239,16 @@ async def setup( raise HTTPException(status_code=400, detail="Setup already completed") password_hash = hash_password(data.password) - new_user = User(username=data.username, password_hash=password_hash) + new_user = User( + username=data.username, + password_hash=password_hash, + role="admin", + last_password_change_at=datetime.now(), + ) db.add(new_user) - await db.flush() # assign new_user.id before creating Settings + await db.flush() - # Create Settings row linked to this user with all defaults - new_settings = Settings(user_id=new_user.id) - db.add(new_settings) + await _create_user_defaults(db, new_user.id) await db.commit() ip = request.client.host if request.client else "unknown" @@ -209,6 +256,11 @@ async def setup( _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, + ) + await db.commit() + return {"message": "Setup completed successfully", "authenticated": True} @@ -223,15 +275,15 @@ async def login( Authenticate with username + password. Returns: - { authenticated: true } — on success (no TOTP) + { authenticated: true } — on success (no TOTP, no enforcement) { authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending - HTTP 401 — wrong credentials (generic; never reveals which field is wrong) + { authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement + { authenticated: false, must_change_password: true } — forced password change after admin reset + HTTP 401 — wrong credentials HTTP 423 — account locked - HTTP 429 — IP rate limited """ client_ip = request.client.host if request.client else "unknown" - # Lookup user — do NOT differentiate "user not found" from "wrong password" result = await db.execute(select(User).where(User.username == data.username)) user = result.scalar_one_or_none() @@ -240,20 +292,36 @@ async def login( await _check_account_lockout(user) - # Transparent bcrypt→Argon2id upgrade valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) if not valid: await _record_failed_login(db, user) + await log_audit_event( + db, action="auth.login_failed", actor_id=user.id, + detail={"reason": "invalid_password"}, ip=client_ip, + ) + await db.commit() raise HTTPException(status_code=401, detail="Invalid username or password") - # Persist upgraded hash if migration happened if new_hash: user.password_hash = new_hash await _record_successful_login(db, user) - # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session + # SEC-03: MFA enforcement — block login entirely until MFA setup completes + if user.mfa_enforce_pending and not user.totp_enabled: + enforce_token = create_mfa_enforce_token(user.id) + await log_audit_event( + db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return { + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + # If TOTP is enabled, issue a short-lived MFA challenge token if user.totp_enabled: mfa_token = create_mfa_token(user.id) return { @@ -262,13 +330,97 @@ async def login( "mfa_token": mfa_token, } + # SEC-12: Forced password change after admin reset + if user.must_change_password: + # Issue a session but flag the frontend to show password change + user_agent = request.headers.get("user-agent") + _, token = await _create_db_session(db, user, client_ip, user_agent) + _set_session_cookie(response, token) + return { + "authenticated": True, + "must_change_password": True, + } + user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.login_success", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return {"authenticated": True} +@router.post("/register") +async def register( + data: RegisterRequest, + response: Response, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + Create a new standard user account. + Only available when system_config.allow_registration is True. + """ + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + if not config or not config.allow_registration: + raise HTTPException(status_code=403, detail="Registration is not available") + + # Check username availability (generic error to prevent enumeration) + existing = await db.execute( + select(User).where(User.username == data.username) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Registration failed") + + password_hash = hash_password(data.password) + # SEC-01: Explicit field assignment — never **data.model_dump() + new_user = User( + username=data.username, + password_hash=password_hash, + role="standard", + last_password_change_at=datetime.now(), + ) + + # Check if MFA enforcement is enabled for new users + if config.enforce_mfa_new_users: + new_user.mfa_enforce_pending = True + + db.add(new_user) + await db.flush() + + await _create_user_defaults(db, new_user.id) + await db.commit() + + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent") + + await log_audit_event( + db, action="auth.registration", actor_id=new_user.id, ip=ip, + ) + await db.commit() + + # If MFA enforcement is pending, don't issue a session — require MFA setup first + if new_user.mfa_enforce_pending: + enforce_token = create_mfa_enforce_token(new_user.id) + return { + "message": "Registration successful", + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + _, token = await _create_db_session(db, new_user, ip, user_agent) + _set_session_cookie(response, token) + + return {"message": "Registration successful", "authenticated": True} + + @router.post("/logout") async def logout( response: Response, @@ -304,13 +456,13 @@ async def auth_status( db: AsyncSession = Depends(get_db), ): """ - Check authentication status and whether initial setup has been performed. - Used by the frontend to decide whether to show login vs setup screen. + Check authentication status, role, and whether initial setup/registration is available. """ user_result = await db.execute(select(User)) existing_user = user_result.scalar_one_or_none() setup_required = existing_user is None authenticated = False + role = None if not setup_required and session_cookie: payload = verify_session_token(session_cookie) @@ -326,9 +478,32 @@ async def auth_status( UserSession.expires_at > datetime.now(), ) ) - authenticated = session_result.scalar_one_or_none() is not None + if session_result.scalar_one_or_none() is not None: + authenticated = True + user_obj_result = await db.execute( + select(User).where(User.id == user_id, User.is_active == True) + ) + u = user_obj_result.scalar_one_or_none() + if u: + role = u.role + else: + authenticated = False - return {"authenticated": authenticated, "setup_required": setup_required} + # Check registration availability + registration_open = False + if not setup_required: + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + registration_open = config.allow_registration if config else False + + return { + "authenticated": authenticated, + "setup_required": setup_required, + "role": role, + "registration_open": registration_open, + } @router.post("/verify-password") @@ -340,8 +515,6 @@ async def verify_password( """ Verify the current user's password without changing anything. Used by the frontend lock screen to re-authenticate without a full login. - Also handles transparent bcrypt→Argon2id upgrade. - Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting. """ await _check_account_lockout(current_user) @@ -350,7 +523,6 @@ async def verify_password( await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") - # Persist upgraded hash if migration happened if new_hash: current_user.password_hash = new_hash await db.commit() @@ -373,6 +545,12 @@ async def change_password( raise HTTPException(status_code=401, detail="Invalid current password") current_user.password_hash = hash_password(data.new_password) + current_user.last_password_change_at = datetime.now() + + # Clear forced password change flag if set (SEC-12) + if current_user.must_change_password: + current_user.must_change_password = False + await db.commit() return {"message": "Password changed successfully"} diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 70322ed..4f35984 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -18,7 +18,11 @@ async def get_calendars( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) + result = await db.execute( + select(Calendar) + .where(Calendar.user_id == current_user.id) + .order_by(Calendar.is_default.desc(), Calendar.name.asc()) + ) return result.scalars().all() @@ -34,6 +38,7 @@ async def create_calendar( is_default=False, is_system=False, is_visible=True, + user_id=current_user.id, ) db.add(new_calendar) await db.commit() @@ -48,7 +53,9 @@ async def update_calendar( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -74,7 +81,9 @@ async def delete_calendar( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -86,8 +95,13 @@ async def delete_calendar( if calendar.is_default: raise HTTPException(status_code=400, detail="Cannot delete the default calendar") - # Reassign all events on this calendar to the default calendar - default_result = await db.execute(select(Calendar).where(Calendar.is_default == True)) + # Reassign all events on this calendar to the user's default calendar + default_result = await db.execute( + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.is_default == True, + ) + ) default_calendar = default_result.scalar_one_or_none() if default_calendar: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index cd0b8b0..c3b3bbc 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -8,9 +8,11 @@ from app.database import get_db from app.models.settings import Settings from app.models.todo import Todo from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.reminder import Reminder from app.models.project import Project -from app.routers.auth import get_current_settings +from app.models.user import User +from app.routers.auth import get_current_user, get_current_settings router = APIRouter() @@ -26,16 +28,21 @@ _not_parent_template = or_( async def get_dashboard( client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get aggregated dashboard data.""" today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) + # Subquery: calendar IDs belonging to this user (for event scoping) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today, datetime.max.time()) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, _not_parent_template, @@ -45,6 +52,7 @@ async def get_dashboard( # Upcoming todos (not completed, with due date from today through upcoming_days) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -55,6 +63,7 @@ async def get_dashboard( # Active reminders (not dismissed, is_active = true, from today onward) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start @@ -62,26 +71,32 @@ async def get_dashboard( reminders_result = await db.execute(reminders_query) active_reminders = reminders_result.scalars().all() - # Project stats - total_projects_result = await db.execute(select(func.count(Project.id))) + # Project stats (scoped to user) + total_projects_result = await db.execute( + select(func.count(Project.id)).where(Project.user_id == current_user.id) + ) total_projects = total_projects_result.scalar() projects_by_status_query = select( Project.status, func.count(Project.id).label("count") - ).group_by(Project.status) + ).where(Project.user_id == current_user.id).group_by(Project.status) projects_by_status_result = await db.execute(projects_by_status_query) projects_by_status = {row[0]: row[1] for row in projects_by_status_result} - # Total incomplete todos count + # Total incomplete todos count (scoped to user) total_incomplete_result = await db.execute( - select(func.count(Todo.id)).where(Todo.completed == False) + select(func.count(Todo.id)).where( + Todo.user_id == current_user.id, + Todo.completed == False, + ) ) total_incomplete_todos = total_incomplete_result.scalar() - # Starred events (upcoming, ordered by date) + # Starred events (upcoming, ordered by date, scoped to user's calendars) now = datetime.now() starred_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > now, _not_parent_template, @@ -143,7 +158,8 @@ async def get_upcoming( days: int = Query(default=7, ge=1, le=90), client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() @@ -151,8 +167,12 @@ async def get_upcoming( cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) - # Get upcoming todos with due dates (today onward only) + # Subquery: calendar IDs belonging to this user + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + # Get upcoming todos with due dates (today onward only, scoped to user) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -161,8 +181,9 @@ async def get_upcoming( todos_result = await db.execute(todos_query) todos = todos_result.scalars().all() - # Get upcoming events (from today onward, exclude parent templates) + # Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, @@ -170,8 +191,9 @@ async def get_upcoming( events_result = await db.execute(events_query) events = events_result.scalars().all() - # Get upcoming reminders (today onward only) + # Get upcoming reminders (today onward only, scoped to user) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start, diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py index 95efe42..7d7a90b 100644 --- a/backend/app/routers/event_templates.py +++ b/backend/app/routers/event_templates.py @@ -20,7 +20,11 @@ async def list_templates( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) + result = await db.execute( + select(EventTemplate) + .where(EventTemplate.user_id == current_user.id) + .order_by(EventTemplate.name) + ) return result.scalars().all() @@ -30,7 +34,7 @@ async def create_template( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - template = EventTemplate(**payload.model_dump()) + template = EventTemplate(**payload.model_dump(), user_id=current_user.id) db.add(template) await db.commit() await db.refresh(template) @@ -45,7 +49,10 @@ async def update_template( current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: @@ -66,7 +73,10 @@ async def delete_template( current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index d91776f..3dc6e1a 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -105,15 +105,29 @@ def _birthday_events_for_range( return virtual_events -async def _get_default_calendar_id(db: AsyncSession) -> int: - """Return the id of the default calendar, raising 500 if not found.""" - result = await db.execute(select(Calendar).where(Calendar.is_default == True)) +async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int: + """Return the id of the user's default calendar, raising 500 if not found.""" + result = await db.execute( + select(Calendar).where( + Calendar.user_id == user_id, + Calendar.is_default == True, + ) + ) default = result.scalar_one_or_none() if not default: raise HTTPException(status_code=500, detail="No default calendar configured") return default.id +async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None: + """Raise 404 if calendar_id does not belong to user_id (SEC-04).""" + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Calendar not found") + + @router.get("/", response_model=None) async def get_events( start: Optional[date] = Query(None), @@ -128,9 +142,13 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.calendar_id.in_(user_calendar_ids)) ) # Exclude parent template rows — they are not directly rendered @@ -154,14 +172,24 @@ async def get_events( response: List[dict] = [_event_to_dict(e) for e in events] - # Fetch Birthdays calendar; only generate virtual events if visible + # Fetch the user's Birthdays system calendar; only generate virtual events if visible bday_result = await db.execute( - select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.name == "Birthdays", + Calendar.is_system == True, + ) ) bday_calendar = bday_result.scalar_one_or_none() if bday_calendar and bday_calendar.is_visible: - people_result = await db.execute(select(Person).where(Person.birthday.isnot(None))) + # Scope birthday people to this user + people_result = await db.execute( + select(Person).where( + Person.user_id == current_user.id, + Person.birthday.isnot(None), + ) + ) people = people_result.scalars().all() virtual = _birthday_events_for_range( @@ -187,9 +215,12 @@ async def create_event( data = event.model_dump() - # Resolve calendar_id to default if not provided + # Resolve calendar_id to user's default if not provided if not data.get("calendar_id"): - data["calendar_id"] = await _get_default_calendar_id(db) + data["calendar_id"] = await _get_default_calendar_id(db, current_user.id) + else: + # SEC-04: verify the target calendar belongs to the requesting user + await _verify_calendar_ownership(db, data["calendar_id"], current_user.id) # Serialize RecurrenceRule object to JSON string for DB storage # Exclude None values so defaults in recurrence service work correctly @@ -245,10 +276,15 @@ async def get_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -265,10 +301,15 @@ async def update_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -285,6 +326,10 @@ async def update_event( if rule_obj is not None: update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None + # SEC-04: if calendar_id is being changed, verify the target belongs to the user + if "calendar_id" in update_data and update_data["calendar_id"] is not None: + await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if end_dt is not None and end_dt < start: @@ -381,7 +426,14 @@ async def delete_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + result = await db.execute( + select(CalendarEvent).where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) + ) event = result.scalar_one_or_none() if not event: diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index ed64541..e209d8f 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -29,14 +29,15 @@ async def search_locations( """Search locations from local DB and Nominatim OSM.""" results: List[LocationSearchResult] = [] - # Local DB search + # Local DB search — scoped to user's locations local_query = ( select(Location) .where( + Location.user_id == current_user.id, or_( Location.name.ilike(f"%{q}%"), Location.address.ilike(f"%{q}%"), - ) + ), ) .limit(5) ) @@ -89,7 +90,7 @@ async def get_locations( current_user: User = Depends(get_current_user) ): """Get all locations with optional category filter.""" - query = select(Location) + query = select(Location).where(Location.user_id == current_user.id) if category: query = query.where(Location.category == category) @@ -109,7 +110,7 @@ async def create_location( current_user: User = Depends(get_current_user) ): """Create a new location.""" - new_location = Location(**location.model_dump()) + new_location = Location(**location.model_dump(), user_id=current_user.id) db.add(new_location) await db.commit() await db.refresh(new_location) @@ -124,7 +125,9 @@ async def get_location( current_user: User = Depends(get_current_user) ): """Get a specific location by ID.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -141,7 +144,9 @@ async def update_location( current_user: User = Depends(get_current_user) ): """Update a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -168,7 +173,9 @@ async def delete_location( current_user: User = Depends(get_current_user) ): """Delete a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 150f268..2bee2b1 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -37,7 +37,7 @@ async def get_people( current_user: User = Depends(get_current_user) ): """Get all people with optional search and category filter.""" - query = select(Person) + query = select(Person).where(Person.user_id == current_user.id) if search: term = f"%{search}%" @@ -75,7 +75,7 @@ async def create_person( parts = data['name'].split(' ', 1) data['first_name'] = parts[0] data['last_name'] = parts[1] if len(parts) > 1 else None - new_person = Person(**data) + new_person = Person(**data, user_id=current_user.id) new_person.name = _compute_display_name( new_person.first_name, new_person.last_name, @@ -96,7 +96,9 @@ async def get_person( current_user: User = Depends(get_current_user) ): """Get a specific person by ID.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -113,7 +115,9 @@ async def update_person( current_user: User = Depends(get_current_user) ): """Update a person and refresh the denormalised display name.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -147,7 +151,9 @@ async def delete_person( current_user: User = Depends(get_current_user) ): """Delete a person.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index dd86618..c49fecf 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -49,7 +49,12 @@ async def get_projects( current_user: User = Depends(get_current_user) ): """Get all projects with their tasks. Optionally filter by tracked status.""" - query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.user_id == current_user.id) + .order_by(Project.created_at.desc()) + ) if tracked is not None: query = query.where(Project.is_tracked == tracked) result = await db.execute(query) @@ -77,6 +82,7 @@ async def get_tracked_tasks( selectinload(ProjectTask.parent_task), ) .where( + Project.user_id == current_user.id, Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, @@ -110,7 +116,7 @@ async def create_project( current_user: User = Depends(get_current_user) ): """Create a new project.""" - new_project = Project(**project.model_dump()) + new_project = Project(**project.model_dump(), user_id=current_user.id) db.add(new_project) await db.commit() @@ -127,7 +133,11 @@ async def get_project( current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" - query = select(Project).options(*_project_load_options()).where(Project.id == project_id) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.id == project_id, Project.user_id == current_user.id) + ) result = await db.execute(query) project = result.scalar_one_or_none() @@ -145,7 +155,9 @@ async def update_project( current_user: User = Depends(get_current_user) ): """Update a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -171,7 +183,9 @@ async def delete_project( current_user: User = Depends(get_current_user) ): """Delete a project and all its tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -190,7 +204,10 @@ async def get_project_tasks( current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -219,7 +236,10 @@ async def create_project_task( current_user: User = Depends(get_current_user) ): """Create a new task or subtask for a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -265,7 +285,10 @@ async def reorder_tasks( current_user: User = Depends(get_current_user) ): """Bulk update sort_order for tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -296,6 +319,13 @@ async def update_project_task( current_user: User = Depends(get_current_user) ): """Update a project task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -332,6 +362,13 @@ async def delete_project_task( current_user: User = Depends(get_current_user) ): """Delete a project task (cascades to subtasks).""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -358,6 +395,13 @@ async def create_task_comment( current_user: User = Depends(get_current_user) ): """Add a comment to a task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -386,6 +430,13 @@ async def delete_task_comment( current_user: User = Depends(get_current_user) ): """Delete a task comment.""" + # Verify project ownership first, then fetch comment scoped through task + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(TaskComment).where( TaskComment.id == comment_id, diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index b33872a..788ea79 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -22,7 +22,7 @@ async def get_reminders( current_user: User = Depends(get_current_user) ): """Get all reminders with optional filters.""" - query = select(Reminder) + query = select(Reminder).where(Reminder.user_id == current_user.id) if active is not None: query = query.where(Reminder.is_active == active) @@ -48,6 +48,7 @@ async def get_due_reminders( now = client_now or datetime.now() query = select(Reminder).where( and_( + Reminder.user_id == current_user.id, Reminder.remind_at <= now, Reminder.is_dismissed == False, Reminder.is_active == True, @@ -74,7 +75,12 @@ async def snooze_reminder( current_user: User = Depends(get_current_user) ): """Snooze a reminder for N minutes from now.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -99,7 +105,7 @@ async def create_reminder( current_user: User = Depends(get_current_user) ): """Create a new reminder.""" - new_reminder = Reminder(**reminder.model_dump()) + new_reminder = Reminder(**reminder.model_dump(), user_id=current_user.id) db.add(new_reminder) await db.commit() await db.refresh(new_reminder) @@ -114,7 +120,12 @@ async def get_reminder( current_user: User = Depends(get_current_user) ): """Get a specific reminder by ID.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -131,7 +142,12 @@ async def update_reminder( current_user: User = Depends(get_current_user) ): """Update a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -164,7 +180,12 @@ async def delete_reminder( current_user: User = Depends(get_current_user) ): """Delete a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -183,7 +204,12 @@ async def dismiss_reminder( current_user: User = Depends(get_current_user) ): """Dismiss a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 57841a7..86cdf65 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -73,15 +73,17 @@ def _calculate_recurrence( return reset_at, next_due -async def _reactivate_recurring_todos(db: AsyncSession) -> None: +async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None: """Auto-reactivate recurring todos whose reset_at has passed. Uses flush (not commit) so changes are visible to the subsequent query within the same transaction. The caller's commit handles persistence. + Scoped to a single user to avoid cross-user reactivation. """ now = datetime.now() query = select(Todo).where( and_( + Todo.user_id == user_id, Todo.completed == True, Todo.recurrence_rule.isnot(None), Todo.reset_at.isnot(None), @@ -110,13 +112,14 @@ async def get_todos( category: Optional[str] = Query(None), search: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get all todos with optional filters.""" # Reactivate any recurring todos whose reset time has passed - await _reactivate_recurring_todos(db) + await _reactivate_recurring_todos(db, current_user.id) - query = select(Todo) + query = select(Todo).where(Todo.user_id == current_user.id) if completed is not None: query = query.where(Todo.completed == completed) @@ -144,10 +147,10 @@ async def get_todos( async def create_todo( todo: TodoCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Create a new todo.""" - new_todo = Todo(**todo.model_dump()) + new_todo = Todo(**todo.model_dump(), user_id=current_user.id) db.add(new_todo) await db.commit() await db.refresh(new_todo) @@ -159,10 +162,12 @@ async def create_todo( async def get_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Get a specific todo by ID.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -176,10 +181,13 @@ async def update_todo( todo_id: int, todo_update: TodoUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Update a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -210,7 +218,7 @@ async def update_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due @@ -229,10 +237,12 @@ async def update_todo( async def delete_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Delete a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -248,10 +258,13 @@ async def delete_todo( async def toggle_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Toggle todo completion status. For recurring todos, calculates reset schedule.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -267,7 +280,7 @@ async def toggle_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index 25f8b94..bed8724 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta +from collections import OrderedDict import asyncio import urllib.request import urllib.parse @@ -11,13 +12,37 @@ import json from app.database import get_db from app.models.settings import Settings +from app.models.user import User from app.config import settings as app_settings from app.routers.auth import get_current_user, get_current_settings -from app.models.user import User router = APIRouter() -_cache: dict = {} +# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries. +# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False) +# to evict the oldest when capacity is exceeded. +_CACHE_MAX = 100 +_cache: OrderedDict = OrderedDict() + + +def _cache_get(key: tuple) -> dict | None: + """Return cached entry if it exists and hasn't expired.""" + entry = _cache.get(key) + if entry and datetime.now() < entry["expires_at"]: + _cache.move_to_end(key) # LRU: promote to most-recently-used + return entry["data"] + if entry: + del _cache[key] # expired — evict immediately + return None + + +def _cache_set(key: tuple, data: dict) -> None: + """Store an entry; evict the oldest if over capacity.""" + if key in _cache: + _cache.move_to_end(key) + _cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)} + while len(_cache) > _CACHE_MAX: + _cache.popitem(last=False) # evict LRU (oldest) class GeoSearchResult(BaseModel): @@ -66,23 +91,24 @@ async def search_locations( @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): - city = current_user.weather_city - lat = current_user.weather_lat - lon = current_user.weather_lon + city = current_settings.weather_city + lat = current_settings.weather_lat + lon = current_settings.weather_lon if not city and (lat is None or lon is None): raise HTTPException(status_code=400, detail="No weather location configured") - # Build cache key from coordinates or city + # Cache key includes user_id so each user gets isolated cache entries use_coords = lat is not None and lon is not None - cache_key = f"{lat},{lon}" if use_coords else city + location_key = f"{lat},{lon}" if use_coords else city + cache_key = (current_user.id, location_key) - # Check cache - now = datetime.now() - if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key: - return _cache["data"] + cached = _cache_get(cache_key) + if cached is not None: + return cached api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: @@ -122,11 +148,7 @@ async def get_weather( "city": current_data["name"], } - # Cache for 1 hour - _cache["data"] = weather_result - _cache["expires_at"] = now + timedelta(hours=1) - _cache["cache_key"] = cache_key - + _cache_set(cache_key, weather_result) return weather_result except urllib.error.URLError: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..075a916 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,133 @@ +""" +Admin API schemas — Pydantic v2. + +All admin-facing request/response shapes live here to keep the admin router +clean and testable in isolation. +""" +import re +from datetime import datetime +from typing import Optional, Literal + +from pydantic import BaseModel, ConfigDict, field_validator + +from app.schemas.auth import _validate_username, _validate_password_strength + + +# --------------------------------------------------------------------------- +# User list / detail +# --------------------------------------------------------------------------- + +class UserListItem(BaseModel): + id: int + username: str + role: str + is_active: bool + last_login_at: Optional[datetime] = None + last_password_change_at: Optional[datetime] = None + totp_enabled: bool + mfa_enforce_pending: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + users: list[UserListItem] + total: int + + +class UserDetailResponse(UserListItem): + active_sessions: int + + +# --------------------------------------------------------------------------- +# Mutating user requests +# --------------------------------------------------------------------------- + +class CreateUserRequest(BaseModel): + """Admin-created user — allows role selection (unlike public RegisterRequest).""" + model_config = ConfigDict(extra="forbid") + + username: str + password: str + role: Literal["admin", "standard", "public_event_manager"] = "standard" + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class UpdateUserRoleRequest(BaseModel): + role: Literal["admin", "standard", "public_event_manager"] + + +class ToggleActiveRequest(BaseModel): + is_active: bool + + +class ToggleMfaEnforceRequest(BaseModel): + enforce: bool + + +# --------------------------------------------------------------------------- +# System config +# --------------------------------------------------------------------------- + +class SystemConfigResponse(BaseModel): + allow_registration: bool + enforce_mfa_new_users: bool + + model_config = ConfigDict(from_attributes=True) + + +class SystemConfigUpdate(BaseModel): + allow_registration: Optional[bool] = None + enforce_mfa_new_users: Optional[bool] = None + + +# --------------------------------------------------------------------------- +# Admin dashboard +# --------------------------------------------------------------------------- + +class AdminDashboardResponse(BaseModel): + total_users: int + active_users: int + admin_count: int + active_sessions: int + mfa_adoption_rate: float + recent_logins: list[dict] + recent_audit_entries: list[dict] + + +# --------------------------------------------------------------------------- +# Password reset +# --------------------------------------------------------------------------- + +class ResetPasswordResponse(BaseModel): + message: str + temporary_password: str + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + +class AuditLogEntry(BaseModel): + id: int + actor_username: Optional[str] = None + target_username: Optional[str] = None + action: str + detail: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime + + +class AuditLogResponse(BaseModel): + entries: list[AuditLogEntry] + total: int diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 806835d..a45dba3 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, field_validator def _validate_password_strength(v: str) -> str: @@ -21,6 +21,16 @@ def _validate_password_strength(v: str) -> str: return v +def _validate_username(v: str) -> str: + """Shared username validation.""" + v = v.strip().lower() + if not 3 <= len(v) <= 50: + raise ValueError("Username must be 3–50 characters") + if not re.fullmatch(r"[a-z0-9_\-]+", v): + raise ValueError("Username may only contain letters, numbers, _ and -") + return v + + class SetupRequest(BaseModel): username: str password: str @@ -28,12 +38,29 @@ class SetupRequest(BaseModel): @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: - v = v.strip().lower() - if not 3 <= len(v) <= 50: - raise ValueError("Username must be 3–50 characters") - if not re.fullmatch(r"[a-z0-9_\-]+", v): - raise ValueError("Username may only contain letters, numbers, _ and -") - return v + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class RegisterRequest(BaseModel): + """ + Public registration schema — SEC-01: extra="forbid" prevents role injection. + An attacker sending {"username": "...", "password": "...", "role": "admin"} + will get a 422 Validation Error instead of silent acceptance. + """ + model_config = ConfigDict(extra="forbid") + + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) @field_validator("password") @classmethod diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..a38548c --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,22 @@ +import json +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.audit_log import AuditLog + + +async def log_audit_event( + db: AsyncSession, + action: str, + actor_id: int | None = None, + target_id: int | None = None, + detail: dict | None = None, + ip: str | None = None, +) -> None: + """Record an action in the audit log. Does NOT commit — caller handles transaction.""" + entry = AuditLog( + actor_user_id=actor_id, + target_user_id=target_id, + action=action, + detail=json.dumps(detail) if detail else None, + ip_address=ip[:45] if ip else None, + ) + db.add(entry) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 186524b..adf2323 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -126,3 +126,32 @@ def verify_mfa_token(token: str) -> int | None: return data["uid"] except Exception: return None + + +# --------------------------------------------------------------------------- +# MFA enforcement tokens (SEC-03: distinct salt from challenge tokens) +# --------------------------------------------------------------------------- + +_mfa_enforce_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="mfa-enforce-setup-v1", +) + + +def create_mfa_enforce_token(user_id: int) -> str: + """Create a short-lived token for MFA enforcement setup (not a session).""" + return _mfa_enforce_serializer.dumps({"uid": user_id}) + + +def verify_mfa_enforce_token(token: str) -> int | None: + """ + Verify an MFA enforcement setup token. + Returns user_id on success, None if invalid or expired (5-minute TTL). + """ + try: + data = _mfa_enforce_serializer.loads( + token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS + ) + return data["uid"] + except Exception: + return None diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3519178..1e8bbd8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,9 @@ # Rate limiting zones (before server block) limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; +# SEC-14: Registration endpoint — slightly more permissive than strict auth endpoints +limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; +# Admin API — generous for legitimate use but still guards against scraping/brute-force +limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access map $http_x_forwarded_proto $forwarded_proto { @@ -60,6 +64,20 @@ server { include /etc/nginx/proxy-params.conf; } + # SEC-14: Rate-limit public registration endpoint + location /api/auth/register { + limit_req zone=register_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Admin API — rate-limited separately from general /api traffic + location /api/admin/ { + limit_req zone=admin_limit burst=10 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # API proxy location /api { proxy_pass http://backend:8000; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e1694d..a969411 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import LockScreen from '@/components/auth/LockScreen'; @@ -12,6 +13,8 @@ import PeoplePage from '@/components/people/PeoplePage'; import LocationsPage from '@/components/locations/LocationsPage'; import SettingsPage from '@/components/settings/SettingsPage'; +const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); @@ -30,6 +33,24 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children}; } +function AdminRoute({ children }: { children: React.ReactNode }) { + const { authStatus, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!authStatus?.authenticated || authStatus?.role !== 'admin') { + return ; + } + + return <>{children}; +} + function App() { return ( @@ -52,6 +73,16 @@ function App() { } /> } /> } /> + + Loading...
}> + + + + } + /> ); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e269f9e..78ea3ed 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { X, LogOut, Lock, + Shield, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; @@ -44,7 +45,7 @@ interface SidebarProps { export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); - const { logout } = useAuth(); + const { logout, isAdmin } = useAuth(); const { lock } = useLock(); const [projectsExpanded, setProjectsExpanded] = useState(false); @@ -193,6 +194,16 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose {showExpanded && Lock} + {isAdmin && ( + + + {showExpanded && Admin} + + )} (null); + const [mfaSetupRequired, setMfaSetupRequired] = useState(false); const authQuery = useQuery({ queryKey: ['auth'], @@ -23,11 +23,34 @@ export function useAuth() { return data; }, onSuccess: (data) => { - if ('mfa_token' in data && data.totp_required) { - // MFA required — store token locally, do NOT mark as authenticated yet + if ('mfa_setup_required' in data && data.mfa_setup_required) { + // MFA enforcement — user must set up TOTP before accessing app + setMfaSetupRequired(true); + setMfaToken(data.mfa_token); + } else if ('mfa_token' in data && 'totp_required' in data && data.totp_required) { + // Regular TOTP challenge + setMfaToken(data.mfa_token); + setMfaSetupRequired(false); + } else { + setMfaToken(null); + setMfaSetupRequired(false); + queryClient.invalidateQueries({ queryKey: ['auth'] }); + } + }, + }); + + const registerMutation = useMutation({ + mutationFn: async ({ username, password }: { username: string; password: string }) => { + const { data } = await api.post('/auth/register', { username, password }); + return data; + }, + onSuccess: (data) => { + if ('mfa_setup_required' in data && data.mfa_setup_required) { + setMfaSetupRequired(true); setMfaToken(data.mfa_token); } else { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); } }, @@ -43,6 +66,7 @@ export function useAuth() { }, onSuccess: () => { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); @@ -64,6 +88,7 @@ export function useAuth() { }, onSuccess: () => { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); @@ -71,12 +96,18 @@ export function useAuth() { return { authStatus: authQuery.data, isLoading: authQuery.isLoading, - mfaRequired: mfaToken !== null, + role: authQuery.data?.role ?? null, + isAdmin: authQuery.data?.role === 'admin', + mfaRequired: mfaToken !== null && !mfaSetupRequired, + mfaSetupRequired, + mfaToken, login: loginMutation.mutateAsync, + register: registerMutation.mutateAsync, verifyTotp: totpVerifyMutation.mutateAsync, setup: setupMutation.mutateAsync, logout: logoutMutation.mutateAsync, isLoginPending: loginMutation.isPending, + isRegisterPending: registerMutation.isPending, isTotpPending: totpVerifyMutation.isPending, isSetupPending: setupMutation.isPending, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7913a72..c169e2d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,7 @@ const api = axios.create({ baseURL: '/api', headers: { 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', }, withCredentials: true, }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 510aacd..0cb34c4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -188,14 +188,19 @@ export interface Location { updated_at: string; } +export type UserRole = 'admin' | 'standard' | 'public_event_manager'; + export interface AuthStatus { authenticated: boolean; setup_required: boolean; + role: UserRole | null; + registration_open: boolean; } // Login response discriminated union export interface LoginSuccessResponse { authenticated: true; + must_change_password?: boolean; } export interface LoginMfaRequiredResponse { @@ -204,7 +209,64 @@ export interface LoginMfaRequiredResponse { mfa_token: string; } -export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse; +export interface LoginMfaSetupRequiredResponse { + authenticated: false; + mfa_setup_required: true; + mfa_token: string; +} + +export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | LoginMfaSetupRequiredResponse; + +// Admin types +export interface AdminUser { + id: number; + username: string; + role: UserRole; + is_active: boolean; + last_login_at: string | null; + last_password_change_at: string | null; + totp_enabled: boolean; + mfa_enforce_pending: boolean; + created_at: string; +} + +export interface AdminUserDetail extends AdminUser { + active_sessions: number; +} + +export interface SystemConfig { + allow_registration: boolean; + enforce_mfa_new_users: boolean; +} + +export interface AuditLogEntry { + id: number; + actor_username: string | null; + target_username: string | null; + action: string; + detail: string | null; + ip_address: string | null; + created_at: string; +} + +export interface AdminDashboardData { + total_users: number; + active_users: number; + admin_count: number; + active_sessions: number; + mfa_adoption_rate: number; + recent_logins: Array<{ + username: string; + last_login_at: string; + ip_address: string; + }>; + recent_audit_entries: Array<{ + action: string; + actor_username: string | null; + target_username: string | null; + created_at: string; + }>; +} // TOTP setup response (from POST /api/auth/totp/setup) export interface TotpSetupResponse {