Pitch: Vapor 4 Authorization System

Motivation:
Other server frameworks like Laravel have fully developed authorization systems that allow developers to specify whether or not a route can be accessed under certain conditions. These packages are more advanced than simply checking "is user.userType == .admin," they are more about checking "is user.id == post.user_id for each route. Drawing from Laravel's Gate system, I'm proposing the following API. I'd love to get some feedback on it about what people like, don't like, or want to see added.

AuthorizationPolicy

protocol AuthorizationPolicy {
    func isAuthorized(req: Request) throws -> Future<Bool>
}

This protocol defines a policy that can be executed before a request.

Example AuthorizationPolicy implementation

struct PostOwnerPolicy: AuthorizationPolicy {
    func isAuthorized(req: Request) throws -> EventLoopFuture<Bool> {
        return req.parameters.next(Post.self).map { post in
            return req.user().id == post.user_id
        }
    }
}

This policy gets the user from the request and the post from the request parameters and makes sure that the user is authorized to access it.

Registering Policies
This part needs some work/feedback. I'm imagining something like DatabaseConfig:

var policyConfig = PolicyConfig()
try policyConfig.add(PostOwnerPolicy())

I would like to do something similar to DatabaseIdentifier as well though so that it might look something like this:

var policyConfig = PolicyConfig()
try policyConfig.add(PostOwnerPolicy(), id: .userMustBePostOwner)

PolicyMiddleware
Middleware for validation might look something like this (I haven't put much effort into optimizing this code):

final class PolicyMiddleware: Middleware {
    let policies: [AuthorizationPolicy]
    
    init(policies: [AuthorizationPolicy]) {
        self.policies = policies
    }
    
    func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture<Response> {
        return try policies.map { try $0.isAuthorized(req: request) }.flatten(on: request).flatMap { results in
            guard !results.contains(false) else { throw Abort(.unauthorized) }
            return try next.respond(to: request)
        }
    }
}

In a controller

final class PolicyController: RouteCollection {
    func boot(router: Router) throws {
        router.group([PolicyMiddleware(policies: [PostOwnerPolicy()])]) { build in
            // register routes here
        }
    }
}

Ideally this is where the identifier would come in so that you could do something like this instead:

final class PolicyController: RouteCollection {
    func boot(router: Router) throws {
        router.group([PolicyMiddleware(policies: [.userMustBePostOwner])]) { build in
            // register routes here
        }
    }
}
8 Likes

Thanks for this Jimmy. I think this area would be a huge step for Vapor. Hence me starting my own as mentioned. I haven't used Laravel to really be able to fairly compare, but it looks nice from the docs. One thing I wasn't able to see is if it supports Hierarchical permissions and being able to use roles and combine permissions + rules into common sets. I've found this really quite enjoyable with a NIST style model.

Here is how Yii does it. The code implementation around Yii might not be as pretty as Laravel, based on my reading of the Laravel docs. But it's very fine grain and powerful in terms of easily being able to build complex permissions and bundle them into roles.

1 Like

Thanks for that link! That's super helpful. In terms of next steps forward, Tanner and I did some brainstorming last night and came up with the following:

let db = try c.make(Database.self)

var root = r.grouped(User.tokenAuthenticator(on: db).middleware())
    .grouped(User.guardMiddleware())

// other routes

let post = root.grouped("posts", ":postID") 
    .grouped(Post.ownershipPolicy(on: db).middleware())


post.get { req in
    return try req.authorized(Post.self)
}

post.delete { req in
    return try req.authorized(Post.self).delete()
}

A few important things to note here:

  1. req.authorized(Post.self) allows us to cache the stored model between the middleware and the request so that double database requests don't have to be executed
  2. It will work similar to authentication where you can have multiple authenticators chained together and they will fail silently and then be picked up by a guard middleware.
  3. There will also be a way to specify which policies should be enabled inside of the ownershipPolicy middleware.
  4. There may be some things that we can automatically infer from the new Fluent library that could help with generic models like post.owner_id == owner.id.

Overall these changes give us some solid building blocks that allow us to inject the idea of policy enforcement into the route chain so that we can continue building out advanced systems like RBAC going forward.

2 Likes

Did some more thinking about this over the weekend. Is the intended Vapor solution going to support dynamic authorization? As a use case.

Website A allows people to sign up for teams
Person B Signs up and is the admin for "Vapor Team"
Person C is a member of the Vapor Team, but needs some permissions, but not out of the box admin functionality. Let's assume the admin functionality is hard coded into the app.
Person D is also a member but has more power than Person C.

Can Person B or how can Person B give access to Person C for the resource api/v1/team/123/user/456 but only using the GET method. Where as Person D can have GET + UPDATE.

Yeah, all of that should be possible. The first step in this proposal is to just outline a very generic solution that can intercept requests and validate them - how that validation is done is left totally up to the developer. After this is built out, though, there may be the opportunity to add a true RBAC-type system that builds on the existing authorization setup. Does that make sense?

Sure does, just trying to get a scope of how powerful the Vapor one might be out of the box

I already made something for this a long time ago.

Thanks for sharing! I'm going to charge forward on my end with creating a vapor-specific solution that can take full advantage of all of the niceties of request lifecycles, etc.

Not sure if this is useful to you or not, but I've found this approach (Ownable) really useful in modeling ownership. Again, just kinda throwing this out there — I'm not sure how it could/would fit into what you're trying to accomplish.

final class User: SomeFluentModel {
    var id: Int?
    ...
} 

final class Category: SomeFluentModel {
    var id: Int?
    var userID: User.ID
    ...
}

extension Category: Ownable {
    typealias OwnerType = User
    static let ownerIDKey: OwnerIDKey = \.userID
}

let user = User(id: 0)
let category = Category(id: 0, userID: 0)
category.isOwned(by: user) // true

I like that a lot!! Thanks for sharing. I imagine that we'll adopt some kind of similar protocol. The goal with building this so close to Vapor is to be able to couple it with some of the unique features of the platform. Specifically, I want to be able to cache models that are used in the auth/z layer for use in the route later on (so that they don't have to be queried from the db twice) and possibly automatic inference for Fluent models via protocol conformance.

1 Like