Apache Shiro: Komplexe Permissions selbst implementieren

Obwohl Apache Shrio mit seinen WildcardPermissions ein mächtiges und flexibles Werkzeug zur feingranularen Vergabe von Berechtigungen mitliefert, stößt man damit bei komplexen Szenarien an Grenzen. Dieser Artikel zeigt, wie sich beliebige Berechtigungsprüfungen über das Permission-Interface selbst implementieren lassen.

Autorisierung mit Apache Shiro

Mit Apache Shiro steht dem Java-Entwickler ein umfangreiches und ausgereiftes Security-Framework mit den Kern-Features Authentifizierung, Autorisierung, Session-Management und Kryptographie zur Verfügung. Dieser Artikel konzentriert sich auf das Thema Autorisierung und zeigt, wie Shiros Permission-Konzept genutzt werden kann, um komplexe Berechtigungen abzubilden. Eine allgemeine Einführung in Shiro steht unter http://shiro.apache.org/ bereit. Ein vollständiges Code-Beispiel der hier vorgestellten Lösung ist unter https://github.com/triologygmbh/complex-shiro-permissions einsehbar.

Shiro-Permissions

Shiro bietet zwei Konzepte für die Autorisierung an: Rollen und Permissions. Rollen-basierte Berechtigungsprüfungen eigenen sich für einfache, überschaubare Szenarien. Permissions hingegen bieten die Möglichkeit Berechtigungen flexibel und beliebig feingranular zu definieren und zu vergeben. Dabei gilt alles als verboten, was nicht explizit erlaubt ist.

Mit WildcardPermissions liefert Shiro bereits eine mächtige Permission-Implementierung mit aus, die für die meisten Anwendungsfälle ausreicht. WildcardPermissions können als Strings definiert werden. So könnte man z. B. über den String „product:update:123“ abfragen, ob der aktuelle Benutzer berechtigt ist, das Produkt mit der ID 123 zu ändern. Andersherum können wir einem Benutzer die Berechtigung „product:update:*“ zuordnen. Diese erlaubt es ihm, alle Produkte zu ändern. „product:*“ würde es erlauben, sämtliche Aktionen auf allen Produkten durchzuführen, „product:*:123“ würde alle Aktionen auf Produkt 123 zulassen usw. Die Anzahl der Teile einer WildcardPermission ist dabei unbegrenzt. Um die Berechtigung auf eine bestimmte Produktlinie einzuschränken, wäre z. B. die Permission „productline:hardware:product:update:*“ denkbar.

Shiro-Grundlagen

Schauen wir uns zunächst an, wie Shiro grundsätzlich bedient wird, bevor wir die Grenzen der String-basierten WildcardPermissions betrachten. Die Schnittstelle für Client-Code ist denkbar einfach:

Subject user = SecurityUtils.getSubject();
user.login(new UsernamePasswordToken("user", "password"));
user.checkRole("admin");
user.checkPermission("product:update:123");
user.logout();

Die Brücke zur Domäne der eigenen Anwendung schlägt bei Shiro der sogenannte Realm. Anwendungsspezifische Belange wie die Anbindung einer Benutzerverwaltung oder die Vergabe von Berechtigungen werden von Shiro an eine zuvor registrierte Implementierung dieses Interfaces delegiert. Versucht der Client-Code, das Subject einzuloggen, wird das definierte AuthenticationToken (im Beispiel ein UsernamePasswordToken) zu Prüfung an den Realm weitergereicht. Analog wird der Realm nach Rollen und Permissions des aktuellen Benutzers gefragt, wenn dessen Berechtigungen geprüft werden.
Damit man sich als Entwickler einzig auf diese beiden Aspekte konzentrieren muss, liefert Shiro die Klasse AuthorizingRealm mit aus, die hierfür zwei Template-Methoden definiert. Wir erweitern sie wie folgt:

public class ComplexPermissionRealm extends AuthorizingRealm {

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        Collection<Permission> permissions = loadPermissions(principalCollection);
        authorizationInfo.addObjectPermissions(permissions);
        return authorizationInfo;
    }

    private Collection<Permission> loadPermissions(PrincipalCollection principalCollection) {
        // In the real world we would most likely load the user's permissions from a database.
        // We keep it simple for demonstration purposes. 
       return Arrays.asList(
               new WildcardPermission("user:create:*"),
               new WildcardPermission("user:update:*"),
               new WildcardPermission("user:delete:*"));
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
              throws AuthenticationException {
        checkCredentials(authenticationToken);
        return new SimpleAuthenticationInfo(authenticationToken.getPrincipal(), authenticationToken.getCredentials(), getName());
    }

    private void checkCredentials(AuthenticationToken authenticationToken) {
        // implement authentication mechanism here using
        // authenticationToken.getPrincipal()
        // authenticationToken.getCredentials()
    }

}

Gibt die Methode doGetAuthenticationInfo nach der Prüfung des übergebenen AuthenticationToken eine AuthenticationInfo-Instanz zurück, ohne eine Exception zu werfen, sieht Shiro den Benutzer als authentifiziert an.
Bei der Prüfung von Berechtigungen wird doGetAuthorizationInfo aufgerufen. Die Methode gibt die Berechtigungen und Rollen eines Benutzers gebündelt in einer AuthorizationInfo-Instanz zurück.
Im Beispiel definieren wir eine Reihe von WildcardPermissions. Diese Klasse implementiert das Interface Permission, das eine nur Methode definiert:

boolean implies(Permission permission);

Beim Aufruf von user.checkPermission("product:update:123") wandelt Shrio zunächst den angefragten Permission-String in eine WildcardPermission um. Mit dieser Instanz werden dann solange die implies-Methoden der dem Benutzer zugewiesenen Permissions aufgerufen, bis eine von ihnen true zurückgibt. In unseren Beispiel wäre das
new WildcardPermission("user:update:*"). Impliziert keine der Permissions des Benutzers die angefragte Permission, ist der Benutzer nicht autorisiert.

Anforderungen komplexer Permissions

Mit den String-basierten WildcardPermissions hat der Entwickler ein einfaches Werkzeug an der Hand, mit dem sich schon recht komplexe Szenarien abbilden lassen. Ihnen ist sofern möglich der Vorzug zu geben.
Allerdings ist diese Art der Berechtigungsdefinition nicht in jedem Fall passend. Nehmen wir z. B. an, dass wir mit Shiro Berechtigungen auf einen Datei-Baum wie folgt abbilden wollen:

  • Dateizugriff kann lesend oder schreibend berechtigt werden.
  • Dateien die geschrieben werden können, können auch gelesen werden.
  • Wenn der Zugriff auf eine Datei erlaubt ist, muss auch lesender Zugriff auf alle übergeordneten Verzeichnisse erlaubt werden, um zur Datei navigieren zu können.
  • Ist ein Verzeichnis lesend oder schreibend berechtigt, wird diese Berechtigung auf alle untergeordneten Verzeichnisse und Dateien vererbt.

Nach dieser Definition führen drei Umstände dazu, dass ein Benutzer Zugriff auf eine Datei hat:

1. Ihm wurde die Berechtigung für die angefragte Datei direkt zugewiesen.
2. Es soll lesend auf die angefragte Datei zugegriffen werden und der Benutzer hat Zugriff auf eine im Baum untergeordnete Datei.
3. Der Benutzer hat Zugriff auf eine Datei, die der angefragten Datei im Baum übergeordnet ist.

Die Prüfung dieser drei Konstellationen werden wir jeweils in einer eigenen Implementierung des Permission-Interfaces umsetzen.

Implementierung komplexer Permissions

Da die zu implementierende implies Methode als Input ebenfalls eine Permission erwartet, benötigen wir noch eine weitere Implementierung, die den angefragten Zugriff auf eine Datei abbildet:

public class RequestedFileAccess implements Permission {

    private final Path requestedFile;
    private final FileOperation operation;

    public RequestedFileAccess(Path file, FileOperation operation) {
        this.requestedFile = file;
        this.operation = operation;
    }

    public Path getRequestedFile() {
        return requestedFile;
    }

    public FileOperation getOperation() {
        return operation;
    }

    @Override
    public boolean implies(Permission permission) {
        return false;
    }
}

Alle drei zu implementierenden Berechtigungsprüfungen haben gemeinsame Grundfunktionen: Sie implizieren nur RequestedFileAccess PermissionS. Außerdem müssen sie sicherstellen, dass die angefragte FileOperation (READ oder WRITE) zur konfigurierten Berechtigung passt. Zu diesem Zweck definieren wir die Basisklasse BaseFilePermission, die den Typ der angefragten Permission sowie die angefragte Operation prüft. Anschließend ruft sie eine Template-Methode auf, so dass spezifische Prüfungen in ihren Subklassen implementiert werden können.

abstract class BaseFilePermission implements Permission {

    protected final Path ownFile;
    protected final FileOperation operation;

    public BaseFilePermission(Path file, FileOperation operation) {
        this.ownFile = file;
        this.operation = operation;
    }

    @Override
    final public boolean implies(Permission permission) {
        return isFileAccessRequest(permission) && fileAccessIsImplied((RequestedFileAccess) permission);
    }

    private boolean isFileAccessRequest(Permission permission) {
        return permission instanceof RequestedFileAccess;
    }

    private boolean fileAccessIsImplied(RequestedFileAccess requestedFileAccess) {
        return requestedOperationIsImplied(requestedFileAccess) &&   
		accessIsAllowed(requestedFileAccess.getRequestedFile());
    }

    private boolean requestedOperationIsImplied(RequestedFileAccess requestedFileAccess) {
        return requestsRead(requestedFileAccess) || writeIsPermitted();
    }

    private boolean requestsRead(RequestedFileAccess requestedFileAccess) {
        return requestedFileAccess.getOperation() == FileOperation.READ;
    }

    private boolean writeIsPermitted() {
        return this.operation == FileOperation.WRITE;
    }

    /**
     * Template method called during permission check that allows subclasses to check access permissions.
     *
     * @param requestedFile Path
     * @return true if the RequestedFileAccess is granted
     */
    protected abstract boolean accessIsAllowed(Path requestedFile);

    
}

Im Folgenden werden wir die definierten Prüfungen 1., 2. und 3. in eigenen Permissions umsetzen. Dabei gehen wir beispielhaft von folgendem Dateibaum aus:

/departments
    /development
        /employee_789
        /employee_987
    /finance
        /employee_123
        /employee_456

Setzen wir nun also die erste Prüfung um – die direkte Berechtigung auf eine Datei. Da uns BaseFilePermission die meiste Arbeit abnimmt, müssen wir nur noch sicherstellen, dass die angefragte Datei dieselbe ist, für die eine direkte Berechtigung vorliegt. Die Prüfung zweier Dateien auf Gleichheit und auf ihre zu einander relative Position im Dateibaum delegieren wir an die Hilfsklasse FileRelations.

class FileRelations {

    private final Path file;

    private FileRelations(Path file) {
        this.file = file;
    }

    static FileRelations is(Path file) {
        return new FileRelations(file);
    }

    boolean descendantOf(Path potentialAncestor) {
        try {
            return isDescendantOf(potentialAncestor);
        } catch (IOException e) {
            // wrap checked exception to avoid cluttering up the example code
            throw new RuntimeException(e);
        }
    }

    private boolean isDescendantOf(Path potentialAncestor) throws IOException {
        return Files.walk(potentialAncestor)
                .anyMatch(descendant -> descendant.equals(this.file));
    }

    boolean theSameAs(Path otherFile) {
        return this.file.equals(otherFile);
    }

}

Über einen statischen Import der is Methode, können wir die Prüfung, ob es sich bei der angefragten Datei um die in der Permission konfigurierte handelt, ausdrucksstark formulieren. Dies geschieht in der Klasse PermissionForTheFileItself:

class PermissionForTheFileItself extends BaseFilePermission {

    public PermissionForTheFileItself(Path file, FileOperation operation) {
        super(file, operation);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
        return is(requestedFile).theSameAs(ownFile);
    }
}

Um das Ganze auszuprobieren, weisen wir dem Benutzer im Realm die neue Permission zu:

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    authorizationInfo.addObjectPermissions(createFilePermissions());
    return authorizationInfo;
}

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ));
}

Der Benutzer hat damit lesenden Zugriff auf die Datei departments/development/employee_987. Schreibender Zugriff sowie Zugriffe auf andere Dateien sind nicht möglich. Zur Demonstration sei auf die Testklasse Demo.java verwiesen, die im eingangs erwähnten GitHub-Repository zu finden ist.

Die Leseberechtigung für alle im Baum übergeordneten Dateien ist ähnlich einfach implementiert:

public class ReadPermissionForAncestors extends BaseFilePermission {

    public ReadPermissionForDirectAncestors(Path file) {
        super(file, FileOperation.READ);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
        return is(ownFile).descendantOf(requestedFile);
    }
}

Weisen wir sie dem Benutzer im Realm mit derselben Datei zu,

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
            new ReadPermissionForDirectAncestors(filePath("departments/development/employee_987")));
}

ist lesender Zugriff auf Verzeichnisse oberhalb der Datei employee_987 möglich.

Analog können wir die dritte und letzte Permission implementieren, die den Zugriff auf alle untergeordneten Dateien in einem Verzeichnis erlaubt:

class PermissionForAllDescendantFiles extends BaseFilePermission {

    public PermissionForAllDescendantFiles(Path file, FileOperation operation) {
        super(file, operation);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
       return is(requestedFile).descendantOf(ownFile);
    }

}

Mit der Zuweisung im Realm

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
            new ReadPermissionForAncestors(filePath("departments/development/employee_987")),
            new PermissionForTheFileItself(filePath("departments/finance"), FileOperation.WRITE),
            new ReadPermissionForAncestors(filePath("departments/finance")),
            new PermissionForAllDescendantFiles(filePath("departments/finance"), FileOperation.WRITE));
}

haben wir nun Zugriff auf alle Dateien unterhalb von departments/finance.

Fazit

Der Artikel zeigt, wie sich mit Apache Shiro beliebige Berechtigungen durch die Implementierung des einfachen Permission-Interfaces abbilden lassen. Statt eine komplexe Permission zu implementieren, die alle Fälle abdeckt, hat es sich in der Praxis bewährt, die Prüfungen auf viele Permissions aufzuteilen. So lassen sich Berechtigungen feingranular im Realm zuweisen.

Dabei ist darauf zu achten, dass Shiro immer alle Permissions eines Benutzers aufruft, bis eine gefunden wird, die den angefragten Zugriff erlaubt. Teure Operationen wie Datenbankzugriffe sollten daher an dieser Stelle wenn möglich vermieden oder durch Caching reduziert werden. Auch der hier demonstrierte Dateizugriff könnte sich negativ auf die Performance auswirken, wenn große Dateihierarchien durchsucht werden oder es sich um ein entferntes Dateisystem handelt. In solchen Fällen bietet es sich an, die aufwändigen Operationen nur einmal vorm Prüfen der Berechtigungen durchzuführen. Im Beispiel könnte die Struktur des Dateibaums z. B. vorab geladen und im Speicher gehalten werden, statt bei jeder Anfrage im Dateisystem zu navigieren.

Im Artikel findet die Frage keine Betrachtung, wie ein Benutzer an seine Berechtigungen kommt. In der Regel wird über ein Rollen- und Berechtigungskonzept in einer Datenbank hinterlegt, was ein Benutzer darf. Bei der Zuweisung der Permissions im Realm, werden diese Informationen dann genutzt, um die richtigen Permissions zu erzeugen. Auch hier ist Vorsicht geboten: Unternimmt man nichts weiter, fragt Shiro die Permissions eines Benutzers bei jeder Rechteprüfung neu ab. Das könnte unnötige Last bedeuten. Hier bietet sich die Verwendung des von Shiro bereitgestellten Caching API an.

Diesen Beitrag teilen

Daniel Behrwind
Software Development
Als leidenschaftlicher Softwareentwickler und Clean-Code-Verfechter ist er begeistert von kreativen Lösungen für komplexe Probleme, die so offensichtlich aussehen, als seien sie von selbst entstanden.