diff --git a/server/internal/api/tokens.go b/server/internal/api/tokens.go index 753029a..527318f 100644 --- a/server/internal/api/tokens.go +++ b/server/internal/api/tokens.go @@ -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): diff --git a/server/internal/models/api_token.go b/server/internal/models/api_token.go index 996a7d8..c9a344e 100644 --- a/server/internal/models/api_token.go +++ b/server/internal/models/api_token.go @@ -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"` diff --git a/server/internal/services/tokens.go b/server/internal/services/tokens.go index e26309e..7d91cd7 100644 --- a/server/internal/services/tokens.go +++ b/server/internal/services/tokens.go @@ -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, diff --git a/server/internal/services/tokenscope.go b/server/internal/services/tokenscope.go new file mode 100644 index 0000000..1a86b64 --- /dev/null +++ b/server/internal/services/tokenscope.go @@ -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 +} diff --git a/server/internal/services/tokenscope_test.go b/server/internal/services/tokenscope_test.go new file mode 100644 index 0000000..8ba3f39 --- /dev/null +++ b/server/internal/services/tokenscope_test.go @@ -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") + } +}