Prawa dostępu, the ultimate edition
02 Apr 2008
Prawa dostępu w aplikacjach WWW - dla jednych przekleństwo, dla innych środek nadmiernego wyrazu, za każdym jednak razem całkiem spory kawałek bardzo ważnego kodu. Co ważniejsze, nie kod jest tu najważniejszy, a poprawny projekt oraz łatwość użycia (bo cóż po systemie dostępu kiedy współprogramiści pozostawiają sobie włączenie go na później - czyli nigdy).
Po jednym z bezcelowych i przeraźliwie nudnych spotkań z managementem zaproponowano mi ("ty to zrobisz")
wykonanie systemu praw dostępu ze szczegółowością na poziomie paranoi: prawami obwarowany miał być
absolutnie każdy element aplikacji, od poszczególnych wierszy i tabel, poprzez podstrony, aż do użytkowników
i ról. To wszystko w logice trójstanowej zezwol-zabroń-dziedzicz dla każdego z przedmiotów oraz dodatkowo dla dowolnej
czynności związanej z przedmiotem i oczywiście z relacją wiele-do-wielu na linii użytkownik-rola.
Ponieważ management nie bardzo rozumie konsekwencje swoich wysokopoziomowych decyzji
ani niuanse implementacyjne (to już w ogóle) poprosiłem o rozwiązanie kilku przypadków, które prędzej czy
później pojawią się w codziennym używaniu:
- jak rozwiązać konflikt ról - gdy użytkownik w roli A ma dostęp do zasobu, a rola B dostępu zabrania?
- jak rozwiązać konflikt hierarchii zasobów - gdy dozwolony zasób X jest częścią niedostępnego zasobu Y
(sytuacja częsta przy udostępnianiu zasobów pomiędzy użytkownikami)
oraz coś, co ich ostatecznie położyło:
- Jeff, skoro to Ty będziesz tym zarządzał, to narysuj mi proszę jak chciałbyś to widzieć na ekranie (podły ze mnie chujek, nie?).
Menedżment się zamknął i dla odmiany zaczął słuchać. Użytkownicy-role będą odwzorowane relacją jeden-do-wielu,
prawa do zasobów będą opisane w logice binarnej zgodnie z zasadą "co nie jest dozwolone, jest zabronione". Konflikty hierarchii
zasobów rozwiązywane są w sposób jawny dla programisty (poprzez wyrzucenie wyjątku). Ekrany zarządzania prawami
narysowałem sobie sam.
Muszę powiedzieć, że mimo początkowych obaw, że tak okrojone prawa będą niewystarczające, warunki bojowe pokazały co następuje:
- użytkownicy olewają konfigurację praw dostępu i jadą na defaultach,
- ci, którzy nie olewają, mają na tyle czytelne ekrany, że nie przybiegają z prośbą o pomoc w konfiguracji praw.
W ten sposób management "poniósł ogromny sukces" a ja mam święty spokój.
Dość przynudzania, pora na mięso. Semantycznie, wszystkie zasoby da się zredukować do dwóch typów:
obiekty z unikalnym identyfikatorem oraz klasy i metody kontrolerów. Na potrzeby tych pierwszych wyrzeźbiłem klasę
usługową AuthService, drugie są obsługiwane przez AppAccessService.
Każdy obiekt o unikalnym identyfikatorze, który może podlegać prawom dostępu musi implementować interfejs
IAuthorizable, składający się z dwóch metod: getDistinctiveId zwracającej
identyfikator w przestrzeni nazw takiej, jak nazwa klasy, oraz getAccessModes zwracającej tablicę
dostępnych typów dostępu (np. 'read', 'write', 'delete'). W ogromnej większości przypadków unikalnym identyfikatorem
jest klucz główny odpowiadającego obiektu trwałego. Schemat tabeli do przechowywania praw dostępu jest następujący:
CREATE TABLE access ( user_id INT REFERENCES users (id) ON DELETE CASCADE, role_id INT REFERENCES roles (id) ON DELETE CASCADE, class_name VARCHAR(100), distinctive_id INT NOT NULL, mode VARCHAR(20) NOT NULL ); CREATE INDEX access_user_id_idx ON access (user_id); CREATE INDEX access_role_id_idx ON access (role_id); CREATE INDEX access_subject_idx ON access (class_name, distinctive_id, mode);
Można jako typ pola distinctive_id zastosować również VARCHAR, ale należy się wówczas liczyć
z istotną utratą wydajności bazy.
Implementacja usługi sprawdzania i definiowana dostępu jest dość prosta
(część odpowiedzialna za obsługę bazy danych została pominięta):
class AuthService {
public static function isUserAuthorized(User $user, IAuthorizable $subject, $mode) {
$distinctiveId = $subject->getDistinctiveId();
$className = get_class($subject);
$userId = $user->getId();
$roleId = $user->getRoleId();
$sql = "
SELECT count(*) AS c FROM access
WHERE distinctive_id = $distinctiveId
AND class_name = '$className'
AND (user_id = '$userId' OR role_id = '$roleId')
";
// dalej mało ważne pierdoły związane z obsługą bazy
}
public static function isRoleAuthorized(Role $role, IAuthorizable $subject, $mode) {
// to samo co w isUserAuthorized, tylko bez sprawdzania user_id = '$userId'
}
public static function authorizeUser(User $user, IAuthorizable $subject, $mode) {
$distinctiveId = $subject->getDistinctiveId();
$className = get_class($subject);
$userId = $user->getId();
$sql = "
INSERT INTO access (user_id, class_name, distinctive_id, mode)
VALUES ($userId, '$className', $distinctiveId, '$mode');
";
}
public static function authorizeRole(Role $role, IAuthorizable $subject, $mode) {
// to samo do w authorizeUser, tylko z role_id zamiast user_id
}
}
Dodajemy jeszcze odpowiednie metody do odbierania praw dostępu i klasa jest prawie gotowa. Prawie używalna. Mamy już kompletną implementację bardzo szczegółowych praw dostępu, która jest łatwa w używaniu. Tak łatwa jak:
if (AuthService::isUserAuthorized($user, $resource, 'read')) {
// zapraszamy
} else {
// wypad
}
To, co łatwo przegapić w tym momencie, to słaba wydajność takiego rozwiązania podczas operacji na dużych zestawach danych.
Wyobraź sobie iterowanie przez 1000 obiektów pobranych jednym selektem. W rezultacie otrzymasz 1001 zapytań do bazy -
jedno szybkie, zwracające ogromną ilość danych i 1000 kompletnie nieoptymalnych, zwracających pojedynczą liczbę. Jest i na to sposób:
wystarczy pobrać tylko te wiersze, które mają dostęp w podanym trybie dla konkretnego użytkownika/roli. Zaimplementowałem to za pomocą
delegata praw dostępu używanego przy wybieraniu wierszy z bazy. Obiekt delegata tworzony jest przez klasę AuthService
class AuthService {
...
...
public static function getUserAuthorizationDelegate(User $user, $mode) {
return new UserAuthorizationDelegate($user, $mode);
}
public static function getRoleAuthorizationDelegate(Role $role, $mode) {
return new RoleAuthorizationDelegate($role, $mode);
}
}
Natomiast implementacja obiektu delegata wywodzi się z klasy o abstrakcyjnej metodzie getExpression:
class UserAuthorizationDelegate extends AuthorizationDelegate {
protected $userId;
protected $roleId;
protected $mode;
public function __construct(User $user, $mode) {
$this->userId = $user->getId();
$this->roleId = $user->getRoleId();
$this->mode = $mode;
}
public function getExpression($className) {
$sql = "
SELECT DISTINCT distinctive_id FROM access
WHERE (user_id = '{$this->userId}' OR role_id = '{$this->roleId}')
AND class_name = '$className'
AND mode = '$mode'
";
return $sql; // tym razem zwracamy czysty SQL
}
}
Implementacja delegata dla ról wygląda podobnie. Teraz możemy zająć się dostosowaniem kodu, który wybiera z bazy wiersze, by współpracował z delegatem. Zwyczajowo najczęściej korzystam z kombinacji wzorców DAO i ValueObject, ale po drobnych modyfikacjach możliwe jest skorzystanie ze wzorców ActiveRecord, TableGateway oraz prawdopodobnie innych dziwactw.
class FooDAO {
...
...
public static function getAll(AuthorizationDelegate $delegate = null) {
$condition = $delegate
? 'id IN (' . $delegate->getExpression('Foo') . ')'
: 'TRUE';
$sql = "
SELECT * FROM foos
WHERE $condition
ORDER BY id
";
// obsługa bazy
}
}
Jeśli preferujesz wybranie wszystkich wierszy i oznaczenie ich dostępności w dodatkowej kolumnie, zmodyfikuj zapytanie w ten sposób:
SELECT f.*, $condition AS is_authorized FROM foos AS f ORDER BY id
Na koniec coś, co otwiera oczy niektórych niedowiarków na piękno i siłę technik obiektowych. Wspomniana powyżej klasa AppAccessService
może również zwracać swoich delegatów. Ich konstruktory zapewne będą różne od tych zwracanych przez AuthService ale interfejsy
zostaną zachowane, dzięki czemu możemy ich używać wymiennie i w takim samym kontekście (zarówno delegatów jak i klas usług dostępu).
Co więcej, możemy bez problemu łączyć delegaty w überdelegaty za pomocą wzorca Composite i nadal używać ich w ten sam sposób.
class UberDelegate extends AuthorizationDelegate {
protected $delegates = array();
public function addDelegate(AuthorizationDelegate $delegate) {
$this->delegates[] = $delegate;
}
public function getExpression($className) {
$out = array();
foreach ($this->delegates as $delegate) {
$out[] = $delegate->getExpression($className);
}
return empty($out) ? 'TRUE' : implode(' UNION ', $out);
}
}
W zrozumieniu wszystkiego co napisałem powyżej wydatnie pomoże znajomość UML-a oraz papier i ołówek. Będzie mi miło usłyszeć o wykorzystaniu tego rozwiązania w praktyce, jak również o alternatywnych sposobach implementacji praw dostępu. Acha, jeszcze tylko legal bullshit: kod jest wolną reimplementacją moich pomysłów z pracy z poprawionym nazewnictwem i usuniętymi niepotrzebnymi zależnościami. Używaj do woli w zgodzie z LGPL 2 lub nowszej.
Update 1207701709: Dodałem diagram klas dla lepszej czytelności rozwiązania.
Update 1207781421: Jest i diagram sekwencji w dwóch wydaniach. Pierwszy obrazuje proste sprawdzenie prawa do zasobu (Product), drugi przedstawia użycie delegata praw.
Klient tworzy obiekt zasobu i odpytuje AuthService o prawo dostępu. Usługa pobiera z produktu jego identyfikator (ufając, że ma doczynienia z obiektem implementującym interfejs IAuthorizable). Zapytanie do bazy odpowiada czy user (pominięty w tym diagramie) ma dostęp w żądanym trybie.
Klient pobiera z AuthService delegata właściwego dla podanego użytkownika (pominięty w diagramie). Delegat jest tworzony i przekazywany do statycznego wywołania getAll klasy zasobów. Klasa ta odpytuje odpowieni obiekt DAO i przekazuje mu delegata. Obiekt dostępu do danych (DAO) pobiera z delegata wyrażenie ograniczające SQL, następnie włącza je do swojego SQLa i odpytuje bazę. Na podstawie zwróconych rekordów tworzy kolekcję zasobów, która wraca do klienta.
Przy okazji: Umbrello nie zna diagramu sekwencji, więc na szybko użyłem Boumla. Jeden gorszy od drugiego, ale do szkiców wystarczające.
- Tomek
Mhm, całkiem wydaje się to fajne ale jako, że własnie w projekcie który tworzę przyda się własnie takie rozwiązanie, aczkolwiek nie do końca wszystko jest dla mnie jasne, wrzuciłbyś jeszcze jakiegoś umla i przykłady wykorzystania konkretnych ról ?
- str()
Jakiś UML został wrzucony - na zdrowie. Przykładów ról może być mnóstwo. U mnie w firmie jest to np. rola Country Manager SA i ma dostęp w trybach create, edit, delete i publish do dokumentów w dziale South Africa, a rola Content Creative ma do nich dostęp tylko w trybie create i edit. Oprócz tego rola COO ma dostęp do wszystkich obiektów Enquiry w całym zakresie, a pozostali użytkownicy tylko do przydzielonych im Enquiry bez prawa delete.
Ponadto, obiekty Role również implementują interfejs IAuthorizable (nie pokazane w przykładze), więc Country Manager SA może obdarzać swoich podwładnych rolami, do których ma dostęp, np. rolą Regional Manager.- Konrad
W jaki sposób tworzysz w BoUML wiadomość tworzącą nowy obiekt (<<create>>). Chodzi mi o strzałkę od linii życia jednego obiektu do bloku symbolizującego inny obiekt. Niestety ja próbowałem na wszystkie sposoby i nie wiem jak to zrobić :/
PS. Na UML się słabo znam, więc z góry przepraszam, jeśli popisałem jakieś głupoty ;)- Ian
Hi!
I found your article from http://stackoverflow.com/questions/228672/php-access-control-system and I find it really interesting. But as you said in stackoverflow, it is written in Polish. Maybe if you can provide a link for a sample source code with some sql data inserts it would be easier for us non-Polish to understand your article without you re-writing it in English.
Ian







