feat(rbac): R2.3a endpoint /permissions/me + frontend hook propre
This commit is contained in:
parent
022add9acc
commit
4d8bd250be
9 changed files with 406 additions and 52 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ beforeEach(() => {
|
||||||
hasPermission: () => true,
|
hasPermission: () => true,
|
||||||
canManageRoles: true,
|
canManageRoles: true,
|
||||||
isJwtClaimAvailable: true,
|
isJwtClaimAvailable: true,
|
||||||
|
isLoading: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue