Let's Go Further Permission-based authorization › Granting permissions
Previous · Contents · Next
Chapter 16.5.

Granting permissions

Our permissions model and authorization middleware are now functioning well. But — at the moment — when a new user registers an account they don’t have any permissions. In this chapter we’re going to change that so that new users are automatically granted the "movies:read" permission by default.

Updating the permissions model

In order to grant permissions to a user, we’ll need to update our PermissionModel to include an AddForUser() method, which adds one or more permission codes for a specific user to our database. The idea is that we will be able to use it in our handlers like this:

// Add the "movies:read" and "movies:write" permissions for the user with ID = 2.
app.models.Permissions.AddForUser(2, "movies:read", "movies:write")

Behind the scenes, the SQL statement that we need to insert this data looks like this:

INSERT INTO users_permissions
SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)

In this query, the $1 parameter will be the user’s ID, and the $2 parameter will be a PostgreSQL array of the permission codes that we want to add for the user, like {'movies:read', 'movies:write'}.

So what’s happening here is that the SELECT ... statement on the second line creates an ‘interim’ table with rows made up of the user ID and the corresponding IDs for the permission codes in the array. Then we insert the contents of this interim table into our user_permissions table.

Let’s go ahead and create the AddForUser() method in the internal/data/permissions.go file:

File: internal/data/permissions.go
package data

import (
    "context"
    "database/sql"
    "time"

    "github.com/lib/pq" // New import
)

...

// Add the provided permission codes for a specific user. Notice that we're using a 
// variadic parameter for the codes so that we can assign multiple permissions in a 
// single call.
func (m PermissionModel) AddForUser(userID int64, codes ...string) error {
    query := `
        INSERT INTO users_permissions
        SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)`

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    _, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes))
    return err
}

Updating the registration handler

Now that’s in place, let’s update our registerUserHandler so that new users are automatically granted the movies:read permission when they register. Like so:

File: cmd/api/users.go
package main

...

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    user := &data.User{
        Name:      input.Name,
        Email:     input.Email,
        Activated: false,
    }

    err = user.Password.Set(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    v := validator.New()

    if data.ValidateUser(v, user); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    err = app.models.Users.Insert(user)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrDuplicateEmail):
            v.AddError("email", "a user with this email address already exists")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Add the "movies:read" permission for the new user.
    err = app.models.Permissions.AddForUser(user.ID, "movies:read")
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.background(func() {
        data := map[string]any{
            "activationToken": token.Plaintext,
            "userID":          user.ID,
        }

        err := app.mailer.Send(user.Email, "user_welcome.tmpl", data)
        if err != nil {
            app.logger.Error(err.Error())
        }
    })

    err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

...

Let’s check that this is working correctly by registering a brand-new user with the email address grace@example.com:

$ BODY='{"name": "Grace Smith", "email": "grace@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
    "user": {
        "id": 8,
        "created_at": "2021-04-16T21:32:56+02:00",
        "name": "Grace Smith",
        "email": "grace@example.com",
        "activated": false
    }
}

If you open psql, you should be able to see that they have the movies:read permission by running the following SQL query:

SELECT email, code FROM users 
INNER JOIN users_permissions ON users.id = users_permissions.user_id
INNER JOIN permissions ON users_permissions.permission_id = permissions.id
WHERE users.email = 'grace@example.com';

Like so:

$ 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=> SELECT email, code FROM users 
greenlight-> INNER JOIN users_permissions ON users.id = users_permissions.user_id
greenlight-> INNER JOIN permissions ON users_permissions.permission_id = permissions.id
greenlight-> WHERE users.email = 'grace@example.com';
       email       |    code     
-------------------+-------------
 grace@example.com | movies:read
(1 row)