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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user