feat: carry the token tag restriction on the session

This commit is contained in:
2026-09-08 13:37:05 +00:00
parent f9df426e6c
commit 2481974b3a
3 changed files with 37 additions and 12 deletions
+17 -5
View File
@@ -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})
+15 -3
View File
@@ -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
}
+5 -4
View File
@@ -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