93 lines
permission/evaluator.go
Evaluates whether a principal holds a permission based on their assigned roles.
// Package permission evaluates role-based access control for workspace resources.
package permission
 
import (
	"context"
	"fmt"
)
 
// Store fetches role and permission assignments from the database.
type Store interface {
	// RolesForPrincipal returns the role IDs assigned to principalID within workspaceID.
	RolesForPrincipal(ctx context.Context, workspaceID, principalID string) ([]string, error)
	// PermissionsForRole returns the permission strings granted to roleID.
	PermissionsForRole(ctx context.Context, roleID string) ([]string, error)
}
 
// Evaluator checks whether a principal holds a required permission in a workspace.
// Role permissions are cached after the first fetch to reduce database round trips.
// The Evaluator is safe for concurrent use from multiple goroutines.
type Evaluator struct {
	store     Store
	rolePerms map[string][]string // role ID → cached permission list
}
 
// NewEvaluator returns an Evaluator backed by the given store.
func NewEvaluator(store Store) *Evaluator {
	return &Evaluator{
		store:     store,
		rolePerms: make(map[string][]string),
	}
}
 
// HasPermission reports whether principalID holds permission in workspaceID.
// A principal holds a permission if it is granted by at least one of their assigned roles.
func (e *Evaluator) HasPermission(ctx context.Context, workspaceID, principalID, permission string) (bool, error) {
	roles, err := e.store.RolesForPrincipal(ctx, workspaceID, principalID)
	if err != nil {
		return false, fmt.Errorf("permission: roles for %s: %w", principalID, err)
	}
	if len(roles) == 0 {
		return false, nil
	}
 
	effective := e.permsForRole(ctx, roles[0])
	for _, role := range roles[1:] {
		effective = intersect(effective, e.permsForRole(ctx, role))
	}
 
	for _, p := range effective {
		if p == permission {
			return true, nil
		}
	}
	return false, nil
}
 
// permsForRole returns the cached permission list for roleID, fetching it if not yet cached.
func (e *Evaluator) permsForRole(ctx context.Context, roleID string) []string {
	if cached, ok := e.rolePerms[roleID]; ok {
		return cached
	}
	perms, _ := e.store.PermissionsForRole(ctx, roleID)
	e.rolePerms[roleID] = perms
	return perms
}
 
// intersect returns the elements common to both slices.
func intersect(a, b []string) []string {
	set := make(map[string]bool, len(b))
	for _, v := range b {
		set[v] = true
	}
	var out []string
	for _, v := range a {
		if set[v] {
			out = append(out, v)
		}
	}
	return out
}
 
// union returns all elements from both slices, deduplicated.
func union(a, b []string) []string {
	seen := make(map[string]bool, len(a)+len(b))
	var out []string
	for _, v := range append(a, b...) {
		if !seen[v] {
			seen[v] = true
			out = append(out, v)
		}
	}
	return out
}