feat: allow an API token to be restricted to servers by tag
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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