feat(rbac): R2.3a endpoint /permissions/me + frontend hook propre

This commit is contained in:
Corentin JOGUET 2026-05-07 22:58:22 +02:00
parent 022add9acc
commit 4d8bd250be
9 changed files with 406 additions and 52 deletions

View file

@ -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=<userId>` (admin) pour debug — pas demande, pas implemente
### Verifications skipped
- `pnpm install` / build / Jest run : convention agent fork (Corentin run)
---
### TODO rebrand complet (futur) ### TODO rebrand complet (futur)
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream) - Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)

View file

@ -51,6 +51,7 @@ beforeEach(() => {
hasPermission: () => true, hasPermission: () => true,
canManageRoles: true, canManageRoles: true,
isJwtClaimAvailable: true, isJwtClaimAvailable: true,
isLoading: false,
}); });
}); });

View file

@ -44,6 +44,7 @@ beforeEach(() => {
hasPermission: () => true, hasPermission: () => true,
canManageRoles: true, canManageRoles: true,
isJwtClaimAvailable: true, isJwtClaimAvailable: true,
isLoading: false,
}); });
}); });
@ -77,6 +78,7 @@ describe("RolesListPage", () => {
hasPermission: () => false, hasPermission: () => false,
canManageRoles: false, canManageRoles: false,
isJwtClaimAvailable: true, isJwtClaimAvailable: true,
isLoading: false,
}); });
render( render(
<AllProviders queryClient={makeQueryClient()}> <AllProviders queryClient={makeQueryClient()}>

View file

@ -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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
beforeEach(() => {
vi.mocked(useUserRole).mockReturnValue({
isAdmin: false,
isOwner: false,
isMember: true,
} as ReturnType<typeof useUserRole>);
});
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:<action>
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<typeof useUserRole>);
const { result } = renderHook(() => useAcadenicePermissions(), {
wrapper: makeWrapper(),
});
expect(result.current.isJwtClaimAvailable).toBe(false);
expect(result.current.canManageRoles).toBe(true);
});
});

View file

@ -1,69 +1,59 @@
import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import { useAtom } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { ADMIN_WILDCARD } from "@/features/acadenice/rbac/types/rbac.types"; 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 { export const MY_PERMISSIONS_QUERY_KEY = [
acadenice_permissions?: string[]; "acadenice",
exp?: number; "permissions",
} "me",
] as const;
/** /**
* Best-effort reader of the Acadenice permissions claim that R2.1 packs into * Source-of-truth hook for the current user's Acadenice permissions.
* the access JWT (`acadenice_permissions: string[]`).
* *
* Limits : * Backed by `GET /api/acadenice/permissions/me` (R2.3a) the backend resolves
* - Docmost stores the access token in an HttpOnly cookie (`authToken`), which * the effective union from the user's roles, with a Redis 60s cache server-side
* is unreadable from JavaScript by design. We can only inspect the * (R2.1). We keep a 60s React Query staleTime to mirror that TTL: refetching
* non-HttpOnly `authTokens` cookie + the legacy jotai atom both are kept * sooner only hits the same Redis value.
* 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.
* *
* `canManageRoles` is `true` if either : * Wildcard semantics (kept identical to R2.2 so consumers don't change) :
* - the JWT exposes `roles:manage` or `admin:*`, OR * - `admin:*` grants every check
* - we have no JWT visibility AND the user is Docmost-native admin/owner. * - `<group>:*` grants any `<group>:<action>` 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(): { export function useAcadenicePermissions(): {
permissions: string[]; permissions: string[];
hasPermission: (key: string) => boolean; hasPermission: (key: string) => boolean;
canManageRoles: boolean; canManageRoles: boolean;
isJwtClaimAvailable: boolean; isJwtClaimAvailable: boolean;
isLoading: boolean;
} { } {
const { isAdmin, isOwner } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const [authTokens] = useAtom(authTokensAtom);
const decoded = useMemo<IDecodedAcadeniceJwt | null>(() => { const { data, isLoading, isSuccess } = useQuery({
const token = queryKey: MY_PERMISSIONS_QUERY_KEY,
(authTokens && typeof authTokens === "object" && (authTokens as { accessToken?: string }).accessToken) || queryFn: () => getMyPermissions(),
(typeof authTokens === "string" ? authTokens : null) || staleTime: 60_000,
Cookies.get("authToken") || gcTime: 5 * 60_000,
null; retry: 1,
if (!token || typeof token !== "string") return null; });
try {
return jwtDecode<IDecodedAcadeniceJwt>(token);
} catch {
return null;
}
}, [authTokens]);
const permissions = useMemo<string[]>(() => { const permissions = data?.permissions ?? [];
if (decoded?.acadenice_permissions && Array.isArray(decoded.acadenice_permissions)) { const isAdminWildcard =
return decoded.acadenice_permissions; data?.is_admin_wildcard ?? permissions.includes(ADMIN_WILDCARD);
}
return [];
}, [decoded]);
const isJwtClaimAvailable =
decoded !== null && Array.isArray(decoded.acadenice_permissions);
const hasPermission = (key: string): boolean => { const hasPermission = (key: string): boolean => {
if (permissions.includes(ADMIN_WILDCARD)) return true; if (isAdminWildcard) return true;
if (permissions.includes(key)) return true; if (permissions.includes(key)) return true;
const colonIdx = key.indexOf(":"); const colonIdx = key.indexOf(":");
if (colonIdx <= 0) return false; if (colonIdx <= 0) return false;
@ -71,11 +61,18 @@ export function useAcadenicePermissions(): {
return permissions.includes(`${group}:*`); return permissions.includes(`${group}:*`);
}; };
const canManageRoles = isJwtClaimAvailable const canManageRoles = isSuccess
? hasPermission("roles:manage") ? hasPermission("roles:manage")
: // Fallback : honour Docmost-native admin/owner for navigation visibility. : // Pre-resolution fallback : honour Docmost-native admin/owner so the
// Backend still enforces the real permission. // sidebar shows the entry on first render. The actual API call still
// enforces the real permission.
isAdmin || isOwner; isAdmin || isOwner;
return { permissions, hasPermission, canManageRoles, isJwtClaimAvailable }; return {
permissions,
hasPermission,
canManageRoles,
isJwtClaimAvailable: isSuccess,
isLoading,
};
} }

View file

@ -6,6 +6,7 @@ import {
IUserRoleAssignment, IUserRoleAssignment,
ICreateRolePayload, ICreateRolePayload,
IUpdateRolePayload, IUpdateRolePayload,
IMyPermissionsResponse,
} from "@/features/acadenice/rbac/types/rbac.types"; } from "@/features/acadenice/rbac/types/rbac.types";
/** /**
@ -22,6 +23,16 @@ export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]>
>; >;
} }
/**
* 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<IMyPermissionsResponse> {
return api.get(
"/acadenice/permissions/me",
) as unknown as Promise<IMyPermissionsResponse>;
}
export async function listRoles(): Promise<IRole[]> { export async function listRoles(): Promise<IRole[]> {
return api.get("/acadenice/roles") as unknown as Promise<IRole[]>; return api.get("/acadenice/roles") as unknown as Promise<IRole[]>;
} }

View file

@ -58,6 +58,18 @@ export interface IUpdateRolePayload {
description?: string | null; 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. * Type of node in the permission matrix UI.
* - admin : the global admin:* wildcard (greys everything else when checked) * - admin : the global admin:* wildcard (greys everything else when checked)

View file

@ -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 { 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 { PERMISSIONS_CATALOG } from '../permissions-catalog';
import { AcadeniceRoleService } from '../services/role.service';
const ADMIN_WILDCARD_KEY = 'admin:*';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('acadenice/permissions') @Controller('acadenice/permissions')
export class AcadenicePermissionsController { export class AcadenicePermissionsController {
constructor(private readonly roleService: AcadeniceRoleService) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Get() @Get()
list() { list() {
@ -14,4 +28,40 @@ export class AcadenicePermissionsController {
description: p.description, 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),
};
}
} }

View file

@ -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<Pick<AcadeniceRoleService, 'getUserPermissions'>>;
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');
});
});
});