Skip to content

Commit 84c443c

Browse files
authored
Merge pull request #176 from ntnn/cluster-groups
Add system:cluster:... groups to effective users
2 parents 88e9595 + 74a7d5a commit 84c443c

File tree

3 files changed

+179
-19
lines changed

3 files changed

+179
-19
lines changed

pkg/registry/rbac/validation/kcp.go

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package validation
33
import (
44
"context"
55
"fmt"
6+
"slices"
67
"strings"
78

89
"github.com/kcp-dev/logicalcluster/v3"
@@ -32,6 +33,10 @@ const (
3233
// are and'ed.
3334
ScopeExtraKey = "authentication.kcp.io/scopes"
3435

36+
// ClusterExtraKey is the key used in a user's "extra" to specify
37+
// the origin cluster of the user.
38+
ClusterExtraKey = "authentication.kcp.io/cluster-name"
39+
3540
// ClusterPrefix is the prefix for cluster scopes.
3641
clusterPrefix = "cluster:"
3742
)
@@ -79,6 +84,35 @@ func withScopesAndWarrants(appliesToUser appliesToUserFunc) appliesToUserFuncCtx
7984
}
8085
}
8186

87+
// EffectiveScopes takes the scopes of a user and returns the
88+
// intersection of all scopes.
89+
func EffectiveScopes(in []string) []string {
90+
if len(in) == 0 {
91+
return in
92+
}
93+
94+
effectiveScopes := strings.Split(in[0], ",")
95+
if len(effectiveScopes) == 0 || len(in) == 1 {
96+
return effectiveScopes
97+
}
98+
99+
for _, further := range in[1:] {
100+
furtherScopes := strings.Split(further, ",")
101+
newEffective := make([]string, 0, len(effectiveScopes))
102+
for _, es := range effectiveScopes {
103+
if slices.Contains(furtherScopes, es) {
104+
newEffective = append(newEffective, es)
105+
}
106+
}
107+
effectiveScopes = newEffective
108+
if len(effectiveScopes) == 0 {
109+
return []string{}
110+
}
111+
}
112+
113+
return effectiveScopes
114+
}
115+
82116
var (
83117
authenticated = &user.DefaultInfo{Name: user.Anonymous, Groups: []string{user.AllAuthenticated}}
84118
unauthenticated = &user.DefaultInfo{Name: user.Anonymous, Groups: []string{user.AllUnauthenticated}}
@@ -96,6 +130,10 @@ func EffectiveUsers(clusterName logicalcluster.Name, u user.Info) []user.Info {
96130
recursive = func(u user.Info) {
97131
if IsInScope(u, clusterName) {
98132
ret = append(ret, u)
133+
// TODO(ntnn): Add system:cluster:<cluster> group?
134+
// Though the user is in scope so it shouldn't need the
135+
// group added but it might be better for consistency?
136+
// Leaving it out for now but could be revisited.
99137
} else {
100138
found := false
101139
for _, g := range u.GetGroups() {
@@ -119,17 +157,29 @@ func EffectiveUsers(clusterName logicalcluster.Name, u user.Info) []user.Info {
119157
if g == user.AllAuthenticated {
120158
rewritten.Groups = append(rewritten.Groups, user.AllAuthenticated)
121159
}
122-
// system:cluster:* groups are used to allow
123-
// controlling binding APIExports between workspaces
124-
// via RBAC
125-
if strings.HasPrefix(g, "system:cluster:") {
126-
rewritten.Groups = append(rewritten.Groups, g)
160+
}
161+
// Add the system:cluster:<cluster> group if the SA has
162+
// an effective scope for the cluster the effective
163+
// users are calculated for.
164+
for _, g := range EffectiveScopes(u.GetExtra()[ScopeExtraKey]) {
165+
if strings.HasPrefix(g, clusterPrefix) {
166+
rewritten.Groups = append(rewritten.Groups, "system:"+g)
127167
}
128168
}
129169
ret = append(ret, rewritten)
130170
}
131171
}
132172

173+
if len(u.GetExtra()[WarrantExtraKey]) > 0 && len(u.GetExtra()[ClusterExtraKey]) > 0 && !IsServiceAccount(u) {
174+
// Users that are not SAs and have a cluster extra key
175+
// cannot have warrants, as they would be coming from
176+
// per-workspace authentication.
177+
// If this happens it is either a misconfiguration from the
178+
// admin or a malicious actor that tries to escalate
179+
// privileges through warrants.
180+
return
181+
}
182+
133183
for _, v := range u.GetExtra()[WarrantExtraKey] {
134184
var w Warrant
135185
if err := json.Unmarshal([]byte(v), &w); err != nil {
@@ -143,9 +193,17 @@ func EffectiveUsers(clusterName logicalcluster.Name, u user.Info) []user.Info {
143193
Extra: w.Extra,
144194
}
145195
if IsServiceAccount(wu) && len(w.Extra[authserviceaccount.ClusterNameKey]) == 0 {
146-
// warrants must be scoped to a cluster
196+
// For SAs warrants must be scoped to a cluster.
147197
continue
148198
}
199+
// Add the system:cluster:<cluster> group if the warrant has
200+
// an effective scope for the cluster the effective
201+
// users are calculated for.
202+
for _, g := range EffectiveScopes(wu.GetExtra()[ScopeExtraKey]) {
203+
if strings.HasPrefix(g, clusterPrefix) {
204+
wu.Groups = append(wu.Groups, "system:"+g)
205+
}
206+
}
149207
recursive(wu)
150208
}
151209
}
@@ -156,11 +214,9 @@ func EffectiveUsers(clusterName logicalcluster.Name, u user.Info) []user.Info {
156214
Name: user.Anonymous,
157215
Groups: []string{user.AllAuthenticated},
158216
}
159-
for _, g := range u.GetGroups() {
160-
// system:cluster:* groups are used to allow controlling
161-
// binding APIExports between workspaces via RBAC
162-
if strings.HasPrefix(g, "system:cluster:") {
163-
authed.Groups = append(authed.Groups, g)
217+
for _, g := range EffectiveScopes(u.GetExtra()[ScopeExtraKey]) {
218+
if strings.HasPrefix(g, clusterPrefix) {
219+
authed.Groups = append(authed.Groups, "system:"+g)
164220
}
165221
}
166222
ret = append(ret, authed)

pkg/registry/rbac/validation/kcp_test.go

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,30 @@ func TestAppliesToUserWithWarrantsAndScopes(t *testing.T) {
176176
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
177177
want: true,
178178
},
179+
{
180+
name: "simple matching user with warrants and from this cluster",
181+
user: &user.DefaultInfo{Name: "user-a", Extra: map[string][]string{
182+
WarrantExtraKey: {`{"user":"user-b"}`},
183+
ClusterExtraKey: {"this"},
184+
}},
185+
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
186+
want: true, // user is subject
187+
},
179188
{
180189
name: "simple non-matching user with matching warrants",
181190
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-a"}`}}},
182191
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
183192
want: true,
184193
},
194+
{
195+
name: "simple non-matching user with matching warrants but with cluster-name",
196+
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{
197+
WarrantExtraKey: {`{"user":"user-a"}`},
198+
ClusterExtraKey: {"this"},
199+
}},
200+
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
201+
want: false, // Warrants are ineffective on users with cluster
202+
},
185203
{
186204
name: "simple non-matching user with non-matching warrants",
187205
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b"}`}}},
@@ -194,6 +212,15 @@ func TestAppliesToUserWithWarrantsAndScopes(t *testing.T) {
194212
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
195213
want: true,
196214
},
215+
{
216+
name: "simple non-matching user with multiple warrants and cluster-name",
217+
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{
218+
WarrantExtraKey: {`{"user":"user-b"}`, `{"user":"user-a"}`, `{"user":"user-c"}`},
219+
ClusterExtraKey: {"this"},
220+
}},
221+
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
222+
want: false, // Warrants are ineffective on users with cluster
223+
},
197224
{
198225
name: "simple non-matching user with nested warrants",
199226
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b","extra":{"authorization.kcp.io/warrant":["{\"user\":\"user-a\"}"]}}`}}},
@@ -210,13 +237,13 @@ func TestAppliesToUserWithWarrantsAndScopes(t *testing.T) {
210237
},
211238
{
212239
name: "non-cluster-aware service account with this scope",
213-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:this"}}},
240+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{ScopeExtraKey: {"cluster:this"}}},
214241
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
215242
want: true,
216243
},
217244
{
218245
name: "non-cluster-aware service account with other scope",
219-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:other"}}},
246+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{ScopeExtraKey: {"cluster:other"}}},
220247
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
221248
want: false,
222249
},
@@ -230,37 +257,46 @@ func TestAppliesToUserWithWarrantsAndScopes(t *testing.T) {
230257
// service accounts with cluster
231258
{
232259
name: "local service account",
233-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"this"}}},
260+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{ClusterExtraKey: {"this"}}},
234261
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
235262
want: true,
236263
},
237264
{
238265
name: "foreign service account",
239-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"other"}}},
266+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{ClusterExtraKey: {"other"}}},
240267
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
241268
want: false,
242269
},
243270
{
244271
name: "foreign service account with local warrant",
245-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"other"}, WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kcp.io/cluster-name":["this"]}}`}}},
272+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{
273+
ClusterExtraKey: {"other"},
274+
WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kcp.io/cluster-name":["this"]}}`},
275+
}},
246276
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
247277
want: true,
248278
},
249279
{
250280
name: "foreign service account with foreign warrant",
251-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"other"}, WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kcp.io/cluster-name":["other"]}}`}}},
281+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{
282+
ClusterExtraKey: {"other"},
283+
WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kcp.io/cluster-name":["other"]}}`},
284+
}},
252285
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
253286
want: false,
254287
},
255288
{
256289
name: "local service account with multiple clusters",
257-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"this", "this"}}},
290+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{ClusterExtraKey: {"this", "this"}}},
258291
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
259292
want: false,
260293
},
261294
{
262295
name: "out-of-scope local service account",
263-
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"this"}, "authentication.kcp.io/scopes": {"cluster:other"}}},
296+
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{
297+
ClusterExtraKey: {"this"},
298+
ScopeExtraKey: {"cluster:other"},
299+
}},
264300
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
265301
want: false,
266302
},
@@ -496,3 +532,68 @@ func TestPrefixUser(t *testing.T) {
496532
})
497533
}
498534
}
535+
536+
func TestEffectiveUsers(t *testing.T) {
537+
tests := map[string]struct {
538+
in []string
539+
want []string
540+
}{
541+
"empty": {
542+
in: []string{},
543+
want: []string{},
544+
},
545+
"one scope entry, one cluster": {
546+
in: []string{"cluster:this"},
547+
want: []string{"cluster:this"},
548+
},
549+
"one scope entry, multiple clusters": {
550+
in: []string{"cluster:this,cluster:that"},
551+
want: []string{"cluster:this", "cluster:that"},
552+
},
553+
"multiple scope entries, multiple clusters, empty result": {
554+
in: []string{
555+
"cluster:this,cluster:that",
556+
"cluster:other",
557+
},
558+
want: []string{},
559+
},
560+
"multiple scope entries, multiple clusters, non-empty result": {
561+
in: []string{
562+
"cluster:this,cluster:that",
563+
"cluster:other,cluster:this",
564+
},
565+
want: []string{"cluster:this"},
566+
},
567+
"multiple scopes entries, multiple clusters, multiple others": {
568+
in: []string{
569+
"cluster:this,foo:bar",
570+
"cluster:this,cluster:other,foo:bar",
571+
"cluster:third,foo:bar,foo:baz",
572+
},
573+
want: []string{
574+
"foo:bar",
575+
},
576+
},
577+
"multiple equal scopes entries": {
578+
in: []string{
579+
"cluster:this,cluster:other,foo:bar",
580+
"cluster:this,cluster:other,foo:bar",
581+
"cluster:this,cluster:other,foo:bar",
582+
},
583+
want: []string{
584+
"cluster:this",
585+
"cluster:other",
586+
"foo:bar",
587+
},
588+
},
589+
}
590+
for name, tt := range tests {
591+
t.Run(name, func(t *testing.T) {
592+
t.Parallel()
593+
got := EffectiveScopes(tt.in)
594+
if diff := cmp.Diff(tt.want, got); diff != "" {
595+
t.Errorf("EffectiveScopes() mismatch (-want +got):\n%s", diff)
596+
}
597+
})
598+
}
599+
}

staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const (
5252
NodeUIDKey = "authentication.kubernetes.io/node-uid"
5353
// ClusterNameKey is the logical cluster name this service-account comes from.
5454
ClusterNameKey = "authentication.kcp.io/cluster-name"
55+
// ClusterScopeKey additionally sets the scope for the service-account to the cluster it belongs to.
56+
ClusterScopeKey = "authentication.kcp.io/scopes"
5557
)
5658

5759
// MakeUsername generates a username from the given namespace and ServiceAccount name.
@@ -170,6 +172,7 @@ func (sa *ServiceAccountInfo) UserInfo() user.Info {
170172
info.Extra = map[string][]string{}
171173
}
172174
info.Extra[ClusterNameKey] = []string{sa.ClusterName.String()}
175+
info.Extra[ClusterScopeKey] = []string{"cluster:" + sa.ClusterName.String()}
173176
return info
174177
}
175178

0 commit comments

Comments
 (0)