Skip to content

Commit 626cd3a

Browse files
authored
Switch from REDEEM custom method to virtual, create-only InvitationRedeemRequest (#133)
1 parent e1d5cac commit 626cd3a

File tree

7 files changed

+297
-106
lines changed

7 files changed

+297
-106
lines changed

api.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func APICommand() *cobra.Command {
4040
WithResourceAndHandler(&billingv1.BillingEntity{}, ob.Build).
4141
WithResourceAndHandler(&userv1.Invitation{}, ib.Build).
4242
WithResourceAndHandler(secretstorage.NewStatusSubResourceRegisterer(&userv1.Invitation{}), ib.Build).
43+
WithResourceAndHandler(&userv1.InvitationRedeemRequest{}, ib.BuildRedeem).
4344
WithoutEtcd().
4445
ExposeLoopbackAuthorizer().
4546
ExposeLoopbackMasterClientConfig().
@@ -102,7 +103,11 @@ type invitationStorageBuilder struct {
102103
}
103104

104105
func (i *invitationStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) {
105-
return user.NewInvitationStorage(i.backingNS, *i.usernamePrefix)(s, g)
106+
return user.NewInvitationStorage(i.backingNS)(s, g)
107+
}
108+
109+
func (i *invitationStorageBuilder) BuildRedeem(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) {
110+
return user.NewInvitationRedeemStorage(*i.usernamePrefix)(s, g)
106111
}
107112

108113
type organizationStatusRegisterer struct {

apis/user/v1/invitation_types.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,70 @@ func (o *RedeemOptions) ConvertFromUrlValues(values *url.Values) error {
166166
return convert_url_Values_To__RedeemOptions(values, o)
167167
}
168168

169+
var _ resource.Object = &InvitationRedeemRequest{}
170+
171+
// +kubebuilder:object:root=true
172+
// InvitationRedeemRequest is a request to redeem an invitation
173+
type InvitationRedeemRequest struct {
174+
metav1.TypeMeta `json:",inline"`
175+
metav1.ObjectMeta `json:"metadata,omitempty"`
176+
177+
// Token is the token to redeem the invitation
178+
Token string `json:"token"`
179+
}
180+
181+
// GetObjectMeta returns the objects meta reference.
182+
func (o *InvitationRedeemRequest) GetObjectMeta() *metav1.ObjectMeta {
183+
return &o.ObjectMeta
184+
}
185+
186+
// GetGroupVersionResource returns the GroupVersionResource for this resource.
187+
// The resource should be the all lowercase and pluralized kind
188+
func (o *InvitationRedeemRequest) GetGroupVersionResource() schema.GroupVersionResource {
189+
return schema.GroupVersionResource{
190+
Group: GroupVersion.Group,
191+
Version: GroupVersion.Version,
192+
Resource: "invitationredeemrequests",
193+
}
194+
}
195+
196+
// 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.
197+
// If false, the resource is expected to implement MultiVersionObject interface.
198+
func (o *InvitationRedeemRequest) IsStorageVersion() bool {
199+
return true
200+
}
201+
202+
// NamespaceScoped returns true if the object is namespaced
203+
func (o *InvitationRedeemRequest) NamespaceScoped() bool {
204+
return false
205+
}
206+
207+
// New returns a new instance of the resource
208+
func (o *InvitationRedeemRequest) New() runtime.Object {
209+
return &InvitationRedeemRequest{}
210+
}
211+
212+
// NewList return a new list instance of the resource
213+
func (o *InvitationRedeemRequest) NewList() runtime.Object {
214+
return &InvitationRedeemRequestList{}
215+
}
216+
217+
var _ resource.ObjectList = &InvitationRedeemRequestList{}
218+
219+
// +kubebuilder:object:root=true
220+
// InvitationList contains a list of Invitations
221+
type InvitationRedeemRequestList struct {
222+
metav1.TypeMeta `json:",inline"`
223+
metav1.ListMeta `json:"metadata,omitempty"`
224+
225+
Items []InvitationRedeemRequest `json:"items"`
226+
}
227+
228+
// GetListMeta returns the list meta reference.
229+
func (in *InvitationRedeemRequestList) GetListMeta() *metav1.ListMeta {
230+
return &in.ListMeta
231+
}
232+
169233
func init() {
170-
SchemeBuilder.Register(&Invitation{}, &InvitationList{})
234+
SchemeBuilder.Register(&Invitation{}, &InvitationList{}, &InvitationRedeemRequest{})
171235
}

apis/user/v1/zz_generated.deepcopy.go

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apiserver/user/invitation_redeem.go

Lines changed: 75 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88
"time"
99

10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
1011
apimeta "k8s.io/apimachinery/pkg/api/meta"
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1213
"k8s.io/apimachinery/pkg/runtime"
@@ -17,96 +18,102 @@ import (
1718
"sigs.k8s.io/controller-runtime/pkg/client"
1819

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

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

28-
var _ rest.Connecter = &invitationRedeemer{}
29-
var _ rest.StandardStorage = &invitationRedeemer{}
28+
var _ rest.Creater = &invitationRedeemer{}
29+
var _ rest.Storage = &invitationRedeemer{}
3030
var _ rest.Scoper = &invitationRedeemer{}
3131

3232
type invitationRedeemer struct {
33-
secretstorage.ScopedStandardStorage
3433
client client.Client
3534

3635
usernamePrefix string
3736
}
3837

39-
func (ir *invitationRedeemer) ConnectMethods() []string {
40-
return []string{"REDEEM"}
38+
func (ir invitationRedeemer) NamespaceScoped() bool {
39+
return false
4140
}
4241

43-
func (ir *invitationRedeemer) NewConnectOptions() (runtime.Object, bool, string) {
44-
return &userv1.RedeemOptions{}, false, ""
42+
func (ir invitationRedeemer) New() runtime.Object {
43+
return &userv1.InvitationRedeemRequest{}
4544
}
4645

47-
// Connect implements the REDEEM method for invitations.
48-
// It is used to redeem an invitation by a user.
46+
func (ir invitationRedeemer) Destroy() {}
47+
48+
// Create implements redeeming invitations, it accepts `InvitationRedeemRequest`.
4949
// The user is identified by the username in the request context.
50-
// The token is taken from the path.
5150
// 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.
5251
// The snapshot is later used in a controller to add the user to the targets in an idempotent and retryable way.
5352
// If user or token are invalid, the request is rejected with a 403.
54-
func (s *invitationRedeemer) Connect(ctx context.Context, name string, options runtime.Object, responder rest.Responder) (http.Handler, error) {
55-
l := klog.FromContext(ctx).WithName("InvitationRedeemer.Connect").WithValues("invitation", name)
56-
opts := options.(*userv1.RedeemOptions)
57-
// Might come from the path, so we need to trim the leading slash
58-
token := strings.TrimLeft(opts.Token, "/")
59-
60-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
61-
inv := &userv1.Invitation{}
62-
if err := s.client.Get(ctx, client.ObjectKey{Name: name}, inv); err != nil {
63-
responder.Error(err)
64-
return
65-
}
53+
func (s *invitationRedeemer) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, opts *metav1.CreateOptions) (runtime.Object, error) {
54+
irr, ok := obj.(*userv1.InvitationRedeemRequest)
55+
if !ok {
56+
return nil, fmt.Errorf("not an InvitationRedeemRequest: %#v", obj)
57+
}
6658

67-
tokenValid := inv.Status.Token != "" && inv.Status.ValidUntil.After(time.Now())
68-
if inv.IsRedeemed() || !tokenValid || inv.Status.Token != token {
69-
l.Info("invalid token")
70-
forbidden(responder)
71-
return
72-
}
59+
name := irr.Name
60+
token := irr.Token
7361

74-
user, ok := userFrom(ctx, s.usernamePrefix)
75-
if !ok {
76-
l.Info("no allowed user found in request context", "usernamePrefix", s.usernamePrefix)
77-
forbidden(responder)
78-
return
79-
}
62+
l := klog.FromContext(ctx).WithName("InvitationRedeemer.Create").WithValues("invitation", name)
8063

81-
ts := make([]userv1.TargetStatus, len(inv.Spec.TargetRefs))
82-
for i, target := range inv.Spec.TargetRefs {
83-
ts[i] = userv1.TargetStatus{
84-
TargetRef: target,
85-
Condition: metav1.Condition{
86-
Type: userv1.ConditionRedeemed,
87-
Status: metav1.ConditionUnknown,
88-
},
89-
}
90-
}
64+
inv := &userv1.Invitation{}
65+
if err := s.client.Get(ctx, client.ObjectKey{Name: name}, inv); err != nil {
66+
return nil, fmt.Errorf("failed to get invitation: %w", err)
67+
}
68+
69+
if inv.Status.Token == "" {
70+
l.Info("token is empty")
71+
return nil, errForbidden()
72+
}
73+
if !inv.Status.ValidUntil.After(time.Now()) {
74+
l.Info("invitation is expired")
75+
return nil, errForbidden()
76+
}
77+
if inv.IsRedeemed() {
78+
l.Info("invitation is already redeemed")
79+
return nil, errForbidden()
80+
}
81+
if inv.Status.Token != token {
82+
l.Info("token does not match")
83+
return nil, errForbidden()
84+
}
85+
86+
user, ok := userFrom(ctx, s.usernamePrefix)
87+
if !ok {
88+
l.Info("no allowed user found in request context", "usernamePrefix", s.usernamePrefix)
89+
return nil, errForbidden()
90+
}
9191

92-
inv.Status.TargetStatuses = ts
93-
inv.Status.RedeemedBy = user.GetName()
94-
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{
95-
Type: userv1.ConditionRedeemed,
96-
Status: metav1.ConditionTrue,
97-
Reason: userv1.ConditionRedeemed,
98-
Message: fmt.Sprintf("Redeemed by %q", user.GetName()),
99-
})
100-
101-
if err := s.client.Status().Update(ctx, inv); err != nil {
102-
responder.Error(err)
103-
return
92+
ts := make([]userv1.TargetStatus, len(inv.Spec.TargetRefs))
93+
for i, target := range inv.Spec.TargetRefs {
94+
ts[i] = userv1.TargetStatus{
95+
TargetRef: target,
96+
Condition: metav1.Condition{
97+
Type: userv1.ConditionRedeemed,
98+
Status: metav1.ConditionUnknown,
99+
},
104100
}
101+
}
105102

106-
responder.Object(http.StatusOK, &metav1.Status{
107-
Status: metav1.StatusSuccess,
108-
})
109-
}), nil
103+
inv.Status.TargetStatuses = ts
104+
inv.Status.RedeemedBy = user.GetName()
105+
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{
106+
Type: userv1.ConditionRedeemed,
107+
Status: metav1.ConditionTrue,
108+
Reason: userv1.ConditionRedeemed,
109+
Message: fmt.Sprintf("Redeemed by %q", user.GetName()),
110+
})
111+
112+
if err := s.client.Status().Update(ctx, inv); err != nil {
113+
return nil, fmt.Errorf("failed to update invitation: %w", err)
114+
}
115+
116+
return irr, nil
110117
}
111118

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

131-
func forbidden(resp rest.Responder) {
132-
resp.Object(http.StatusForbidden, &metav1.Status{
133-
Status: metav1.StatusFailure,
134-
Code: http.StatusForbidden,
135-
Reason: metav1.StatusReasonUnauthorized,
136-
})
138+
func errForbidden() *apierrors.StatusError {
139+
return &apierrors.StatusError{
140+
ErrStatus: metav1.Status{
141+
Status: metav1.StatusFailure,
142+
Code: http.StatusForbidden,
143+
Reason: metav1.StatusReasonForbidden,
144+
}}
137145
}

0 commit comments

Comments
 (0)