Skip to content

Commit

Permalink
Switch from REDEEM custom method to virtual, create-only `Invitatio…
Browse files Browse the repository at this point in the history
…nRedeemRequest` (#133)
  • Loading branch information
bastjan authored Mar 22, 2023
1 parent e1d5cac commit 626cd3a
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 106 deletions.
7 changes: 6 additions & 1 deletion api.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func APICommand() *cobra.Command {
WithResourceAndHandler(&billingv1.BillingEntity{}, ob.Build).
WithResourceAndHandler(&userv1.Invitation{}, ib.Build).
WithResourceAndHandler(secretstorage.NewStatusSubResourceRegisterer(&userv1.Invitation{}), ib.Build).
WithResourceAndHandler(&userv1.InvitationRedeemRequest{}, ib.BuildRedeem).
WithoutEtcd().
ExposeLoopbackAuthorizer().
ExposeLoopbackMasterClientConfig().
Expand Down Expand Up @@ -102,7 +103,11 @@ type invitationStorageBuilder struct {
}

func (i *invitationStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) {
return user.NewInvitationStorage(i.backingNS, *i.usernamePrefix)(s, g)
return user.NewInvitationStorage(i.backingNS)(s, g)
}

func (i *invitationStorageBuilder) BuildRedeem(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) {
return user.NewInvitationRedeemStorage(*i.usernamePrefix)(s, g)
}

type organizationStatusRegisterer struct {
Expand Down
66 changes: 65 additions & 1 deletion apis/user/v1/invitation_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,70 @@ func (o *RedeemOptions) ConvertFromUrlValues(values *url.Values) error {
return convert_url_Values_To__RedeemOptions(values, o)
}

var _ resource.Object = &InvitationRedeemRequest{}

// +kubebuilder:object:root=true
// InvitationRedeemRequest is a request to redeem an invitation
type InvitationRedeemRequest struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

// Token is the token to redeem the invitation
Token string `json:"token"`
}

// GetObjectMeta returns the objects meta reference.
func (o *InvitationRedeemRequest) GetObjectMeta() *metav1.ObjectMeta {
return &o.ObjectMeta
}

// GetGroupVersionResource returns the GroupVersionResource for this resource.
// The resource should be the all lowercase and pluralized kind
func (o *InvitationRedeemRequest) GetGroupVersionResource() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: GroupVersion.Group,
Version: GroupVersion.Version,
Resource: "invitationredeemrequests",
}
}

// IsStorageVersion returns true if the object is also the internal version -- i.e. is the type defined for the API group or an alias to this object.
// If false, the resource is expected to implement MultiVersionObject interface.
func (o *InvitationRedeemRequest) IsStorageVersion() bool {
return true
}

// NamespaceScoped returns true if the object is namespaced
func (o *InvitationRedeemRequest) NamespaceScoped() bool {
return false
}

// New returns a new instance of the resource
func (o *InvitationRedeemRequest) New() runtime.Object {
return &InvitationRedeemRequest{}
}

// NewList return a new list instance of the resource
func (o *InvitationRedeemRequest) NewList() runtime.Object {
return &InvitationRedeemRequestList{}
}

var _ resource.ObjectList = &InvitationRedeemRequestList{}

// +kubebuilder:object:root=true
// InvitationList contains a list of Invitations
type InvitationRedeemRequestList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`

Items []InvitationRedeemRequest `json:"items"`
}

// GetListMeta returns the list meta reference.
func (in *InvitationRedeemRequestList) GetListMeta() *metav1.ListMeta {
return &in.ListMeta
}

func init() {
SchemeBuilder.Register(&Invitation{}, &InvitationList{})
SchemeBuilder.Register(&Invitation{}, &InvitationList{}, &InvitationRedeemRequest{})
}
57 changes: 57 additions & 0 deletions apis/user/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

142 changes: 75 additions & 67 deletions apiserver/user/invitation_redeem.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -17,96 +18,102 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"

userv1 "github.com/appuio/control-api/apis/user/v1"
"github.com/appuio/control-api/apiserver/secretstorage"
)

//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations,verbs=get;list;watch
//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations,verbs=get;list;watch
//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations/status,verbs=get;update;patch
//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations/status,verbs=get;update;patch

var _ rest.Connecter = &invitationRedeemer{}
var _ rest.StandardStorage = &invitationRedeemer{}
var _ rest.Creater = &invitationRedeemer{}
var _ rest.Storage = &invitationRedeemer{}
var _ rest.Scoper = &invitationRedeemer{}

type invitationRedeemer struct {
secretstorage.ScopedStandardStorage
client client.Client

usernamePrefix string
}

func (ir *invitationRedeemer) ConnectMethods() []string {
return []string{"REDEEM"}
func (ir invitationRedeemer) NamespaceScoped() bool {
return false
}

func (ir *invitationRedeemer) NewConnectOptions() (runtime.Object, bool, string) {
return &userv1.RedeemOptions{}, false, ""
func (ir invitationRedeemer) New() runtime.Object {
return &userv1.InvitationRedeemRequest{}
}

// Connect implements the REDEEM method for invitations.
// It is used to redeem an invitation by a user.
func (ir invitationRedeemer) Destroy() {}

// Create implements redeeming invitations, it accepts `InvitationRedeemRequest`.
// The user is identified by the username in the request context.
// The token is taken from the path.
// If the invitation is valid, the invitation is marked as redeemed, the user, and a snapshot of the invitations's targets are stored in the status.
// The snapshot is later used in a controller to add the user to the targets in an idempotent and retryable way.
// If user or token are invalid, the request is rejected with a 403.
func (s *invitationRedeemer) Connect(ctx context.Context, name string, options runtime.Object, responder rest.Responder) (http.Handler, error) {
l := klog.FromContext(ctx).WithName("InvitationRedeemer.Connect").WithValues("invitation", name)
opts := options.(*userv1.RedeemOptions)
// Might come from the path, so we need to trim the leading slash
token := strings.TrimLeft(opts.Token, "/")

return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
inv := &userv1.Invitation{}
if err := s.client.Get(ctx, client.ObjectKey{Name: name}, inv); err != nil {
responder.Error(err)
return
}
func (s *invitationRedeemer) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, opts *metav1.CreateOptions) (runtime.Object, error) {
irr, ok := obj.(*userv1.InvitationRedeemRequest)
if !ok {
return nil, fmt.Errorf("not an InvitationRedeemRequest: %#v", obj)
}

tokenValid := inv.Status.Token != "" && inv.Status.ValidUntil.After(time.Now())
if inv.IsRedeemed() || !tokenValid || inv.Status.Token != token {
l.Info("invalid token")
forbidden(responder)
return
}
name := irr.Name
token := irr.Token

user, ok := userFrom(ctx, s.usernamePrefix)
if !ok {
l.Info("no allowed user found in request context", "usernamePrefix", s.usernamePrefix)
forbidden(responder)
return
}
l := klog.FromContext(ctx).WithName("InvitationRedeemer.Create").WithValues("invitation", name)

ts := make([]userv1.TargetStatus, len(inv.Spec.TargetRefs))
for i, target := range inv.Spec.TargetRefs {
ts[i] = userv1.TargetStatus{
TargetRef: target,
Condition: metav1.Condition{
Type: userv1.ConditionRedeemed,
Status: metav1.ConditionUnknown,
},
}
}
inv := &userv1.Invitation{}
if err := s.client.Get(ctx, client.ObjectKey{Name: name}, inv); err != nil {
return nil, fmt.Errorf("failed to get invitation: %w", err)
}

if inv.Status.Token == "" {
l.Info("token is empty")
return nil, errForbidden()
}
if !inv.Status.ValidUntil.After(time.Now()) {
l.Info("invitation is expired")
return nil, errForbidden()
}
if inv.IsRedeemed() {
l.Info("invitation is already redeemed")
return nil, errForbidden()
}
if inv.Status.Token != token {
l.Info("token does not match")
return nil, errForbidden()
}

user, ok := userFrom(ctx, s.usernamePrefix)
if !ok {
l.Info("no allowed user found in request context", "usernamePrefix", s.usernamePrefix)
return nil, errForbidden()
}

inv.Status.TargetStatuses = ts
inv.Status.RedeemedBy = user.GetName()
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{
Type: userv1.ConditionRedeemed,
Status: metav1.ConditionTrue,
Reason: userv1.ConditionRedeemed,
Message: fmt.Sprintf("Redeemed by %q", user.GetName()),
})

if err := s.client.Status().Update(ctx, inv); err != nil {
responder.Error(err)
return
ts := make([]userv1.TargetStatus, len(inv.Spec.TargetRefs))
for i, target := range inv.Spec.TargetRefs {
ts[i] = userv1.TargetStatus{
TargetRef: target,
Condition: metav1.Condition{
Type: userv1.ConditionRedeemed,
Status: metav1.ConditionUnknown,
},
}
}

responder.Object(http.StatusOK, &metav1.Status{
Status: metav1.StatusSuccess,
})
}), nil
inv.Status.TargetStatuses = ts
inv.Status.RedeemedBy = user.GetName()
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{
Type: userv1.ConditionRedeemed,
Status: metav1.ConditionTrue,
Reason: userv1.ConditionRedeemed,
Message: fmt.Sprintf("Redeemed by %q", user.GetName()),
})

if err := s.client.Status().Update(ctx, inv); err != nil {
return nil, fmt.Errorf("failed to update invitation: %w", err)
}

return irr, nil
}

// userFrom returns the user from the context if it is a non-serviceaccount user and has the usernamePrefix.
Expand All @@ -128,10 +135,11 @@ func userFrom(ctx context.Context, usernamePrefix string) (u user.Info, ok bool)
return user, true
}

func forbidden(resp rest.Responder) {
resp.Object(http.StatusForbidden, &metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusForbidden,
Reason: metav1.StatusReasonUnauthorized,
})
func errForbidden() *apierrors.StatusError {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
}}
}
Loading

0 comments on commit 626cd3a

Please sign in to comment.