Moj sposob na system autoryzacji oparty na rolach

Ostrzegam, ze post jest długi :wink:

Wczoraj postawiłem sobie cel stworzenia elastycznego systemu uwierzytelniania opartego na rolach i chcialbym sie z wami podzielic tym, co stworzylem.
Jesli chodzi o RoR to dopiero zaczynam swoja przygode, dlatego prosze o wskazowki na temat tego co zmienic zeby system byl jeszcze bardziej ‘rails way’. Nie wiem tez czy moje rozwiazanie jest optymalne (moze nie nadaje sie do realnych zastosowan? :)), opiera sie glownie na wyrazeniach regularnych, ale slyszalem, ze w Ruby smigaja one calkiem szybko.
W koncu, moze ten post przyczyni sie do rozwiazania problemow innych osob? :wink:
Ok, przejdzmy do rzeczy. Chcialem miec dosc rozbudowany system uwierzytelniania uzytkownikow na podstawie rol. Zalozylem ze kazda akcja (ktora wymaga uwierzytelnienia) wymaga od uzytkownika posiadania roli w formie: ‘controller_name/action_name’ czyli np. akcja ‘delete_article’ ktora nalezy do kontrolera admin/content_controller.rb wymaga posiadania roli ‘admin/content/delete_article’. Zalozylem rowniez ze moga sie zdazyc jakies szczegolne przypadki, kiedy dana akcja wymaga roli w innym formacie, albo kilku rol, ale o tym pozniej.
Role nie naleza bezposrednio do uzytkownikow, ale do grup a uzytkownicy i grupy powiazane sa relacja wiele-do-wielu. Jest to duzo rozsadniejsze rozwiazanie niz duplikowac te same role dla wielu rekordow w tabeli users.
Teraz cos o dzialaniu systemu. Pierwsza sprawa to zaprojektowanie go tak, aby mozna bylo w latwy sposob okreslac to, ktore akcje uzytkownik nalezacy do danej grupy moze wykonywac, a ktore nie. Najprostrze rozwiazanie to wpisac kolejno wszystkie dozwolone role na liste grupy. Niestety rodzi to pewien problem: taka lista moglaby sie okazac bardzo dluga, na przyklad dla grupy o nazwie ‘Moderatorzy’ trzebaby wpisac wszystkie akcje pozwalajace na zarzadzanie zbiorem artykulow, komentarzy, tematow na forum i Bog wie czego jeszcze. To rozwiazanie uznalem za kiepskie, nikomu nie bedzie sie chcialo wpisywac dziesiatek rol, lepiej juz ustalic jakies na sztywno w kodzie.
Wymyslilem cos innego mianowicie wzorce rol - zamiast zmudnie dodawac kolejne role wpisujemy na liste wzorzec - jesli wymagana przez akcje rola pasuje do wzorca uzytkownik dostaje pozwolenie na jej wykonanie - wzorzec przechodzi przez szereg wyrazen regularnych, ktore sprawdzaja czy pasuje do listy wymaganych rol. Ponizej kilka przykladow uzycia wzorcow rol:

Standard: dostep do akcji edit z kontrolera admin/articles_controller oraz akcji edit z kontrolera admin/comments_controller, role musi dzielic przecinek:

admin/articles/edit, admin/comments/edit

jesli rola konczy sie znakiem / to daje dostep do wszystkich akcji danego kontrolera albo calego modulu:

admin/articles/, admin/comments/

Wykluczanie: role w bloku except() sa wykluczone z listy, w tym wypadku mamy dostep do wszystkich akcji kontrolera admin/comments_controller oraz do akcji kontrolera admin/articles_controller z wyjatkiem jego akcji delete_article

admin/articles/ except(delete_article), admin/comments/

Mozna tez tak: wszystko z modulu admin oprocz rol z articles/ i oprocz roli delete_comment z admin/comments_controller

admin/ except( articles/, comments/delete_comment )

Only: prawa tylko do rol w bloku only()

admin/articles/ only( edit_article, publish_article )

Ok, czas na konkret, oto kod tego systemu, bede wdzieczny za wszelkie uwagi:

W application.rb sa metody authorize - sprawdza czy user jest zalogowany oraz metoda require_roles - to wlasnie filtr uzywany do sprawdzania rol dla danej akcji. require_role sprawdza parametry z request.path_parameters w celu okreslenia jaka rola bedzie potrzeban do wywolania akcji - tutaj musze przyznac ze nie jestem do konca przekonany o tym czy to najlepsza metoda, jak myslicie ?

[code=ruby]#application.rb

def authorize
@user = session[:user]
unless @user
session[:redirect_to] = request.request_uri
flash[:notice] = ‘Musisz się zalogować.’
redirect_to_login
end
end

def require_roles( *roles )
authorize
if @user
if roles.empty?
#skladamy wymagana role z parametrow path_parameters
roles << request.path_parameters[:controller] + ‘/’ + request.path_parameters[:action]
end
unless @user.has_roles( roles ) || @user.has_roles( ‘admin’ )
flash[:notice] = ‘Nie masz uprawnień do wykononia tej akcji.’
redirect_to_login
end
end
end[/code]
filtr require_roles jest uniwersalny, jesli jakas akcja musi byc walidowana w inny niz domyslny sposob to w kontrolerze mozemy napisac cos w stylu:

[code=ruby]before_filter :require_roles, :except => :nasza_akcja

def nasza_akcja
require_roles :rola1, :rola2
#…
end[/code]
Fragment modelu user.rb:

[code=ruby]class User < ActiveRecord::Base
has_and_belongs_to_many :groups

validacje, metody autoryzujace itp…

def has_roles( required_roles )
matches = Array.new
# przeszukuj wszystkie grupy do ktorych nalezy pod katem wymaganych rol
self.groups.each { |group| matches = matches | group.has_roles( required_roles ) }
required_roles.each do |required_role|
unless matches.include?( required_role )
return false
end
end
return true
end

end[/code]
Najciekawsza rzecz: model group.rb

[code=ruby]class Group < ActiveRecord::Base
has_and_belongs_to_many :users
validates_presence_of :name
validates_uniqueness_of :name

def has_roles( required_roles )
matches = Array.new
# wstepne parsowanie
roles = self.parse_roles( self.roles )
#sprawdzamy czy wymagane role pasuja do wzorcow z listy
required_roles.each do |required_role|
roles.each do |role_pattern|
if self.role_matches( role_pattern, required_role )
#dodaj do tablicy dopasowanych
matches << required_role
end
end
end

return matches

end

sprawdza czy rola pasuje do wzorca

def role_matches( pattern, role )
if role == pattern
return true

# przypadek gry rola konczy sie '/' - mamy dostep do wszystkich akcji w tym module
elsif pattern =~ /\/$/
if role =~ /^#{pattern}/
  return true
end  
  
# przypadek z blokiem except()
elsif pattern =~ /\/\s*except\([\w\/,\s]*\)$/
  #dzielimy na to co przed except oraz liste rol w bloku except
  pattern_parts = pattern.split( /\s*except\(\s*/ )
  # sprawdzamy czy pierwsza czesc patterny pasuje
  if role =~ /^#{pattern_parts[0]}/
    # zrobimy sobie liste rol z bloku except, pamietajac zeby wywalic
    # jeszcze nawias zamykajacy z konca lancucha
    except_roles = self.split_roles( pattern_parts[1].sub( /\s*\)/, '' ) )
    except_roles.each do |except_role|
    # odwrotnie niz w poprzednich przypadkach:
    # tutaj jesli role pasuje do wzorca to znaczy ze nie mamy do niej praw ;)
    if role == pattern_parts[0] + except_role ||
    ( role =~ /^#{pattern_parts[0] + except_role}/ && except_role.ends_with?('/') )
      return false
    end
  end
return true

end
end
return false
end

#wstepna obrobka
def parse_roles( roles_string )
roles = Array.new
# jesli pojawia sie blok only() to od razu dodajemy wzorce dla elementow
# tego bloku do listy wzorcow
roles_string.gsub!(/[\w/]/\s\w*\sonly([\w/,\s])/) do |match|
pattern_parts = match.split( /\sonly(\s/ )
only_roles = self.split_roles( pattern_parts[1].sub( /\s*)/, ‘’ ) )
only_roles.each { |role| roles << pattern_parts[0] + role }
match = ‘’
# dodajemy role z blokiem except() w niemienionej postaci
# i pozniej usuwamy to wszystko z lancucha, aby role z listy
# except() nie braly udzialu w reszcie procesu
end
roles_string.gsub!(/[\w/]/\s\w*\sexcept([\w/,\s])/) do |match|
roles << match
match = ‘’
end
# to co zostalo dzielimy wzgledem przecinka - sa to wzorce rol bez zadnych blokow
roles | self.split_roles( roles_string )
end

def split_roles( roles_string )
roles_string.split( /\s*,\s*/ )
end[/code]
Skrobalem to wczoraj wieczorem, wytykanie bledow i konstruktywna krytyka mile widziana, zamieszcze pelne zrodla jak tylko znajde jakis serwer.

Ogolnie - bardzo fajnie. Ale :slight_smile: (zawsze musi byc jakies ale :slight_smile: Nie podoba mi sie rozwiazanie z except w rolach - jak przegladalem kod to dostalem oczoplasu od splitow, regexpow itd :wink: Jestem zbyt zmeczony zeby myslec - wiec zadanie domowe dla Ciebie :wink: - pomysl czy nie dalo by sie tego jakos przerobic zeby kod byl “ladniejszy”.

Pozdrawiam
Pawel

Tak, zgadzam się z Tobą - jutro będę miał trochę wolnego czasu żeby doszlifować kod, myślę, żeby jak największą część ‘podmian’ przenieść do wstępnego parsowania i trochę to jeszcze odchudzić. To co wkleiłem powyżej jest po prostu pierwszą wersją która prawidłowo działa, a siedziałem nad tym do późna i pod koniec kodowania byłem juz strasznie zmęczony, więc prace porządkowe zostawiłem sobie na następną sesję :wink:

apropo autoryzacji, zobacz sobie na:

http://technoweenie.stikipad.com/plugins/show/Acts+as+Authenticated

oraz

http://lists.rubyonrails.org/pipermail/rails/2006-January/015163.html

pozdrawiam