feat: Define the API token scope vocabulary

Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
This commit is contained in:
2026-08-12 14:19:47 +00:00
parent 6ad65a1242
commit 1b718e7c59
+92
View File
@@ -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
}