Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions src/app/api/admin/feature-flags/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@ import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';
import { logAuditMutation } from '@/middleware/audit';
import { edgeLog } from '@/../infra/edge-config';
import { requireAuth, hasRoleOrForbidden, getUserFromRequest } from '@/lib/authMiddleware';

export const runtime = 'edge';

// ─── GET /api/admin/feature-flags/[id] ───────────────────────────────────────
// Fetch a single feature flag by ID.
// Requires ADMIN role.

export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
edgeLog('info', '/api/admin/feature-flags/[id]', 'GET request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ');
if (rateLimitResponse) return rateLimitResponse;

Expand All @@ -23,9 +35,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:

// ─── PUT /api/admin/feature-flags/[id] ───────────────────────────────────────
// Full or partial update. Also handles toggle via { enabled: boolean }.
// Requires ADMIN role.

export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
edgeLog('info', '/api/admin/feature-flags/[id]', 'PUT request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

Expand All @@ -36,7 +58,8 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
const body = await req.json().catch(() => null);
if (!body) return addHeaders(NextResponse.json({ message: 'Invalid JSON' }, { status: 400 }));

const actor = req.headers.get('x-admin-user') ?? 'anonymous';
const user = getUserFromRequest(req);
const actor = user?.id ?? user?.email ?? 'anonymous';

const updated: FeatureFlag = {
...existing,
Expand Down Expand Up @@ -66,24 +89,37 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
targetType: 'feature-flag',
targetId: updated.id,
statusCode: response.status,
metadata: { action },
metadata: { action, actor },
});

return response;
}

// ─── DELETE /api/admin/feature-flags/[id] ────────────────────────────────────
// Delete a feature flag by ID.
// Requires ADMIN role.

export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
edgeLog('info', '/api/admin/feature-flags/[id]', 'DELETE request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

const { id } = await params;
const existing = flagStore.get(id);
if (!existing) return addHeaders(NextResponse.json({ message: 'Not found' }, { status: 404 }));

const actor = req.headers.get('x-admin-user') ?? 'anonymous';
const user = getUserFromRequest(req);
const actor = user?.id ?? user?.email ?? 'anonymous';

flagStore.delete(id);
createAuditEntry('deleted', actor, existing, null);

Expand All @@ -93,7 +129,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
targetType: 'feature-flag',
targetId: id,
statusCode: response.status,
metadata: { name: existing.name },
metadata: { name: existing.name, actor },
});

return response;
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/admin/feature-flags/audit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@ import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';
import { edgeLog } from '@/../infra/edge-config';
import { requireAuth, hasRoleOrForbidden } from '@/lib/authMiddleware';

export const runtime = 'edge';

/**
* GET /api/admin/feature-flags/audit?flagId=<id>&limit=50&offset=0
* Requires ADMIN role.
*/
export async function GET(req: NextRequest) {
edgeLog('info', '/api/admin/feature-flags/audit', 'GET request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ');
if (rateLimitResponse) return rateLimitResponse;

Expand Down
11 changes: 11 additions & 0 deletions src/app/api/admin/feature-flags/evaluate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@ import { NextRequest, NextResponse } from 'next/server';
import { flagStore, evaluateFlag } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';
import { edgeLog } from '@/../infra/edge-config';
import { requireAuth, hasRoleOrForbidden } from '@/lib/authMiddleware';

export const runtime = 'edge';

/**
* GET /api/admin/feature-flags/evaluate?id=<flagId>&userId=<uid>&plan=<plan>…
*
* All query params beyond `id` are passed as the evaluation context.
* Requires ADMIN role.
*/
export async function GET(req: NextRequest) {
edgeLog('info', '/api/admin/feature-flags/evaluate', 'GET request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ');
if (rateLimitResponse) return rateLimitResponse;

Expand Down
26 changes: 24 additions & 2 deletions src/app/api/admin/feature-flags/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@ import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';
import { logAuditMutation } from '@/middleware/audit';
import { edgeLog } from '@/../infra/edge-config';
import { requireAuth, hasRoleOrForbidden, getUserFromRequest } from '@/lib/authMiddleware';

export const runtime = 'edge';

// ─── GET /api/admin/feature-flags ─────────────────────────────────────────────
// Returns the full flag list sorted by updatedAt desc.
// Requires ADMIN role.

export async function GET(req: NextRequest) {
edgeLog('info', '/api/admin/feature-flags', 'GET request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ');
if (rateLimitResponse) return rateLimitResponse;

Expand All @@ -24,9 +35,19 @@ export async function GET(req: NextRequest) {

// ─── POST /api/admin/feature-flags ───────────────────────────────────────────
// Creates a new flag.
// Requires ADMIN role.

export async function POST(req: NextRequest) {
edgeLog('info', '/api/admin/feature-flags', 'POST request received');

// Authentication check
const authError = requireAuth(req);
if (authError) return authError;

// Authorization check: ADMIN only
const authzError = hasRoleOrForbidden(req, 'ADMIN');
if (authzError) return authzError;

const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

Expand All @@ -35,7 +56,8 @@ export async function POST(req: NextRequest) {
return addHeaders(NextResponse.json({ message: 'name is required' }, { status: 400 }));
}

const actor = req.headers.get('x-admin-user') ?? 'anonymous';
const user = getUserFromRequest(req);
const actor = user?.id ?? user?.email ?? 'anonymous';
const now = new Date().toISOString();

const flag: FeatureFlag = {
Expand All @@ -62,7 +84,7 @@ export async function POST(req: NextRequest) {
targetType: 'feature-flag',
targetId: flag.id,
statusCode: response.status,
metadata: { name: flag.name },
metadata: { name: flag.name, actor },
});

return response;
Expand Down
8 changes: 3 additions & 5 deletions src/lib/auth/acl.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { User, UserRole, Permission } from '@/types/api';

/**
* Mapping of roles to their granted permissions.
*/
export const ROLES_PERMISSIONS = {
export const ROLES_PERMISSIONS: Record<UserRole, Permission[]> = {
ADMIN: Object.values(Permission),
INSTRUCTOR: [
Permission.COURSE_VIEW,
Expand All @@ -19,15 +17,15 @@ export const ROLES_PERMISSIONS = {
],
STUDENT: [Permission.COURSE_VIEW, Permission.COURSE_DOWNLOAD, Permission.CONTENT_ACCESS],
GUEST: [Permission.COURSE_VIEW],
} satisfies Record<UserRole, Permission[]>;
};

/**
* Check if a user has a specific permission based on their role.
*/
export function hasPermission(user: User | null | undefined, permission: Permission): boolean {
if (!user) return false;

const permissions = ROLES_PERMISSIONS[user.role] || [];
const permissions = ROLES_PERMISSIONS[user.role] ?? [];
return permissions.includes(permission);
}

Expand Down
76 changes: 76 additions & 0 deletions src/lib/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import type { User, UserRole } from '@/types/api';
import { isAtLeast, hasPermission as checkPermission } from '@/lib/auth/acl';
import type { Permission } from '@/types/api';

/**
* Checks for authentication via Bearer token or internal API secret.
Expand Down Expand Up @@ -26,3 +29,76 @@

return null;
}

/**
* Extracts user information from request headers.
* If internal token matches secret, user is treated as ADMIN.
* Otherwise, returns user from x-user-id/x-user-email headers with role from x-user-role.
*/
export function getUserFromRequest(request: NextRequest): User | null {
const internalToken = request.headers.get('x-internal-token');
const internalSecret = process.env.INTERNAL_API_SECRET;

if (internalToken && internalSecret && internalToken === internalSecret) {
return {
id: 'internal-system',
email: 'system@internal',
role: 'ADMIN' as UserRole,
createdAt: new Date().toISOString(),

Check failure on line 47 in src/lib/authMiddleware.ts

View workflow job for this annotation

GitHub Actions / Type Check, Lint & Validation

Object literal may only specify known properties, and 'createdAt' does not exist in type '{ id: string; name: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; referralCount: number; referralCode?: string | undefined; referredBy?: string | undefined; }'.
updatedAt: new Date().toISOString(),
emailVerified: true,
};
}

const userId = request.headers.get('x-user-id');
const userEmail = request.headers.get('x-user-email');
const userRole = (request.headers.get('x-user-role') || 'GUEST') as UserRole;
const adminUser = request.headers.get('x-admin-user');

if (adminUser || userId || userEmail) {
return {
id: userId || adminUser || 'unknown',
email: userEmail || `${adminUser}@admin.local`,
role: adminUser ? ('ADMIN' as UserRole) : userRole,
createdAt: new Date().toISOString(),

Check failure on line 63 in src/lib/authMiddleware.ts

View workflow job for this annotation

GitHub Actions / Type Check, Lint & Validation

Object literal may only specify known properties, and 'createdAt' does not exist in type '{ id: string; name: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; referralCount: number; referralCode?: string | undefined; referredBy?: string | undefined; }'.
updatedAt: new Date().toISOString(),
emailVerified: adminUser ? true : false,
};
}

return null;
}

/**
* Check if user has at least the required role.
* Usage: const forbidden = hasRoleOrForbidden(request, 'ADMIN'); if (forbidden) return forbidden;
*/
export function hasRoleOrForbidden(request: NextRequest, requiredRole: UserRole): NextResponse | null {
const user = getUserFromRequest(request);
if (!user || !isAtLeast(user, requiredRole)) {
return forbidden();
}
return null;
}

/**
* Check if user has a specific permission.
* Usage: const forbidden = hasPermissionOrForbidden(request, Permission.SYSTEM_SETTINGS); if (forbidden) return forbidden;
*/
export function hasPermissionOrForbidden(request: NextRequest, permission: Permission): NextResponse | null {
const user = getUserFromRequest(request);
if (!user || !checkPermission(user, permission)) {
return forbidden();
}
return null;
}

/**
* Returns a 403 Forbidden response.
*/
export function forbidden(): NextResponse {
return NextResponse.json(
{ message: 'Forbidden: Insufficient permissions' },
{ status: 403 },
);
}
Loading