Pewnego dnia zaczęło się od jednego zgłoszenia, a po godzinie było ich kilkanaście. Redaktorzy nagle nie mogli się zalogować do systemu (na potrzeby tego artykułu przyjmijmy, że był to CMS dużej redakcji newsowej, bo prawdziwą domenę specjalnie zmieniłem, żeby nie opisywać cudzego systemu z nazwy). Nie "logowanie chodzi wolno", tylko twarda ściana: request wychodził z przeglądarki i nie wracał nic sensownego. Co gorsza, dotyczyło to tylko NIEKTÓRYCH kont, a inne śmigały jak zawsze, więc na pierwszy rzut oka wyglądało to na jakąś magię.
Debugowaliśmy to pół dnia. Puenta okazała się boleśnie prozaiczna. Requesty tych użytkowników były odrzucane, zanim w ogóle dotknęły aplikacji. Odbijał je Azure Load Balancer, bo token JWT ważył ponad 8 KB i przekraczał dopuszczalny rozmiar nagłówka. Aplikacja o tym nie wiedziała, bo do niej nic nie docierało, więc w logach po jej stronie panowała cisza. Sam się na to nadziałem i powiem szczerze, że długo nie wpadaliśmy, bo nikt nie podejrzewa nagłówka o to, że jest za gruby.
A czemu ten token tak spuchł? Bo wpakowaliśmy do niego granularny dostęp do encji. Listę uprawnień per obiekt: article:412:edit, article:412:read, category:7:manage i tak dalej, pozycja po pozycji. Im więcej artykułów i kategorii user miał pod sobą, tym grubszy robił się jego token. Redaktorzy prowadzący jeden dział mieścili się bez problemu. Ci od kilkunastu sekcji, czyli nasi najbardziej zapracowani ludzie, przekroczyli 8 KB i zderzyli się ze ścianą. Czyli im ktoś robił więcej roboty, tym pewniej nie mógł się zalogować. Piękne.
Nie chcę tu bronić tezy, że RBAC jest zły, bo nie jest. Problem był inny: uprawnienia trafiły w złe miejsce. Upychanie granularnego dostępu do encji w tokenie to droga donikąd i ten incydent tylko pokazał, gdzie ta droga się kończy. Prawdziwe pytanie brzmi: jak w ogóle modelować taki dostęp, żeby nie skończyć z 8-kilobajtowym tokenem i sfrustrowanym redaktorem na czacie supportu.
W tym artykule przejdę przez trzy modele autoryzacji, czyli RBAC, ABAC i ReBAC, opowiem, skąd wziął się googlowy Zanzibar, a potem pokażę dwa podejścia w .NET: Casbin.NET, który został u nas na etapie rozważań, oraz SpiceDB, którego realnie używamy. Zaczynajmy!
Trzy litery, trzy sposoby myślenia
Zacznijmy od tego, co każdy z nas ma w palcach, czyli RBAC, role-based access control. User dostaje rolę, rola to worek uprawnień. Admin może wszystko, Editor edytuje artykuły, Author zakłada nowe wpisy. Sprawdzasz rolę i wiesz, co wolno. Prościej się nie da i przez większość życia projektu to w zupełności wystarcza.
Do czasu. RBAC pęka dokładnie wtedy, gdy pytanie robi się węższe. Nie "czy ten user edytuje artykuły", tylko "czy ten user ruszy TEN JEDEN wpis o onboardingu, bo autor dorzucił go do jej redakcji". Rola tego nie wie i wiedzieć nie może, bo nie zna pojedynczych obiektów. Zostają Ci wtedy dwie drogi i obie pod górkę. Albo mnożysz role aż do potworków typu Editor_Of_Article_412. Albo, jak my, upychasz uprawnienia per encja do tokena. Finał tej drugiej drogi już znasz, bo od niego zacząłem ten tekst.
Zupełnie inny pomysł to ABAC, attribute-based access control. Tu nie ma nigdzie zapisanej decyzji, liczy się ją na żądanie z atrybutów. Bierzesz atrybuty usera, atrybuty zasobu, czasem kontekst w rodzaju pory dnia, i przepuszczasz to przez policy engine. Reguła brzmi na przykład "pozwól, jeśli resource.author == user.id" albo "pozwól redaktorowi z tej samej sekcji, ale tylko w godzinach pracy". Elastyczne aż miło i łapie warunki, o których RBAC-owi się nie śniło.
Tylko że ABAC ma cenę i płacisz ją później. Spróbuj kiedyś odpowiedzieć audytorowi na banalne z pozoru pytanie "kto tak naprawdę może ruszyć ten artykuł?". Skoro decyzja liczy się dynamicznie, to żeby ją poznać, musisz odpalić silnik dla każdego usera po kolei. Do tego reguły się rozmnażają. Zaczynasz od trzech warunków, po roku masz ich czterdzieści, nachodzą na siebie i nikt w zespole nie jest już pewien, która wygrywa. Widziałem taki policy set po dwóch latach. Niezbyt pięknie.
I zostaje ostatni z tej trójki, ReBAC, relationship-based access control, ten, który w końcu nas uratował. Zamiast pytać o rolę albo liczyć z atrybutów, pytasz graf relacji. Kamil jest autorem artykułu. Artykuł należy do redakcji. Ania siedzi w tej redakcji. Dostęp wypada ze ŚCIEŻKI w grafie: skoro Ania jest w redakcji, do której należy wpis, to Ania go zedytuje. Nigdzie nie ma zdania "Ania edytuje ten artykuł". Są relacje, a uprawnienie wychodzi z ich połączenia dopiero w chwili, gdy pytasz.
I nie jest to żaden wymysł startupu od security sprzed roku. To model, który Google zbudował dla siebie, bo przy jego skali klasyczne podejścia po prostu się przewracały. Opisali go w 2019 roku w paperze o systemie Zanzibar, który stoi za autoryzacją w Dokumentach, Drive czy YouTube. Jeśli kiedyś udostępniłeś komuś plik w Google Docs i ta osoba od razu go zobaczyła, to pod spodem odpowiedział właśnie taki graf. Z Zanzibara wyrosło potem kilka otwartych implementacji, z których najbardziej znane to SpiceDB oraz jego rodzeństwo OpenFGA. O tym pierwszym za chwilę opowiem z pierwszej ręki.
Casbin.NET, czyli lekka droga w procesie
Zanim rzuciliśmy się na Zanzibara, popatrzyliśmy na coś dużo lżejszego, czyli Casbin.NET. Port popularnego Casbina na .NET, biblioteka in-process, siedzi w procesie Twojej aplikacji. Zero osobnego serwisu, zero dodatkowego pudełka w infrze. Dodajesz paczkę, liczysz decyzje lokalnie. Brzmiało jak dokładnie to, czego szukaliśmy.
Casbin stoi na modelu PERM, czyli Policy, Effect, Request, Matchers. W jednym pliku definiujesz kształt zapytania i reguły dopasowania, w drugim trzymasz właściwe policy, czyli kto-co-może. Dokumentacja Casbina pokazuje to na wielu wariantach, od czystego RBAC po ABAC. Model dla naszej domeny mógłby wyglądać tak:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Zaleta bije po oczach. Zero infry, decyzja liczona lokalnie w pamięci, czyli szybko. Do zwykłego RBAC czy ABAC to naprawdę dobre narzędzie i gdyby nasz problem kończył się na rolach, wziąłbym je bez zastanowienia.
Uwierał nas jeden szczegół, akurat ten najważniejszy. Casbin to model reguł, nie natywny graf relacji jak Zanzibar. Nasze "kto komu przypisał który artykuł przez którą redakcję" dawało się w nim zapisać i na papierze wyglądało nawet znośnie. Ale im głębiej brnęliśmy w te scenariusze, tym bardziej matchery zamieniały się w drut kolczasty, a relacje i tak trzeba było gdzieś trzymać i podawać silnikowi z zewnątrz. Czyli Casbin brał na siebie łatwiejszą połowę roboty, a graf, tę trudniejszą, zrzucał na nas. Dlatego skończył u nas na etapie rozważań. Nie odpaliliśmy go na naszej domenie na poważnie, więc nie będę udawał, że mamy z niego prawdziwe blizny. Zważyliśmy go na papierze i odłożyliśmy, bo do granularnego dostępu per encja to po prostu nie ten młotek.
SpiceDB, czyli Zanzibar, który realnie u nas stanął
To, przy czym ostatecznie zostaliśmy, to SpiceDB, otwarta implementacja Zanzibara. Tu już mówię z pierwszej ręki, bo to my go postawiliśmy, spięliśmy i utrzymujemy.
SpiceDB to osobny serwis, gadasz z nim po gRPC. Definiujesz schema w pliku schema.zed, wrzucasz relacje jako tuple, a o dostęp pytasz przez CheckPermission. I teraz rzecz, którą trzeba złapać raz a dobrze: uprawnienie nie jest nigdzie zapisane. Jest LICZONE z relacji, dopiero gdy zapytasz. Ty zapisujesz fakty o świecie, resztę dolicza silnik.
Nasza schema, oczywiście uproszczona, wygląda mniej więcej tak:
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
}
Przeczytaj powoli tę linijkę z definicji article: permission edit = author + editor + team->edit. Nie ma tu żadnej zapisanej listy "kto może edytować ten artykuł". Jest przepis. Edytować wpis może jego autor, jego przypisany redaktor, albo, i tu jest cała sól, ktokolwiek ma edit w redakcji, do której artykuł należy. Ten zapis team->edit to dosłownie "przejdź po relacji team i sprawdź tam uprawnienie edit". Graf w czystej postaci. Kategoryzację modelujesz zresztą tym samym trybem, dokładając na przykład permission categorize = author + editor + team->edit.
Relacje zapisujesz jako tuple w formacie obiekt-relacja-podmiot. Na przykład article:intro#author@user:kamil czyta się jako "Kamil jest autorem wpisu o onboardingu". Dorzucasz article:intro#team@team:redakcja i team:redakcja#editor@user:ania, i już masz komplet faktów potrzebnych do tego, żeby Ania mogła edytować ten artykuł, mimo że nikt nigdzie nie zapisał wprost, że Ania ma do niego dostęp. Zapytanie article:intro#edit@user:ania zwróci ALLOW, bo silnik przejdzie ścieżką team->edit i znajdzie Anię jako redaktora w redakcji.
Przeklikaj to sam
Zamiast wierzyć mi na słowo, sprawdź to na grafie. Poniżej ten sam graf co w schemie: Kamil jest autorem wpisu, wpis należy do redakcji, a Ania i Basia siedzą w tej redakcji z różnymi rolami. Wybierz, kogo i o co pytamy, a potem poprzełączaj relacje i patrz, jak CheckPermission przelicza wynik na żywo. Zwróć uwagę na Basię: jako member widzi wpis, ale go nie ruszy.
Kliknij Basię i przełącz na edit. Dostajesz DENY, bo member daje tylko view. Zdejmij Ani rolę editor i zobacz, jak jej edit gaśnie w tej samej sekundzie. To jest cała idea ReBAC w pigułce: nie ma zapisanego uprawnienia, jest graf i przepis, a wynik zależy od tego, jakie krawędzie akurat istnieją.
Odpalmy to u siebie
Teorię już masz, ale najłatwiej to poczuć, jak sam odpalisz. I tu miła niespodzianka: żeby dotknąć SpiceDB, nie musisz stawiać żadnej bazy. Wystarczy Docker i jedna komenda, bo silnik ma wbudowany tryb in-memory:
docker run -d --name spicedb -p 50051:50051 \
authzed/spicedb serve \
--grpc-preshared-key "klucz-do-lokalnych-testow" \
--datastore-engine memory
I już. Masz działający serwer autoryzacji na localhost:50051. Jeden haczyk: memory znaczy dokładnie to, co znaczy. Zrestartujesz kontener i wszystkie relacje wyparują. Do zabawy i testów idealne, do niczego więcej.
Do gadania z serwerem z palca służy zed, oficjalne CLI. Najpierw mówisz mu, gdzie ma pukać:
zed context set local localhost:50051 "klucz-do-lokalnych-testow" --insecure
--insecure, bo lokalnie nie bawimy się w TLS. Teraz wgrywasz schemę, tę samą, którą pokazałem wyżej:
zed schema write schema.zed
zed schema read # sprawdzenie, że się wgrała
Dorzucamy kilka faktów o świecie, czyli nasze tuple:
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
I teraz to samo, co przed chwilą przeklikiwałeś w grafie, tylko z terminala:
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 (ale zobaczyć może)
Jak chcesz zobaczyć, KTÓRĘDY silnik doszedł do odpowiedzi, dorzuć --explain. Dostaniesz drzewko rozwiązania, dokładnie tę ścieżkę po relacjach, którą podświetlał interaktywny graf. Przy debugowaniu "czemu ten ktoś ma dostęp, choć nie powinien" to pierwsza rzecz, po którą sięgam.
No dobra, a gdzie te dane naprawdę mieszkają, skoro memory to zabawka? SpiceDB rozdziela silnik od składu i pod spód możesz podpiąć kilka prawdziwych baz. Najczęstszy wybór przy self-hoście to PostgreSQL, dobry na jeden region. Jak potrzebujesz wielu regionów albo naprawdę dużego ruchu, celujesz w CockroachDB. Na Google Cloud siedzi Spanner. MySQL też jest, ale twórcy sami go nie polecają. Przełączenie to w gruncie rzeczy dwie flagi, plus jednorazowa migracja schematu bazy (uwaga, to schemat TABEL SpiceDB, a nie Twój schema.zed):
# raz, żeby założyć tabele SpiceDB w bazie
docker run --rm authzed/spicedb datastore migrate head \
--datastore-engine postgres \
--datastore-conn-uri "postgres://cms:haslo@localhost:5432/cms_spicedb?sslmode=disable"
# potem serwis wskazujący na tego samego Postgresa
docker run -d --name spicedb -p 50051:50051 \
authzed/spicedb serve \
--grpc-preshared-key "klucz-do-lokalnych-testow" \
--datastore-engine postgres \
--datastore-conn-uri "postgres://cms:haslo@localhost:5432/cms_spicedb?sslmode=disable"
U nas w projekcie nie odpalamy tego z palca, tylko trzymamy w docker-compose.yml obok reszty. Te same wartości podaje się flagami albo przez zmienne środowiskowe z prefiksem SPICEDB_, co w composie czyta się przyjemniej:
spicedb:
image: authzed/spicedb:latest
command: serve
environment:
- SPICEDB_GRPC_PRESHARED_KEY=dev_key_123
- SPICEDB_DATASTORE_ENGINE=postgres
- SPICEDB_DATASTORE_CONN_URI=postgres://cms:haslo@postgres:5432/cms_spicedb?sslmode=disable
ports:
- "50051:50051"
depends_on:
postgres:
condition: service_healthy
Migrację odpalamy osobnym, jednorazowym kontenerem z command: datastore migrate head na tych samych zmiennych, schowanym pod profilem, żeby nie startował przy każdym up. I tyle wystarczy, żeby lokalne środowisko miało pełnoprawny serwer autoryzacji, który przeżywa restart. Wgranie schemy na wyższe środowiska to potem zwykłe zed schema write z poziomu CI, ale to już materiał na osobny wpis.
Po stronie C# opakowaliśmy to w prosty serwis nad klientem gRPC PermissionsService. Sedno wygląda tak:
public async Task<bool> CheckPermissionAsync(
string resourceType, string resourceId, string permission, string userId,
CancellationToken ct)
{
var request = new CheckPermissionRequest
{
Resource = new ObjectReference { ObjectType = resourceType, ObjectId = resourceId },
Permission = permission,
Subject = new SubjectReference
{
Object = new ObjectReference { ObjectType = "user", ObjectId = userId }
},
Consistency = new Consistency { FullyConsistent = true }
};
try
{
var response = await _permissions.CheckPermissionAsync(request, cancellationToken: ct);
return response.Permissionship == CheckPermissionResponse.Types.Permissionship.HasPermission;
}
catch (RpcException ex)
{
_logger.LogError(ex, "SpiceDB check failed for {Resource}:{Id}", resourceType, resourceId);
return false;
}
}
Dwie rzeczy chcę tu wyciągnąć na wierzch. Po pierwsze, Consistency { FullyConsistent = true }. SpiceDB, jak każdy porządny system rozproszony, godzi spójność z wydajnością, a FullyConsistent mówi "daj mi najświeższą prawdę, nawet jeśli będzie drożej". Dla decyzji autoryzacyjnej wolę zapłacić te kilka milisekund niż wpuścić kogoś do edycji na podstawie nieaktualnego cache'a. Po drugie, catch (RpcException) zwracający false. To świadome fail closed, czyli błąd oznacza brak dostępu. Jak SpiceDB nie odpowie, nikogo nie wpuszczamy. Odwrotne zachowanie, czyli wpuszczanie w razie awarii, to gotowy incydent bezpieczeństwa, więc tutaj nie ma miejsca na wygodę kosztem zasady.
Do listowania "które artykuły ten user w ogóle może edytować" nie odpytujemy silnika w pętli po każdym obiekcie, tylko używamy LookupResources w metodzie GetUserAccessibleArticleIdsAsync. Silnik zwraca od razu zbiór identyfikatorów, do których user ma dane uprawnienie, i to jest dokładnie ta operacja, której ABAC ze swoim liczeniem per obiekt nie daje za darmo.
Jak to wygląda w realnej komendzie? Weźmy AssignArticleToTeam, czyli przypisanie artykułu do redakcji. Zanim cokolwiek zapiszemy, robimy dwa sprawdzenia. Najpierw CheckTeamPermissionAsync(teamId, "manage_articles", userId), czyli czy ten user w ogóle zarządza tą redakcją. Potem CheckPermissionAsync("article", articleId, "share", userId), czyli czy ma prawo przypisać akurat ten artykuł. Dopiero gdy oba przejdą, dopisujemy nową relację. Autoryzacja jest bramką przed operacją, nie ozdobnikiem po fakcie.
I teraz wróćmy do bohatera z początku, czyli do tokena. Przy tym modelu w JWT leci samo sub: kamil i nic poza tym. Zero listy uprawnień per encja, zero article:412:edit. Uprawnienia liczy SpiceDB na żądanie, więc token się nie rozdyma niezależnie od tego, czy user prowadzi trzy artykuły czy trzy tysiące. Load Balancer śpi spokojnie. Przy okazji token robi się na tyle chudy i stabilny, że sensowniej mi się nim zarządza, o czym pisałem swego czasu przy integracji .NET z ZITADEL-em.
Nie ma jednak darmowego lunchu i uczciwie muszę pokazać drugą stronę. SpiceDB to osobny serwis, czyli kolejne pudełko w infrastrukturze, które trzeba postawić, monitorować i aktualizować. Każdy CheckPermission to skok po sieci, więc dokładasz sobie latencję tam, gdzie wcześniej była lokalna gałąź if. Do tego dochodzi spójność, o której wspomniałem przy Consistency. Musisz świadomie zdecydować, kiedy wolno Ci czytać szybciej i mniej świeżo, a kiedy koniecznie potrzebujesz pełnej prawdy. To realne koszty i jeśli Twój problem autoryzacyjny da się rozwiązać grubą rolą, wciąganie do projektu całego Zanzibara byłoby strzelaniem z armaty do wróbla.
Podsumowanie, czyli kiedy przerastasz RBAC
RBAC nie jest zły. Jest po prostu za gruby na granularny dostęp per encja. Pytanie "czy ten user jest redaktorem" obsłuży śpiewająco. Pytanie "czy ten redaktor ruszy akurat ten artykuł, bo ktoś przypisał mu go do redakcji" już go przerasta. To dwie różne ligi, a próba grania w drugą sprzętem z pierwszej kończy się dokładnie tak, jak nasze 8 KB odbite od Load Balancera.
Jest zresztą prosty sygnał ostrzegawczy, ten sam, na który sam się nadziałem. Jeśli łapiesz się na tym, że wpychasz uprawnienia do konkretnych encji w token, listę obiektów do claimów albo w sesję, to znaczy, że przerosłeś RBAC. Token ma nieść tożsamość, czyli kim jesteś, a nie kompletną mapę tego, co wolno Ci dotknąć.
Gdy już do tego dojdziesz, masz realny wybór. ReBAC w wydaniu Zanzibara, u nas SpiceDB, rozwiązuje granularny dostęp naprawdę elegancko, bo trzymasz relacje, a uprawnienia liczysz z grafu. Płacisz za to osobnym serwisem, latencją sieciową i przemyśleniem spójności, i to jest cena, którą trzeba świadomie zaakceptować, a nie odkryć po fakcie. Jeśli natomiast pełnego grafu nie potrzebujesz, a chcesz coś sprytniejszego niż gołe role bez stawiania kolejnego pudełka, Casbin.NET jako lekka biblioteka in-process jest bardzo rozsądnym pierwszym przystankiem. Poszliśmy w SpiceDB, bo nasza domena była grafem od samego początku, tylko długo udawaliśmy, że nim nie jest.
Mam nadzieję, że artykuł się podobał. Jak zwykle do następnego!
Cześć!




