Trzy modele autoryzacji i jeden feralny wtorek.
Jedno zgłoszenie. Nic ciekawego.
Technical Lead w Jit Team · architekt i konsultant, 15+ lat
Case, o którym dziś mówię, jest poskładany z kilku realnych projektów. Domenę zmieniłem, żeby nie opisywać cudzych systemów z nazwy.
Jednym z moich hobby stało się ostatnio zdawanie certyfikacji. Wyszło z tego trzynaście sztuk.
Poza IT robimy to od stu lat i nikomu nie przychodzi do głowy nazywać tego autoryzacją.
{
"iss": "https://id.redakcja.pl",
"sub": "kamil",
"name": "Kamil Kiełbasa",
"exp": 1789999999
}
.eyJhbGciOiJSUzI1NiJ9… podpis wystawcy
Ten sam pomysł w dwóch światach: dokument wystawiony przez stronę, której obie strony ufają.
| Rola w dokumencie | Dowód osobisty | Token JWT |
|---|---|---|
| kto wystawił | Prezydent miasta | iss |
| kogo dotyczy | imię, nazwisko, PESEL | sub |
| do kiedy ważny | data ważności na awersie | exp |
| czemu mam wierzyć | hologram, mikrodruk, warstwa elektroniczna | podpis kryptograficzny wystawcy |
| co się dzieje po utracie | zastrzeżenie w rejestrze | lista unieważnionych, krótki czas życia |
Żaden z nich nie jest przepustką. Oba tylko poświadczają tożsamość.
Ochroniarz na bramce sprawdza dowód, a potem zagląda do listy gości. To dwie osobne czynności i dwa osobne źródła prawdy.
Wypożyczalnia aut patrzy na dowód, na kategorię prawa jazdy, na to, od ilu lat je masz, i na klasę auta, po które przyszedłeś. Tożsamość jedna, warunków kilka.
Szlaban na osiedlu wpuszcza Cię, bo mieszka tam ktoś, kto Cię zaprosił. Decyzja nie wynika z Twoich cech, tylko z powiązania.
Trzy bramki, trzy sposoby podejmowania decyzji. Zapamiętajcie te obrazki, bo jeszcze do nich wrócimy.
Kim jesteś?
Dzieje się raz, na wejściu. Wynik: tożsamość.
{ "sub": "kamil" }
Co wolno Ci zrobić i na czym?
Dzieje się przy każdej operacji. Wynik: decyzja.
allow / deny
Awaria, od której zacząłem, była awarią uwierzytelnienia. Przyczyna leżała w autoryzacji.
Czy Ania może edytować artykuł 412?
Jedno pytanie, jedna odpowiedź. Łatwe.
Które artykuły Ania może edytować?
Tu przewraca się większość implementacji. Wróci na końcu.
Debugowaliśmy to pół dnia. Na tablicy zostały trzy fakty:
Nie wchodzą tylko niektóre konta. Reszta redakcji pracuje normalnie.
Hasła są poprawne, provider odpowiada, certyfikat ważny.
W logach aplikacji nie ma ani jednego z tych requestów.
Pytanie przestało brzmieć „co padło”. Zaczęło brzmieć „czemu akurat ci ludzie”.
Aplikacja nic nie wie, bo nic do niej nie dociera.
Odrzucał je Load Balancer, bo nagłówek był za duży
Tyle ważył token JWT najbardziej zapracowanych redaktorów.
Nikt nie podejrzewa nagłówka o to, że jest za gruby.
Przesuń suwak: ile sekcji redakcyjnych prowadzi ten user.
| Warstwa | Domyślny limit | Co robi po przekroczeniu |
|---|---|---|
| nginx large_client_header_buffers | 4 KB na bufor | 400 Bad Request |
| AWS ALB | ~16 KB całość, 8 KB linia | odrzuca przed targetem |
| Azure Application Gateway | ~8 KB na nagłówek | odrzuca przed backendem |
| Cookie w przeglądarce | 4 KB | po cichu ucina |
Każda z tych warstw odpowiada zamiast aplikacji. Dlatego w logach nic nie ma.
Nasi najlepsi ludzie odbijali się od ściany pierwsi.
Uprawnienia trafiły w złe miejsce. Sygnały, że dzieje się to u Ciebie:
Token ma nieść tożsamość, a nie kompletną mapę tego, co wolno Ci dotknąć.
Kim jesteś w organizacji?
Decyzja zapisana w roli.
Jakie masz atrybuty?
Decyzja liczona z reguł.
Jak jesteś połączony z zasobem?
Decyzja wychodzi ze ścieżki w grafie.
Przez większość życia projektu to w zupełności wystarcza.
Nie „czy ona edytuje artykuły”,
tylko „czy ruszy ten jeden wpis,
bo ktoś dorzucił go do jej redakcji”.
Rola nie zna pojedynczych obiektów i znać nie może.
[Authorize(Roles = "Editor,Admin")] public class ArticlesController : Controller { // rola wystarcza: pytanie dotyczy całej operacji [HttpGet] public IActionResult Index() => View(_repo.GetAll()); [HttpPost] [Authorize(Policy = "CanPublish")] public IActionResult Publish(int id) { /* ... */ } // i tu role się kończą [HttpPost] public IActionResult Edit(int id, ArticleDto dto) { // atrybut nie wie, o KTÓRY artykuł chodzi if (!User.HasClaim("perm", $"article:{id}:edit")) return Forbid(); _repo.Update(id, dto); return RedirectToAction(nameof(Index)); } }
Dwie pierwsze akcje to RBAC zrobiony poprawnie. Trzecia to moment, w którym ktoś dopisał claim per encja, i tak właśnie zaczyna się tycie tokena.
Role mnożą się przez iloczyn wymiarów, nie przez sumę.
Zanim wciągniesz do projektu graf, sprawdź, czy nie wystarczy rola z zakresem.
rola: Editor zakres: team:sport
Jeśli Twój problem kończy się tutaj, dalsza część tej prelekcji to ciekawostka.
Nigdzie nie ma zapisanej decyzji. Bierzesz atrybuty usera, atrybuty zasobu i kontekst, przepuszczasz przez silnik reguł.
allow if resource.author == user.id allow if user.section == resource.section and time in 08:00..18:00
Które artykuły Ania może edytować?
foreach (var a in wszystkieArtykuły) // 240 000
if (policy.Eval(ania, a, "edit"))
wynik.Add(a);
Skoro decyzja jest liczona, to nie ma indeksu, po którym można zapytać odwrotnie.
Da się to obejść cache’em albo denormalizacją. Wtedy jednak wracasz do trzymania decyzji, czyli do problemu, od którego uciekałeś.
Nigdzie nie ma zdania „Ania edytuje ten artykuł”.
Są relacje, a uprawnienie wychodzi z ich połączenia dopiero wtedy, gdy pytasz.
AuthZed, gRPC, schema .zed
CNCF, wyrosło z Auth0
oryginał, wewnętrzny w Google
definition user {} definition team { relation admin: user relation editor: user relation member: user permission edit = admin + editor permission view = admin + editor + member }
definition article { relation author: user relation editor: user relation team: team permission view = author + editor + team->view permission edit = author + editor + team->edit }
Relacje po lewej, przepisy po prawej. Nigdzie nie ma listy nazwisk.
Przeczytaj powoli
permission edit = author + editor + team->edit
autor wpisu
przypisany redaktor
ktokolwiek ma edit w redakcji, do której wpis należy
team->edit znaczy dosłownie: przejdź po relacji i sprawdź uprawnienie tam.
Biblioteka autoryzacji działająca w procesie aplikacji. Jeden pakiet, decyzja liczona lokalnie, brak dodatkowego serwisu w infrastrukturze.
Policy, Effect, Request, Matchers. Kształt zapytania i reguły dopasowania opisujesz w pliku konfiguracyjnym, osobno od samych uprawnień.
RBAC z hierarchią ról i rolami na zasobach, ABAC na atrybutach żądania, ReBAC na relacjach między encjami. Zmiana modelu to zmiana pliku.
Plik, baza przez Entity Framework, Redis. Adapter decyduje, skąd enforcer ładuje reguły i gdzie zapisuje zmiany.
Plik z policy wygląda na magię, dopóki nie zobaczysz, że każda linia zaczyna się od znacznika mówiącego, czym ona jest.
| Linia | Co deklaruje | Jak to przeczytać |
|---|---|---|
| p, collaborator, doc, read | regułę rola, typ zasobu, akcja |
Kto jest współpracownikiem przy czymś typu doc, ten może to czytać. |
| g, alice, doc1, collaborator | relację user, zasób, rola |
Alice jest współpracownikiem przy doc1. To jest fakt o świecie, nie uprawnienie. |
| g2, doc1, doc | typ zasobu zasób, typ |
doc1 jest dokumentem. Dzięki temu reguła nie musi znać tego obiektu z nazwy. |
p to przepis, g i g2 to fakty. Uprawnienie powstaje dopiero ze złożenia jednego z drugim.
m = g(r.sub, r.obj, p.role) && g2(r.obj, p.obj_type) && r.act == p.act
Trzy warunki naraz: właściwa rola na tym zasobie, właściwy typ zasobu, właściwa akcja.
alice, doc1, read
alice, doc2, read
Nie odpaliliśmy go na naszej domenie na poważnie, więc nie mam z niego prawdziwych blizn. Zważyliśmy go na papierze i odłożyliśmy.
Ale nie skreśliliśmy go. W innym projekcie rozważamy go właśnie dlatego, że jest w procesie. Klient nie musi stawiać i utrzymywać kolejnego serwisu, żeby mieć sensowną autoryzację.
SpiceDB, tryb in-memory, wszystko na localhost
Bez bazy, bez sieci meetupowej, jeden kontener.
# serwer, tryb in-memory docker run -d --name spicedb -p 50051:50051 \ authzed/spicedb serve \ --grpc-preshared-key "dev" \ --datastore-engine memory # CLI zed context set local localhost:50051 \ "dev" --insecure zed schema write schema.zed # fakty o świecie zed relationship create article:intro author user:kamil zed relationship create article:intro team team:redakcja zed relationship create team:redakcja editor user:ania zed relationship create team:redakcja member user:basia
# pytania zed permission check article:intro edit user:ania # true, przez team->edit zed permission check article:intro edit user:basia # false, member nie edytuje zed permission check article:intro view user:basia # true # moment sceniczny zed relationship delete team:redakcja editor user:ania zed permission check article:intro edit user:ania # false, a artykułu nikt nie dotknął # skąd ta odpowiedź i co widzi Ania zed permission check article:intro edit user:ania --explain zed permission lookup-resources article edit user:ania
Consistency = { FullyConsistent = true }
Daj mi najświeższą prawdę, nawet jeśli będzie drożej. Przy decyzji autoryzacyjnej wolę zapłacić kilka milisekund niż wpuścić kogoś na podstawie starego cache’a.
catch (RpcException) => false
Silnik nie odpowiada, więc nikogo nie wpuszczamy. Odwrotne zachowanie to gotowy incydent bezpieczeństwa.
| RBAC | ABAC | ReBAC | |
|---|---|---|---|
| pyta o | rolę użytkownika | atrybuty i warunki | ścieżkę w grafie |
| dostęp per encja | rozsadza role albo token | da się, ale reguła musi znać obiekt | naturalny |
| kto ma dostęp do X | łatwe | trzeba przelecieć userów | zapytanie do grafu |
| co widzi user Y | łatwe | pętla po obiektach | lookup zasobów |
| co rośnie z czasem | liczba ról | liczba reguł | liczba krawędzi |
Żaden wiersz nie mówi nic o infrastrukturze. To osobna decyzja.
Każdy z trzech modeli da się uruchomić na oba sposoby. To są dwie niezależne osie i warto je wybierać osobno.
Casbin.NET, także w wariancie ReBAC. Oso.
SpiceDB, OpenFGA, OPA, Cedar w wersji hostowanej.
Po zmianie modelu w tokenie leci
{ "sub": "kamil" }
i nic poza tym. Trzy artykuły czy trzy tysiące, token waży tyle samo.
Pytajcie.
Kamil Kiełbasa
Slajdy, ściąga do dema
i link do artykułu.