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 {}
3030var _ rest.Scoper = & invitationRedeemer {}
3131
3232type 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