Skip to content

Commit 7239094

Browse files
authored
Allow users to create their own settings (#163)
1 parent 17dccc4 commit 7239094

File tree

7 files changed

+228
-70
lines changed

7 files changed

+228
-70
lines changed

config/user-rbac/basic-user-role.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ rules:
4040
- apiGroups: ["user.appuio.io"]
4141
resources: ["invitationredeemrequests"]
4242
verbs: ["create"]
43+
# Allow users to create themselves, user create requests are validated by the users validation webhook
44+
- apiGroups: ["appuio.io"]
45+
resources: ["users"]
46+
verbs: ["create"]

pkg/sar/resource_attributes.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package sar
2+
3+
// ResourceAttributes includes the authorization attributes available for resource requests to the Authorizer interface.
4+
// From https://github.com/kubernetes/api/blob/2f9553831ec24dc60e3e1c3a374fb63ca091688f/authorization/v1/types.go#L92-L118.
5+
// Importing the whole package confuses go mod.
6+
type ResourceAttributes struct {
7+
// Namespace is the namespace of the action being requested. Currently, there is no distinction between no namespace and all namespaces
8+
// "" (empty) is defaulted for LocalSubjectAccessReviews
9+
// "" (empty) is empty for cluster-scoped resources
10+
// "" (empty) means "all" for namespace scoped resources from a SubjectAccessReview or SelfSubjectAccessReview
11+
// +optional
12+
Namespace string `json:"namespace,omitempty" protobuf:"bytes,1,opt,name=namespace"`
13+
// Verb is a kubernetes resource API verb, like: get, list, watch, create, update, delete, proxy. "*" means all.
14+
// +optional
15+
Verb string `json:"verb,omitempty" protobuf:"bytes,2,opt,name=verb"`
16+
// Group is the API Group of the Resource. "*" means all.
17+
// +optional
18+
Group string `json:"group,omitempty" protobuf:"bytes,3,opt,name=group"`
19+
// Version is the API Version of the Resource. "*" means all.
20+
// +optional
21+
Version string `json:"version,omitempty" protobuf:"bytes,4,opt,name=version"`
22+
// Resource is one of the existing resource types. "*" means all.
23+
// +optional
24+
Resource string `json:"resource,omitempty" protobuf:"bytes,5,opt,name=resource"`
25+
// Subresource is one of the existing resource types. "" means none.
26+
// +optional
27+
Subresource string `json:"subresource,omitempty" protobuf:"bytes,6,opt,name=subresource"`
28+
// Name is the name of the resource being requested for a "get" or deleted for a "delete". "" (empty) means all.
29+
// +optional
30+
Name string `json:"name,omitempty" protobuf:"bytes,7,opt,name=name"`
31+
}

pkg/sar/sar.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package sar
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
authenticationv1 "k8s.io/api/authentication/v1"
9+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
)
13+
14+
// AuthorizeResource checks if the given user is allowed to access the given resource, using SubjectAccessReviews.
15+
func AuthorizeResource(ctx context.Context, c client.Client, user authenticationv1.UserInfo, resource ResourceAttributes) error {
16+
// I could not find a way to create a SubjectAccessReview object with the client.
17+
// `no kind "CreateOptions" is registered for the internal version of group "authorization.k8s.io" in scheme`
18+
// even after installing the authorization scheme.
19+
rawSAR := &unstructured.Unstructured{
20+
Object: map[string]any{
21+
"spec": sarSpec{
22+
ResourceAttributes: resource,
23+
24+
User: user.Username,
25+
Groups: user.Groups,
26+
Extra: user.Extra,
27+
UID: user.UID,
28+
},
29+
}}
30+
rawSAR.SetGroupVersionKind(schema.GroupVersionKind{Group: "authorization.k8s.io", Version: "v1", Kind: "SubjectAccessReview"})
31+
32+
if err := c.Create(ctx, rawSAR); err != nil {
33+
return fmt.Errorf("failed to create SubjectAccessReview: %w", err)
34+
}
35+
36+
allowed, _, err := unstructured.NestedBool(rawSAR.Object, "status", "allowed")
37+
if err != nil {
38+
return fmt.Errorf("failed to get SubjectAccessReview status.allowed: %w", err)
39+
}
40+
41+
if !allowed {
42+
return fmt.Errorf("%q is not allowed by %q", resource, user)
43+
}
44+
45+
return nil
46+
}
47+
48+
// MOCK_SubjectAccessReviewResponder is a wrapper for client.WithWatch that responds to SubjectAccessReview create requests
49+
// and allows or denies the request based on the AllowedUser name.
50+
type MOCK_SubjectAccessReviewResponder struct {
51+
client.WithWatch
52+
53+
AllowedUser string
54+
}
55+
56+
func (r MOCK_SubjectAccessReviewResponder) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
57+
if sar, ok := obj.(*unstructured.Unstructured); ok {
58+
if sar.GetKind() == "SubjectAccessReview" {
59+
o, ok, err := unstructured.NestedFieldNoCopy(sar.Object, "spec")
60+
if err != nil {
61+
return err
62+
}
63+
if !ok {
64+
return errors.New("spec not found")
65+
}
66+
s, ok := o.(sarSpec)
67+
if !ok {
68+
return errors.New("unknown spec type, might not originate from this package")
69+
}
70+
71+
unstructured.SetNestedField(sar.Object, s.User == r.AllowedUser, "status", "allowed")
72+
return nil
73+
}
74+
}
75+
return r.WithWatch.Create(ctx, obj, opts...)
76+
}
77+
78+
type sarSpec struct {
79+
ResourceAttributes ResourceAttributes `json:"resourceAttributes"`
80+
81+
User string `json:"user,omitempty"`
82+
Groups []string `json:"groups,omitempty"`
83+
Extra map[string]authenticationv1.ExtraValue `json:"extra,omitempty"`
84+
UID string `json:"uid,omitempty"`
85+
}

webhooks/invitation_webhook.go

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import (
77

88
"go.uber.org/multierr"
99
authenticationv1 "k8s.io/api/authentication/v1"
10-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1110
"k8s.io/apimachinery/pkg/runtime/schema"
1211
"sigs.k8s.io/controller-runtime/pkg/client"
1312
"sigs.k8s.io/controller-runtime/pkg/log"
1413
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1514

1615
userv1 "github.com/appuio/control-api/apis/user/v1"
1716
"github.com/appuio/control-api/controllers/targetref"
17+
"github.com/appuio/control-api/pkg/sar"
1818
)
1919

2020
// +kubebuilder:webhook:path=/validate-user-appuio-io-v1-invitation,mutating=false,failurePolicy=fail,groups="user.appuio.io",resources=invitations,verbs=create;update,versions=v1,name=validate-invitations.user.appuio.io,admissionReviewVersions=v1,sideEffects=None
@@ -81,55 +81,27 @@ func canEditTarget(ctx context.Context, c client.Client, user authenticationv1.U
8181
if err != nil {
8282
return err
8383
}
84-
ra["verb"] = verb
85-
86-
// I could not find a way to create a SubjectAccessReview object with the client.
87-
// `no kind "CreateOptions" is registered for the internal version of group "authorization.k8s.io" in scheme`
88-
// even after installing the authorization scheme.
89-
rawSAR := &unstructured.Unstructured{
90-
Object: map[string]any{
91-
"spec": map[string]any{
92-
"resourceAttributes": ra,
93-
94-
"user": user.Username,
95-
"groups": user.Groups,
96-
"uid": user.UID,
97-
},
98-
}}
99-
rawSAR.SetGroupVersionKind(schema.GroupVersionKind{Group: "authorization.k8s.io", Version: "v1", Kind: "SubjectAccessReview"})
100-
101-
if err := c.Create(ctx, rawSAR); err != nil {
102-
return fmt.Errorf("failed to create SubjectAccessReview: %w", err)
103-
}
104-
105-
allowed, _, err := unstructured.NestedBool(rawSAR.Object, "status", "allowed")
106-
if err != nil {
107-
return fmt.Errorf("failed to get SubjectAccessReview status.allowed: %w", err)
108-
}
84+
ra.Verb = verb
10985

110-
if !allowed {
111-
return fmt.Errorf("%q on target %q.%q/%q in namespace %q is not allowed", verb, target.APIGroup, target.Kind, target.Name, target.Namespace)
112-
}
113-
114-
return nil
86+
return sar.AuthorizeResource(ctx, c, user, ra)
11587
}
11688

117-
func mapTargetRefToResourceAttribute(c client.Client, target userv1.TargetRef) (map[string]any, error) {
89+
func mapTargetRefToResourceAttribute(c client.Client, target userv1.TargetRef) (sar.ResourceAttributes, error) {
11890
rm, err := c.RESTMapper().RESTMapping(schema.GroupKind{
11991
Group: target.APIGroup,
12092
Kind: target.Kind,
12193
})
12294

12395
if err != nil {
124-
return nil, fmt.Errorf("failed to get REST mapping for %q.%q: %w", target.APIGroup, target.Kind, err)
96+
return sar.ResourceAttributes{}, fmt.Errorf("failed to get REST mapping for %q.%q: %w", target.APIGroup, target.Kind, err)
12597
}
12698

127-
return map[string]any{
128-
"group": target.APIGroup,
129-
"version": rm.Resource.Version,
130-
"resource": rm.Resource.Resource,
99+
return sar.ResourceAttributes{
100+
Group: target.APIGroup,
101+
Version: rm.Resource.Version,
102+
Resource: rm.Resource.Resource,
131103

132-
"namespace": target.Namespace,
133-
"name": target.Name,
104+
Namespace: target.Namespace,
105+
Name: target.Name,
134106
}, nil
135107
}

webhooks/invitation_webhook_test.go

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package webhooks
33
import (
44
"context"
55
"encoding/json"
6-
"errors"
76
"net/http"
87
"testing"
98

@@ -14,7 +13,6 @@ import (
1413
rbacv1 "k8s.io/api/rbac/v1"
1514
"k8s.io/apimachinery/pkg/api/meta"
1615
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1816
"k8s.io/apimachinery/pkg/runtime"
1917
"k8s.io/apimachinery/pkg/runtime/schema"
2018
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -25,6 +23,7 @@ import (
2523
orgv1 "github.com/appuio/control-api/apis/organization/v1"
2624
userv1 "github.com/appuio/control-api/apis/user/v1"
2725
controlv1 "github.com/appuio/control-api/apis/v1"
26+
"github.com/appuio/control-api/pkg/sar"
2827
)
2928

3029
func TestInvitationValidator_Handle(t *testing.T) {
@@ -338,9 +337,9 @@ func prepareInvitationValidatorTest(t *testing.T, sarAllowedUser string, initObj
338337
WithRESTMapper(drm).
339338
Build()
340339

341-
client = subjectAccessReviewResponder{
342-
client,
343-
sarAllowedUser,
340+
client = sar.MOCK_SubjectAccessReviewResponder{
341+
WithWatch: client,
342+
AllowedUser: sarAllowedUser,
344343
}
345344

346345
iv := &InvitationValidator{}
@@ -349,28 +348,3 @@ func prepareInvitationValidatorTest(t *testing.T, sarAllowedUser string, initObj
349348

350349
return iv
351350
}
352-
353-
// subjectAccessReviewResponder is a wrapper for client.WithWatch that responds to SubjectAccessReview create requests
354-
// and allows or denies the request based on the allowedUser name.
355-
type subjectAccessReviewResponder struct {
356-
client.WithWatch
357-
358-
allowedUser string
359-
}
360-
361-
func (r subjectAccessReviewResponder) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
362-
if sar, ok := obj.(*unstructured.Unstructured); ok {
363-
if sar.GetKind() == "SubjectAccessReview" {
364-
u, p, err := unstructured.NestedString(sar.Object, "spec", "user")
365-
if err != nil {
366-
return err
367-
}
368-
if !p {
369-
return errors.New("spec.user not found")
370-
}
371-
unstructured.SetNestedField(sar.Object, u == r.allowedUser, "status", "allowed")
372-
return nil
373-
}
374-
}
375-
return r.WithWatch.Create(ctx, obj, opts...)
376-
}

webhooks/user_webhook.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import (
55
"fmt"
66
"net/http"
77

8+
admissionv1 "k8s.io/api/admission/v1"
89
"k8s.io/apimachinery/pkg/types"
910
"sigs.k8s.io/controller-runtime/pkg/client"
1011
"sigs.k8s.io/controller-runtime/pkg/log"
1112
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1213

1314
controlv1 "github.com/appuio/control-api/apis/v1"
15+
"github.com/appuio/control-api/pkg/sar"
1416
)
1517

1618
// +kubebuilder:webhook:path=/validate-appuio-io-v1-user,mutating=false,failurePolicy=fail,groups="appuio.io",resources=users,verbs=create;update,versions=v1,name=validate-users.appuio.io,admissionReviewVersions=v1,sideEffects=None
@@ -27,6 +29,21 @@ type UserValidator struct {
2729
func (v *UserValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
2830
log := log.FromContext(ctx).WithName("webhook.validate-users.appuio.io")
2931

32+
// Allow the user to create or update itself.
33+
// A special permission, used for controllers, `create rbac.appuio.io users` can override this.
34+
if req.AdmissionRequest.Operation == admissionv1.Create && req.UserInfo.Username != req.Name {
35+
if err := sar.AuthorizeResource(ctx, v.client, req.UserInfo, sar.ResourceAttributes{
36+
Verb: "create",
37+
Group: "rbac.appuio.io",
38+
Resource: req.Resource.Group,
39+
Version: req.Resource.Version,
40+
Name: req.Name,
41+
}); err != nil {
42+
return admission.Denied(fmt.Sprintf("user %q is not allowed to create or update %q", req.UserInfo.Username, req.Name))
43+
}
44+
log.Info("User authorized to create other users", "user", req.AdmissionRequest.UserInfo)
45+
}
46+
3047
user := &controlv1.User{}
3148
if err := v.decoder.Decode(req, user); err != nil {
3249
return admission.Errored(http.StatusBadRequest, err)

0 commit comments

Comments
 (0)