Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Role-based Authorization - hierarchia w Imperium

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.

Role jako drabina

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.

Dekorator - zapisanie wymagania przy metodzie

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.

Guard - odczytanie notatki i decyzja

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ń.

Użycie w kontrolerze

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.

Podsumowanie

Hierarchia Imperium zapisana, wartownicy na miejscach:

  • uwierzytelnienie odpowiada, kim jest przybysz; autoryzacja - co mu wolno,
  • role układamy w drabinę liczb, a całe dziedziczenie uprawnień sprowadza się do porównania
    >=
    ,
  • numeruj co dziesięć, żeby dało się później wcisnąć nową rangę bez przepisywania,
  • SetMetadata
    tylko przypina notatkę do metody - niczego nie sprawdza,
  • Reflector.getAllAndOverride
    czyta notatkę z metody i z klasy, a bliższa metodzie wygrywa,
  • brak metadanych musi znaczyć "wpuść", inaczej guard zamknie całą aplikację,
  • guard zwraca
    true
    albo
    false
    ;
    false
    kończy żądanie odpowiedzią 403,
  • w
    @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.

Przejdź do CodeWorlds