cmd/tsidp: add groups claim to tsidp (#15127)

* cmd/tsidp: add groups claim to tsidp

This feature adds support for a `groups` claim in tsidp using the grants
syntax:

```json
{
  "grants": [
    {
      "src": ["group:admins"],
      "dst": ["*"],
      "ip":  ["*"],
      "app": {
        "tailscale.com/cap/tsidp": [
          {
            "groups": ["admin"]
          }
        ]
      }
    },
    {
      "src": ["group:reader"],
      "dst": ["*"],
      "ip":  ["*"],
      "app": {
        "tailscale.com/cap/tsidp": [
          {
            "groups": ["reader"]
          }
        ]
      }
    }
  ]
}
```

For #10263

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>

* cmd/tsidp: refactor cap/tsidp to allow extraClaims

This commit refactors the `capRule` struct to allow specifying arbitrary
extra claims:

```json
{
  "src": ["group:reader"],
  "dst": ["*"],
  "ip":  ["*"],
  "app": {
    "tailscale.com/cap/tsidp": [
      {
        "extraClaims": {
          "groups": ["reader"],
          "entitlements": ["read-stuff"],
        },
      }
    ]
  }
}
```

Overwriting pre-existing claims cannot be modified/overwritten.

Also adding more unit-testing

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>

* Update cmd/tsidp/tsidp.go

Signed-off-by: cedi <cedi@users.noreply.github.com>

* Update cmd/tsidp/tsidp_test.go

Co-authored-by: Patrick O'Doherty <hello@patrickod.com>
Signed-off-by: Cedric Kienzler <cedi@users.noreply.github.com>

* Update cmd/tsidp/tsidp_test.go

Co-authored-by: Patrick O'Doherty <hello@patrickod.com>
Signed-off-by: Cedric Kienzler <cedi@users.noreply.github.com>

* Fix logical error in test case

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>

* fix error printing for failed to unmarshal capability in tsidp

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>

* clarify doc string for withExtraClaims

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>

---------

Signed-off-by: Cedric Kienzler <github@cedric-kienzler.de>
Signed-off-by: cedi <cedi@users.noreply.github.com>
Signed-off-by: Cedric Kienzler <cedi@users.noreply.github.com>
Co-authored-by: Patrick O'Doherty <hello@patrickod.com>
This commit is contained in:
Cedric Kienzler
2025-04-18 02:31:40 +02:00
committed by GitHub
parent 3a8a174308
commit b34a2bdb22
3 changed files with 1002 additions and 2 deletions
+172 -2
View File
@@ -498,6 +498,7 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tsidp: tagged nodes not supported", http.StatusBadRequest)
return
}
ui.Sub = ar.remoteUser.Node.User.String()
ui.Name = ar.remoteUser.UserProfile.DisplayName
ui.Email = ar.remoteUser.UserProfile.LoginName
@@ -506,8 +507,29 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) {
// TODO(maisem): not sure if this is the right thing to do
ui.UserName, _, _ = strings.Cut(ar.remoteUser.UserProfile.LoginName, "@")
rules, err := tailcfg.UnmarshalCapJSON[capRule](ar.remoteUser.CapMap, tailcfg.PeerCapabilityTsIDP)
if err != nil {
http.Error(w, "tsidp: failed to unmarshal capability: %v", http.StatusBadRequest)
return
}
// Only keep rules where IncludeInUserInfo is true
var filtered []capRule
for _, r := range rules {
if r.IncludeInUserInfo {
filtered = append(filtered, r)
}
}
userInfo, err := withExtraClaims(ui, filtered)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Write the final result
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(ui); err != nil {
if err := json.NewEncoder(w).Encode(userInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
@@ -520,6 +542,140 @@ type userInfo struct {
UserName string `json:"username"`
}
type capRule struct {
IncludeInUserInfo bool `json:"includeInUserInfo"`
ExtraClaims map[string]interface{} `json:"extraClaims,omitempty"` // list of features peer is allowed to edit
}
// flattenExtraClaims merges all ExtraClaims from a slice of capRule into a single map.
// It deduplicates values for each claim and preserves the original input type:
// scalar values remain scalars, and slices are returned as deduplicated []interface{} slices.
func flattenExtraClaims(rules []capRule) map[string]interface{} {
// sets stores deduplicated stringified values for each claim key.
sets := make(map[string]map[string]struct{})
// isSlice tracks whether each claim was originally provided as a slice.
isSlice := make(map[string]bool)
for _, rule := range rules {
for claim, raw := range rule.ExtraClaims {
// Track whether the claim was provided as a slice
switch raw.(type) {
case []string, []interface{}:
isSlice[claim] = true
default:
// Only mark as scalar if this is the first time we've seen this claim
if _, seen := isSlice[claim]; !seen {
isSlice[claim] = false
}
}
// Add the claim value(s) into the deduplication set
addClaimValue(sets, claim, raw)
}
}
// Build final result: either scalar or slice depending on original type
result := make(map[string]interface{})
for claim, valSet := range sets {
if isSlice[claim] {
// Claim was provided as a slice: output as []interface{}
var vals []interface{}
for val := range valSet {
vals = append(vals, val)
}
result[claim] = vals
} else {
// Claim was a scalar: return a single value
for val := range valSet {
result[claim] = val
break // only one value is expected
}
}
}
return result
}
// addClaimValue adds a claim value to the deduplication set for a given claim key.
// It accepts scalars (string, int, float64), slices of strings or interfaces,
// and recursively handles nested slices. Unsupported types are ignored with a log message.
func addClaimValue(sets map[string]map[string]struct{}, claim string, val interface{}) {
switch v := val.(type) {
case string, float64, int, int64:
// Ensure the claim set is initialized
if sets[claim] == nil {
sets[claim] = make(map[string]struct{})
}
// Add the stringified scalar to the set
sets[claim][fmt.Sprintf("%v", v)] = struct{}{}
case []string:
// Ensure the claim set is initialized
if sets[claim] == nil {
sets[claim] = make(map[string]struct{})
}
// Add each string value to the set
for _, s := range v {
sets[claim][s] = struct{}{}
}
case []interface{}:
// Recursively handle each item in the slice
for _, item := range v {
addClaimValue(sets, claim, item)
}
default:
// Log unsupported types for visibility and debugging
log.Printf("Unsupported claim type for %q: %#v (type %T)", claim, val, val)
}
}
// withExtraClaims merges flattened extra claims from a list of capRule into the provided struct v,
// returning a map[string]interface{} that combines both sources.
//
// v is any struct whose fields represent static claims; it is first marshaled to JSON, then unmarshalled into a generic map.
// rules is a slice of capRule objects that may define additional (extra) claims to merge.
//
// These extra claims are flattened and merged into the base map unless they conflict with protected claims.
// Claims defined in openIDSupportedClaims are considered protected and cannot be overwritten.
// If an extra claim attempts to overwrite a protected claim, an error is returned.
//
// Returns the merged claims map or an error if any protected claim is violated or JSON (un)marshaling fails.
func withExtraClaims(v any, rules []capRule) (map[string]interface{}, error) {
// Marshal the static struct
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
// Unmarshal into a generic map
var claimMap map[string]interface{}
if err := json.Unmarshal(data, &claimMap); err != nil {
return nil, err
}
// Convert views.Slice to a map[string]struct{} for efficient lookup
protected := make(map[string]struct{}, len(openIDSupportedClaims.AsSlice()))
for _, claim := range openIDSupportedClaims.AsSlice() {
protected[claim] = struct{}{}
}
// Merge extra claims
extra := flattenExtraClaims(rules)
for k, v := range extra {
if _, isProtected := protected[k]; isProtected {
log.Printf("Skip overwriting of existing claim %q", k)
return nil, fmt.Errorf("extra claim %q overwriting existing claim", k)
}
claimMap[k] = v
}
return claimMap, nil
}
func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
@@ -596,8 +752,22 @@ func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) {
tsClaims.Issuer = s.loopbackURL
}
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, tailcfg.PeerCapabilityTsIDP)
if err != nil {
log.Printf("tsidp: failed to unmarshal capability: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tsClaimsWithExtra, err := withExtraClaims(tsClaims, rules)
if err != nil {
log.Printf("tsidp: failed to merge extra claims: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Create an OIDC token using this issuer's signer.
token, err := jwt.Signed(signer).Claims(tsClaims).CompactSerialize()
token, err := jwt.Signed(signer).Claims(tsClaimsWithExtra).CompactSerialize()
if err != nil {
log.Printf("Error getting token: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)