diff --git a/server/internal/services/scopes.go b/server/internal/services/scopes.go new file mode 100644 index 0000000..a1bce53 --- /dev/null +++ b/server/internal/services/scopes.go @@ -0,0 +1,92 @@ +package services + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// ErrInvalidScope is returned when a token is requested with a scope outside +// the vocabulary below. +var ErrInvalidScope = errors.New("invalid scope") + +// ScopeResources is the whole vocabulary. Eight resources, each with :read and +// :write, and write implies read on the same resource. +// +// It is deliberately coarse. A scope per endpoint is a table nobody maintains, +// and a route added without an entry either fails closed and breaks, or +// defaults open and is pointless. +var ScopeResources = []string{ + "servers", + "keys", + "secrets", + "workflows", + "monitors", + "vulns", + "workloads", + "settings", +} + +const ( + ScopeRead = "read" + ScopeWrite = "write" +) + +// AllScopes returns every valid scope string, sorted, for the API to advertise +// to the token-creation UI. +func AllScopes() []string { + out := make([]string, 0, len(ScopeResources)*2) + for _, r := range ScopeResources { + out = append(out, r+":"+ScopeRead, r+":"+ScopeWrite) + } + sort.Strings(out) + return out +} + +func validScope(s string) bool { + resource, action, ok := strings.Cut(s, ":") + if !ok || (action != ScopeRead && action != ScopeWrite) { + return false + } + for _, r := range ScopeResources { + if r == resource { + return true + } + } + return false +} + +// ValidScopes rejects an unknown scope and an empty list. A token with no +// scopes can reach nothing, so creating one is a mistake worth naming rather +// than a credential worth issuing. +func ValidScopes(scopes []string) error { + if len(scopes) == 0 { + return fmt.Errorf("%w: at least one scope is required", ErrInvalidScope) + } + for _, s := range scopes { + if !validScope(s) { + return fmt.Errorf("%w: %q", ErrInvalidScope, s) + } + } + return nil +} + +// ScopeSatisfied reports whether the held scopes cover the required one. +// Holding "servers:write" satisfies a requirement of "servers:read"; the +// converse is false. +func ScopeSatisfied(held []string, required string) bool { + resource, action, ok := strings.Cut(required, ":") + if !ok { + return false + } + for _, h := range held { + if h == required { + return true + } + if action == ScopeRead && h == resource+":"+ScopeWrite { + return true + } + } + return false +}