Skip to content
Merged
12 changes: 10 additions & 2 deletions src/middleware/api-key-scope.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { ApiKeyScope } from "../modules/api-keys/api-keys.schema.js";
import { ForbiddenError } from "../utils/errors.js";

function routeScope(method: string, routePath: string): ApiKeyScope | "admin:all" {
function routeScope(method: string, routePath: string): ApiKeyScope | "admin:all" | null {
// null = any authenticated API key may call this endpoint (no additional scope needed)
if (routePath === "/auth/me") return null;

if (method === "GET" && routePath.startsWith("/admin/employees")) return "read:employees";
if (method === "GET" && (routePath.startsWith("/admin/sessions") || routePath === "/attendance/my-sessions")) {
return "read:sessions";
}
if ((method === "POST" && routePath === "/expenses") || (method === "PATCH" && routePath.startsWith("/admin/expenses/"))) {
if (
(method === "POST" && routePath === "/expenses") ||
(method === "GET" && routePath === "/expenses/my") ||
(method === "PATCH" && routePath.startsWith("/admin/expenses/"))
) {
return "write:expenses";
}
return "admin:all";
Expand All @@ -19,6 +26,7 @@ export function hasApiKeyScope(scopes: ApiKeyScope[], required: ApiKeyScope | "a

export function enforceApiKeyScope(method: string, routePath: string, scopes: ApiKeyScope[]): void {
const required = routeScope(method.toUpperCase(), routePath);
if (required === null) return; // endpoint accessible to any authenticated API key
if (!hasApiKeyScope(scopes, required)) {
throw new ForbiddenError(`API key missing required scope: ${required}`);
}
Expand Down
18 changes: 10 additions & 8 deletions src/modules/admin/monitoring.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,16 @@ export const monitoringRepository = {
page: number,
limit: number,
): Promise<{ data: AdminSession[]; total: number }> {
const { data, error, count } = await applyPagination(
orgTable(request, "admin_sessions")
.select(MONITORING_COLS, { count: "exact" })
.eq("admin_id", request.user.sub)
.order("started_at", { ascending: false }),
page,
limit,
);
// When auth is via API key, request.user.sub is "api_key:<id>" β€” not a real
// users.id UUID. Filtering by admin_id would cause a DB type error. Instead,
// return all org sessions (API key callers have org-level access, not user-level).
let baseQuery = orgTable(request, "admin_sessions")
.select(MONITORING_COLS, { count: "exact" })
.order("started_at", { ascending: false });
if (request.authType !== "api_key") {
baseQuery = baseQuery.eq("admin_id", request.user.sub);
}
const { data, error, count } = await applyPagination(baseQuery, page, limit);

if (error) {
throw new Error(`Failed to fetch monitoring history: ${error.message}`);
Expand Down
8 changes: 7 additions & 1 deletion src/modules/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
LeaderboardQuery,
} from "./analytics.schema.js";
import { ok, handleError } from "../../utils/response.js";
import { BadRequestError } from "../../utils/errors.js";

/**
* Analytics controller β€” delegates to service and returns consistent
Expand Down Expand Up @@ -51,7 +52,12 @@ export const analyticsController = {
): Promise<void> {
try {
const query = request.query as UserSummaryQuery;
const userId = query.userId ?? request.user.sub;
// When authenticating via API key, request.user.sub is "api_key:<id>" which
// is not a valid users.id UUID. Require an explicit userId in that case.
const userId = query.userId ?? (request.authType === "api_key" ? null : request.user.sub);
if (!userId) {
throw new BadRequestError("userId query parameter is required when using API key authentication");
}
const data = await analyticsService.getUserSummary(
request,
userId,
Expand Down
3 changes: 2 additions & 1 deletion src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { authenticate } from "../../middleware/auth.js";
const authMeResponseSchema = z.object({
success: z.literal(true),
data: z.object({
id: z.string().uuid(),
// sub can be a UUID (JWT) or "api_key:<uuid>" (API key auth) β€” accept both
id: z.string(),
email: z.string().email().optional(),
role: z.enum(["ADMIN", "EMPLOYEE"]),
orgId: z.string().uuid(),
Expand Down
Loading