From 2481974b3a819d9f2c6c34250f51fdd85400c669 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 13:37:05 +0000 Subject: [PATCH] feat: carry the token tag restriction on the session --- server/internal/api/tokens.go | 22 +++++++++++++++++----- server/internal/auth/middleware.go | 18 +++++++++++++++--- server/internal/auth/session.go | 9 +++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/internal/api/tokens.go b/server/internal/api/tokens.go index 527318f..4c10a37 100644 --- a/server/internal/api/tokens.go +++ b/server/internal/api/tokens.go @@ -74,8 +74,9 @@ func createToken(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` Role string `json:"role" binding:"required"` - Scopes []string `json:"scopes" binding:"required"` - ExpiresInDays *int `json:"expires_in_days"` + Scopes []string `json:"scopes" binding:"required"` + ExpiresInDays *int `json:"expires_in_days"` + TagSelector map[string]string `json:"tag_selector"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -108,9 +109,16 @@ func createToken(c *gin.Context) { } } + if !services.SelectorNarrowerOrEqual(body.TagSelector, auth.ServerScope(c)) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "a token cannot reach servers its creator cannot reach", + }) + return + } + tok, plaintext, err := services.CreateAPIToken( auth.InstanceID(c), auth.UserID(c), - body.Name, body.Role, body.Scopes, nil, body.ExpiresInDays, c.ClientIP(), + body.Name, body.Role, body.Scopes, body.TagSelector, body.ExpiresInDays, c.ClientIP(), ) switch { case errors.Is(err, services.ErrTokenNameTaken): @@ -137,8 +145,12 @@ func createToken(c *gin.Context) { if tok.ExpiresAt != nil { expiry = "expires " + tok.ExpiresAt.Format("2006-01-02") } - services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", - fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry)) + detail := fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", + tok.Name, tok.Role, tok.Scopes, expiry) + if len(tok.TagSelector) > 0 { + detail += fmt.Sprintf(", restricted to %v", tok.TagSelector) + } + services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", detail) // The plaintext is returned exactly once and is not stored anywhere. c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok}) diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 151e602..f428c82 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -167,9 +167,10 @@ func sessionFromToken(c *gin.Context) (*Session, bool) { Role: services.LowerRole(user.Role, tok.Role), Email: user.Email, Name: user.Email, - TokenID: tok.TokenID, - TokenName: tok.Name, - Scopes: tok.Scopes, + TokenID: tok.TokenID, + TokenName: tok.Name, + Scopes: tok.Scopes, + TokenScope: tok.TagSelector, }, true } @@ -199,3 +200,14 @@ func Scopes(c *gin.Context) []string { // IsToken reports whether this request authenticated with an API token rather // than a browser session. func IsToken(c *gin.Context) bool { return TokenID(c) != "" } + +// ServerScope is the tag restriction the acting credential carries, or nil for +// an unrestricted token and for every cookie session. Callers pass it to +// services.ServerInTokenScope or services.IntersectSelectors — nil means the +// whole fleet, never nothing. +func ServerScope(c *gin.Context) map[string]string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenScope + } + return nil +} diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 888f74d..8d24af1 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -23,13 +23,14 @@ type Session struct { Email string `json:"email"` Name string `json:"name"` - // The three fields below are set only when the request authenticated with + // The four fields below are set only when the request authenticated with // an API token. They are never persisted to Redis — a token authenticates // per request and mints no session, so a revoked token stops working // immediately rather than at the end of a session TTL. - TokenID string `json:"-"` - TokenName string `json:"-"` - Scopes []string `json:"-"` + TokenID string `json:"-"` + TokenName string `json:"-"` + Scopes []string `json:"-"` + TokenScope map[string]string `json:"-"` } var rdb *redis.Client