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:
+172
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user