Checking permissions
Now that our PermissionModel is set up, let’s look at how we can use it to restrict access to our API endpoints.
Conceptually, what we need to do here isn’t too complicated.
- We’ll make a new
requirePermission()middleware which accepts a specific permission code like"movies:read"as an argument. - In this middleware, we’ll retrieve the current user from the request context, and call the
app.models.Permissions.GetAllForUser()method (which we just made) to get a slice of their permissions. - Then we can check to see if the slice contains the specific permission code needed. If it doesn’t, we should send the client a
403 Forbiddenresponse.
To put this into practice, let’s first make a new notPermittedResponse() helper function for sending the 403 Forbidden response. Like so:
package main ... func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) { message := "your user account doesn't have the necessary permissions to access this resource" app.errorResponse(w, r, http.StatusForbidden, message) }
Then let’s head to our cmd/api/middleware.go file and create the new requirePermission() middleware method.
We’re going to set this up so that the requirePermission() middleware automatically wraps our existing requireActivatedUser() middleware, which in turn — don’t forget — wraps our requireAuthenticatedUser() middleware.
This is important — it means that when we use the requirePermission() middleware we’ll actually be carrying out three checks which together ensure that the request is from an authenticated (non-anonymous) and activated user, who has a specific permission.
Let’s go ahead and create this in the cmd/api/middleware.go file like so:
package main ... // Note that the first parameter for the middleware function is the permission code that // we require the user to have. func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc { fn := func(w http.ResponseWriter, r *http.Request) { // Retrieve the user from the request context. user := app.contextGetUser(r) // Get the slice of permissions for the user. permissions, err := app.models.Permissions.GetAllForUser(user.ID) if err != nil { app.serverErrorResponse(w, r, err) return } // Check if the slice includes the required permission. If it doesn't, then // return a 403 Forbidden response. if !permissions.Include(code) { app.notPermittedResponse(w, r) return } // Otherwise they have the required permission so we call the next handler in // the chain. next.ServeHTTP(w, r) } // Wrap this with the requireActivatedUser() middleware before returning it. return app.requireActivatedUser(fn) }
Once that’s done, the final step is to update our cmd/api/routes.go file to utilize the new middleware on the necessary endpoints.
Go ahead and update the routes so that our API requires the "movies:read" permission for the endpoints that fetch movie data, and the "movies:write" permission for the endpoints that create, edit or delete a movie.
package main ... func (app *application) routes() http.Handler { router := httprouter.New() router.NotFound = http.HandlerFunc(app.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) // Use the requirePermission() middleware on each of the /v1/movies** endpoints, // passing in the required permission code as the first parameter. router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler)) router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler)) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler)) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler)) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler)) router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) return app.recoverPanic(app.rateLimit(app.authenticate(router))) }
Demonstration
Showing this in action is a bit awkward because, if you’ve been following along, none of the users in our database currently have any permissions set for them.
To help demonstrate this new functionality, let’s open psql and add some permissions. Specifically, we will:
- Activate the user
alice@example.com. - Give all users the
"movies:read"permission. - Give the user
faith@example.comthe"movies:write"permission.
If you’re following along, open your psql prompt:
$ psql $GREENLIGHT_DB_DSN Password for user greenlight: psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1)) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type "help" for help. greenlight=>
And execute the following statements:
-- Set the activated field for alice@example.com to true. UPDATE users SET activated = true WHERE email = 'alice@example.com'; -- Give all users the 'movies:read' permission INSERT INTO users_permissions SELECT id, (SELECT id FROM permissions WHERE code = 'movies:read') FROM users; -- Give faith@example.com the 'movies:write' permission INSERT INTO users_permissions VALUES ( (SELECT id FROM users WHERE email = 'faith@example.com'), (SELECT id FROM permissions WHERE code = 'movies:write') ); -- List all activated users and their permissions. SELECT email, array_agg(permissions.code) as permissions FROM permissions INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id INNER JOIN users ON users_permissions.user_id = users.id WHERE users.activated = true GROUP BY email;
Once that completes, you should see a list of the currently activated users and their permissions, similar to this:
email | permissions
-------------------+----------------------------
alice@example.com | {movies:read}
faith@example.com | {movies:read,movies:write}
(2 rows)
Now that our users have some permissions assigned to them, we’re ready to try this out.
To begin with, let’s try making some requests as alice@example.com to our GET /v1/movies/1 and DELETE /v1/movies/1 endpoints. The first request should work correctly, but the second should fail because the user doesn’t have the necessary movies:write permission.
$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "OPFXEPOYZWMGNWXWKMYIMEGATU",
"expiry": "2021-04-17T20:49:39.963768416+02:00"
}
}
$ curl -H "Authorization: Bearer OPFXEPOYZWMGNWXWKMYIMEGATU" localhost:4000/v1/movies/1
{
"movie": {
"id": 1,
"title": "Moana",
"year": 2016,
"runtime": "107 mins",
"genres": [
"animation",
"adventure"
],
"version": 1
}
}
$ curl -X DELETE -H "Authorization: Bearer OPFXEPOYZWMGNWXWKMYIMEGATU" localhost:4000/v1/movies/1
{
"error": "your user account doesn't have the necessary permissions to access this resource"
}
Great, that’s working just as we expected — the DELETE operation is blocked because alice@example.com doesn’t have the necessary movies:write permission.
In contrast, let’s try the same operation but with faith@example.com as the user. This time the DELETE operation should work correctly, like so:
$ BODY='{"email": "faith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "E42XD5OBBBO4MPUPYGLLY2GURE",
"expiry": "2021-04-17T20:51:14.924813208+02:00"
}
}
$ curl -X DELETE -H "Authorization: Bearer E42XD5OBBBO4MPUPYGLLY2GURE" localhost:4000/v1/movies/1
{
"message": "movie successfully deleted"
}