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
+1 -1
View File
@@ -110,7 +110,7 @@ func createToken(c *gin.Context) {
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
body.Name, body.Role, body.Scopes, nil, body.ExpiresInDays, c.ClientIP(),
)
switch {
case errors.Is(err, services.ErrTokenNameTaken):
+12 -3
View File
@@ -14,9 +14,10 @@ import (
// full-entropy random rather than a chosen password, and a per-token salt would
// force a collection scan where an indexed lookup is wanted.
//
// Role and Scopes are immutable after creation. There is no update endpoint:
// editing what a credential already deployed in CI can do, with no record of
// what it could do before, is worse than requiring a rotation.
// Role, Scopes and TagSelector are immutable after creation. There is no
// update endpoint: editing what a credential already deployed in CI can do,
// with no record of what it could do before, is worse than requiring a
// rotation.
type APIToken struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
TokenID string `bson:"token_id" json:"token_id"`
@@ -33,6 +34,14 @@ type APIToken struct {
Role string `bson:"role" json:"role"`
Scopes []string `bson:"scopes" json:"scopes"`
// TagSelector restricts this token to servers carrying every tag in the
// map. Empty or nil means the whole fleet.
//
// Immutable after creation for the same reason as Role and Scopes: changing
// what a credential already deployed in CI can reach, with no record of what
// it could reach before, is worse than requiring a rotation.
TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"`
// ExpiresAt nil means the token never expires. Whether that is allowed is
// a per-instance policy, settings.api_token_max_days.
ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
+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")
}
}