One of the things I often struggle with in my go applications is authorization. Lots of the tools out there are complex or made for use-cases much larger than my own. In a recent project I came up with a very simple approach.
TLDR; Checkout a generic form here: https://github.com/ryanfaerman/can
I'll show you what I came up with first and then, a slightly more generic version.
Let's define a couple interfaces and a model:
// Canner allows a resource to define if an account can perform
// a given action.
type Canner interface {
Can(account *Account, action string) error
}
// Verber is used to allow a resource to define the actions
// it supports.
type Verber interface {
Verbs() []string
}
type Account {
ID int
Name string
Anonymous bool
// ... use your actual model
}
var AnonymousAccount = Account{ID: -1, Name: "Anon", Anonymous: true}
And while we're at it, a basic container type:
// Policy is a map of actions to functions that
// return an error if the action cannot be performed for the
// given account.
type Policy map[string]func(*Account) error
// Verbs implements the Verber interface.
func (p Policy) Verbs() []string {
var verbs []string
for verb := range p {
verbs = append(verbs, verb)
}
return verbs
}
// Can implements the Canner interface.
func (p Policy) Can(account *Account, action string) error {
if fn, ok := p[action]; ok {
return fn(account)
}
return nil
}
// An example default policy.
var DefaultPolicy = Policy{
// Anonymous users aren't allowed to view anything
"view": func(account *Account) error {
if account.Anonymous {
return errors.New("must not be anonymous")
}
},
// Editing is disabled for everyone
"edit": func(account *Account) error {
return errors.New("editing is forbidden")
},
}
And, let's also create an example subject that we want to act on:
type BlogPost struct {
Title string
Body string
Owner *Account
}
func (b *BlogPost) Verbs() []string {
return []string{"view", "edit", "promote", "delete"}
}
func (b *BlogPost) Can(account *Account, action string) error {
switch action {
case "view":
// anyone can view
return nil
case "edit"
if b.Owner.ID != account.ID {
return errors.New("cannot edit someone elses blogpost")
}
return nil
// case ...
}
return nil
}
Now with those few things, we can build out the actual function that makes this idea work:
func Can(account *Account, action string, resources ...any) error {
if account == nil {
account = AnonymousAccount
}
if len(resources) == 0 {
resources = append(resources, DefaultPolicy)
}
for _, resource := range resources {
if r, ok := resource.(Verber); ok {
if !slices.Contains(r.Verbs(), action) {
return ErrInvalidAction
}
}
if r, ok := resource.(Canner); ok {
if err := r.Can(account, action); err != nil {
return errors.Join(ErrNotAuthorized, err)
}
}
}
return nil
}
// Some sugar!
func Cannot(account *Account, action string, resources ...any) error {
!Can(account, action, resources...)
}
Now, scattered in our codebase we can do things like:
// ..
b := LoadBlogPost()
if Cannot(someAccount, "edit", b) {
//... do something
}
Granted, this is partially contrived. In my application I'm using an actual model, not just a random struct, but most of it is pretty similar to what I have.
Two more fun things before we wrap this up.
If you're using templ, like I am, I made a couple helpers.
func CurrentAccount(ctx context.Context) *models.Account {
// Get the current user from the account
// Or return an AnonymousUser
}
func UserCan(ctx context.Context, action string, resources ...any) bool {
if err := services.Authorization.Can(CurrentAccount(ctx), action, resources...); err != nil {
return false
}
return true
}
templ Can(action string, resources ...any) {
if UserCan(ctx, action, resources...) {
{ children... }
}
}
templ Cannot(action string, resources ...any) {
if !UserCan(ctx, action, resources...) {
{ children... }
}
}
// in some templ file:
@Can("edit", theBlogPost) {
<h1>You can Edit this!</h1>
}
@Cannot("edit", theBlogPost) {
<h1>Sorry bud! You cannot do that</h1>
}
I've split up the UserCan
from Can
and Cannot
because doing assignments and error checking is a bit of a pain in templ.
If you want to see this in a less of a contrived fashion, you can view it in action in my application: https://github.com/ryanfaerman/netctl/blob/master/internal/services/authorization.go
I've also made a simple package with a generic form here: https://github.com/ryanfaerman/can