From c64ba9b489e9991f18ded1de13b68c9e56839b17 Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Fri, 31 Mar 2023 09:10:24 +0200 Subject: [PATCH] Allow redeemer of an invitation to access (read) it (#138) --- controllers/invitation_redeem_controller.go | 83 +++++++++++++++++++ ...itation_redeem_controller_rolename_test.go | 17 ++++ .../invitation_redeem_controller_test.go | 15 ++++ 3 files changed, 115 insertions(+) create mode 100644 controllers/invitation_redeem_controller_rolename_test.go diff --git a/controllers/invitation_redeem_controller.go b/controllers/invitation_redeem_controller.go index b6d7fc43..e8a311ea 100644 --- a/controllers/invitation_redeem_controller.go +++ b/controllers/invitation_redeem_controller.go @@ -2,15 +2,22 @@ package controllers import ( "context" + "crypto/sha1" + "encoding/hex" "errors" + "fmt" "strings" "go.uber.org/multierr" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" + kstrings "k8s.io/utils/strings" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" userv1 "github.com/appuio/control-api/apis/user/v1" @@ -57,6 +64,10 @@ func (r *InvitationRedeemReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, errors.New("redeemed invitation has no user") } + if err := r.createRedeemerRole(ctx, &inv); err != nil { + return ctrl.Result{}, err + } + var errors []error statusHasChanged := false for i := range inv.Status.TargetStatuses { @@ -92,6 +103,8 @@ func (r *InvitationRedeemReconciler) Reconcile(ctx context.Context, req ctrl.Req func (r *InvitationRedeemReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&userv1.Invitation{}). + Owns(&rbacv1.ClusterRoleBinding{}). + Owns(&rbacv1.ClusterRole{}). Complete(r) } @@ -113,3 +126,73 @@ func addUserToTarget(ctx context.Context, c client.Client, user, prefix string, return nil } + +func (r *InvitationRedeemReconciler) createRedeemerRole(ctx context.Context, inv *userv1.Invitation) error { + rolename := invRedeemRoleName(inv.Name) + + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: rolename, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"rbac.appuio.io", "user.appuio.io"}, + Resources: []string{"invitations"}, + Verbs: []string{"get", "list", "watch"}, + ResourceNames: []string{inv.Name}, + }, + }, + } + + rolebinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: rolename, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "User", + APIGroup: "rbac.authorization.k8s.io", + Name: inv.Status.RedeemedBy, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + APIGroup: "rbac.authorization.k8s.io", + Name: rolename, + }, + } + + for _, o := range [...]client.Object{role, rolebinding} { + if err := controllerutil.SetControllerReference(inv, o, r.Scheme); err != nil { + return fmt.Errorf("failed setting controller reference for %T/%s: %w", o, o.GetName(), err) + } + + if err := r.Create(ctx, o); client.IgnoreAlreadyExists(err) != nil { + if apierrors.IsAlreadyExists(err) { + log.FromContext(ctx).Error(err, "object already exists while redeeming invitation", "invitation", inv.Name) + } else { + return fmt.Errorf("failed creating %T/%s: %w", o, o.GetName(), err) + } + } + } + + return nil +} + +func invRedeemRoleName(objName string) string { + prefix := "invitations-" + suffix := "-redeemer" + + if len(prefix)+len(suffix)+len(objName) <= 63 { + return fmt.Sprintf("%s%s%s", prefix, objName, suffix) + } + + h := sha1.New() + h.Write([]byte(objName)) + hsh := kstrings.ShortenString(hex.EncodeToString(h.Sum(nil)), 7) + + maxLength := 63 - len(prefix) - len(suffix) - len(hsh) - 1 + maxSafe := kstrings.ShortenString(objName, maxLength) + + return fmt.Sprintf("%s%s-%s%s", prefix, maxSafe, hsh, suffix) +} diff --git a/controllers/invitation_redeem_controller_rolename_test.go b/controllers/invitation_redeem_controller_rolename_test.go new file mode 100644 index 00000000..259144e1 --- /dev/null +++ b/controllers/invitation_redeem_controller_rolename_test.go @@ -0,0 +1,17 @@ +package controllers + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_invRedeemRoleName(t *testing.T) { + require.Equal(t, "invitations-subject-redeemer", invRedeemRoleName("subject")) + require.Equal(t, + "invitations-subjectsubjectsubjectsubjectsubjec-c726eeb-redeemer", + invRedeemRoleName(strings.Repeat("subject", 100)), + "Role name must be limited to 63 characters", + ) +} diff --git a/controllers/invitation_redeem_controller_test.go b/controllers/invitation_redeem_controller_test.go index 9c7ab798..7cfc9424 100644 --- a/controllers/invitation_redeem_controller_test.go +++ b/controllers/invitation_redeem_controller_test.go @@ -114,6 +114,21 @@ func Test_InvitationRedeemReconciler_Reconcile_Success(t *testing.T) { require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(crb), crb)) assert.Equal(t, []rbacv1.Subject{{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: redeemedBy}}, crb.Subjects) + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invitations-subject-redeemer", + }, + } + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(role), role), "Redeeming person should have read access to the invitation") + rolebinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invitations-subject-redeemer", + }, + } + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(rolebinding), rolebinding), "Redeeming person should have read access to the invitation") + require.Len(t, rolebinding.Subjects, 1) + require.Equal(t, redeemedBy, rolebinding.Subjects[0].Name) + _, err = r.Reconcile(ctx, requestFor(subject)) require.NoError(t, err) }