1
0

2 Revīzijas 4dd01736ea ... c17a32de3a

Autors SHA1 Ziņojums Datums
  Code_Uwe c17a32de3a RHL-004-docs(auth): enhance authentication documentation with branch-level RBAC details and response semantics 2 dienas atpakaļ
  Code_Uwe bdac8a94bc RHI-004-feat(auth): implement session-based access control for API routes and enhance tests 2 dienas atpakaļ

+ 115 - 35
Docs/auth.md

@@ -1,3 +1,13 @@
+<!-- --------------------------------------------------------------------------- -->
+
+<!-- Folder: Docs -->
+
+<!-- File: auth.md -->
+
+<!-- Relative path: Docs/auth.md -->
+
+<!-- --------------------------------------------------------------------------- -->
+
 # Authentication & Authorization
 
 This document describes the authentication and authorization model for the internal delivery note browser.
@@ -7,6 +17,7 @@ The system uses:
 - MongoDB to store users.
 - Cookie-based sessions with a signed JWT payload.
 - Role-aware access control (`branch`, `admin`, `dev`).
+- Branch-level RBAC enforcement for filesystem APIs.
 - Extensible password management and recovery flows.
 
 > NOTE: This document is a living document. As we extend the auth system (sessions, routes, policies, password flows), we will update this file.
@@ -15,9 +26,9 @@ The system uses:
 
 ## 1. Goals & Scope
 
-The main goals of the authentication system are:
+The main goals of the authentication and authorization system are:
 
-- Only authenticated users can access the application.
+- Only authenticated users can access protected backend APIs.
 - Branch users can only see delivery notes for **their own branch**.
 - Admin and dev users can access data across branches.
 - Passwords are never stored in plaintext.
@@ -28,6 +39,7 @@ This document covers:
 
 - User model and roles.
 - Environment variables related to auth.
+- RBAC rules and protected filesystem endpoints.
 - Session payload and cookie configuration.
 - Login and logout endpoints.
 - Planned endpoints for password management and recovery.
@@ -143,6 +155,7 @@ This ensures that sensitive information is not exposed via API responses or logs
 ### 3.4 Role Assignment & User Provisioning
 
 - Users are **created by an admin** (no public self-registration).
+
 - When a user is created:
 
   - `role` is set by the admin.
@@ -192,11 +205,80 @@ This ensures that sensitive information is not exposed via API responses or logs
 
 ---
 
-## 5. Sessions & Cookies
+## 5. Authorization: Branch-Level RBAC
+
+The backend enforces **Role-Based Access Control (RBAC)** on branch-related filesystem APIs.
+
+### 5.1 Response Semantics
+
+- **401 Unauthorized**: no valid session (`getSession()` returns `null`).
+
+  ```json
+  { "error": "Unauthorized" }
+  ```
+
+- **403 Forbidden**: session exists but the user is not allowed to access the requested branch.
+
+  ```json
+  { "error": "Forbidden" }
+  ```
+
+> Note: Some legacy `400`/`500` messages are still returned in German (e.g. missing params, filesystem errors). We may normalize these later.
+
+### 5.2 Permission Helpers
+
+RBAC rules are implemented in `lib/auth/permissions.js`:
+
+- `canAccessBranch(session, branchId)`
+
+  - No session → `false`
+  - `role = "branch"` → `true` only if `session.branchId === branchId`
+  - `role = "admin" | "dev"` → `true` for any branch
+
+- `filterBranchesForSession(session, branchIds)`
+
+  - `role = "branch"` → returns only the user’s own branch (if present)
+  - `role = "admin" | "dev"` → returns all
+
+### 5.3 Protected Filesystem APIs
+
+The following endpoints are protected and must be called only with a valid session:
+
+- `GET /api/branches`
+
+  - Requires session (401 otherwise)
+  - `branch` role: returns only `[session.branchId]`
+  - `admin`/`dev`: returns all branches
+
+- `GET /api/branches/[branch]/years`
+
+- `GET /api/branches/[branch]/[year]/months`
+
+- `GET /api/branches/[branch]/[year]/[month]/days`
+
+  - Requires session (401 otherwise)
+  - Requires branch access (403 if not allowed)
+
+- `GET /api/files?branch=&year=&month=&day=`
+
+  - Requires session (401 otherwise)
+  - Requires branch access (403 if not allowed)
+
+Implementation pattern (high-level):
+
+1. `const session = await getSession()`
+2. If `!session` → return 401
+3. Extract requested branch (`params.branch` or `query.branch`)
+4. If `!canAccessBranch(session, requestedBranch)` → return 403
+5. Proceed with storage access and return data
+
+---
+
+## 6. Sessions & Cookies
 
 Sessions are implemented as signed JWTs stored in HTTP-only cookies.
 
-### 5.1 Session Payload Format
+### 6.1 Session Payload Format
 
 A session payload has the following structure:
 
@@ -218,7 +300,7 @@ A session payload has the following structure:
 
 The `iat` and `exp` fields are managed by the JWT library.
 
-### 5.2 JWT Signing
+### 6.2 JWT Signing
 
 - JWTs are signed using a symmetric secret (`SESSION_SECRET`).
 - Algorithm: `HS256` (HMAC using SHA-256).
@@ -228,7 +310,7 @@ The `iat` and `exp` fields are managed by the JWT library.
   - `SESSION_MAX_AGE_SECONDS = 60 * 60 * 8` (8 hours).
   - Configured in `lib/auth/session.js`.
 
-### 5.3 Cookie Settings
+### 6.3 Cookie Settings
 
 The session token is stored in an HTTP-only cookie with the following properties:
 
@@ -260,9 +342,9 @@ Cookies are written and cleared using Next.js `cookies()` from `next/headers` in
 
 ---
 
-## 6. Core Auth Endpoints
+## 7. Core Auth Endpoints
 
-### 6.1 `POST /api/auth/login`
+### 7.1 `POST /api/auth/login`
 
 **Purpose**
 Authenticate a user using `username` and `password`, create a session, and set the session cookie.
@@ -295,6 +377,7 @@ Authenticate a user using `username` and `password`, create a session, and set t
    - If `username` or `password` is missing or empty → `400 { "error": "Missing username or password" }`.
 
 3. Connect to MongoDB.
+
 4. Look up the user in MongoDB by normalized `username`.
 
    - If no user is found → `401 { "error": "Invalid credentials" }`.
@@ -307,6 +390,7 @@ Authenticate a user using `username` and `password`, create a session, and set t
 6. On success:
 
    - Create a session payload `{ userId, role, branchId }`.
+
    - Call `createSession({ userId, role, branchId })`:
 
      - Signs a JWT with the session payload.
@@ -324,8 +408,6 @@ Authenticate a user using `username` and `password`, create a session, and set t
   }
   ```
 
-  (Session cookie is set in the response headers.)
-
 - `400 Bad Request`:
 
   ```json
@@ -358,7 +440,7 @@ Authenticate a user using `username` and `password`, create a session, and set t
   }
   ```
 
-### 6.2 `GET /api/auth/logout`
+### 7.2 `GET /api/auth/logout`
 
 **Purpose**
 Destroy the current session by clearing the session cookie.
@@ -404,11 +486,11 @@ Logout is **idempotent**:
 
 ---
 
-## 7. Password Management & Recovery (Planned)
+## 8. Password Management & Recovery (Planned)
 
 The database model is already prepared for password management and password recovery flows, but the respective endpoints may be implemented in a separate epic.
 
-### 7.1 Change Password
+### 8.1 Change Password
 
 **Endpoint**
 `POST /api/auth/change-password` (planned)
@@ -445,7 +527,7 @@ Allow logged-in users to change their password by providing the current password
 8. Optionally update a `passwordChangedAt` field if introduced later.
 9. Return `{ "ok": true }`.
 
-### 7.2 Request Password Reset
+### 8.2 Request Password Reset
 
 **Endpoint**
 `POST /api/auth/request-password-reset` (planned)
@@ -465,8 +547,6 @@ Start the "forgot password" flow by sending a reset link to the user's email add
 }
 ```
 
-- The frontend may allow either username or email. The backend resolves it accordingly.
-
 **Planned Behavior**
 
 1. Normalize the identifier (trim + lowercase).
@@ -481,22 +561,17 @@ Start the "forgot password" flow by sending a reset link to the user's email add
 4. If a user is found:
 
    - Generate a secure random token (or a signed token).
-
    - Store it in `passwordResetToken`.
-
    - Set `passwordResetExpiresAt` to a timestamp in the near future (e.g. now + 30 minutes).
-
    - Send an email to `user.email` containing a link like:
 
      ```
      https://<app-domain>/reset-password?token=<passwordResetToken>
      ```
 
-   - The email is sent using a mailer (e.g. `nodemailer`).
+5. Always return `{ "ok": true }`.
 
-5. Always return `{ "ok": true }` to the client, regardless of whether a user was found.
-
-### 7.3 Reset Password
+### 8.3 Reset Password
 
 **Endpoint**
 `POST /api/auth/reset-password` (planned)
@@ -520,8 +595,11 @@ Complete the password reset process using a valid reset token.
 **Planned Behavior**
 
 1. Find user by `passwordResetToken`.
+
 2. If no user is found → return a generic error (e.g. `{ "error": "Invalid or expired token" }`).
+
 3. Check that `passwordResetExpiresAt` is in the future.
+
 4. If the token has expired:
 
    - Return a generic error.
@@ -535,15 +613,17 @@ Complete the password reset process using a valid reset token.
    - Optionally set `mustChangePassword = false`.
 
 6. Optionally invalidate other active sessions if a "global logout on password change" is implemented.
+
 7. Return `{ "ok": true }`.
 
-### 7.4 Email Sending
+### 8.4 Email Sending
 
 Password reset emails will be sent using a mailer library (e.g. `nodemailer`), configured for the environment.
 
 Key points:
 
 - Emails are sent to `user.email`.
+
 - The content includes:
 
   - A short explanation of the password reset process.
@@ -554,12 +634,12 @@ Key points:
 
 ---
 
-## 8. Security Considerations
+## 9. Security Considerations
 
-1. **Never trust client-provided `branchId`.**
+1. **Never trust client-provided branch information.**
 
-   - The effective `branchId` for authorization must always come from the **session payload** (derived from the user record), not from query parameters or request bodies.
-   - Even if routes use `branch` parameters for URL structure, the backend must enforce access based on the `branchId` in the session.
+   - The effective branch authorization is enforced using the **session payload** (`session.branchId`) and RBAC rules.
+   - Even if routes use `branch` parameters for URL structure, the backend enforces branch access based on the session.
 
 2. **Password handling.**
 
@@ -589,13 +669,9 @@ Key points:
 
 ---
 
-## 9. Future Work & Integration
+## 10. Future Work & Integration
 
-- Protect existing filesystem APIs (`/api/branches/*`, `/api/files`, etc.) by:
-
-  - Calling `getSession()` at the start of each route.
-  - Returning `401` if no valid session exists.
-  - Resolving the effective `branchId` from the session and enforcing that branch users only see their own branch.
+- **(Optional)** Add a `middleware.js` for frontend route protection (redirect unauthenticated users to login for certain pages).
 
 - Implement password management endpoints:
 
@@ -604,6 +680,7 @@ Key points:
   - `POST /api/auth/reset-password`
 
 - Integrate an email provider using `nodemailer` or similar for password reset.
+
 - Build frontend UI for:
 
   - Login
@@ -611,4 +688,7 @@ Key points:
   - Change password
   - “Forgot password” / reset password flows.
 
-- Optionally extend auditing and logging for security-relevant events.
+- Optional improvements:
+
+  - Normalize API error messages (language and structure) across all endpoints.
+  - Add auditing for admin actions and branch access.

+ 12 - 4
app/api/branches/[branch]/[year]/[month]/days/route.js

@@ -1,14 +1,19 @@
 // app/api/branches/[branch]/[year]/[month]/days/route.js
 import { NextResponse } from "next/server";
 import { listDays } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/[year]/[month]/days
- *
- * Returns the list of day folders for a given branch, year, and month.
- * Example: /api/branches/NL01/2024/10/days → { days: ["01", "02", ...] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { branch, year, month } = await ctx.params;
 	console.log("[/api/branches/[branch]/[year]/[month]/days] params:", {
 		branch,
@@ -23,9 +28,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const days = await listDays(branch, year, month);
-
 		return NextResponse.json({ branch, year, month, days });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/[year]/[month]/days] Error:", error);

+ 89 - 36
app/api/branches/[branch]/[year]/[month]/days/route.test.js

@@ -1,41 +1,83 @@
-// app/api/branches/[branch]/[year]/[month]/days/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getDays } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-days-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-days-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10/23
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
-		recursive: true,
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
+			recursive: true,
+		});
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
-	it("returns days for a valid branch/year/month", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/10/days");
-		const ctx = {
-			params: Promise.resolve({
-				branch: "NL01",
-				year: "2024",
-				month: "10",
-			}),
-		};
-
-		const res = await getDays(req, ctx);
-		expect(res.status).toBe(200);
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
+			}
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL02", year: "2024", month: "10" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
+	});
+
+	it("returns days for a valid branch/year/month when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
 		expect(body).toEqual({
 			branch: "NL01",
@@ -45,16 +87,27 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 		});
 	});
 
-	it("returns 400 when any param is missing", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/10/days");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01", year: "2024" }), // month missing
-		};
+	it("returns 400 when any param is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
 
-		const res = await getDays(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024//days"),
+			{
+				params: Promise.resolve({
+					branch: "NL01",
+					year: "2024",
+					month: undefined,
+				}),
+			}
+		);
 
-		const body = await res.json();
-		expect(body.error).toBe("branch, year oder month fehlt");
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: "branch, year oder month fehlt",
+		});
 	});
 });

+ 12 - 4
app/api/branches/[branch]/[year]/months/route.js

@@ -1,14 +1,19 @@
 // app/api/branches/[branch]/[year]/months/route.js
 import { NextResponse } from "next/server";
 import { listMonths } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/[year]/months
- *
- * Returns the list of month folders for a given branch and year.
- * Example: /api/branches/NL01/2024/months → { months: ["01", "02", ...] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { branch, year } = await ctx.params;
 	console.log("[/api/branches/[branch]/[year]/months] params:", {
 		branch,
@@ -22,9 +27,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const months = await listMonths(branch, year);
-
 		return NextResponse.json({ branch, year, months });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/[year]/months] Error:", error);

+ 82 - 35
app/api/branches/[branch]/[year]/months/route.test.js

@@ -1,55 +1,102 @@
-// app/api/branches/[branch]/[year]/months/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getMonths } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/[year]/months", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-months-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-months-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10"), {
-		recursive: true,
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10"), {
+			recursive: true,
+		});
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/[year]/months", () => {
-	it("returns months for a valid branch/year", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/months");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01", year: "2024" }),
-		};
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-		const res = await getMonths(req, ctx);
-		expect(res.status).toBe(200);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024" }),
+			}
+		);
 
-		const body = await res.json();
-		expect(body).toEqual({
-			branch: "NL01",
-			year: "2024",
-			months: ["10"],
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
 		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL02", year: "2024" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
 	});
 
-	it("returns 400 when branch or year is missing", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/months");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }), // year missing
-		};
+	it("returns months for a valid branch/year when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
 
-		const res = await getMonths(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
-		expect(body.error).toBe("branch oder year fehlt");
+		expect(body).toEqual({ branch: "NL01", year: "2024", months: ["10"] });
+	});
+
+	it("returns 400 when branch or year is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01//months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: undefined }),
+			}
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({ error: "branch oder year fehlt" });
 	});
 });

+ 12 - 5
app/api/branches/[branch]/years/route.js

@@ -1,19 +1,23 @@
 // app/api/branches/[branch]/years/route.js
 import { NextResponse } from "next/server";
 import { listYears } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/years
- *
- * Returns the list of year folders for a given branch.
- * Example: /api/branches/NL01/years → { branch: "NL01", years: ["2023", "2024"] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	// Next.js 16: params are resolved asynchronously via ctx.params
 	const { branch } = await ctx.params;
 	console.log("[/api/branches/[branch]/years] params:", { branch });
 
-	// Basic validation of required params
 	if (!branch) {
 		return NextResponse.json(
 			{ error: "branch Parameter fehlt" },
@@ -21,9 +25,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const years = await listYears(branch);
-
 		return NextResponse.json({ branch, years });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/years] Error:", error);

+ 90 - 46
app/api/branches/[branch]/years/route.test.js

@@ -1,75 +1,119 @@
-// app/api/branches/[branch]/years/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getYears } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/years", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-years-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-years-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024"), {
-		recursive: true,
+		// Minimal structure for NL01
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024"), { recursive: true });
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/years", () => {
-	it("returns years for a valid branch", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/years");
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-		// In Next.js 16, ctx.params is a Promise the framework would resolve.
-		// In tests we simulate that.
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }),
-		};
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
-		const res = await getYears(req, ctx);
-		expect(res.status).toBe(200);
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
 
-		const body = await res.json();
-		expect(body).toEqual({
-			branch: "NL01",
-			years: ["2024"],
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
 		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/years"),
+			{
+				params: Promise.resolve({ branch: "NL02" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
 	});
 
-	it("returns 400 when branch param is missing", async () => {
-		const req = new Request("http://localhost/api/branches/UNKNOWN/years");
-		const ctx = {
-			params: Promise.resolve({}), // no branch
-		};
+	it("returns years for a valid branch when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
 
-		const res = await getYears(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
-		expect(body.error).toBe("branch Parameter fehlt");
+		expect(body).toEqual({ branch: "NL01", years: ["2024"] });
 	});
 
-	it("returns 500 when NAS_ROOT_PATH is invalid", async () => {
-		const originalRoot = process.env.NAS_ROOT_PATH;
+	it("returns 400 when branch param is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(new Request("http://localhost/api/branches//years"), {
+			params: Promise.resolve({ branch: undefined }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({ error: "branch Parameter fehlt" });
+	});
+
+	it("returns 500 when NAS_ROOT_PATH is invalid (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
 		process.env.NAS_ROOT_PATH = path.join(tmpRoot, "does-not-exist");
 
-		const req = new Request("http://localhost/api/branches/NL01/years");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }),
-		};
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
-		const res = await getYears(req, ctx);
 		expect(res.status).toBe(500);
-
 		const body = await res.json();
 		expect(body.error).toContain("Fehler beim Lesen der Jahre:");
-
-		process.env.NAS_ROOT_PATH = originalRoot;
 	});
 });

+ 15 - 3
app/api/branches/route.js

@@ -1,23 +1,35 @@
 // app/api/branches/route.js
 import { NextResponse } from "next/server";
 import { listBranches } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { filterBranchesForSession } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches
  *
  * Returns the list of branches (e.g. ["NL01", "NL02", ...]) based on the
  * directory names under NAS_ROOT_PATH.
+ *
+ * RBAC:
+ * - 401 if no session
+ * - branch role: only returns its own branch
+ * - admin/dev: returns all branches
  */
 export async function GET() {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	try {
 		const branches = await listBranches();
+		const visibleBranches = filterBranchesForSession(session, branches);
 
-		return NextResponse.json({ branches });
+		return NextResponse.json({ branches: visibleBranches });
 	} catch (error) {
-		// Log the full error on the server for debugging
 		console.error("[api/branches] Error reading branches:", error);
 
-		// Return a generic error message to the client
 		return NextResponse.json(
 			{ error: "Fehler beim Lesen der Niederlassungen" },
 			{ status: 500 }

+ 69 - 29
app/api/branches/route.test.js

@@ -1,51 +1,91 @@
-// app/api/branches/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getBranches } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
 
-let tmpRoot;
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-beforeAll(async () => {
-	// Create a dedicated temporary root for this test suite
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-branches-"));
+describe("GET /api/branches", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-	// Make this temp root the NAS root for the duration of these tests
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-branches-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
+	});
 
-	// Create a branch and a snapshot folder
-	await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
-	await fs.mkdir(path.join(tmpRoot, "@Recently-Snapshot"));
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) {
+			await fs.rm(tmpRoot, { recursive: true, force: true });
+		}
+	});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-describe("GET /api/branches", () => {
-	it("returns branch names and filters snapshot folders", async () => {
-		const req = new Request("http://localhost/api/branches");
-		const res = await getBranches(req);
+		const res = await GET();
+		expect(res.status).toBe(401);
+
+		const body = await res.json();
+		expect(body).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns only the own branch for branch users", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
+		await fs.mkdir(path.join(tmpRoot, "NL02"), { recursive: true });
+
+		const res = await GET();
 		expect(res.status).toBe(200);
 
 		const body = await res.json();
-		expect(body).toEqual({ branches: ["NL01"] });
+		expect(body.branches).toEqual(["NL01"]);
 	});
 
-	it("returns 500 when NAS_ROOT_PATH is invalid", async () => {
-		const originalRoot = process.env.NAS_ROOT_PATH;
+	it("returns all branches for admin/dev users", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
+		await fs.mkdir(path.join(tmpRoot, "NL02"), { recursive: true });
+
+		const res = await GET();
+		expect(res.status).toBe(200);
+
+		const body = await res.json();
+		expect([...body.branches].sort()).toEqual(["NL01", "NL02"]);
+	});
+
+	it("returns 500 when NAS_ROOT_PATH is invalid (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
 		process.env.NAS_ROOT_PATH = path.join(tmpRoot, "does-not-exist");
 
-		const req = new Request("http://localhost/api/branches");
-		const res = await getBranches(req);
+		const res = await GET();
 		expect(res.status).toBe(500);
 
 		const body = await res.json();
-		expect(body.error).toContain("Fehler beim Lesen der Niederlassungen");
-
-		// Restore valid root for subsequent tests
-		process.env.NAS_ROOT_PATH = originalRoot;
+		expect(body).toEqual({ error: "Fehler beim Lesen der Niederlassungen" });
 	});
 });

+ 12 - 6
app/api/files/route.js

@@ -1,15 +1,19 @@
 // app/api/files/route.js
 import { NextResponse } from "next/server";
 import { listFiles } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/files?branch=&year=&month=&day=
- *
- * Returns the list of PDF files for a specific branch + date.
- * Example:
- *   /api/files?branch=NL01&year=2024&month=10&day=23
  */
 export async function GET(request) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { searchParams } = new URL(request.url);
 	const branch = searchParams.get("branch");
 	const year = searchParams.get("year");
@@ -18,7 +22,6 @@ export async function GET(request) {
 
 	console.log("[/api/files] query:", { branch, year, month, day });
 
-	// Validate required query params
 	if (!branch || !year || !month || !day) {
 		return NextResponse.json(
 			{ error: "branch, year, month, day sind erforderlich" },
@@ -26,9 +29,12 @@ export async function GET(request) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const files = await listFiles(branch, year, month, day);
-
 		return NextResponse.json({ branch, year, month, day, files });
 	} catch (error) {
 		console.error("[/api/files] Error:", error);

+ 76 - 34
app/api/files/route.test.js

@@ -1,60 +1,102 @@
-// app/api/files/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getFiles } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/files", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-files-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-files-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10/23/test.pdf
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
-		recursive: true,
+		const dir = path.join(tmpRoot, "NL01", "2024", "10", "23");
+		await fs.mkdir(dir, { recursive: true });
+		await fs.writeFile(path.join(dir, "test.pdf"), "dummy-pdf-content");
 	});
-	await fs.writeFile(
-		path.join(tmpRoot, "NL01", "2024", "10", "23", "test.pdf"),
-		"content"
-	);
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const req = new Request(
+			"http://localhost/api/files?branch=NL01&year=2024&month=10&day=23"
+		);
+
+		const res = await GET(req);
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		const req = new Request(
+			"http://localhost/api/files?branch=NL02&year=2024&month=10&day=23"
+		);
+
+		const res = await GET(req);
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
+	});
+
+	it("returns files for a valid query when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
 
-describe("GET /api/files", () => {
-	it("returns files for a valid query", async () => {
 		const req = new Request(
 			"http://localhost/api/files?branch=NL01&year=2024&month=10&day=23"
 		);
 
-		const res = await getFiles(req);
+		const res = await GET(req);
 		expect(res.status).toBe(200);
 
 		const body = await res.json();
 		expect(body.branch).toBe("NL01");
-		expect(body.year).toBe("2024");
-		expect(body.month).toBe("10");
-		expect(body.day).toBe("23");
-		expect(body.files).toEqual([
-			{
-				name: "test.pdf",
-				relativePath: "NL01/2024/10/23/test.pdf",
-			},
-		]);
+		expect(body.files).toHaveLength(1);
+		expect(body.files[0]).toMatchObject({
+			name: "test.pdf",
+			relativePath: "NL01/2024/10/23/test.pdf",
+		});
 	});
 
-	it("returns 400 when query params are missing", async () => {
-		const req = new Request("http://localhost/api/files"); // no params
+	it("returns 400 when query params are missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const req = new Request("http://localhost/api/files");
 
-		const res = await getFiles(req);
+		const res = await GET(req);
 		expect(res.status).toBe(400);
 
 		const body = await res.json();
-		expect(body.error).toBe("branch, year, month, day sind erforderlich");
+		expect(body).toEqual({
+			error: "branch, year, month, day sind erforderlich",
+		});
 	});
 });