Hi, this is your host Michał Rudnicki and ye are visiting my jagged border of life and work.
Have a nice read, and please feel free to drop me a line or twelve.

Since this is a bilingual blog you may find it useful to filter it: english | polish

Welcome to the machine…

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.

diagram klas systemu praw dostępu

Update 1207781421: Jest i diagram sekwencji w dwóch wydaniach. Pierwszy obrazuje proste sprawdzenie prawa do zasobu (Product), drugi przedstawia użycie delegata praw.

diagram sekwencji sprawdzania praw dostępu

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.

diagram sekwencji wybierania dostępnych obiektów

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.


permalink | trackback | rss

 
 
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

Your turn:

signature:
comment:
www (if any):
Wpisz kod:code

Prawa dostępu, the ultimate edition php prawa dostępu acl