diff --git a/.env.example b/.env.example index bd84848..8224e14 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,14 @@ SAML_SP_CERT="-----BEGIN CERTIFICATE----- SAML_SP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----" + +# Authorization Configuration +# Global access via eduPersonEntitlement (comma-separated list) +# Users with any of these entitlements get full access to all applications +CHURRO_GLOBAL_ENTITLEMENTS=stanford:staff + +# Per-application access via SUNet ID mappings +# Format: uuid1:uid1,uid2;uuid2:uid3,uid4 +# Each section (separated by ;) maps an application UUID to authorized SUNet IDs +# Example: CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +CHURRO_APP_ACCESS= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9a68617..35e9e45 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,6 +141,49 @@ const getAttr = (key: string): string | undefined => { - JWT tokens stored in HTTP-only cookies (not accessible to JavaScript) - Clock skew: 5 minutes (`acceptedClockSkewMs: 300000`) +### Authorization System + +**Two-Tier Access Control**: +1. **Global Access**: Users with specific `eduPersonEntitlement` values access everything +2. **Per-Application Access**: SUNet ID mappings grant access to specific applications + +**Environment Variables**: +- `CHURRO_GLOBAL_ENTITLEMENTS` - Comma-separated list (e.g., `uit:sws,stanford:faculty`) +- `CHURRO_APP_ACCESS` - UUID:uid mappings (e.g., `uuid1:jdoe,jsmith;uuid2:jdoe`) + +**Key Components** (`lib/auth-utils.ts`): +- `hasGlobalAccess(user)` - Check if user has global entitlement +- `hasApplicationAccess(user, uuid)` - Check specific app access +- `hasDashboardAccess(user)` - Check if can access dashboard (global OR any app) +- `parseAppAccessMappings()` - Parse environment variable mappings + +**Middleware Protection** (`middleware.ts`): +- `/` - Dashboard requires global access OR access to ≥1 application +- `/applications/[uuid]` - Requires global access OR specific application access +- Returns 403 with clear error messages for unauthorized access + +**API Protection** (`lib/api-auth.ts`): +```typescript +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Protected API logic with user context + return NextResponse.json({ data: 'protected' }); + })(request); +} +``` + +**Client-Side Handling**: +- Authorization errors (403) display user-friendly error pages +- Client components check API response status and handle authorization failures +- Provides contact information and "Return to Dashboard" links + +**Authorization Flow**: +1. User authenticates via Stanford SAML +2. Middleware checks global entitlements first +3. If no global access, checks per-application mappings +4. Routes/APIs enforce access before rendering/processing +5. Clear error messages for authorization failures + ### Component Patterns **Client Components** - Use `'use client'` directive when: diff --git a/.gitignore b/.gitignore index b92cb84..4a494f7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ next-env.d.ts # saml saml-sp.key saml-sp.crt + +# Cache directory, if present +.cache diff --git a/README.md b/README.md index 284851b..7db7604 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,13 @@ Then open: | `SAML_SP_CERT` | Your SP certificate | Multi-line PEM format | | `SAML_SP_PRIVATE_KEY` | Your SP private key | Multi-line PEM format | +### Authorization Variables + +| Variable | Description | Example | +|----------|-------------|----------| +| `CHURRO_GLOBAL_ENTITLEMENTS` | eduPersonEntitlement values for global access | `uit:sws` or `uit:sws,other:entitlement` | +| `CHURRO_APP_ACCESS` | Per-application SUNet ID mappings | `uuid1:jdoe,jsmith;uuid2:jdoe` | + ### Optional Variables | Variable | Description | Default | diff --git a/app/api/acquia/applications/route.ts b/app/api/acquia/applications/route.ts index 463fc78..9eb97d5 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { // console.log('🚀 Applications API Route called'); // Update the API service initialization with better error handling @@ -55,4 +57,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } \ No newline at end of file diff --git a/app/api/acquia/views/route.ts b/app/api/acquia/views/route.ts index 0975826..f6eeb84 100644 --- a/app/api/acquia/views/route.ts +++ b/app/api/acquia/views/route.ts @@ -1,27 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const subscriptionUuid = searchParams.get('subscriptionUuid'); - const from = searchParams.get('from'); - const to = searchParams.get('to'); - const resolution = searchParams.get('resolution'); // Get granularity for daily data - /** - console.log('🚀 Views by Application API Route called with params:', { - subscriptionUuid, - from, - to, - resolution - }); - */ - if (!subscriptionUuid) { - console.error('❌ Missing required parameter: subscriptionUuid'); - return NextResponse.json( - { error: 'subscriptionUuid is required' }, - { status: 400 } - ); - } + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { + const searchParams = request.nextUrl.searchParams; + const subscriptionUuid = searchParams.get('subscriptionUuid'); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const resolution = searchParams.get('resolution'); // Get granularity for daily data + /** + console.log('🚀 Views by Application API Route called with params:', { + subscriptionUuid, + from, + to, + resolution + }); + */ + if (!subscriptionUuid) { + console.error('❌ Missing required parameter: subscriptionUuid'); + return NextResponse.json( + { error: 'subscriptionUuid is required' }, + { status: 400 } + ); + } if (!process.env.ACQUIA_API_KEY || !process.env.ACQUIA_API_SECRET) { console.error('❌ Missing required environment variables!'); @@ -82,4 +84,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/api/acquia/visits/route.ts b/app/api/acquia/visits/route.ts index 94104ae..56e3067 100644 --- a/app/api/acquia/visits/route.ts +++ b/app/api/acquia/visits/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); @@ -85,4 +87,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index dc98848..9946422 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -8,11 +8,22 @@ import { getBaseUrl } from '@/lib/url-utils' */ export async function GET(request: NextRequest) { const cookieStore = await cookies() + const { searchParams } = new URL(request.url) // Delete the JWT cookie cookieStore.delete(getSessionCookieName()) - // Redirect to home page or login page + // Check if redirect URL is specified (e.g., from test page) + const redirectTo = searchParams.get('redirectTo') const baseUrl = getBaseUrl(request) + + if (redirectTo) { + // Validate that redirect is to a safe path (starts with /) + if (redirectTo.startsWith('/')) { + return NextResponse.redirect(new URL(redirectTo, baseUrl)) + } + } + + // Default redirect to home page return NextResponse.redirect(new URL('/', baseUrl)) } diff --git a/app/applications/[uuid]/page.tsx b/app/applications/[uuid]/page.tsx index f010300..9518bfe 100644 --- a/app/applications/[uuid]/page.tsx +++ b/app/applications/[uuid]/page.tsx @@ -41,6 +41,20 @@ export default function ApplicationDetailPage({ params }: any) { const [error, setError] = useState(null); const [dailyViews, setDailyViews] = useState([]); const [dailyVisits, setDailyVisits] = useState([]); + const [authError, setAuthError] = useState(null); + + // Check for authorization errors from API calls + const handleApiResponse = async (response: Response) => { + if (response.status === 403) { + const errorData = await response.json().catch(() => ({})); + setAuthError(errorData.error || 'Access denied. You do not have permission to view this application.'); + return null; + } + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + return response.json(); + }; // Fetch application name on mount or when subscriptionUuid changes useEffect(() => { @@ -48,10 +62,13 @@ export default function ApplicationDetailPage({ params }: any) { try { setLoadingStep('Fetching application info...'); const res = await fetch(`/api/acquia/applications?subscriptionUuid=${subscriptionUuid}`); - const apps = await res.json(); + const apps = await handleApiResponse(res); + if (!apps) return; // Authorization error handled by handleApiResponse + const app = Array.isArray(apps) ? apps.find((a: any) => a.uuid === typedParams.uuid) : null; setAppName(app ? app.name : ''); - } catch { + } catch (err) { + console.error('Error fetching app name:', err); setAppName(''); } finally { setLoadingStep(''); @@ -60,6 +77,31 @@ export default function ApplicationDetailPage({ params }: any) { if (subscriptionUuid) fetchAppName(); }, [subscriptionUuid, typedParams.uuid]); + // Show authorization error if user doesn't have access + if (authError) { + return ( +
+
+
+

Access Denied

+

{authError}

+

+ If you believe you should have access to this application, please contact your administrator. +

+ +
+
+
+ ); + } + const fetchAppDetail = async () => { setLoading(true); setLoadingStep('Fetching analytics data...'); @@ -80,10 +122,11 @@ export default function ApplicationDetailPage({ params }: any) { fetch(`/api/acquia/visits?${dailyQuery}`), ]); - const [dailyViewsRaw, dailyVisitsRaw]: [AcquiaApiResponse, AcquiaApiResponse] = await Promise.all([ - dailyViewsRes.ok ? dailyViewsRes.json() : {}, - dailyVisitsRes.ok ? dailyVisitsRes.json() : {}, - ]); + // Handle authorization errors + const dailyViewsRaw = await handleApiResponse(dailyViewsRes); + const dailyVisitsRaw = await handleApiResponse(dailyVisitsRes); + + if (!dailyViewsRaw || !dailyVisitsRaw) return; // Authorization error handled // Helper to process and aggregate daily data with proper types const processDailyData = (rawData: AcquiaApiResponse, metric: 'views' | 'visits'): DailyDataPoint[] => { @@ -115,8 +158,8 @@ export default function ApplicationDetailPage({ params }: any) { const overallViewsData = dailyViewsRaw.data || []; const overallVisitsData = dailyVisitsRaw.data || []; - const overallTotalViews = overallViewsData.reduce((sum, v) => sum + (v.views || 0), 0); - const overallTotalVisits = overallVisitsData.reduce((sum, v) => sum + (v.visits || 0), 0); + const overallTotalViews = overallViewsData.reduce((sum: number, v: any) => sum + (v.views || 0), 0); + const overallTotalVisits = overallVisitsData.reduce((sum: number, v: any) => sum + (v.visits || 0), 0); setViews(appTotalViews); setVisits(appTotalVisits); diff --git a/app/applications/page.tsx b/app/applications/page.tsx index e6421d2..e7d34d8 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,89 +1,395 @@ -import React from 'react'; - -// Force dynamic rendering - don't try to pre-render at build time -export const dynamic = 'force-dynamic'; - -const BASE_URL = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : process.env.NEXT_PUBLIC_BASE_URL - ? process.env.NEXT_PUBLIC_BASE_URL - : 'http://localhost:3000'; - -async function fetchData() { - const [appsRes, viewsRes, visitsRes] = await Promise.all([ - fetch(`${BASE_URL}/api/acquia/applications`), - fetch(`${BASE_URL}/api/acquia/views`), - fetch(`${BASE_URL}/api/acquia/visits`), - ]); - const [apps, viewsRaw, visitsRaw] = await Promise.all([ - appsRes.ok ? appsRes.json() : [], - viewsRes.ok ? viewsRes.json() : [], - visitsRes.ok ? visitsRes.json() : [], - ]); - - // Defensive: ensure arrays - const views = Array.isArray(viewsRaw) - ? viewsRaw - : viewsRaw && Array.isArray(viewsRaw.data) - ? viewsRaw.data - : []; - const visits = Array.isArray(visitsRaw) - ? visitsRaw - : visitsRaw && Array.isArray(visitsRaw.data) - ? visitsRaw.data - : []; - return { apps, views, visits }; +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import CountUpTimer from '@/components/CountUpTimer'; + +// Helper function to get previous month date range +function getPreviousMonthRange() { + const now = new Date(); + const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastDayOfPreviousMonth = new Date(now.getFullYear(), now.getMonth(), 0); + + // Format dates as YYYY-MM-DD + const from = previousMonth.toISOString().split('T')[0]; + const to = lastDayOfPreviousMonth.toISOString().split('T')[0]; + + // Get formatted month name for display + const monthName = previousMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric' + }); + + return { from, to, monthName }; +} + +interface Application { + uuid: string; + name: string; } -function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }, visits: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }) { - // Map views/visits by app uuid - const viewsByApp = Object.fromEntries(views.map(v => [v.uuid, v.views])); - const visitsByApp = Object.fromEntries(visits.map(v => [v.uuid, v.visits])); +interface AppStats extends Application { + views: number; + visits: number; + viewsPct: number; + visitsPct: number; +} + +interface User { + sunetId?: string; + email?: string; +} + +function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats[] { + // Aggregate views/visits by app uuid (sum across all dates) + const viewsByApp: Record = {}; + const visitsByApp: Record = {}; + + // Ensure views and visits are arrays + const viewsArray = Array.isArray(views) ? views : []; + const visitsArray = Array.isArray(visits) ? visits : []; + + console.log('🔍 Processing views data:', viewsArray.length, 'items'); + console.log('🔍 Processing visits data:', visitsArray.length, 'items'); + if (viewsArray.length > 0) { + console.log('📊 Sample views item:', JSON.stringify(viewsArray[0], null, 2)); + console.log('📊 Views item keys:', Object.keys(viewsArray[0])); + } + if (visitsArray.length > 0) { + console.log('📊 Sample visits item:', JSON.stringify(visitsArray[0], null, 2)); + console.log('📊 Visits item keys:', Object.keys(visitsArray[0])); + } + + viewsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const viewCount = v.views || 0; + if (index < 3) { + console.log(`📈 Processing views item ${index}:`, { uuid, viewCount, hasUuid: !!uuid, hasViews: !!v.views, item: JSON.stringify(v, null, 2) }); + } + if (uuid && viewCount > 0) { + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + viewCount; + console.log(`📈 Views accumulated: ${uuid} = ${viewsByApp[uuid]} (added ${viewCount})`); + } + }); + + visitsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const visitCount = v.visits || 0; + if (index < 3) { + console.log(`👥 Processing visits item ${index}:`, { uuid, visitCount, hasUuid: !!uuid, hasVisits: !!v.visits, item: JSON.stringify(v, null, 2) }); + } + if (uuid && visitCount > 0) { + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + visitCount; + console.log(`👥 Visits accumulated: ${uuid} = ${visitsByApp[uuid]} (added ${visitCount})`); + } + }); + // Calculate totals - const totalViews = views.reduce((sum, v) => sum + v.views, 0); - const totalVisits = visits.reduce((sum, v) => sum + v.visits, 0); + const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); + const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); + + console.log('📊 Aggregated views by app:', JSON.stringify(viewsByApp, null, 2)); + console.log('📊 Aggregated visits by app:', JSON.stringify(visitsByApp, null, 2)); + console.log('📊 Total views:', totalViews); + console.log('📊 Total visits:', totalVisits); + console.log('📊 Apps to process:', apps.map(a => `${a.name} (${a.uuid})`)); // Merge stats - return apps.map(app => ({ + const result = apps.map(app => ({ ...app, views: viewsByApp[app.uuid] || 0, visits: visitsByApp[app.uuid] || 0, viewsPct: totalViews ? ((viewsByApp[app.uuid] || 0) / totalViews) * 100 : 0, visitsPct: totalVisits ? ((visitsByApp[app.uuid] || 0) / totalVisits) * 100 : 0, })); + + console.log('📈 Final stats for apps:', result.map(r => `${r.name}: ${r.views} views, ${r.visits} visits`)); + return result; } -export default async function ApplicationsPage() { - const { apps, views, visits } = await fetchData(); - const stats = getAppStats(apps, views, visits); +export default function ApplicationsPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [applications, setApplications] = useState([]); + const [stats, setStats] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMetrics, setLoadingMetrics] = useState(false); + const [error, setError] = useState(null); + const [monthName, setMonthName] = useState(''); + const [loadTime, setLoadTime] = useState(null); + + // Check authentication and load applications first + useEffect(() => { + async function checkAuthAndLoadApps() { + try { + // Check authentication + const authResponse = await fetch('/api/auth/status'); + if (!authResponse.ok) { + router.push('/api/saml/login'); + return; + } + + const { authenticated, user: userData } = await authResponse.json(); + if (!authenticated) { + router.push('/api/saml/login'); + return; + } + + setUser(userData); + + // Get month name + const { monthName: currentMonthName } = getPreviousMonthRange(); + setMonthName(currentMonthName); + + // Load applications quickly first + const appsResponse = await fetch('/api/acquia/applications'); + if (!appsResponse.ok) { + throw new Error(`Failed to load applications: ${appsResponse.status}`); + } + + const appsData = await appsResponse.json(); + + // Filter out excluded UUIDs and apply user permissions + const excludedUuids = ['2b2d2517-3839-414e-85a4-7183adc22283', '1ef402a7-c301-42d7-9b63-f226fa1b2329']; + const filteredApps = appsData.filter((app: Application) => !excludedUuids.includes(app.uuid)); + + // Sort applications alphabetically by name + const sortedApps = filteredApps.sort((a: Application, b: Application) => + a.name.localeCompare(b.name) + ); + + setApplications(sortedApps); + + // Initialize stats with just application info (no metrics yet) + const initialStats: AppStats[] = sortedApps.map((app: Application) => ({ + ...app, + views: 0, + visits: 0, + viewsPct: 0, + visitsPct: 0, + })); + setStats(initialStats); + setLoading(false); + + // Now start loading the metrics data with timer + setLoadingMetrics(true); + await loadMetricsData(sortedApps); + + } catch (err) { + console.error('Error loading applications:', err); + setError(err instanceof Error ? err.message : 'Failed to load applications'); + setLoading(false); + } + } + + checkAuthAndLoadApps(); + }, [router]); + + async function loadMetricsData(apps: Application[]) { + const startTime = Date.now(); + + try { + const { from, to } = getPreviousMonthRange(); + const subscriptionUuid = process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID; + + // Fetch views and visits data with retry logic for 503 errors + const fetchWithRetry = async (url: string, retries = 3): Promise => { + for (let i = 0; i < retries; i++) { + const response = await fetch(url); + + if (response.status === 503) { + // Wait before retrying (exponential backoff) + await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); + continue; + } + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + return response; + } + throw new Error('Service temporarily unavailable (503). Please try again later.'); + }; + + const [viewsResponse, visitsResponse] = await Promise.all([ + fetchWithRetry(`/api/acquia/views?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`), + fetchWithRetry(`/api/acquia/visits?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`) + ]); + + const [viewsData, visitsData] = await Promise.all([ + viewsResponse.json(), + visitsResponse.json() + ]); + + console.log('🌐 Raw views API response:', { + type: typeof viewsData, + isArray: Array.isArray(viewsData), + length: viewsData?.length, + keys: viewsData && typeof viewsData === 'object' ? Object.keys(viewsData) : 'N/A', + sample: viewsData + }); + + console.log('🌐 Raw visits API response:', { + type: typeof visitsData, + isArray: Array.isArray(visitsData), + length: visitsData?.length, + keys: visitsData && typeof visitsData === 'object' ? Object.keys(visitsData) : 'N/A', + sample: visitsData + }); + + // Handle different response formats - API routes wrap data in { data: [...] } + const viewsArray = Array.isArray(viewsData) ? viewsData : + Array.isArray(viewsData.data) ? viewsData.data : []; + + const visitsArray = Array.isArray(visitsData) ? visitsData : + Array.isArray(visitsData.data) ? visitsData.data : []; + + console.log('🔧 Extracted arrays:', { + viewsLength: viewsArray.length, + visitsLength: visitsArray.length + }); + + // Calculate final stats + const finalStats = getAppStats(apps, viewsArray, visitsArray); + setStats(finalStats); + + const endTime = Date.now(); + setLoadTime((endTime - startTime) / 1000); + setLoadingMetrics(false); + + } catch (err) { + console.error('Error loading metrics:', err); + setError(err instanceof Error ? err.message : 'Failed to load metrics data'); + setLoadingMetrics(false); + } + } + + if (loading) { + return ( +
+

Application Views & Visits

+
+
+

Loading applications...

+
+
+ ); + } + + if (error) { + return ( +
+

Application Views & Visits ({monthName})

+
+

Error Loading Data

+

{error}

+ +
+
+ ); + } + + if (stats.length === 0) { + return ( +
+

Application Views & Visits ({monthName})

+
+

No Applications Available

+

+ You don't have access to any applications. Contact your administrator if you believe this is an error. +

+

+ Logged in as: {user?.sunetId || user?.email || 'Unknown user'} +

+
+
+ ); + } return ( -
-

Application Views & Visits

- - - - - - - - - - - - - {stats.map(app => ( - - - - - - - +
+

Application Views & Visits ({monthName})

+ + {loadingMetrics && ( +
+
+

Loading usage metrics...

+ +
+
+ )} + +
+
ApplicationUUIDViews% of ViewsVisits% of Visits
{app.name}{app.uuid}{app.views.toLocaleString()}{app.viewsPct.toFixed(1)}%{app.visits.toLocaleString()}{app.visitsPct.toFixed(1)}%
+ + + + + + + + - ))} - -
ApplicationUUIDViews% of ViewsVisits% of Visits
+ + + {stats.map(app => ( + + + + {app.name} + + + + + {app.uuid} + + + + {loadingMetrics ? ( + Loading... + ) : ( + app.views.toLocaleString() + )} + + + {loadingMetrics ? ( + + ) : ( + `${app.viewsPct.toFixed(1)}%` + )} + + + {loadingMetrics ? ( + Loading... + ) : ( + app.visits.toLocaleString() + )} + + + {loadingMetrics ? ( + + ) : ( + `${app.visitsPct.toFixed(1)}%` + )} + + + ))} + + +
); } \ No newline at end of file diff --git a/app/auth/test/page.tsx b/app/auth/test/page.tsx index cbe4424..d35138b 100644 --- a/app/auth/test/page.tsx +++ b/app/auth/test/page.tsx @@ -53,7 +53,7 @@ function AuthTestContent() { } const handleLogout = () => { - window.location.href = '/api/auth/logout' + window.location.href = '/api/auth/logout?redirectTo=/auth/test' } if (loading) { diff --git a/docs/SAML.md b/docs/SAML.md index 1a23e95..cf0c255 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -513,6 +513,142 @@ export async function GET(request: NextRequest) { } ``` +## Authorization System + +After successful SAML authentication, CHURRO implements a comprehensive two-tier authorization system to control access to resources. + +### Authorization Levels + +1. **Global Access**: Users with specific `eduPersonEntitlement` values can access the entire application +2. **Per-Application Access**: SUNet ID-based mappings grant access to specific applications only + +### Environment Variables + +Add these to your `.env.local`: + +```env +# Global access via Stanford entitlements +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Per-application access mappings (format: uuid:uid1,uid2;uuid:uid3) +CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +``` + +### Configuration Examples + +#### Global Entitlement Access +```env +# All users with "uit:sws" entitlement can access everything +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Multiple entitlements (comma-separated) +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws,uit:admin,stanford:faculty +``` + +#### Per-Application Access Mappings +```env +# Format: applicationUuid:uid1,uid2,uid3;anotherUuid:uid4,uid5 +CHURRO_APP_ACCESS=12345678-1234-1234-1234-123456789abc:jdoe,jsmith;87654321-4321-4321-4321-cba987654321:jdoe +``` + +### Authorization Components + +#### 1. Authorization Utilities (`lib/auth-utils.ts`) + +Core functions for access checking: + +```typescript +import { hasGlobalAccess, hasApplicationAccess, hasDashboardAccess } from '@/lib/auth-utils'; + +// Check if user has global access via entitlements +const canAccessAll = hasGlobalAccess(user); + +// Check access to specific application +const canAccessApp = hasApplicationAccess(user, 'app-uuid-here'); + +// Check if user can access dashboard (global OR any app) +const canAccessDashboard = hasDashboardAccess(user); +``` + +#### 2. Middleware Protection (`middleware.ts`) + +Automatic route-level authorization enforcement: + +- **Dashboard**: `/` - Requires global access OR access to at least one application +- **Applications**: `/applications/[uuid]` - Requires global access OR specific application access +- **API Routes**: Protected via `withApiAuthorization` wrapper + +#### 3. API Authorization (`lib/api-auth.ts`) + +Server-side API route protection: + +```typescript +import { withApiAuthorization } from '@/lib/api-auth'; + +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Your protected API logic here + const { user } = context; + return NextResponse.json({ data: 'Protected data' }); + })(request); +} +``` + +### Access Control Flow + +1. **Authentication**: User authenticates via Stanford SAML +2. **Global Check**: System checks if user has global entitlement (e.g., `uit:sws`) +3. **Per-App Check**: If no global access, check specific application mappings +4. **Route Protection**: Middleware enforces access rules before rendering pages +5. **API Protection**: API routes verify authorization before processing requests + +### User Experience + +#### Global Access Users +- Can access dashboard showing all applications +- Can view any application detail page +- Have full access to all API endpoints + +#### Per-Application Access Users +- Can access dashboard (shows only their authorized applications) +- Can only view detail pages for applications they have access to +- API calls filtered to only return authorized application data + +#### Unauthorized Users +- Receive 403 Forbidden with clear error messages +- Redirected to appropriate pages with access denial information +- Provided contact information for requesting access + +### Debugging Authorization + +Enable authorization debugging: + +```typescript +// In lib/auth-utils.ts, uncomment debug logs +console.log('🔐 Authorization check:', { hasGlobal, hasApp, result }); +``` + +Common debug scenarios: + +```bash +# Check user's entitlements +console.log('User entitlements:', user.entitlements); + +# Check parsed app mappings +console.log('App access mappings:', parseAppAccessMappings()); + +# Check specific access decision +console.log('Access decision for app XYZ:', hasApplicationAccess(user, 'xyz-uuid')); +``` + +### Configuration Best Practices + +1. **Production Security**: Never commit real SUNet IDs or UUIDs to version control +2. **Entitlement Standards**: Use Stanford's standard entitlement format (`department:role`) +3. **UUID Management**: Use actual Acquia application UUIDs from the API +4. **Access Reviews**: Regularly audit access mappings in production +5. **Error Handling**: Provide clear error messages for authorization failures + ## Stanford Attribute Mapping The implementation automatically maps Stanford's SAML attributes using official OID identifiers: diff --git a/lib/api-auth.ts b/lib/api-auth.ts new file mode 100644 index 0000000..ab3c143 --- /dev/null +++ b/lib/api-auth.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCurrentUser, hasApplicationAccess, hasGlobalAccess } from '@/lib/auth-utils' + +/** + * Middleware helper for API route authorization + * + * This provides authorization checking specifically for API routes. + * Use this in individual API route handlers where you need to check + * user permissions before processing the request. + */ + +/** + * Check if the current user is authorized to access API endpoints + * @param request NextRequest object + * @param requireGlobalAccess If true, require global access (not just app-specific) + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApiAuthorization( + request: NextRequest, + requireGlobalAccess = false +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + if (requireGlobalAccess) { + const hasGlobal = hasGlobalAccess(user) + if (!hasGlobal) { + return { + authorized: false, + user, + error: 'Global access required. Contact administrator for access.' + } + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Check if the current user is authorized to access a specific application via API + * @param request NextRequest object + * @param applicationUuid Application UUID to check access for + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApplicationApiAuthorization( + request: NextRequest, + applicationUuid: string +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + const hasAccess = hasApplicationAccess(user, applicationUuid) + if (!hasAccess) { + return { + authorized: false, + user, + error: `Access denied. You do not have permission to access application ${applicationUuid}.` + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Create a standardized unauthorized response + * @param error Error message + * @param status HTTP status code (default: 403) + * @returns NextResponse with error + */ +export function createUnauthorizedResponse(error: string, status = 403) { + return NextResponse.json( + { + error, + code: status === 401 ? 'AUTHENTICATION_REQUIRED' : 'ACCESS_DENIED' + }, + { status } + ) +} + +/** + * Wrapper function to protect API routes with authorization + * Usage: + * + * export const GET = withApiAuthorization(async (request, { user }) => { + * // Your API logic here, user is guaranteed to be authenticated and authorized + * return NextResponse.json({ data: 'authorized data' }) + * }, { requireGlobalAccess: true }) + */ +export function withApiAuthorization( + handler: (request: NextRequest, context: { user: NonNullable>> }) => Promise, + options: { requireGlobalAccess?: boolean; requireApplicationUuid?: boolean } = {} +) { + return async (request: NextRequest) => { + // Check if application UUID is required and extract it from URL + let applicationUuid: string | undefined + + if (options.requireApplicationUuid) { + const url = new URL(request.url) + // Try to extract UUID from path segments or query params + const pathSegments = url.pathname.split('/') + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + applicationUuid = pathSegments.find(segment => uuidRegex.test(segment)) || + url.searchParams.get('uuid') || + url.searchParams.get('applicationUuid') || + undefined + + if (!applicationUuid) { + return createUnauthorizedResponse('Application UUID required', 400) + } + } + + // Perform authorization check + const authResult = applicationUuid + ? await checkApplicationApiAuthorization(request, applicationUuid) + : await checkApiAuthorization(request, options.requireGlobalAccess) + + if (!authResult.authorized || !authResult.user) { + const status = authResult.error?.includes('Authentication required') ? 401 : 403 + return createUnauthorizedResponse(authResult.error || 'Access denied', status) + } + + // Call the actual handler with the authorized user + return handler(request, { user: authResult.user }) + } +} \ No newline at end of file diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 05f9a16..3bb7027 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -12,6 +12,73 @@ export async function getCurrentUser(): Promise { return payload } +/** + * Parse application access mappings from environment variable + * Format: uuid1:uid1,uid2;uuid2:uid3,uid4 + */ +export function parseAppAccessMappings(): Map> { + const mappings = new Map>() + const envVar = process.env.CHURRO_APP_ACCESS + + if (!envVar) { + return mappings + } + + // Split by semicolon to get each app mapping + const appMappings = envVar.split(';') + + for (const appMapping of appMappings) { + const [uuid, uidsStr] = appMapping.split(':') + if (uuid && uidsStr) { + const uids = new Set(uidsStr.split(',').map(uid => uid.trim())) + mappings.set(uuid.trim(), uids) + } + } + + return mappings +} + +/** + * Check if user has global access via eduPersonEntitlement + */ +export function hasGlobalAccess(user: SamlUser): boolean { + const globalEntitlements = process.env.CHURRO_GLOBAL_ENTITLEMENTS + if (!globalEntitlements || !user.eduPersonEntitlement) { + return false + } + + const allowedEntitlements = new Set(globalEntitlements.split(',').map(e => e.trim())) + return allowedEntitlements.has(user.eduPersonEntitlement) +} + +/** + * Check if user has access to a specific application + */ +export function hasApplicationAccess(user: SamlUser, appUuid: string): boolean { + // Global access users can access everything + if (hasGlobalAccess(user)) { + return true + } + + // Check per-application access + const appMappings = parseAppAccessMappings() + const allowedUsers = appMappings.get(appUuid) + + if (!allowedUsers || !user.sunetId) { + return false + } + + return allowedUsers.has(user.sunetId) +} + +/** + * Check if user can access the dashboard + * Dashboard access requires global access (not per-app access) + */ +export function hasDashboardAccess(user: SamlUser): boolean { + return hasGlobalAccess(user) +} + /** * Client-side hook to check authentication status * Since cookies are HTTP-only, client can only check if they're logged in diff --git a/middleware.ts b/middleware.ts index 14ae7a5..49077eb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,49 +1,80 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -import { verifySession, getSessionCookieName } from '@/lib/session-auth'; +import { verifySession } from '@/lib/session-auth'; +import { hasApplicationAccess, hasDashboardAccess } from '@/lib/auth-utils'; /** - * Middleware for JWT authentication + * Middleware for session authentication and authorization * - * DESIGN DECISION: Authentication (AuthN) vs Authorization (AuthZ) + * IMPLEMENTATION: Authentication (AuthN) + Authorization (AuthZ) * - * This middleware currently implements AUTHENTICATION ONLY: - * - Verifies user identity via JWT tokens from SAML SSO - * - Protects routes with the /protected/* prefix - * - Adds user identity headers for authenticated requests + * This middleware implements both AUTHENTICATION and AUTHORIZATION: + * - Verifies user identity via encrypted sessions from SAML SSO + * - Enforces authorization rules based on eduPersonEntitlement and uid mappings + * - Protects application routes and dashboard based on user permissions * - * API routes (/api/*) are intentionally NOT protected because: - * 1. This branch implements AuthN only - AuthZ is future work - * 2. API routes currently return all data for a given subscription UUID - * 3. There's no user-data association or permission filtering yet - * 4. Main application routes (/, /applications/*) are also public + * Authorization Rules: + * 1. Global Access: Users with configured eduPersonEntitlement (e.g., 'uit:sws') + * can access everything + * 2. Per-App Access: Individual uid mappings grant access to specific applications + * 3. Dashboard Access: Granted only to users with global access + * (per-app users should go directly to their authorized applications) * - * FUTURE AuthZ work should: - * - Associate users with specific applications/data they can access - * - Filter API responses based on user permissions - * - Implement role-based access control (RBAC) - * - Then protect API routes to enforce these permissions + * API routes (/api/*) are intentionally NOT protected in middleware because: + * - API-level authorization is implemented in individual route handlers + * - This allows for more granular permission checking with request context + * - Different APIs may have different authorization requirements */ export async function middleware(request: NextRequest) { - // For protected routes, check authentication + // Check for protected routes that always require authentication const isProtectedRoute = request.nextUrl.pathname.startsWith('/protected'); - if (isProtectedRoute) { + // Check for application-specific routes + const appRouteMatch = request.nextUrl.pathname.match(/^\/applications\/([a-f0-9-]+)(?:\/.*)?$/); + const isApplicationRoute = !!appRouteMatch; + const appUuid = appRouteMatch?.[1]; + + // Check for dashboard route + const isDashboardRoute = request.nextUrl.pathname === '/'; + + // Routes that need authorization checking + const needsAuth = isProtectedRoute || isApplicationRoute || isDashboardRoute; + + if (needsAuth) { // Verify the session - const payload = await verifySession(); - if (!payload) { + const user = await verifySession(); + if (!user) { // Invalid session, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); } - // Token is valid, add user info to request headers for downstream use + // Authorization checks + if (isApplicationRoute && appUuid) { + if (!hasApplicationAccess(user, appUuid)) { + // User doesn't have access to this specific application + return NextResponse.json( + { error: 'Access denied. You do not have permission to view this application.' }, + { status: 403 } + ); + } + } else if (isDashboardRoute) { + if (!hasDashboardAccess(user)) { + // User doesn't have access to dashboard + return NextResponse.json( + { error: 'Access denied. You do not have permission to access this application.' }, + { status: 403 } + ); + } + } + + // Session is valid and user is authorized, add user info to request headers const requestHeaders = new Headers(request.headers); - requestHeaders.set('x-user-id', payload.id); - if (payload.sunetId) { - requestHeaders.set('x-user-sunetid', payload.sunetId); + requestHeaders.set('x-user-id', user.id); + if (user.sunetId) { + requestHeaders.set('x-user-sunetid', user.sunetId); } - if (payload.email) { - requestHeaders.set('x-user-email', payload.email); + if (user.email) { + requestHeaders.set('x-user-email', user.email); } return NextResponse.next({ @@ -63,11 +94,16 @@ export const config = { * * Excludes from middleware processing: * - _next: Next.js internal routes (static files, build assets) - * - api: API routes (intentionally public - see comment above) + * - api: API routes (have individual authorization in route handlers) * - favicon.ico: Browser favicon requests * - * Protected routes must use the /protected/* prefix to require authentication. - * Example: /protected/admin, /protected/dashboard + * Protected routes: + * - /protected/*: Always require authentication + * - /applications/[uuid]: Require app-specific authorization + * - / (dashboard): Require dashboard authorization + * + * Public routes that bypass middleware: + * - /auth/*: Authentication flows (login, test pages) */ - matcher: ['/((?!_next|api|favicon.ico).*)'], + matcher: ['/((?!_next|api|favicon.ico|auth).*)'], }; \ No newline at end of file