Rails und DRY Zugriffsrechte mit CanCan(Can) und ein wenig Spucke

Die Ausgangslge

Wie es halt immer so ist: man legt los mit einer kleinen Rails-Anwendung, drei, vier, fünf Modell-Klassen, dann kommt noch eine Null hinter diese Zahl, gefolgt von neuen Benutzerrollen und die User<->Anwendungslogik-Interaktionsmöglichkeiten explodieren nach dem 42. Sprint kombinatorisch, anschließend gart der Code ein Jahr lang im eigenen Saft, der Kunde tut, was Kunden so tun, eines Tages kommt dann der Anruf und man stellt fest: „Huch! Plötzlich sind es der Datenbank-Records nicht mehr Duzende, sondern Zehntausende!“ und irgendwie schaffe ich es, während eines Seitenreload-Intervalls nicht nur mir, sondern auch meinem Kollegen einen frischen Kaffee zu brühen[1] – und obendrauf sind die Benutzer auch noch unzufrieden.

Dann schaut man mal in die Logdateien der Anwendung und findet zuhauf „N+1“-Probleme, also Massen von Datenbankabfragen, die sich durch eager loading von assoziierten Objekten in Listenansichten verhindern ließen. Doch beim genauen Hischauen fällt einem auf, dass ein Großteil der geladenen Objekte gar nie wirklich verwendet (i.S.v. dem Benutzer angezeigt) werden, sondern nur mal eben benötigt werden, um zu bestimmen, ob der aktuell mit der Anwendung interagierende Benutzer überhaupt den „Beschreibung bearbeiten“-Link und den „Freigabe erteilen“-Button in dem Dropdown-Menü sehen soll, welches nur für diejenigen aktiv sein dürfte, welche mindestens drei Wochen vor dem letzten Osterdatum ihr Konto aktiviert und all ihre Rechnungen innerhalb der von fünf anderen Entitäten abhängigen Frist (…) bezahlt haben. Oder (temporäre) Adminrechte im Kontext der übergeordneten Entität haben. Oder.

Kurzum: Zeit für ein masssives Refactoring. In diesem Fall von Zugriffsrechtedefinitionen.

Das Modell

Wer Webanwendungen programmiert, wird das kennen: Ein Objekt, z.B. eine Rechnung, erlaubt verschiedene Interaktionen, abhängig von dessen internem Status und Benutzerrolle des Anwenders. Fangen wir mit dem (fast) einfachst-denkbaren Fall an:

class Invoice < ActiveRecord::Base
  attribute :approved_at, DateTime
  attribute :sent_at,     Datetime

  belongs_to :principal, class_name: 'User'

  # Aktionen
  def approve!
    update approved_at: Time.now
  end

  def send!
    update sent_at: Time.now
  end

  # Statusabfragen
  def approved?
    approved_at.present?
  end

  def sent?
    sent_at.present?
  end
end

class User
  attribute :role, String
end
Unser Business-Modell

Die Berechtigungen

Nehmen wir an, dass User mit der Rolle ‚principal‘ ihre eigenen Rechnungen bearbeiten und freigeben können, aber nur solange diese nicht freigegeben oder versandt sind. Nehmen wir weiter an, dass User mit der Rolle ‚backoffice‘ alle Rechnungen bearbeiten können, solange diese nicht versandt sind, und versenden können, wenn diese freigegeben (aber noch nicht versandt) sind. Diese Anforderungen lassen sich mit CanCan einfach abbilden, und zwar auf mehrere Arten.

Eie Möglichkeit wäre, die Block-Notation zu verwenden (do ... end), diese erlaubt die uneingeschränkte Verwendung der Business-Logik des betreffenden Objektes:

class Ability
  include CanCan::Ability

  def initialize(user)
    case user.role
    when 'backoffice'
      can :update, Invoice do |invoice|
        not invoice.sent?
      end
      can :send, Invoice do |invoice|
        invoice.approved? and not invoice.sent?
      end
    when 'principal'
      can [:approve, :update], Invoice do |invoice|
        invoice.principal == user and not (invoice.approved? or invoice.sent?)
      end
    end
  end
end
Ability-Definition mittels Blöcken

Bei einem einfachen Beispiel wie dem hier gegebenen, wo wir es lediglich mit drei Status und jeweils einer einfachen Status-Bedingung zu tun haben, wäre jede weitere Überlegung zur Vereinfachung unnötige Zeitverwschwendung. Leider sieht der Alltag in der Rechnungserstellungsbranche anders aus und die Liste der Bedingungen, Status und Transitionsmöglichkeiten wächst schnell. Für jede Berechtigung eine zugehörige Status-Liste zu pflegen, dazu noch außerhalb des betreffenden Objektes, könnte man als Verletzung des Geheimnisprinzips sehen. Außerdem ist die Abbildung eines Status auf eine Liste von Transitionen zumindest meinem Hirn leichter begreiflich als der umgekehrte Fall.

Die Maschine

Eine formale Statusmaschine wie AASM oder Workflow hilft erheblich dabei, fachlich bedingte Zugriffsrechte auf Transitionen im Modell zu definieren und diese von den durch Benutzerrollen vorgegebenen Zugriffsrechten zu trennen. Zugegeben, die skizzierte Trennung ist manchmal nicht so eindeutig, aber deswegen ganz darauf zu verzichten, macht den Code nicht verständlicher.

Jedenfalls lässt sich das Initialbeispiel mit Hilfe von Workflow auch folgendermaßen definieren:

class Invoice < ActiveRecord::Base
  include Workflow             # zur Definition unserer Statusmaschine
  include WorkflowActiverecord # persistiert den Status in der DB

  attribute :approved_at,    DateTime
  attribute :sent_at,        Datetime
  attribute :workflow_state, String, default: 'initial'

  belongs_to :principal, class_name: 'User'

  workflow do
    state :initial do
      event :approve, transitions_to: :approved do
        update approved_at: Time.now
      end

      event :update, transitions_to: :initial
    end

    state :approved do
      event :send, transitions_to: :sent do
        update sent_at: Time.now
      end

      event :update, transitions_to: :sent
    end

    state :sent
  end
end
Unser Business-Modell mit formalem Workflow

Die wesentliche Fachlogik und das Interface der Klasse sind identisch mit dem ersten Beispiel. Der Unterschied ist nun aber, dass Workflow den Status nicht aus den approved_at- und sent_at-Attributen ‚berechnet‘, sondern der Status im Attribut workflow_state maßgeblich ist. Das ist, streng genommen, nicht ganz DRY; auf dieses Problem werden wir ganz am Schluss nochmal zurückkommen.

Soweit ist scheinbar nicht viel gewonnen. Doch weit gefehlt: Workflow stellt sicher, dass nur Transitionen aufgerufen werden können, die gemäß aktuellem Status zulässig sind und stellt entsprechende Abfrage-Methoden zur Verfügung: @invoice.approve! if @invoice.can_approve? ruft die Transition ‚approve‘ nur dann auf, wenn der Status (egal welcher!) diese erlaubt.

Damit ist der fachlich motivierte Berechtigungs-Teil ins Fachmodell gewandert, wo er hingehört. Die Benutzer-Rechtedefinition vereinfacht sich damit etwas:

case user.role
    when 'backoffice'
      can :update, Invoice do |invoice|
        invoice.can_update?
      end
      can :send, Invoice do |invoice|
        invoice.can_send?
      end
    when 'principal'
      can :approve, Invoice do |invoice|
        invoice.principal == user and invoice.can_approve?
      end
      can :update, Invoice do |invoice|
        invoice.principal == user and invoice.can_update?
      end
    end
Ability-Definition mit Workflow

Alles super, oder? Jein. Schwierigkeiten treten dann auf, sobald Objektlisten aus der Datenbank geladen werden sollen, auf denen der aktuelle Benutzer bestimmte Zugriffsrechte hat.

Die Liste

Um bei unserem Beispiel zu bleiben: Die Liste aller durch den aktuellen Benutzer freizugebenden Rechnungen erfordert die Definition eines neuen Model-Scopes, der wiederum in der CanCan-Rechtedefinition verwendet werden kann:

class Invoice
  # ...
  scope :approvable_by, ->(user) { where principal: user, approved_at: nil }
  # oder alternativ über den Status:
  scope :approvable_by, ->(user) { where principal: user, workflow_state: 'initial' }
  # ... und das gleiche für `sendable` und `updateable` ...
end

class Ability
  # ...
    when 'principal'
      can :approve, Invoice, Invoice.approvable_by(user) do |invoice| # Scope wird beim Laden von Listen verwendet
        invoice.principal == user and invoice.can_approve?            # Block für die Prüfung eines Einzel-Records
      end
  # ...
end
Invoice-Model mit scopes

Damit können Übersichten passend zur Berechtigung geladen, als auch Berechtigungen pro Objekt abgefragt werden:

class InvoicesController < ApplicationController
  # GET /invoices/approvable(.:format)
  def approvable
    # Enthält nur die durch den aktuellen User freigebbaren Rechnungen
    @invoices = Invoice.accessible_by(current_ability, :approve)
  end
end
Invoices-Controler: Lädt nur die zulässigen Records

Allerdings hat sich damit schon wieder eine zweimalige Implementierung der gleichen Logik eingeschlichen (Statusmaschine und Scope-Definition), womit Bugs bei komplexeren Szenarien vorprogrammiert sind.

Der Hash

CanCan ermöglicht aber neben der ‚Scope-und-Block‘-Notation auch eine Hash-Notation, die an sich kompakt und DRY aussieht:

case user.role
    when 'backoffice'
      can :update, Invoice, workflow_state: %w(initial approved)
      can :send, Invoice, workflow_state: 'approved'
    when 'principal'
      can [:approve, :update], Invoice, principal_id: user.id, workflow_state: 'initial'
    end
Ability-Definition mittels Hashes

… allerdings tauchen jetzt wieder die Status-Innereien auf, die wir doch gerade mit der Statusmaschine weggekapselt haben wollten! Damit ist das alles wieder nicht DRY und auch sonst zum verzweifeln![scream]

Die Datenbank

Bleibt also nur noch die Verlagerung der Statusmaschine in die Datenbank! Dazu werfen wir das Workflow-Gem wieder weg, workflow_state wird ersetzt durch ein InvoiceStatus-Objekt mit einem Namen und einer Liste von Booleans zur Beschreibung der erlaubten Übergänge und ein kleinwenig (naive) Transitions-Logik wird selbst geschrieben:

class InvoiceStatus < ActiveRecord::Base
  has_many :invoices

  attribute :can_approve, default: false
  attribute :can_send,    default: false
  attribute :can_update,  default: false

  def can?(event_name)
    self["can_#{event_name}"]
  end
end

class Invoice < ActiveRecord::Base
  # alle Attribute bleiben gleich wie oben

  belongs_to :invoice_state,
    foreign_key: :workflow_status,
    primary_key: :name

  def approve!
    run_transition :approve, to_status: 'approved' do
      self.approved_at = Time.now
    end
  end

  def send!
    run_transition :send, to_status: 'sent' do
      self.sent_at = Time.now
    end
  end

  def update!(attrs)
    run_transition :update do
      self.assign_attributes attrs
    end
  end

  def run_transition(event_name, to_status:)
    if invoice_status.can? event_name # darf ich überhaupt?
      yield
      if self.save
        self.update_column :workflow_status, to_status
      end
    else
      raise "Transition '#{event_name}' not allowed in state '#{invoice_status.name}'"
    end
  end

end
Invoice-Modell mit WorkflowState-Assoziation

Als nächstes muss für jeden möglichen Status ein InvoiceStatus-Record in der Datenbank abgelegt werden, wo die zulässigen Transitionen definiert sind:

InvoiceStatus.create name: 'initial',  can_update: true, can_approve: true
InvoiceStatus.create name: 'approved', can_update: true, can_send: true
InvoiceStatus.create name: 'sent'
Die Status-Matrix

Damit reduziert sich die Ability-Definition auf ein lesbares Minimum:

case user.role
    when 'backoffice'
      can :update, Invoice, invoice_status: {can_update: true}
      can :send,   Invoice, invoice_status: {can_send: true}
    when 'principal'
      can :update,  Invoice, principal_id: user.id, invoice_status: {can_update:  true}
      can :approve, Invoice, principal_id: user.id, invoice_status: {can_approve: true}
    end
Ability-Definition mittels Hashes und InvoiceStatus

Diese Zugriffsrechtedefinition lässt deutlich erkennen, welche Teile Benutzer- und welche Statusabhängig sind. Die Implementierung der Statusmaschine in den Modell-Klassen lässt sich durch ein wenig Metaprogrammierung auch kompakter gestalten. Und was wir hierdurch geschenkt bekommen, ist die Möglichkeit, zulässige Transitionen in einer Admin-Oberfläche der Anwendung durch Setzen von Checkboxen zu definieren. Ob das ein tatsächlicher Mehrwert ist, hängt natürlich stark vom Einzelfall ab.

Nachteilig fällt allerdings auf, dass der Code alleine nicht mehr ausreicht, um die Statuslogik komplett zu verstehen. Da das bei komplexeren Anwendungsfällen aber ohnehin schwierig wird, ist das womöglich ein akzeptabler Preis. Je nach Betrachtungsweise wurde hier aber auch eine grundlegendere Grenze aufgeweicht: „was ist“ (=Status) sollte in den Daten stehen und „was passieren kann“ (=Verhalten) sollte im Code definiert sein[2]. Dass mögliches Verhalten nun in der Datenbank steht, lässt sich m.M.n. nur dadurch rechtfertigen, dass es sich um Caching zwecks Performance-Optimierung handelt.

Da war noch was mit DRY-Status!

Wer aufmerksam mitgelesen und -gedacht hat, wird sich denken: ein sent_at- und ein invoice_status-Feld entbehrt auch nicht einer gewissen Redundanz, wo im Initialbeispiel das sent_at-Feld doch ausgereichend war, um den Status „sent“ vollumfänglich zu definieren. Das ist vollkommen richtig, und daher bin ich persönlich ein Fan davon, den Status beim Laden eines Objektes zum Zwecke der (Benutzer-)Interaktion nochmal explizit zu berechnen. Das ‚Status‘-Feld in der Datenbank ist damit nicht die Quelle aller Wahrheit, sondern nur ein gecacheter Wert, der zwar in 99.9…% der Fälle korrekt ist, aber gerade in Langzeit-Entwicklungsprojekten mit einer Historie von teilweise verpatzten Datenmigrationen und sich immer mal wieder ändernden Statusmaschinendetails gelegentlich daneben liegen kann. Ein fehlerhafter Status von „alten“ Objekten ist meist verschmerzbar, da diese oft nur noch statistische Relevanz in Reports haben. Das Status-Feld „aktueller“ Objekte wird hingegen spätestes beim nächsten Speichervorgang transparent korrigiert. Konkret könnte so etwas bei unserem Beispiel wie folgt aussehen:

class Invoice < ActiveRecord::Base
  # ... Rest wie in obigem Listing

  # Statusabfragen: wie ganz zu Beginn!
  def approved?
    approved_at.present?
  end

  def sent?
    sent_at.present?
  end

  def workflow_state
    if readonly? # Invoice.readonly.accessible_by(...) ist so schneller als Invoice.accessible_by(...)
      self[:workflow_state]
    elsif approved?
      'approved'
    elsif sent?
      'sent'
    else
      'initial'
    end
  end

  before_validation do
    self[:workflow_state] = self.workflow_state
  end

end
Status-Feld als gecacheter Wert

Die Methode workflow_state wird nur bei geladenen Objekten aufgerufen, sofern diese nicht readonly geladen werden[3]. Ansonsten liefert es garantiert den korrekten aggregierten Objektstatus zurück. Die Abfragemethoden sent? und approved? prüfen jeweils eine unabhängige Bedingung, was weniger Information vernichtet und komplexeres Verhalten erlaubt[4].

  1. nicht, dass ich so etwas jemals tun würde  ↩

  2. :scream:  ↩

  3. Es kann aus Performance–Gründen ratsam sein, Collections immer readonly zu laden, um sich den Overhead von Dirty–Tracking und ähnliches zu sparen. Wie im aufgeführten Beispiel hat der Anwendungsprogrammierer es selbst in der Hand, in diesem Zuge weitere Optimierungen zu implementieren.  ↩

  4. Nicht jede versandte Rechnung muss z.B. zwangsweise freigegeben sein, auch wenn der gegenwärtig definierte Workflow das vielleicht so vorschreibt.  ↩

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen