Skip to content

Commit b4b5488

Browse files
authored
Merge pull request #42 from datum-cloud/feat/add-user-controller
feat: add UserController for managing user lifecycle and finalization with Zitadel integration
2 parents 529af9c + 5f5b5ff commit b4b5488

File tree

4 files changed

+268
-0
lines changed

4 files changed

+268
-0
lines changed

cmd/controller/controller.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,15 @@ func runController(cfg *config.ControllerConfig, globalConfig *config.GlobalConf
416416
os.Exit(1)
417417
}
418418

419+
// Setup UserController on core control plane manager
420+
if err = (&controller.UserController{
421+
Client: coreControlPlaneMgr.GetClient(),
422+
Zitadel: zitadelHtppClient,
423+
}).SetupWithManager(coreControlPlaneMgr); err != nil {
424+
setupLog.Error(err, "unable to create controller", "controller", "User")
425+
os.Exit(1)
426+
}
427+
419428
// Add core control plane manager as a runnable that starts only when main manager is leader
420429
if err := mgr.Add(&coreControlPlaneRunnable{
421430
mgr: mgr,

config/rbac/role.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ rules:
2222
- machineaccounts/finalizers
2323
- userdeactivations/finalizers
2424
- userdeactivations/status
25+
- users/finalizers
2526
verbs:
2627
- update
2728
- apiGroups:
@@ -47,6 +48,10 @@ rules:
4748
- users
4849
verbs:
4950
- get
51+
- list
52+
- patch
53+
- update
54+
- watch
5055
- apiGroups:
5156
- infrastructure.miloapis.com
5257
resources:
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
iammiloapiscomv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1"
8+
"k8s.io/apimachinery/pkg/api/errors"
9+
ctrl "sigs.k8s.io/controller-runtime"
10+
"sigs.k8s.io/controller-runtime/pkg/client"
11+
"sigs.k8s.io/controller-runtime/pkg/finalizer"
12+
logf "sigs.k8s.io/controller-runtime/pkg/log"
13+
"sigs.k8s.io/controller-runtime/pkg/manager"
14+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
15+
16+
"go.miloapis.com/auth-provider-zitadel/internal/zitadel"
17+
)
18+
19+
const (
20+
userFinalizerKey = "iam.miloapis.com/user"
21+
)
22+
23+
// UserController reconciles User objects to handle deletions and Zitadel cleanup.
24+
type UserController struct {
25+
Client client.Client
26+
Finalizers finalizer.Finalizers
27+
Zitadel *zitadel.Client
28+
}
29+
30+
type userFinalizer struct {
31+
Zitadel *zitadel.Client
32+
}
33+
34+
// Finalize implements finalizer.Finalizer for User resources.
35+
func (f *userFinalizer) Finalize(ctx context.Context, obj client.Object) (finalizer.Result, error) {
36+
log := logf.FromContext(ctx).WithName("user-finalizer")
37+
38+
user, ok := obj.(*iammiloapiscomv1alpha1.User)
39+
if !ok {
40+
err := fmt.Errorf("unexpected object type %T, expected User", obj)
41+
log.Error(err, "Type assertion failed")
42+
return finalizer.Result{}, err
43+
}
44+
45+
log.Info("Finalizing User deletion", "userName", user.GetName(), "userUID", user.GetUID())
46+
47+
// Verify if the user exists in Zitadel
48+
_, err := f.Zitadel.GetUser(ctx, user.GetName())
49+
if err != nil {
50+
if errors.IsNotFound(err) {
51+
log.Info("User not found in Zitadel, skipping deletion in Zitadel", "userName", user.GetName())
52+
return finalizer.Result{}, nil
53+
} else {
54+
log.Error(err, "Failed to get user from Zitadel", "userName", user.GetName())
55+
return finalizer.Result{}, fmt.Errorf("failed to get user from Zitadel: %w", err)
56+
}
57+
}
58+
59+
// Delete the user in Zitadel.
60+
if err := f.Zitadel.DeleteUser(ctx, user.GetName()); err != nil {
61+
log.Error(err, "Failed to delete user in Zitadel", "userName", user.GetName())
62+
return finalizer.Result{}, fmt.Errorf("failed to delete user in Zitadel: %w", err)
63+
}
64+
65+
log.Info("Successfully deleted user in Zitadel", "userName", user.GetName())
66+
67+
return finalizer.Result{}, nil
68+
}
69+
70+
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=users,verbs=get;update;list;watch;patch
71+
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=users/finalizers,verbs=update
72+
73+
// Reconcile executes the reconciliation loop for User resources.
74+
func (r *UserController) Reconcile(ctx context.Context, req reconcile.Request) (ctrl.Result, error) {
75+
log := logf.FromContext(ctx).WithName("user-reconciler")
76+
log.Info("Starting reconciliation", "request", req)
77+
78+
user := &iammiloapiscomv1alpha1.User{}
79+
if err := r.Client.Get(ctx, req.NamespacedName, user); err != nil {
80+
if errors.IsNotFound(err) {
81+
log.Info("User resource not found. Ignoring since object must be deleted.")
82+
return ctrl.Result{}, nil
83+
}
84+
log.Error(err, "Failed to get User resource")
85+
return ctrl.Result{}, fmt.Errorf("failed to get User resource: %w", err)
86+
}
87+
88+
// Run finalizers.
89+
finalizeResult, err := r.Finalizers.Finalize(ctx, user)
90+
if err != nil {
91+
log.Error(err, "Failed to run finalizers for User")
92+
return ctrl.Result{}, fmt.Errorf("failed to run finalizers for User: %w", err)
93+
}
94+
if finalizeResult.Updated {
95+
log.Info("Finalizer updated the User object, updating API server")
96+
if updateErr := r.Client.Update(ctx, user); updateErr != nil {
97+
log.Error(updateErr, "Failed to update User after finalizer update")
98+
return ctrl.Result{}, fmt.Errorf("failed to update User after finalizer update: %w", updateErr)
99+
}
100+
return ctrl.Result{}, nil
101+
}
102+
103+
log.Info("Reconciliation complete")
104+
return ctrl.Result{}, nil
105+
}
106+
107+
// SetupWithManager registers the controller with the provided manager.
108+
func (r *UserController) SetupWithManager(mgr manager.Manager) error {
109+
r.Finalizers = finalizer.NewFinalizers()
110+
if err := r.Finalizers.Register(userFinalizerKey, &userFinalizer{
111+
Zitadel: r.Zitadel,
112+
}); err != nil {
113+
return fmt.Errorf("failed to register user finalizer: %w", err)
114+
}
115+
116+
return ctrl.NewControllerManagedBy(mgr).
117+
For(&iammiloapiscomv1alpha1.User{}).
118+
Named("user").
119+
Complete(r)
120+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
"time"
9+
10+
ginkgo "github.com/onsi/ginkgo/v2"
11+
gomega "github.com/onsi/gomega"
12+
13+
iammiloapiscomv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1"
14+
"k8s.io/apimachinery/pkg/runtime"
15+
"k8s.io/apimachinery/pkg/types"
16+
ctrl "sigs.k8s.io/controller-runtime"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
18+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
19+
"sigs.k8s.io/controller-runtime/pkg/finalizer"
20+
21+
"go.miloapis.com/auth-provider-zitadel/internal/zitadel"
22+
"golang.org/x/oauth2"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
)
25+
26+
var _ = ginkgo.Describe("UserController", func() {
27+
var (
28+
scheme *runtime.Scheme
29+
k8sClient client.Client
30+
ctx context.Context
31+
)
32+
33+
ginkgo.BeforeEach(func() {
34+
ctx = context.TODO()
35+
scheme = runtime.NewScheme()
36+
gomega.Expect(iammiloapiscomv1alpha1.AddToScheme(scheme)).To(gomega.Succeed())
37+
k8sClient = fake.NewClientBuilder().WithScheme(scheme).Build()
38+
})
39+
40+
ginkgo.Context("Reconcile", func() {
41+
ginkgo.It("should ignore requests for missing User resources", func() {
42+
r := &UserController{
43+
Client: k8sClient,
44+
Finalizers: finalizer.NewFinalizers(),
45+
}
46+
47+
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "ghost"}}
48+
res, err := r.Reconcile(ctx, req)
49+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
50+
gomega.Expect(res.RequeueAfter).To(gomega.Equal(time.Duration(0)))
51+
})
52+
})
53+
54+
ginkgo.Context("userFinalizer", func() {
55+
ginkgo.It("should skip deletion when user not found in Zitadel", func() {
56+
var getCalls, deleteCalls int32
57+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
if r.Method == http.MethodGet {
59+
atomic.AddInt32(&getCalls, 1)
60+
http.NotFound(w, r)
61+
return
62+
}
63+
ginkgo.GinkgoT().Errorf("unexpected method %s", r.Method)
64+
}))
65+
defer ts.Close()
66+
67+
client := zitadel.NewClientWithTokenSource(ts.URL, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test", TokenType: "Bearer"}))
68+
f := &userFinalizer{Zitadel: client}
69+
70+
user := &iammiloapiscomv1alpha1.User{ObjectMeta: metav1.ObjectMeta{Name: "john"}}
71+
res, err := f.Finalize(ctx, user)
72+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
73+
gomega.Expect(res.Updated).To(gomega.BeFalse())
74+
gomega.Expect(getCalls).To(gomega.BeNumerically(">=", 1))
75+
gomega.Expect(deleteCalls).To(gomega.BeZero())
76+
})
77+
78+
ginkgo.It("should delete user when present in Zitadel", func() {
79+
var deleteCalls int32
80+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+
if r.Method == http.MethodGet {
82+
w.Header().Set("Content-Type", "application/json")
83+
w.WriteHeader(http.StatusOK)
84+
_, _ = w.Write([]byte(`{"user": {"userId": "john"}, "details": {}}`))
85+
return
86+
}
87+
if r.Method == http.MethodDelete {
88+
atomic.AddInt32(&deleteCalls, 1)
89+
w.WriteHeader(http.StatusOK)
90+
return
91+
}
92+
ginkgo.GinkgoT().Errorf("unexpected method %s", r.Method)
93+
}))
94+
defer ts.Close()
95+
96+
client := zitadel.NewClientWithTokenSource(ts.URL, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test", TokenType: "Bearer"}))
97+
f := &userFinalizer{Zitadel: client}
98+
user := &iammiloapiscomv1alpha1.User{ObjectMeta: metav1.ObjectMeta{Name: "john"}}
99+
100+
res, err := f.Finalize(ctx, user)
101+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
102+
gomega.Expect(res.Updated).To(gomega.BeFalse())
103+
gomega.Expect(deleteCalls).To(gomega.Equal(int32(1)))
104+
})
105+
})
106+
107+
ginkgo.Context("Finalizer registration", func() {
108+
ginkgo.It("should add the user finalizer on first reconcile", func() {
109+
// Arrange: create a User without finalizers in the fake cluster
110+
user := &iammiloapiscomv1alpha1.User{
111+
ObjectMeta: metav1.ObjectMeta{Name: "alice"},
112+
}
113+
gomega.Expect(k8sClient.Create(ctx, user)).To(gomega.Succeed())
114+
115+
// Setup controller with proper finalizer registration (no Zitadel interaction needed)
116+
finalizers := finalizer.NewFinalizers()
117+
gomega.Expect(finalizers.Register(userFinalizerKey, &userFinalizer{})).To(gomega.Succeed())
118+
119+
r := &UserController{
120+
Client: k8sClient,
121+
Finalizers: finalizers,
122+
}
123+
124+
// Act: reconcile the user
125+
_, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "alice"}})
126+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
127+
128+
// Assert: the user object now contains the finalizer key
129+
updated := &iammiloapiscomv1alpha1.User{}
130+
gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "alice"}, updated)).To(gomega.Succeed())
131+
gomega.Expect(updated.GetFinalizers()).To(gomega.ContainElement(userFinalizerKey))
132+
})
133+
})
134+
})

0 commit comments

Comments
 (0)