feat: allow an API token to be restricted to servers by tag
This commit is contained in:
@@ -59,7 +59,7 @@ func LowerRole(a, b string) string {
|
||||
|
||||
// CreateAPIToken mints a token and returns the document plus the plaintext.
|
||||
// The plaintext is the only copy: it is returned once and never stored.
|
||||
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
|
||||
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > tokenNameMax {
|
||||
return nil, "", fmt.Errorf("%w: token name must be 1 to %d characters", ErrTokenInvalid, tokenNameMax)
|
||||
@@ -70,6 +70,11 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
|
||||
if err := ValidScopes(scopes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(tagSelector) > 0 {
|
||||
if err := ValidateTags(tagSelector); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
owner, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
@@ -131,6 +136,7 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
|
||||
TokenHash: HashToken(plaintext),
|
||||
Role: role,
|
||||
Scopes: scopes,
|
||||
TagSelector: tagSelector,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedByIP: ip,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
|
||||
// ServerInTokenScope reports whether a credential restricted to sel may see
|
||||
// this server.
|
||||
//
|
||||
// This is deliberately NOT MatchesTags. That function serves workflow
|
||||
// targeting, where an empty selector selects nothing because the caller named
|
||||
// servers by ID instead. Here an empty selector means the token is
|
||||
// unrestricted, so it must select everything. The two rules are opposite and
|
||||
// sharing one function would silently lock every unrestricted token out of the
|
||||
// whole fleet.
|
||||
func ServerInTokenScope(srv models.Server, sel map[string]string) bool {
|
||||
if len(sel) == 0 {
|
||||
return true
|
||||
}
|
||||
for k, v := range sel {
|
||||
if srv.Tags[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IntersectSelectors merges the caller's token restriction with a selector the
|
||||
// request asked for. ok is false when the two can never both hold, which means
|
||||
// the request resolves to no servers rather than to an error.
|
||||
func IntersectSelectors(caller, requested map[string]string) (map[string]string, bool) {
|
||||
out := make(map[string]string, len(caller)+len(requested))
|
||||
for k, v := range caller {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range requested {
|
||||
if existing, ok := out[k]; ok && existing != v {
|
||||
return nil, false
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// SelectorNarrowerOrEqual reports whether child reaches no further than parent.
|
||||
//
|
||||
// It is the tag equivalent of the rule ScopeSatisfied already enforces for
|
||||
// scopes: a credential may only mint one no more powerful than itself.
|
||||
func SelectorNarrowerOrEqual(child, parent map[string]string) bool {
|
||||
for k, v := range parent {
|
||||
if child[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func srv(tags map[string]string) models.Server {
|
||||
return models.Server{ServerID: "s1", Tags: tags}
|
||||
}
|
||||
|
||||
// The critical asymmetry with MatchesTags: an EMPTY token selector means the
|
||||
// whole fleet, where an empty workflow selector matches nothing. Reusing
|
||||
// MatchesTags here would lock every unrestricted token out of everything.
|
||||
func TestServerInTokenScopeEmptySelectorAllowsAll(t *testing.T) {
|
||||
if !ServerInTokenScope(srv(nil), nil) {
|
||||
t.Error("nil selector rejected a server, want whole-fleet access")
|
||||
}
|
||||
if !ServerInTokenScope(srv(map[string]string{"env": "prod"}), map[string]string{}) {
|
||||
t.Error("empty selector rejected a server, want whole-fleet access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerInTokenScopeRequiresEveryTag(t *testing.T) {
|
||||
s := srv(map[string]string{"env": "staging", "team": "core"})
|
||||
|
||||
if !ServerInTokenScope(s, map[string]string{"env": "staging"}) {
|
||||
t.Error("matching selector rejected")
|
||||
}
|
||||
if !ServerInTokenScope(s, map[string]string{"env": "staging", "team": "core"}) {
|
||||
t.Error("fully matching selector rejected")
|
||||
}
|
||||
if ServerInTokenScope(s, map[string]string{"env": "prod"}) {
|
||||
t.Error("non-matching selector accepted")
|
||||
}
|
||||
if ServerInTokenScope(s, map[string]string{"env": "staging", "team": "web"}) {
|
||||
t.Error("partially matching selector accepted, every tag must match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntersectSelectors(t *testing.T) {
|
||||
// No token restriction: the request's own selector stands.
|
||||
got, ok := IntersectSelectors(nil, map[string]string{"env": "prod"})
|
||||
if !ok || got["env"] != "prod" || len(got) != 1 {
|
||||
t.Errorf("IntersectSelectors(nil, env=prod) = %v, %v", got, ok)
|
||||
}
|
||||
|
||||
// Disjoint values for the same key can never both hold.
|
||||
if _, ok := IntersectSelectors(
|
||||
map[string]string{"env": "staging"},
|
||||
map[string]string{"env": "prod"},
|
||||
); ok {
|
||||
t.Error("conflicting selectors intersected to something, want impossible")
|
||||
}
|
||||
|
||||
// Different keys combine.
|
||||
got, ok = IntersectSelectors(
|
||||
map[string]string{"env": "staging"},
|
||||
map[string]string{"team": "core"},
|
||||
)
|
||||
if !ok || got["env"] != "staging" || got["team"] != "core" {
|
||||
t.Errorf("IntersectSelectors = %v, %v, want both keys", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorNarrowerOrEqual(t *testing.T) {
|
||||
parent := map[string]string{"env": "staging"}
|
||||
|
||||
// Same selector, and a stricter one, are both allowed.
|
||||
if !SelectorNarrowerOrEqual(parent, parent) {
|
||||
t.Error("identical selector rejected")
|
||||
}
|
||||
if !SelectorNarrowerOrEqual(map[string]string{"env": "staging", "team": "core"}, parent) {
|
||||
t.Error("stricter selector rejected")
|
||||
}
|
||||
|
||||
// A token may not mint one that reaches further than itself.
|
||||
if SelectorNarrowerOrEqual(nil, parent) {
|
||||
t.Error("unrestricted child of a restricted parent allowed")
|
||||
}
|
||||
if SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, parent) {
|
||||
t.Error("child escaping the parent's tag allowed")
|
||||
}
|
||||
|
||||
// An unrestricted parent permits anything.
|
||||
if !SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, nil) {
|
||||
t.Error("restricted child of an unrestricted parent rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user