Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ authz: add warrants to default rule resolver #155

Merged
merged 3 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions pkg/registry/rbac/validation/kcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import (

"github.com/kcp-dev/logicalcluster/v3"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/util/json"
"k8s.io/apimachinery/pkg/util/sets"
authserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
)

const (
// WarrantExtraKey is the key used in a user's "extra" to specify
// JSON-encoded user infos for attached extra permissions for that user
// evaluated by the authorizer.
WarrantExtraKey = "authorization.kcp.io/warrant"
mjudeikis marked this conversation as resolved.
Show resolved Hide resolved

// ScopeExtraKey is the key used in a user's "extra" to specify
// that the user is restricted to a given scope. Valid values for
// one extra value are:
Expand All @@ -27,15 +33,71 @@ const (
clusterPrefix = "cluster:"
)

// Warrant is serialized into the user's "extra" field authorization.kcp.io/warrant
// to hold user information for extra permissions.
type Warrant struct {
// User is the user you're testing for.
// If you specify "User" but not "Groups", then is it interpreted as "What if User were not a member of any groups
// +optional
User string `json:"user,omitempty"`
// Groups is the groups you're testing for.
// +optional
// +listType=atomic
Groups []string `json:"groups,omitempty"`
// Extra corresponds to the user.Info.GetExtra() method from the authenticator. Since that is input to the authorizer
// it needs a reflection here.
// +optional
Extra map[string][]string `json:"extra,omitempty"`
// UID information about the requesting user.
// +optional
UID string `json:"uid,omitempty"`
}

type appliesToUserFunc func(user user.Info, subject rbacv1.Subject, namespace string) bool
type appliesToUserFuncCtx func(ctx context.Context, user user.Info, subject rbacv1.Subject, namespace string) bool

var appliesToUserWithScopes = withScopes(appliesToUser)
var (
appliesToUserWithScopes = withScopes(appliesToUser)
appliesToUserWithScopedAndWarrants = withWarrants(appliesToUserWithScopes)
)

// withScopes wraps the appliesToUser predicate to check for the base user and any warrants.
func withScopes(appliesToUser appliesToUserFunc) appliesToUserFuncCtx {
// withWarrants wraps the appliesToUser predicate to check for the base user and any warrants.
func withWarrants(appliesToUser appliesToUserFuncCtx) appliesToUserFuncCtx {
var recursive appliesToUserFuncCtx
recursive = func(ctx context.Context, u user.Info, bindingSubject rbacv1.Subject, namespace string) bool {
if appliesToUser(ctx, u, bindingSubject, namespace) {
return true
}

for _, v := range u.GetExtra()[WarrantExtraKey] {
var w Warrant
if err := json.Unmarshal([]byte(v), &w); err != nil {
continue
}

wu := &user.DefaultInfo{
Name: w.User,
UID: w.UID,
Groups: w.Groups,
Extra: w.Extra,
}
if IsServiceAccount(wu) && len(w.Extra[authserviceaccount.ClusterNameKey]) == 0 {
// warrants must be scoped to a cluster
continue
}
if recursive(ctx, wu, bindingSubject, namespace) {
return true
}
}

return false
}
return recursive
}

// withScopes wraps the appliesToUser predicate to check the scopes.
func withScopes(appliesToUser appliesToUserFunc) appliesToUserFuncCtx {
return func(ctx context.Context, u user.Info, bindingSubject rbacv1.Subject, namespace string) bool {
var clusterName logicalcluster.Name
if cluster := genericapirequest.ClusterFrom(ctx); cluster != nil {
clusterName = cluster.Name
Expand All @@ -49,7 +111,6 @@ func withScopes(appliesToUser appliesToUserFunc) appliesToUserFuncCtx {

return false
}
return recursive
}

var (
Expand Down
138 changes: 138 additions & 0 deletions pkg/registry/rbac/validation/kcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,141 @@ func TestAppliesToUserWithScopes(t *testing.T) {
})
}
}

func TestAppliesToUserWithWarrantsAndScopes(t *testing.T) {
tests := []struct {
name string
user user.Info
sub rbacv1.Subject
want bool
}{
{
name: "simple matching user without warrants",
user: &user.DefaultInfo{Name: "user-a"},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "simple non-matching user without warrants",
user: &user.DefaultInfo{Name: "user-a"},
sub: rbacv1.Subject{Kind: "User", Name: "user-b"},
want: false,
},
{
name: "simple matching user with warrants",
user: &user.DefaultInfo{Name: "user-a", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b"}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "simple non-matching user with matching warrants",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-a"}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "simple non-matching user with non-matching warrants",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b"}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: false,
},
{
name: "simple non-matching user with multiple warrants",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b"}`, `{"user":"user-a"}`, `{"user":"user-c"}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "simple non-matching user with nested warrants",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-b","extra":{"authorization.kcp.io/warrant":["{\"user\":\"user-a\"}"]}}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "foreign service account",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kubernetes.io/cluster-name": {"other"}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: false,
},
{
name: "local service account",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kubernetes.io/cluster-name": {"this"}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: true,
},
{
name: "foreign service account with local warrant",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kubernetes.io/cluster-name": {"other"}, WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kubernetes.io/cluster-name":["this"]}}`}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: true,
},
{
name: "foreign service account with foreign warrant",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kubernetes.io/cluster-name": {"other"}, WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa","extra":{"authentication.kubernetes.io/cluster-name":["other"]}}`}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: false,
},
{
name: "non-cluster-aware service account",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa"},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: true,
},
{
name: "non-cluster-aware service account as warrant",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"system:serviceaccount:ns:sa"}`}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: false,
},
{
name: "in-scope scoped user",
user: &user.DefaultInfo{Name: "user-a", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:this"}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "out-of-scope user",
user: &user.DefaultInfo{Name: "user-a", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:other"}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: false,
},
{
name: "out-of-scope user with warrent",
user: &user.DefaultInfo{Name: "user-a", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:other"}, WarrantExtraKey: {`{"user":"user-a"}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "out-of-scope warrant",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-a","extra":{"authentication.kcp.io/scopes":["cluster:other"]}}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: false,
},
{
name: "in-scope warrant",
user: &user.DefaultInfo{Name: "user-b", Extra: map[string][]string{WarrantExtraKey: {`{"user":"user-a","extra":{"authentication.kcp.io/scopes":["cluster:this"]}}`}}},
sub: rbacv1.Subject{Kind: "User", Name: "user-a"},
want: true,
},
{
name: "in-scope service account",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:this"}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: true,
},
{
name: "out-of-scope service account",
user: &user.DefaultInfo{Name: "system:serviceaccount:ns:sa", Extra: map[string][]string{"authentication.kcp.io/scopes": {"cluster:other"}}},
sub: rbacv1.Subject{Kind: "ServiceAccount", Namespace: "ns", Name: "sa"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := request.WithCluster(context.Background(), request.Cluster{Name: "this"})
if got := appliesToUserWithScopedAndWarrants(ctx, tt.user, tt.sub, "ns"); got != tt.want {
t.Errorf("withWarrants(withScopes(base)) = %v, want %v", got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion pkg/registry/rbac/validation/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func (r *DefaultRuleResolver) GetRoleReferenceRules(ctx context.Context, roleRef
// and if true, the index of the first subject that applies
func appliesTo(ctx context.Context, user user.Info, bindingSubjects []rbacv1.Subject, namespace string) (int, bool) {
for i, bindingSubject := range bindingSubjects {
if appliesToUserWithScopes(ctx, user, bindingSubject, namespace) {
if appliesToUserWithScopedAndWarrants(ctx, user, bindingSubject, namespace) {
return i, true
}
}
Expand Down