Legionista pokazał przepustkę i wszedł do obozu. Wiemy już, kim jest - to uwierzytelnienie, którym zajmowaliśmy się dotąd. Ale czy wolno mu wejść do skarbca? Rozwiązać kohortę? Wypłacić żołd?
To już inne pytanie: nie "kto to jest", tylko "co mu wolno". Nazywamy je autoryzacją i w Imperium odpowiada na nie ranga. Rekrut i legat mają tę samą przepustkę, ale nie te same prawa. Ta lekcja jest o tym, jak zapisać rzymską hierarchię w kodzie.
Zacznijmy od wyliczenia rang - od rekruta po dowódcę legionu:
1export enum Role {
2 TIRO = 'TIRO', // rekrut
3 MILES = 'MILES', // żołnierz
4 CENTURION = 'CENTURION', // dowódca centurii
5 LEGATUS = 'LEGATUS', // dowódca legionu
6}Sam enum wystarczyłby, gdyby role były rozłączne. Ale w wojsku tak nie jest: centurion może wszystko to, co żołnierz, i jeszcze więcej. Ranga to nie etykieta, tylko szczebel drabiny - a drabinę zapisujemy liczbami:
1export const ROLE_HIERARCHY = {
2 [Role.LEGATUS]: 90,
3 [Role.CENTURION]: 80,
4 [Role.MILES]: 50,
5 [Role.TIRO]: 40,
6};
7
8export function hasRequiredRole(userRole: Role, requiredRole: Role): boolean {
9 return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
10}Cała hierarchia sprowadza się do jednego porównania:
>=. Legat (90) przejdzie tam, gdzie wymagany jest centurion (80), bo stoi wyżej na drabinie. Rekrut (40) nie przejdzie nigdzie poza własnym szczeblem.Zwróć uwagę na odstępy między liczbami - 40, 50, 80, 90, a nie 1, 2, 3, 4. To celowe: gdy za rok dojdzie ranga między żołnierzem a centurionem, wpiszesz jej 60 i nie ruszasz reszty. Numerowanie co dziesięć to drobiazg, który oszczędza przepisywania.
Skoro wiemy, jak porównywać rangi, trzeba jeszcze przy każdym endpointie zapisać, jakiej rangi wymaga. Służy do tego własny dekorator:
1import { SetMetadata } from '@nestjs/common';
2
3export const ROLES_KEY = 'roles';
4export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);To trzy linijki, ale dzieje się w nich rzecz, którą warto nazwać.
SetMetadata nie sprawdza niczego - przypina tylko notatkę do metody kontrolera, pod kluczem ROLES_KEY. Notatka leży sobie i czeka; ktoś inny będzie musiał ją przeczytać i wyciągnąć wnioski.Zapis
(...roles: Role[]) to parametr resztowy - pozwala wywołać @Roles(Role.CENTURION) albo @Roles(Role.CENTURION, Role.LEGATUS), a role trafią do tablicy.Notatkę czyta guard. To on jest w tym układzie wartownikiem, który porównuje rangę przybysza z rangą wymaganą przy drzwiach:
1@Injectable()
2export class RolesGuard implements CanActivate {
3 constructor(private reflector: Reflector) {}
4
5 canActivate(context: ExecutionContext): boolean {
6 const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
7 context.getHandler(),
8 context.getClass(),
9 ]);
10
11 if (!requiredRoles) {
12 return true;
13 }
14
15 const { user } = context.switchToHttp().getRequest();
16
17 return requiredRoles.some((role) => hasRequiredRole(user.role, role));
18 }
19}Prześledźmy to po kolei, bo tu spina się cała lekcja.
Reflector to narzędzie NestJS do odczytywania metadanych - tych samych, które przypiął SetMetadata. Metoda getAllAndOverride szuka notatki w dwóch miejscach: najpierw przy metodzie (getHandler()), potem przy klasie kontrolera (getClass()), i wygrywa to, co bliżej metody. Dzięki temu możesz ustawić wymóg dla całego kontrolera, a przy jednym endpointie go nadpisać.Brak notatki znaczy, że endpoint nie stawia wymagań - wtedy
return true wpuszcza każdego zalogowanego. To ważne zachowanie: guard, który przy braku metadanych blokowałby dostęp, zamknąłby całą aplikację.context.switchToHttp().getRequest() sięga po żądanie, a z niego po user - obiekt, który wcześniej zostawiła tam strategia JWT. I tu widać, dlaczego kolejność ma znaczenie: RolesGuard działa tylko wtedy, gdy przed nim zadziałał guard uwierzytelniający. Bez tego user byłby pusty. Autoryzacja zawsze następuje po uwierzytelnieniu - najpierw wiemy, kim jest przybysz, dopiero potem sprawdzamy jego rangę.Ostatnia linia zwraca
true lub false, a to jedyna rzecz, jakiej NestJS od guarda oczekuje: false zatrzymuje żądanie z odpowiedzią 403, a metoda kontrolera nie wykona się w ogóle. Metoda some wystarcza, bo lista ról to alternatywa - dość spełnić jedno z wymagań.Oba klocki spinamy przy endpointach:
1@Controller('treasury')
2@UseGuards(JwtAuthGuard, RolesGuard)
3export class TreasuryController {
4 @Get()
5 findAll() {
6 return this.treasuryService.findAll();
7 }
8
9 @Post('withdraw')
10 @Roles(Role.CENTURION)
11 withdraw(@Body() dto: WithdrawDto) {
12 return this.treasuryService.withdraw(dto);
13 }
14
15 @Delete('cohort/:id')
16 @Roles(Role.LEGATUS)
17 disbandCohort(@Param('id') id: string) {
18 return this.treasuryService.disbandCohort(id);
19 }
20}Kolejność w
@UseGuards(JwtAuthGuard, RolesGuard) nie jest przypadkowa - guardy wykonują się od lewej do prawej, więc najpierw ustalamy tożsamość, potem rangę. Odwrócenie tej pary sprawiłoby, że RolesGuard szukałby user, którego jeszcze nie ma.Zauważ też, że
findAll() nie ma dekoratora @Roles - i właśnie dlatego zobaczy go każdy zalogowany legionista. Wypłata wymaga centuriona, a rozwiązanie kohorty - legata. Ten sam skarbiec, trzy różne progi.Hierarchia Imperium zapisana, wartownicy na miejscach:
>=,SetMetadata tylko przypina notatkę do metody - niczego nie sprawdza,Reflector.getAllAndOverride czyta notatkę z metody i z klasy, a bliższa metodzie wygrywa,true albo false; false kończy żądanie odpowiedzią 403,@UseGuards() guardy działają od lewej do prawej - uwierzytelniający musi stać przed autoryzującym, bo to on zostawia user w żądaniu.W następnej lekcji zajmiemy się odnawianiem przepustek, czyli refresh tokenami - bo przepustka centuriona też kiedyś wygasa. A na razie zapamiętaj: rola to szczebel drabiny, dekorator przypina notatkę o wymaganej wysokości, a guard porównuje jedno z drugim.