feat: allow an API token to be restricted to servers by tag

This commit is contained in:
2026-09-08 13:34:58 +00:00
parent e8e41f197a
commit f9df426e6c
5 changed files with 164 additions and 5 deletions
+7 -1
View File
@@ -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,
+54
View File
@@ -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")
}
}