Simplified identity with 'Sign-in with Google' and Casbin

Serverless platforms like Cloud Run are low-cost and flexible solutions to deploy a web application. Additionally, almost every web application today needs and identity provider, which is a mechanism to authenticate users and enforce authorization rules. Combining “Sign in with Google” with Casbin you can add authentication and authorization to a Cloud Run service without using any identity provider solution (on premise or on cloud) and maintain a high level of security.

TL;DR

You can find the sample project of how to use ‘Sign in with Google’ together with Casbin here: github.com/gabihodoroaga/cloud-run-casbin

The solution

diagram

The solution is using 2 GCP resources:

  • Cloud SQL - PostgreSQL
  • Cloud Run

The Cloud Run service is a web application based on Gin Web Framework with 2 custom middleware components: the authentications middleware and the authorization middleware.

First, the gin server setup

package main

import (
	"net/http"

	"github.com/gin-gonic/contrib/static"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.Use(static.Serve("/", static.LocalFile("./public", true)))

	r.GET("/api/v1/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	r.Run()
}

To restrict access to the endpoints to only authenticated users a middleware must be added to the request pipeline.

// RequireAuthentication is a gin middleware that checks if the request has a valid
// jwt token issued by google
func RequireAuthentication() gin.HandlerFunc {
	clientID := config.GetConfig().ClientID
	return func(c *gin.Context) {
		logger := zap.L().With(zap.String("request_id", c.GetString("request_id")))
		authHeader := c.Request.Header.Get("Authorization")

		if authHeader == "" {
			logger.Sugar().Debugf("authorization header not found")
			c.Header("WWW-Authenticate", "Bearer realm=\"sign-in-test-app\", error=\"invalid_token\", error_description=\"Authorization header not found\"")
			c.AbortWithStatus(401)
			return
		}

		prefix := "Bearer "
		if !strings.HasPrefix(authHeader, prefix) {
			logger.Sugar().Debugf("bearer prefix not found in authorization header")
			c.Header("WWW-Authenticate", "Bearer realm=\"sign-in-test-app\", error=\"invalid_token\", error_description=\"Bearer prefix not found in authorization header\"")
			c.AbortWithStatus(401)
			return
		}

		token := authHeader[strings.Index(authHeader, prefix)+len(prefix):]
		if token == "" {
			logger.Sugar().Debugf("not a valid jwt token")
			c.Header("WWW-Authenticate", "Bearer realm=\"sign-in-test-app\",error=\"invalid_token\",error_description=\"Empty jwt token\"")
			c.AbortWithStatus(401)
			return
		}

		payload, err := idtoken.Validate(c.Request.Context(), token, clientID)
		if err != nil {
			logger.Sugar().Debugf("token validation error: %s", err.Error())
			c.Header("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"sign-in-test-app\",error=\"invalid_token\",error_description=\"%s\"", err.Error()))
			c.AbortWithStatus(401)
			return
		}

		// validate IssuedAt - this is not validate by the google package
		if payload.IssuedAt == 0 || payload.IssuedAt-30 > time.Now().Unix() {
			logger.Sugar().Debugf("token validation error: Token emitted in the future")
			c.Header("WWW-Authenticate", "Bearer realm=\"sign-in-test-app\",error=\"invalid_token\",error_description=\"Token emitted in the future\"")
			c.AbortWithStatus(401)
			return
		}
		// add the user to the context
		c.Set("user", payload.Claims["email"])
	}
}

This middleware will check if the Authorization header is present and if contains a valid jwt token emitted by Google. The clientID is also validated. You can follow the instructions from here Get your Google API client ID in order to get a Google Client ID. Also it will save the user email to the gin context.

Now that the identity is known and validated the next middleware should check if the user has the permission to access a specific path and method.

// RequireAuthorization is the gin middles that checks if the use had the 
// required permission to access the path and method
func RequireAuthorization() gin.HandlerFunc {
	return func(c *gin.Context) {

		logger := zap.L().With(zap.String("request_id", c.GetString("request_id")))

		sub := c.GetString("user")
		obj := strings.TrimPrefix(c.Request.URL.Path, basePath)
		act := c.Request.Method

		allow, err := enforcer.Enforce(sub, obj, act)
		logger.Sugar().Debugf("check casbin with args: sub=%s, obj=%s, act=%s", sub, obj, act)
		if err != nil {
			c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "error while calling casbin.Enforce"))
			return
		}

		if !allow {
			logger.Debug("enforcer failed with this reasons")
			c.AbortWithStatus(403)
			return
		}
	}
}

The Casbin enforcer used by this middleware was created as follows:

  • The policy configuration
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = (g(r.sub, p.sub) || p.sub == "any") && keyMatch2(r.obj, p.obj) && r.act == p.act 
  • The permission model
# routes for any authenticated user
p, any, /users/info, GET

# routes for admins only
p, admin, /users, GET
p, admin, /users/:userid/:role, POST
p, admin, /users/:userid/:role, DELETE

# routes for editors only
p, editor, /ping, POST
p, editor, /ping/:id, PUT
p, editor, /ping/:id, DELETE

# routes for viewers
p, viewer, /ping, GET

# admin is also editor and editor is also a viewer
g, admin, editor
g, editor, viewer
  • The users and the roles
// SetupAuth loads the casbin permission model
func SetupAuth() error {
	ctx := context.TODO()

	enf, err := casbin.NewEnforcer("auth/casbin_model.conf", "auth/casbin_permissions.csv")
	if err != nil {
		return errors.Wrap(err, "unable to create the casbin enforcer")
	}
	enforcer = enf

	zap.L().Info("auth.init: loaded casbin model and permission")

	err = reloadGroupPolicy(ctx)
	if err != nil {
		return errors.Wrap(err, "unable to read group policies")
	}

	err = setupNotificationsListener(ctx)
	if err != nil {
		return errors.Wrap(err, "unable to setup the policy update notifier")
	}

	zap.L().Info("auth.init: loaded users and roles")
	return nil
}

func reloadGroupPolicy(ctx context.Context) error {
	row := struct {
		email string
		role  string
	}{}
	query := "SELECT email, role FROM users_roles;"
	_, err := db.GetDB().QueryFunc(ctx, query, []interface{}{}, []interface{}{&row.email, &row.role},
		func(pgx.QueryFuncRow) error {
			_, err := enforcer.AddGroupingPolicy(row.email, row.role)
			return err
		},
	)
	return err
}

Now the enforcer contains the policy the permissions and all the user and role associations stored in the database.

But, this is not enough. In a distributed environment like “Google Cloud Run” where the number of running instances is unknown and when a new role is added or removed the other running instances will not be aware of the updates.

In order to fix this the LISTEN and NOTIFY commands from PostgreSQL can be used by all running instances to listen for updates regarding the user roles and to notify other instances when a update is handled.

The notification listener will create a new database connection and it will listen on any message received on the channel casbin_notify and it will update the enforcer by adding or removing user roles.

func setupNotificationsListener(ctx context.Context) error {
	conn, err := pgx.Connect(ctx, config.GetConfig().ConnString)
	if err != nil {
		return err
	}

	casbinNotifyChannel := "casbin_notify"
	_, err = conn.Exec(ctx, "LISTEN "+casbinNotifyChannel)

	if err != nil {
		return err
	}

	err = enforcer.SetWatcher(newPolicyUpdateWatcher(conn, casbinNotifyChannel))
	if err != nil {
		return err
	}

	go func() {
		for {
			zap.L().Sugar().Debugf("notificationsListener: waiting for notifications on channels %s", casbinNotifyChannel)
			notification, err := conn.WaitForNotification(ctx)
			if err != nil {
				if errors.Is(err, ctx.Err()) {
					zap.L().Info("notificationsListener: context done, exiting...")
					conn.Close(ctx)
					return
				}
				zap.L().Error("notificationsListener: error waiting for notification", zap.Error(err))
			}
			zap.L().Sugar().Debugf("notificationsListener: received notification on channel %s, pid %d, payload %s", notification.Channel, notification.PID, notification.Payload)

			params := strings.Split(notification.Payload, ",")
			if len(params) != 4 {
				zap.L().Sugar().Errorf("notificationsListener: invalid policy update notification received '%s', expected 4 values separated by comma", notification.Payload)
			}
			switch params[0] {
			case "add":
				enforcer.AddGroupingPolicy(params[1:])
			case "remove":
				enforcer.RemoveGroupingPolicy(params[1:])
			default:
				zap.L().Sugar().Errorf("notificationsListener: invalid policy update notification received '%s', first value must be one of 'add' or 'remove'", notification.Payload)
			}
		}
	}()

	return nil
}

In order to notify other instances that there was an update for the user role a custom Casbin watcher must be created

type policyUpdateWatcher struct {
	reload        func(string)
	notifyChannel string
}

// UpdateForAddPolicy is called when a new policy is added to the current
// enforcer instance
func (w *policyUpdateWatcher) UpdateForAddPolicy(sec, ptype string, params ...string) error {
	zap.L().Sugar().Debugf("received UpdateForAddPolicy: %s,%s,%v\n", sec, ptype, params)
	// only grouping policy is used for now
	if sec == "g" && ptype == "g" {
		_, err := db.GetDB().Exec(context.Background(),
			fmt.Sprintf("NOTIFY %s, '%s,%s'", w.notifyChannel, "add", strings.Join(params, ",")))
		if err != nil {
			zap.L().Error(fmt.Sprintf("error exec notify with params %v", params), zap.Error(err))
		}
	}
	return nil
}

// UpdateForRemovePolicy is called when a policy is removed from the current
// enforcer instance
func (w *policyUpdateWatcher) UpdateForRemovePolicy(sec, ptype string, params ...string) error {
	zap.L().Sugar().Debugf("received UpdateForRemovePolicy: %s,%s,%v\n", sec, ptype, params)
	if sec == "g" && ptype == "g" {
		_, err := db.GetDB().Exec(context.Background(),
			fmt.Sprintf("NOTIFY %s, '%s,%s'", w.notifyChannel, "remove", strings.Join(params, ",")))
		if err != nil {
			zap.L().Error(fmt.Sprintf("error exec notify with params %v", params), zap.Error(err))
		}
	}
	return nil
}

/* all other persist.WatcherEx interface methods must be implemented*/

Now, every time a new role is added or removed the change will be propagated to all other running instances.

The full source code and instruction on how run this on “Google Cloud Run” can be found here: github.com/gabihodoroaga/cloud-run-casbin.

Conclusion

This solution can hold up easily 100.000 users without any problem and after this limit there are ways to make it scalable to million of users. You don’t have to spend a lot of money on expensive and complicated identity solution to benefit from the highest security. Everyone has a google account.