diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 80aa180..2fe8f15 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -28,6 +28,29 @@ rules: - get - patch - update +- apiGroups: + - appuio.io + resources: + - teams + verbs: + - get + - patch + - update + - watch +- apiGroups: + - appuio.io + resources: + - teams/finalizers + verbs: + - update +- apiGroups: + - appuio.io + resources: + - teams/status + verbs: + - get + - patch + - update - apiGroups: - organization.appuio.io resources: diff --git a/controllers/ZZ_mock_keycloak_test.go b/controllers/ZZ_mock_keycloak_test.go index de75804..1ae9b83 100644 --- a/controllers/ZZ_mock_keycloak_test.go +++ b/controllers/ZZ_mock_keycloak_test.go @@ -36,17 +36,22 @@ func (m *MockKeycloakClient) EXPECT() *MockKeycloakClientMockRecorder { } // DeleteGroup mocks base method. -func (m *MockKeycloakClient) DeleteGroup(ctx context.Context, groupName string) error { +func (m *MockKeycloakClient) DeleteGroup(ctx context.Context, path ...string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteGroup", ctx, groupName) + varargs := []interface{}{ctx} + for _, a := range path { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteGroup", varargs...) ret0, _ := ret[0].(error) return ret0 } // DeleteGroup indicates an expected call of DeleteGroup. -func (mr *MockKeycloakClientMockRecorder) DeleteGroup(ctx, groupName interface{}) *gomock.Call { +func (mr *MockKeycloakClientMockRecorder) DeleteGroup(ctx interface{}, path ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockKeycloakClient)(nil).DeleteGroup), ctx, groupName) + varargs := append([]interface{}{ctx}, path...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockKeycloakClient)(nil).DeleteGroup), varargs...) } // ListGroups mocks base method. diff --git a/controllers/organization_controller.go b/controllers/organization_controller.go index b0c9782..6fc6caf 100644 --- a/controllers/organization_controller.go +++ b/controllers/organization_controller.go @@ -23,9 +23,6 @@ type OrganizationReconciler struct { Scheme *runtime.Scheme Keycloak KeycloakClient - - // ClusteRoles to give to group members when importing - SyncClusterRoles []string } //go:generate go run github.com/golang/mock/mockgen -destination=./ZZ_mock_eventrecorder_test.go -package controllers_test k8s.io/client-go/tools/record EventRecorder @@ -35,7 +32,7 @@ type OrganizationReconciler struct { // KeycloakClient is an abstraction to interact with the Keycloak API type KeycloakClient interface { PutGroup(ctx context.Context, group keycloak.Group) (keycloak.Group, error) - DeleteGroup(ctx context.Context, groupName string) error + DeleteGroup(ctx context.Context, path ...string) error ListGroups(ctx context.Context) ([]keycloak.Group, error) } @@ -166,10 +163,7 @@ func buildKeycloakGroup(org *orgv1.Organization, memb *controlv1.OrganizationMem groupMem = append(groupMem, u.Name) } - return keycloak.Group{ - Name: org.Name, - Members: groupMem, - } + return keycloak.NewGroup(org.Name).WithMembers(groupMem...) } // SetupWithManager sets up the controller with the Manager. diff --git a/controllers/organization_controller_test.go b/controllers/organization_controller_test.go index d788edc..1bf96d4 100644 --- a/controllers/organization_controller_test.go +++ b/controllers/organization_controller_test.go @@ -24,11 +24,11 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) -func Test_Reconcile_Success(t *testing.T) { +func Test_OrganizationController_Reconcile_Success(t *testing.T) { ctx := context.Background() c, keyMock, _ := prepareTest(t, fooOrg, fooMemb) - group := keycloak.Group{Name: "foo", Members: []string{"bar", "bar3"}} + group := keycloak.NewGroup("foo").WithMembers("bar", "bar3") keyMock.EXPECT(). PutGroup(gomock.Any(), group). Return(group, nil). @@ -55,11 +55,11 @@ func Test_Reconcile_Success(t *testing.T) { assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", newMemb.Finalizers[0], "expected finalizer") } -func Test_Reconcile_Failure(t *testing.T) { +func Test_OrganizationController_Reconcile_Failure(t *testing.T) { ctx := context.Background() c, keyMock, erMock := prepareTest(t, fooOrg, fooMemb) - group := keycloak.Group{Name: "foo", Members: []string{"bar", "bar3"}} + group := keycloak.NewGroup("foo").WithMembers("bar", "bar3") keyMock.EXPECT(). PutGroup(gomock.Any(), group). Return(keycloak.Group{}, errors.New("create failed")). @@ -91,11 +91,11 @@ func Test_Reconcile_Failure(t *testing.T) { assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", newMemb.Finalizers[0], "expected finalizer") } -func Test_Reconcile_Member_Failure(t *testing.T) { +func Test_OrganizationController_Reconcile_Member_Failure(t *testing.T) { ctx := context.Background() c, keyMock, erMock := prepareTest(t, fooOrg, fooMemb) - group := keycloak.Group{Name: "foo", Members: []string{"bar", "bar3"}} + group := keycloak.NewGroup("foo").WithMembers("bar", "bar3") keyMock.EXPECT(). PutGroup(gomock.Any(), group). Return(keycloak.Group{}, &keycloak.MembershipSyncErrors{ @@ -141,7 +141,7 @@ func Test_Reconcile_Member_Failure(t *testing.T) { assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", newMemb.Finalizers[0], "expected finalizer") } -func Test_Reconcile_Delete(t *testing.T) { +func Test_OrganizationController_Reconcile_Delete(t *testing.T) { ctx := context.Background() org := *fooOrg @@ -170,7 +170,7 @@ func Test_Reconcile_Delete(t *testing.T) { require.Error(t, c.Get(ctx, types.NamespacedName{Name: "foo"}, &newOrg)) } -func Test_Reconcile_Delete_Failure(t *testing.T) { +func Test_OrganizationController_Reconcile_Delete_Failure(t *testing.T) { ctx := context.Background() org := *fooOrg @@ -205,7 +205,7 @@ func Test_Reconcile_Delete_Failure(t *testing.T) { } // Reconcile should ignore organizations that are being imported -func Test_Reconcile_Ignore(t *testing.T) { +func Test_OrganizationController_Reconcile_Ignore(t *testing.T) { ctx := context.Background() org := *fooOrg diff --git a/controllers/organization_controller_sync.go b/controllers/periodic_syncer.go similarity index 52% rename from controllers/organization_controller_sync.go rename to controllers/periodic_syncer.go index bb5d328..f406db8 100644 --- a/controllers/organization_controller_sync.go +++ b/controllers/periodic_syncer.go @@ -12,14 +12,28 @@ import ( 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/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" ) -var orgImportAnnot = "keycloak-adapter.vshn.net/importing" +const orgImportAnnot = "keycloak-adapter.vshn.net/importing" + +// PeriodicSyncer reconciles a Organization object +type PeriodicSyncer struct { + client.Client + Recorder record.EventRecorder + + Keycloak KeycloakClient + + // SyncClusterRoles to give to group members when importing + SyncClusterRoles []string +} // Sync lists all Keycloak groups in the realm and creates corresponding Organizations if they do not exist -func (r *OrganizationReconciler) Sync(ctx context.Context) error { +func (r *PeriodicSyncer) Sync(ctx context.Context) error { logger := log.FromContext(ctx) gs, err := r.Keycloak.ListGroups(ctx) @@ -27,14 +41,14 @@ func (r *OrganizationReconciler) Sync(ctx context.Context) error { return fmt.Errorf("cannot list Keycloak groups: %w", err) } - orgMap, err := r.fetchOrganiztionMap(ctx) + orgMap, err := r.fetchOrganizationMap(ctx) if err != nil { return fmt.Errorf("cannot list Organizations: %w", err) } var groupErr error for _, g := range gs { - org, err := r.syncGroup(ctx, g, orgMap[g.Name]) + org, err := r.syncGroup(ctx, g, orgMap) if err != nil { if groupErr == nil { groupErr = errors.New("") @@ -43,7 +57,7 @@ func (r *OrganizationReconciler) Sync(ctx context.Context) error { if org != nil { r.Recorder.Event(org, "Warning", "ImportFailed", err.Error()) } - groupErr = fmt.Errorf("%w\n%s: %s", groupErr, g.Name, err.Error()) + groupErr = fmt.Errorf("%w\n%s: %s", groupErr, g.BaseName(), err.Error()) } } if groupErr != nil { @@ -52,7 +66,45 @@ func (r *OrganizationReconciler) Sync(ctx context.Context) error { return nil } -func (r *OrganizationReconciler) syncGroup(ctx context.Context, g keycloak.Group, org *orgv1.Organization) (*orgv1.Organization, error) { +func (r *PeriodicSyncer) syncGroup(ctx context.Context, g keycloak.Group, orgMap map[string]*orgv1.Organization) (runtime.Object, error) { + logger := log.FromContext(ctx) + + const depth = 0 + switch len(g.PathMembers()) - depth { + case 1: + return r.syncOrganization(ctx, g, orgMap[g.BaseName()]) + case 2: + return r.syncTeam(ctx, g) + } + + logger.Info("skipped syncing group. invalid hierarchy", "group", g) + return nil, nil +} + +func (r *PeriodicSyncer) syncTeam(ctx context.Context, g keycloak.Group) (*controlv1.Team, error) { + logger := log.FromContext(ctx) + var err error + + path := g.PathMembers() + teamKey := types.NamespacedName{Namespace: path[len(path)-2], Name: path[len(path)-1]} + + team := &controlv1.Team{} + err = r.Client.Get(ctx, teamKey, team) + if err != nil && apierrors.IsNotFound(err) { + logger.V(1).WithValues("group", g).Info("creating team") + t, err := r.createTeam(ctx, teamKey.Namespace, teamKey.Name, g.Members) + if err != nil { + return nil, fmt.Errorf("error creating team %+v: %w", teamKey, err) + } + team = t + } else if err != nil { + return nil, fmt.Errorf("error getting team %+v: %w", teamKey, err) + } + + return team, nil +} + +func (r *PeriodicSyncer) syncOrganization(ctx context.Context, g keycloak.Group, org *orgv1.Organization) (*orgv1.Organization, error) { logger := log.FromContext(ctx) var err error @@ -81,7 +133,7 @@ func (r *OrganizationReconciler) syncGroup(ctx context.Context, g keycloak.Group return org, err } -func (r *OrganizationReconciler) fetchOrganiztionMap(ctx context.Context) (map[string]*orgv1.Organization, error) { +func (r *PeriodicSyncer) fetchOrganizationMap(ctx context.Context) (map[string]*orgv1.Organization, error) { orgs := orgv1.OrganizationList{} err := r.List(ctx, &orgs) if err != nil { @@ -95,32 +147,51 @@ func (r *OrganizationReconciler) fetchOrganiztionMap(ctx context.Context) (map[s return orgMap, nil } -func (r *OrganizationReconciler) startImportOrganizationFromGroup(ctx context.Context, group keycloak.Group) (*orgv1.Organization, error) { +func (r *PeriodicSyncer) createTeam(ctx context.Context, namespace, name string, members []string) (*controlv1.Team, error) { + team := &controlv1.Team{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: controlv1.TeamSpec{ + DisplayName: name, + }, + } + + team.Spec.UserRefs = make([]controlv1.UserRef, len(members)) + for i, m := range members { + team.Spec.UserRefs[i] = controlv1.UserRef{Name: m} + } + err := r.Create(ctx, team) + return team, err +} + +func (r *PeriodicSyncer) startImportOrganizationFromGroup(ctx context.Context, group keycloak.Group) (*orgv1.Organization, error) { org := &orgv1.Organization{ ObjectMeta: metav1.ObjectMeta{ - Name: group.Name, + Name: group.BaseName(), Annotations: map[string]string{ orgImportAnnot: "true", }, }, Spec: orgv1.OrganizationSpec{ - DisplayName: group.Name, + DisplayName: group.BaseName(), }, } err := r.Create(ctx, org) return org, err } -func (r *OrganizationReconciler) finishImportOrganizationFromGroup(ctx context.Context, org *orgv1.Organization) error { +func (r *PeriodicSyncer) finishImportOrganizationFromGroup(ctx context.Context, org *orgv1.Organization) error { delete(org.Annotations, orgImportAnnot) return r.Update(ctx, org) } -func (r *OrganizationReconciler) updateOrganizationMembersFromGroup(ctx context.Context, group keycloak.Group) error { +func (r *PeriodicSyncer) updateOrganizationMembersFromGroup(ctx context.Context, group keycloak.Group) error { orgMemb := controlv1.OrganizationMembers{} err := r.Get(ctx, types.NamespacedName{ - Namespace: group.Name, + Namespace: group.BaseName(), Name: "members", }, &orgMemb) if err != nil { @@ -133,7 +204,7 @@ func (r *OrganizationReconciler) updateOrganizationMembersFromGroup(ctx context. return r.Update(ctx, &orgMemb) } -func (r *OrganizationReconciler) setRolebindingsFromGroup(ctx context.Context, group keycloak.Group) error { +func (r *PeriodicSyncer) setRolebindingsFromGroup(ctx context.Context, group keycloak.Group) error { subjects := []rbacv1.Subject{} for _, m := range group.Members { subjects = append(subjects, rbacv1.Subject{ @@ -146,14 +217,14 @@ func (r *OrganizationReconciler) setRolebindingsFromGroup(ctx context.Context, g for _, rbName := range r.SyncClusterRoles { rb := rbacv1.RoleBinding{} - err := r.Get(ctx, types.NamespacedName{Namespace: group.Name, Name: rbName}, &rb) + err := r.Get(ctx, types.NamespacedName{Namespace: group.BaseName(), Name: rbName}, &rb) if err != nil && !apierrors.IsNotFound(err) { return err } if apierrors.IsNotFound(err) { rb := rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Namespace: group.Name, + Namespace: group.BaseName(), Name: rbName, }, Subjects: subjects, diff --git a/controllers/organization_controller_sync_test.go b/controllers/periodic_syncer_test.go similarity index 87% rename from controllers/organization_controller_sync_test.go rename to controllers/periodic_syncer_test.go index 23ead2a..8063f9a 100644 --- a/controllers/organization_controller_sync_test.go +++ b/controllers/periodic_syncer_test.go @@ -16,7 +16,6 @@ import ( rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) @@ -47,17 +46,17 @@ func Test_Sync_Success(t *testing.T) { ) groups := []keycloak.Group{ - {Name: "bar", Members: []string{"bar", "bar3"}}, + keycloak.NewGroup("bar").WithMembers("bar", "bar3"), + keycloak.NewGroup("bar", "bar-team").WithMembers("bar-tm-1", "bar-tm-2"), } keyMock.EXPECT(). ListGroups(gomock.Any()). Return(groups, nil). Times(1) - err := (&OrganizationReconciler{ + err := (&PeriodicSyncer{ Client: c, Recorder: erMock, - Scheme: &runtime.Scheme{}, Keycloak: keyMock, SyncClusterRoles: []string{"import-role", "existing-role"}, }).Sync(ctx) @@ -100,6 +99,12 @@ func Test_Sync_Success(t *testing.T) { }, }, rb.Subjects, "update exiting role") + newTeam := controlv1.Team{} + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: "bar-team", Namespace: "bar"}, &newTeam), "create team under organization") + assert.ElementsMatch(t, []controlv1.UserRef{ + {Name: "bar-tm-1"}, + {Name: "bar-tm-2"}, + }, newTeam.Spec.UserRefs, "user refs for created team") } func Test_Sync_Fail_Update(t *testing.T) { @@ -114,8 +119,8 @@ func Test_Sync_Fail_Update(t *testing.T) { // By not adding buzzMember manually we simulate an error while updating the members resource groups := []keycloak.Group{ - {Name: "buzz", Members: []string{"buzz1", "buzz"}}, - {Name: "bar", Members: []string{"bar", "bar3"}}, + keycloak.NewGroup("buzz").WithMembers("buzz1", "buzz"), + keycloak.NewGroup("bar").WithMembers("bar", "bar3"), } keyMock.EXPECT(). ListGroups(gomock.Any()). @@ -125,10 +130,9 @@ func Test_Sync_Fail_Update(t *testing.T) { Event(gomock.Any(), "Warning", "ImportFailed", gomock.Any()). Times(1) - err := (&OrganizationReconciler{ + err := (&PeriodicSyncer{ Client: c, Recorder: erMock, - Scheme: &runtime.Scheme{}, Keycloak: keyMock, }).Sync(ctx) assert.Error(t, err) @@ -155,16 +159,15 @@ func Test_Sync_Skip_Existing(t *testing.T) { c, keyMock, _ := prepareTest(t, fooOrg, fooMemb) // We need to add barMember manually as there is no control API in the tests creating them groups := []keycloak.Group{ - {Name: "foo", Members: []string{"foo", "foo2"}}, + keycloak.NewGroup("foo").WithMembers("foo", "foo2"), } keyMock.EXPECT(). ListGroups(gomock.Any()). Return(groups, nil). Times(1) - err := (&OrganizationReconciler{ + err := (&PeriodicSyncer{ Client: c, - Scheme: &runtime.Scheme{}, Keycloak: keyMock, }).Sync(ctx) require.NoError(t, err) diff --git a/controllers/team_controller.go b/controllers/team_controller.go new file mode 100644 index 0000000..274489a --- /dev/null +++ b/controllers/team_controller.go @@ -0,0 +1,121 @@ +package controllers + +import ( + "context" + "errors" + + controlv1 "github.com/appuio/control-api/apis/v1" + "github.com/vshn/appuio-keycloak-adapter/keycloak" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + 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" +) + +// TeamReconciler reconciles a Team object +type TeamReconciler struct { + client.Client + Recorder record.EventRecorder + Scheme *runtime.Scheme + + Keycloak KeycloakClient +} + +//+kubebuilder:rbac:groups=appuio.io,resources=teams,verbs=get;watch;update;patch +//+kubebuilder:rbac:groups=appuio.io,resources=teams/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=appuio.io,resources=teams/finalizers,verbs=update + +// Reconcile reacts on changes of teams and mirrors these changes to groups in Keycloak +func (r *TeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.V(4).WithValues("request", req).Info("Reconciling") + + log.V(4).Info("Getting the Team..") + team := &controlv1.Team{} + if err := r.Get(ctx, req.NamespacedName, team); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if !team.ObjectMeta.DeletionTimestamp.IsZero() { + log.V(4).Info("Deleting Keycloak group..") + err := r.Keycloak.DeleteGroup(ctx, team.Namespace, team.Name) + if err != nil { + r.Recorder.Event(team, "Warning", "DeletionFailed", "Failed to delete Keycloak Group") + return ctrl.Result{}, err + } + + err = r.removeFinalizer(ctx, team) + return ctrl.Result{}, err + } + err := r.addFinalizer(ctx, team) + if err != nil { + return ctrl.Result{}, err + } + + log.V(4).Info("Reconciling Keycloak group..") + group, err := r.Keycloak.PutGroup(ctx, buildTeamKeycloakGroup(team)) + var membErrs *keycloak.MembershipSyncErrors + if errors.As(err, &membErrs) { + for _, membErr := range *membErrs { + r.Recorder.Eventf(team, "Warning", string(membErr.Event), "Failed to update membership of user %s", membErr.Username) + log.Error(membErr, "Failed to update membership", "user", membErr.Username) + } + } else if err != nil { + r.Recorder.Event(team, "Warning", "UpdateFailed", "Failed to update Keycloak Group") + return ctrl.Result{}, err + } + + log.V(4).Info("Updating status..") + err = r.updateTeamStatus(ctx, team, group) + return ctrl.Result{}, err +} + +func (r *TeamReconciler) addFinalizer(ctx context.Context, team client.Object) error { + if !controllerutil.ContainsFinalizer(team, orgFinalizer) { + controllerutil.AddFinalizer(team, orgFinalizer) + if err := r.Update(ctx, team); err != nil { + return err + } + } + return nil +} + +func (r *TeamReconciler) removeFinalizer(ctx context.Context, team client.Object) error { + if controllerutil.ContainsFinalizer(team, orgFinalizer) { + controllerutil.RemoveFinalizer(team, orgFinalizer) + if err := r.Update(ctx, team); err != nil { + return err + } + } + return nil +} + +func (r *TeamReconciler) updateTeamStatus(ctx context.Context, team *controlv1.Team, group keycloak.Group) error { + userRefs := make([]controlv1.UserRef, 0, len(group.Members)) + for _, u := range group.Members { + userRefs = append(userRefs, controlv1.UserRef{ + Name: u, + }) + } + team.Status.ResolvedUserRefs = userRefs + return r.Status().Update(ctx, team) +} + +func buildTeamKeycloakGroup(team *controlv1.Team) keycloak.Group { + groupMem := make([]string, 0, len(team.Spec.UserRefs)) + + for _, u := range team.Spec.UserRefs { + groupMem = append(groupMem, u.Name) + } + + return keycloak.NewGroup(team.Namespace, team.Name).WithMembers(groupMem...) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TeamReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&controlv1.Team{}). + Complete(r) +} diff --git a/controllers/team_controller_test.go b/controllers/team_controller_test.go new file mode 100644 index 0000000..8cc03a1 --- /dev/null +++ b/controllers/team_controller_test.go @@ -0,0 +1,216 @@ +package controllers_test + +import ( + "context" + "errors" + "testing" + + controlv1 "github.com/appuio/control-api/apis/v1" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vshn/appuio-keycloak-adapter/keycloak" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + . "github.com/vshn/appuio-keycloak-adapter/controllers" +) + +func Test_TeamController_Reconcile_Success(t *testing.T) { + ctx := context.Background() + + c, keyMock, _ := prepareTest(t, barTeam) + group := keycloak.NewGroup(barTeam.Namespace, barTeam.Name).WithMembers("baz", "qux") + keyMock.EXPECT(). + PutGroup(gomock.Any(), group). + Return(group, nil). + Times(1) + + _, err := (&TeamReconciler{ + Client: c, + Scheme: &runtime.Scheme{}, + Keycloak: keyMock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, + }) + require.NoError(t, err) + + reconciledTeam := controlv1.Team{} + require.NoError(t, c.Get(ctx, types.NamespacedName{Namespace: barTeam.Namespace, Name: barTeam.Name}, &reconciledTeam)) + require.Len(t, reconciledTeam.Finalizers, 1, "has finalizer") + assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", reconciledTeam.Finalizers[0], "expected finalizer") +} + +func Test_TeamController_Reconcile_Failure(t *testing.T) { + ctx := context.Background() + + c, keyMock, erMock := prepareTest(t, barTeam) + group := keycloak.NewGroup(barTeam.Namespace, barTeam.Name).WithMembers("baz", "qux") + keyMock.EXPECT(). + PutGroup(gomock.Any(), group). + Return(keycloak.Group{}, errors.New("create failed")). + Times(1) + + erMock.EXPECT(). + Event(gomock.Any(), "Warning", "UpdateFailed", gomock.Any()). + Times(1) + + _, err := (&TeamReconciler{ + Client: c, + Scheme: &runtime.Scheme{}, + Keycloak: keyMock, + Recorder: erMock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, + }) + require.Error(t, err) + + reconciledTeam := controlv1.Team{} + require.NoError(t, c.Get(ctx, types.NamespacedName{Namespace: barTeam.Namespace, Name: barTeam.Name}, &reconciledTeam)) + require.Len(t, reconciledTeam.Finalizers, 1, "has finalizer") + assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", reconciledTeam.Finalizers[0], "expected finalizer") +} + +func Test_TeamController_Reconcile_Member_Failure(t *testing.T) { + ctx := context.Background() + + c, keyMock, erMock := prepareTest(t, barTeam) + group := keycloak.NewGroup(barTeam.Namespace, barTeam.Name).WithMembers("baz", "qux") + keyMock.EXPECT(). + PutGroup(gomock.Any(), group). + Return(keycloak.Group{}, &keycloak.MembershipSyncErrors{ + { + Err: errors.New("no user 'bar' found"), + Username: "bar", + Event: keycloak.UserAddError, + }, + { + Err: errors.New("permission denied"), + Username: "foo", + Event: keycloak.UserRemoveError, + }, + }). + Times(1) + + erMock.EXPECT(). + Eventf(gomock.Any(), "Warning", string(keycloak.UserRemoveError), gomock.Any(), "foo"). + Times(1) + erMock.EXPECT(). + Eventf(gomock.Any(), "Warning", string(keycloak.UserAddError), gomock.Any(), "bar"). + Times(1) + + _, err := (&TeamReconciler{ + Client: c, + Scheme: &runtime.Scheme{}, + Recorder: erMock, + Keycloak: keyMock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, + }) + require.NoError(t, err) + + reconciledTeam := controlv1.Team{} + require.NoError(t, c.Get(ctx, types.NamespacedName{Namespace: barTeam.Namespace, Name: barTeam.Name}, &reconciledTeam)) + require.Len(t, reconciledTeam.Finalizers, 1, "has finalizer") + assert.Equal(t, "keycloak-adapter.vshn.net/finalizer", reconciledTeam.Finalizers[0], "expected finalizer") +} + +func Test_TeamController_Reconcile_Delete(t *testing.T) { + ctx := context.Background() + + team := *barTeam + now := metav1.Now() + team.DeletionTimestamp = &now + team.Finalizers = []string{"keycloak-adapter.vshn.net/finalizer"} + + c, keyMock, _ := prepareTest(t, &team) + keyMock.EXPECT(). + DeleteGroup(gomock.Any(), team.Namespace, team.Name). + Return(nil). + Times(1) + + _, err := (&TeamReconciler{ + Client: c, + Scheme: &runtime.Scheme{}, + Keycloak: keyMock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, + }) + require.NoError(t, err) + + newTeam := controlv1.Team{} + require.Error(t, c.Get(ctx, types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, &newTeam)) +} + +func Test_TeamController_Reconcile_Delete_Failure(t *testing.T) { + ctx := context.Background() + + team := *barTeam + now := metav1.Now() + team.DeletionTimestamp = &now + team.Finalizers = []string{"keycloak-adapter.vshn.net/finalizer"} + + c, keyMock, erMock := prepareTest(t, &team) + keyMock.EXPECT(). + DeleteGroup(gomock.Any(), team.Namespace, team.Name). + Return(errors.New("Failed to delete")). + Times(1) + + erMock.EXPECT(). + Event(gomock.Any(), "Warning", "DeletionFailed", gomock.Any()). + Times(1) + + _, err := (&TeamReconciler{ + Client: c, + Recorder: erMock, + Scheme: &runtime.Scheme{}, + Keycloak: keyMock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, + }) + require.Error(t, err) + + newTeam := controlv1.Team{} + require.NoError(t, c.Get(ctx, types.NamespacedName{ + Namespace: barTeam.Namespace, + Name: barTeam.Name, + }, &newTeam)) +} + +var barTeam = &controlv1.Team{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "bar", + }, + Spec: controlv1.TeamSpec{ + DisplayName: "Bar Team at Foo Inc.", + UserRefs: []controlv1.UserRef{ + { + Name: "baz", + }, + { + Name: "qux", + }, + }, + }, +} diff --git a/example-organization.yaml b/example-organization.yaml new file mode 100644 index 0000000..5222f5d --- /dev/null +++ b/example-organization.yaml @@ -0,0 +1,4 @@ +kind: Organization +apiVersion: organization.appuio.io/v1 +metadata: + name: example-org diff --git a/example-team.yaml b/example-team.yaml new file mode 100644 index 0000000..336ea57 --- /dev/null +++ b/example-team.yaml @@ -0,0 +1,8 @@ +kind: Team +apiVersion: appuio.io/v1 +metadata: + namespace: example-org + name: example-team +spec: + userRefs: + - name: example-user diff --git a/go.mod b/go.mod index 4c9ed24..39c92c2 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/vshn/appuio-keycloak-adapter go 1.17 require ( - github.com/Nerzal/gocloak/v10 v10.0.1 - github.com/appuio/control-api v0.4.0 + github.com/Nerzal/gocloak/v11 v11.0.0 + github.com/appuio/control-api v0.5.0 github.com/golang/mock v1.6.0 github.com/stretchr/testify v1.7.0 k8s.io/apimachinery v0.23.3 @@ -38,7 +38,7 @@ require ( github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/go-errors/errors v1.0.1 // indirect - github.com/go-logr/logr v1.2.2 // indirect + github.com/go-logr/logr v1.2.2 github.com/go-logr/zapr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.5 // indirect @@ -101,7 +101,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect - k8s.io/api v0.23.3 // indirect + k8s.io/api v0.23.3 k8s.io/apiextensions-apiserver v0.23.0 // indirect k8s.io/apiserver v0.23.2 // indirect k8s.io/component-base v0.23.2 // indirect diff --git a/go.sum b/go.sum index 4146389..cb70fd4 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/Nerzal/gocloak/v10 v10.0.1 h1:W9pyD4I6w57ceNmjJoS4mXezBAxpupj11ytxper2KA8= -github.com/Nerzal/gocloak/v10 v10.0.1/go.mod h1:18jh1lwSHEJeSvmdH+08JyJU/XjPdNYLWEZ7paDB2k8= +github.com/Nerzal/gocloak/v11 v11.0.0 h1:GAS2XEBq6bGY/LX1rx4e+3tIohXqLSi/veoRpFb3bmo= +github.com/Nerzal/gocloak/v11 v11.0.0/go.mod h1:sxvvWpGVsEuxHVoM3D3vQBSqC1qgmWJwRHwlsJwyeiw= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -108,8 +108,8 @@ github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQY github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20210826220005-b48c857c3a0e/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= -github.com/appuio/control-api v0.4.0 h1:PO14fvN6cZEoJJa4oDfknTZVLfpsupd68v5jTyRuz5k= -github.com/appuio/control-api v0.4.0/go.mod h1:scJy9uk9wtxbeJ42UYcqPykc9BWXRyO1EjC7S697A4U= +github.com/appuio/control-api v0.5.0 h1:VeVJlANhZhjr+fhdAL6J2yidBzZERM3j3nm3881oW3M= +github.com/appuio/control-api v0.5.0/go.mod h1:scJy9uk9wtxbeJ42UYcqPykc9BWXRyO1EjC7S697A4U= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= diff --git a/keycloak/ZZ_mock_gocloak_test.go b/keycloak/ZZ_mock_gocloak_test.go index fea07ff..15ab72c 100644 --- a/keycloak/ZZ_mock_gocloak_test.go +++ b/keycloak/ZZ_mock_gocloak_test.go @@ -8,7 +8,7 @@ import ( context "context" reflect "reflect" - gocloak "github.com/Nerzal/gocloak/v10" + gocloak "github.com/Nerzal/gocloak/v11" gomock "github.com/golang/mock/gomock" ) @@ -49,6 +49,21 @@ func (mr *MockGoCloakMockRecorder) AddUserToGroup(ctx, token, realm, userID, gro return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUserToGroup", reflect.TypeOf((*MockGoCloak)(nil).AddUserToGroup), ctx, token, realm, userID, groupID) } +// CreateChildGroup mocks base method. +func (m *MockGoCloak) CreateChildGroup(ctx context.Context, accessToken, realm, groupID string, group gocloak.Group) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateChildGroup", ctx, accessToken, realm, groupID, group) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateChildGroup indicates an expected call of CreateChildGroup. +func (mr *MockGoCloakMockRecorder) CreateChildGroup(ctx, accessToken, realm, groupID, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChildGroup", reflect.TypeOf((*MockGoCloak)(nil).CreateChildGroup), ctx, accessToken, realm, groupID, group) +} + // CreateGroup mocks base method. func (m *MockGoCloak) CreateGroup(ctx context.Context, accessToken, realm string, group gocloak.Group) (string, error) { m.ctrl.T.Helper() diff --git a/keycloak/client.go b/keycloak/client.go index 93b0a97..94856f0 100644 --- a/keycloak/client.go +++ b/keycloak/client.go @@ -3,16 +3,57 @@ package keycloak import ( "context" "fmt" + "strings" - "github.com/Nerzal/gocloak/v10" + "github.com/Nerzal/gocloak/v11" ) -// Group is a representation of a top level group in keycloak +// Group is a representation of a group in keycloak type Group struct { - Name string + id string + + path []string + Members []string } +// NewGroup creates a new group. +func NewGroup(path ...string) Group { + return Group{path: path} +} + +// NewGroupFromPath creates a new group. +func NewGroupFromPath(path string) Group { + return NewGroup(strings.Split(strings.TrimPrefix(path, "/"), "/")...) +} + +// WithMembers returns a copy of the group with given members added. +func (g Group) WithMembers(members ...string) Group { + g.Members = members + return g +} + +// Path returns the path of the group. +func (g Group) Path() string { + if len(g.path) == 0 { + return "" + } + return fmt.Sprintf("/%s", strings.Join(g.path, "/")) +} + +// PathMembers returns the split path of the group. +func (g Group) PathMembers() []string { + return g.path +} + +// BaseName returns the name of the group. +func (g Group) BaseName() string { + if len(g.path) == 0 { + return "" + } + return g.path[len(g.path)-1] +} + // MembershipSyncError is a custom error indicating the failure of syncing the membership of a single user. type MembershipSyncError struct { Err error @@ -54,6 +95,7 @@ type GoCloak interface { LogoutUserSession(ctx context.Context, accessToken, realm, session string) error CreateGroup(ctx context.Context, accessToken, realm string, group gocloak.Group) (string, error) + CreateChildGroup(ctx context.Context, accessToken, realm, groupID string, group gocloak.Group) (string, error) GetGroups(ctx context.Context, accessToken, realm string, params gocloak.GetGroupsParams) ([]*gocloak.Group, error) DeleteGroup(ctx context.Context, accessToken, realm, groupID string) error @@ -85,9 +127,7 @@ func NewClient(host, realm, username, password string) Client { // PutGroup creates the provided Keycloak group if it does not exist and adjusts the group members accordingly. // The method is idempotent. func (c Client) PutGroup(ctx context.Context, group Group) (Group, error) { - res := Group{ - Name: group.Name, - } + res := NewGroup(group.path...) token, err := c.Client.LoginAdmin(ctx, c.Username, c.Password, c.Realm) if err != nil { @@ -95,21 +135,16 @@ func (c Client) PutGroup(ctx context.Context, group Group) (Group, error) { } defer c.Client.LogoutUserSession(ctx, token.AccessToken, c.Realm, token.SessionState) - found, foundMemb, err := c.getGroupAndMembersByName(ctx, token, group.Name) + found, foundMemb, err := c.getGroupAndMembers(ctx, token, group) if err != nil { return res, fmt.Errorf("failed finding group: %w", err) } if found == nil { - id, err := c.Client.CreateGroup(ctx, token.AccessToken, c.Realm, gocloak.Group{ - Name: gocloak.StringP(group.Name), - }) + created, err := c.createGroup(ctx, token, group) if err != nil { return res, err } - found = &gocloak.Group{ - ID: &id, - Name: gocloak.StringP(group.Name), - } + found = &created } membErr := MembershipSyncErrors{} @@ -144,16 +179,39 @@ func (c Client) PutGroup(ctx context.Context, group Group) (Group, error) { return res, nil } +func (c Client) createGroup(ctx context.Context, token *gocloak.JWT, group Group) (gocloak.Group, error) { + toCreate := gocloak.Group{ + Name: gocloak.StringP(group.BaseName()), + Path: gocloak.StringP(group.Path()), + } + + if len(group.PathMembers()) == 1 { + id, err := c.Client.CreateGroup(ctx, token.AccessToken, c.Realm, toCreate) + toCreate.ID = &id + return toCreate, err + } + + p := group.PathMembers() + parent, err := c.getGroup(ctx, token, NewGroup(p[0:len(p)-1]...)) + if err != nil { + return toCreate, fmt.Errorf("could not find parent group for %v: %w", group, err) + } + + id, err := c.Client.CreateChildGroup(ctx, token.AccessToken, c.Realm, *parent.ID, toCreate) + toCreate.ID = &id + return toCreate, err +} + // DeleteGroup deletes the Keycloak group by name. // The method is idempotent and will not do anything if the group does not exits. -func (c Client) DeleteGroup(ctx context.Context, groupName string) error { +func (c Client) DeleteGroup(ctx context.Context, path ...string) error { token, err := c.Client.LoginAdmin(ctx, c.Username, c.Password, c.Realm) if err != nil { return fmt.Errorf("failed binding to keycloak: %w", err) } defer c.Client.LogoutUserSession(ctx, token.AccessToken, c.Realm, token.SessionState) - found, err := c.getGroupByName(ctx, token, groupName) + found, err := c.getGroup(ctx, token, NewGroup(path...)) if err != nil { return fmt.Errorf("failed finding group: %w", err) } @@ -172,18 +230,17 @@ func (c Client) ListGroups(ctx context.Context) ([]Group, error) { } defer c.Client.LogoutUserSession(ctx, token.AccessToken, c.Realm, token.SessionState) - groups, err := c.Client.GetGroups(ctx, token.AccessToken, c.Realm, gocloak.GetGroupsParams{}) + groups, err := c.Client.GetGroups(ctx, token.AccessToken, c.Realm, defaultParams) if err != nil { return nil, err } - res := make([]Group, len(groups)) + res := flatGroups(groups) - for i, g := range groups { - res[i].Name = *g.Name - memb, err := c.Client.GetGroupMembers(ctx, token.AccessToken, c.Realm, *g.ID, gocloak.GetGroupsParams{}) + for i, g := range res { + memb, err := c.Client.GetGroupMembers(ctx, token.AccessToken, c.Realm, g.id, defaultParams) if err != nil { - return res, fmt.Errorf("failed finding groupmembers for group %s: %w", *g.Name, err) + return res, fmt.Errorf("failed finding groupmembers for group %s: %w", g.BaseName(), err) } res[i].Members = make([]string, len(memb)) for j, m := range memb { @@ -194,32 +251,50 @@ func (c Client) ListGroups(ctx context.Context) ([]Group, error) { return res, nil } -func (c Client) getGroupByName(ctx context.Context, token *gocloak.JWT, name string) (*gocloak.Group, error) { +func (c Client) getGroup(ctx context.Context, token *gocloak.JWT, toSearch Group) (*gocloak.Group, error) { + if len(toSearch.PathMembers()) == 0 { + return nil, nil + } // This may return more than one 1 result groups, err := c.Client.GetGroups(ctx, token.AccessToken, c.Realm, gocloak.GetGroupsParams{ - Search: &name, + Max: defaultParams.Max, + Search: gocloak.StringP(toSearch.BaseName()), }) if err != nil { return nil, err } - for i := range groups { - if *groups[i].Name == name { - return groups[i], err + var find func(groups []gocloak.Group) *gocloak.Group + find = func(groups []gocloak.Group) *gocloak.Group { + for i := range groups { + if groups[i].SubGroups != nil { + if sub := find(*groups[i].SubGroups); sub != nil { + return sub + } + } + if *groups[i].Name == toSearch.BaseName() && *groups[i].Path == toSearch.Path() { + return &groups[i] + } } + return nil + } + + g := make([]gocloak.Group, len(groups)) + for i := range groups { + g[i] = *groups[i] } - return nil, nil + return find(g), nil } -func (c Client) getGroupAndMembersByName(ctx context.Context, token *gocloak.JWT, name string) (*gocloak.Group, []*gocloak.User, error) { - group, err := c.getGroupByName(ctx, token, name) +func (c Client) getGroupAndMembers(ctx context.Context, token *gocloak.JWT, toFind Group) (*gocloak.Group, []*gocloak.User, error) { + group, err := c.getGroup(ctx, token, toFind) if err != nil || group == nil { return group, nil, err } - foundMemb, err := c.Client.GetGroupMembers(ctx, token.AccessToken, c.Realm, *group.ID, gocloak.GetGroupsParams{}) + foundMemb, err := c.Client.GetGroupMembers(ctx, token.AccessToken, c.Realm, *group.ID, defaultParams) if err != nil { - return group, foundMemb, fmt.Errorf("failed finding groupmembers for group %s: %w", name, err) + return group, foundMemb, fmt.Errorf("failed finding groupmembers for group %v: %w", toFind, err) } return group, foundMemb, nil @@ -240,6 +315,8 @@ func (c Client) addUsersToGroup(ctx context.Context, token *gocloak.JWT, groupID } err = c.Client.AddUserToGroup(ctx, token.AccessToken, c.Realm, *usr.ID, groupID) if err != nil { + errs = append(errs, MembershipSyncError{Err: err, Username: uname, Event: UserAddError}) + continue } res = append(res, uname) } @@ -252,6 +329,7 @@ func (c Client) addUsersToGroup(ctx context.Context, token *gocloak.JWT, groupID func (c Client) getUserByName(ctx context.Context, token *gocloak.JWT, name string) (*gocloak.User, error) { // This may return more than one 1 result users, err := c.Client.GetUsers(ctx, token.AccessToken, c.Realm, gocloak.GetUsersParams{ + Max: defaultParams.Max, Username: &name, }) if err != nil { @@ -288,3 +366,30 @@ func diff(a, b []string) []string { } return diff } + +func flatGroups(gcp []*gocloak.Group) []Group { + rootGroups := make([]gocloak.Group, len(gcp)) + for i := range gcp { + rootGroups[i] = *gcp[i] + } + + flat := make([]Group, 0) + var flatten func([]gocloak.Group) + flatten = func(groups []gocloak.Group) { + for _, g := range groups { + group := NewGroupFromPath(*g.Path) + group.id = *g.ID + flat = append(flat, group) + if g.SubGroups != nil { + flatten(*g.SubGroups) + } + } + } + flatten(rootGroups) + + return flat +} + +var defaultParams = gocloak.GetGroupsParams{ + Max: gocloak.IntP(-1), +} diff --git a/keycloak/client_delete_test.go b/keycloak/client_delete_test.go index 7b2d433..a9c1755 100644 --- a/keycloak/client_delete_test.go +++ b/keycloak/client_delete_test.go @@ -5,7 +5,7 @@ import ( "testing" - gocloak "github.com/Nerzal/gocloak/v10" + gocloak "github.com/Nerzal/gocloak/v11" "github.com/stretchr/testify/require" . "github.com/vshn/appuio-keycloak-adapter/keycloak" @@ -27,13 +27,32 @@ func TestDeleteGroup_simple(t *testing.T) { mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{ - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, + newGocloakGroup("foo-id", "foo-gmbh"), }) mockDeleteGroup(mKeycloak, c, "foo-id") err := c.DeleteGroup(context.TODO(), "foo-gmbh") require.NoError(t, err) } + +func TestDeleteGroup_subgroup(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mKeycloak := NewMockGoCloak(ctrl) + c := Client{ + Client: mKeycloak, + Realm: "foo", + Username: "bar", + Password: "buzz", + } + mockLogin(mKeycloak, c) + mockGetGroups(mKeycloak, c, "foo-gmbh", + []*gocloak.Group{ + newGocloakGroup("foo-id", "parent", "foo-gmbh"), + }) + mockDeleteGroup(mKeycloak, c, "foo-id") + + err := c.DeleteGroup(context.TODO(), "parent", "foo-gmbh") + require.NoError(t, err) +} diff --git a/keycloak/client_list_test.go b/keycloak/client_list_test.go index f90ec09..78b9677 100644 --- a/keycloak/client_list_test.go +++ b/keycloak/client_list_test.go @@ -6,7 +6,7 @@ import ( "testing" - gocloak "github.com/Nerzal/gocloak/v10" + gocloak "github.com/Nerzal/gocloak/v11" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,22 +28,17 @@ func TestListGroups_simple(t *testing.T) { } gs := []*gocloak.Group{ - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, - { - ID: gocloak.StringP("bar-id"), - Name: gocloak.StringP("bar-gmbh"), - }, - { - ID: gocloak.StringP("buzz-id"), - Name: gocloak.StringP("buzz-gmbh"), - }, + newGocloakGroup("foo-id", "foo-gmbh"), + newGocloakGroup("bar-id", "bar-gmbh"), + func() *gocloak.Group { + g := newGocloakGroup("parent-id", "parent-gmbh") + g.SubGroups = &[]gocloak.Group{*newGocloakGroup("qux-id", "parent-gmbh", "qux-team")} + return g + }(), } mockLogin(mKeycloak, c) mockListGroups(mKeycloak, c, gs) - for i := range gs { + for i, id := range []string{"foo-id", "bar-id", "parent-id", "qux-id"} { us := []*gocloak.User{} for j := 0; j < i; j++ { us = append(us, &gocloak.User{ @@ -51,14 +46,23 @@ func TestListGroups_simple(t *testing.T) { Username: gocloak.StringP(fmt.Sprintf("user-%d", i)), }) } - mockGetGroupMembers(mKeycloak, c, *gs[i].ID, us) + mockGetGroupMembers(mKeycloak, c, id, us) } res, err := c.ListGroups(context.TODO()) require.NoError(t, err) - assert.Len(t, res, 3) + assert.Len(t, res, 4) + assert.Equal(t, "/foo-gmbh", res[0].Path()) + assert.Equal(t, "/bar-gmbh", res[1].Path()) + assert.Equal(t, "/parent-gmbh", res[2].Path()) + assert.Equal(t, "/parent-gmbh/qux-team", res[3].Path()) + assert.Len(t, res[0].Members, 0) + assert.Len(t, res[1].Members, 1) + assert.Len(t, res[2].Members, 2) + assert.Len(t, res[3].Members, 3) + assert.Equal(t, "user-1", res[1].Members[0]) assert.Equal(t, "user-2", res[2].Members[1]) } diff --git a/keycloak/client_put_test.go b/keycloak/client_put_test.go index 49d3aa3..bc1a6b9 100644 --- a/keycloak/client_put_test.go +++ b/keycloak/client_put_test.go @@ -5,7 +5,7 @@ import ( "testing" - gocloak "github.com/Nerzal/gocloak/v10" + gocloak "github.com/Nerzal/gocloak/v11" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,10 +28,7 @@ func TestPutGroup_simple(t *testing.T) { mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{ - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, + newGocloakGroup("foo-id", "foo-gmbh"), }) mockGetGroupMembers(mKeycloak, c, "foo-id", []*gocloak.User{ @@ -45,12 +42,7 @@ func TestPutGroup_simple(t *testing.T) { mockAddUser(mKeycloak, c, "3", "foo-id") mockAddUser(mKeycloak, c, "2", "foo-id") - g, err := c.PutGroup(context.TODO(), Group{ - Name: "foo-gmbh", - Members: []string{ - "user", "user2", "user3", - }, - }) + g, err := c.PutGroup(context.TODO(), NewGroup("foo-gmbh").WithMembers("user", "user2", "user3")) require.NoError(t, err) assert.Len(t, g.Members, 3) } @@ -68,17 +60,43 @@ func TestPutGroup_new(t *testing.T) { } mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{}) - mockCreateGroup(mKeycloak, c, "foo-gmbh", "foo-id") + mockCreateGroup(mKeycloak, c, "foo-gmbh", "/foo-gmbh", "foo-id") mockGetUser(mKeycloak, c, "user", "1") mockAddUser(mKeycloak, c, "1", "foo-id") - g, err := c.PutGroup(context.TODO(), Group{ - Name: "foo-gmbh", - Members: []string{ - "user", + g, err := c.PutGroup(context.TODO(), NewGroup("foo-gmbh").WithMembers("user")) + require.NoError(t, err) + require.Equal(t, "/foo-gmbh", g.Path()) + assert.Len(t, g.Members, 1) +} + +func TestPutGroup_new_with_path(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mKeycloak := NewMockGoCloak(ctrl) + c := Client{ + Client: mKeycloak, + Realm: "foo", + Username: "bar", + Password: "buzz", + } + mockLogin(mKeycloak, c) + mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{}) + mockGetGroups(mKeycloak, c, "Parent", []*gocloak.Group{ + { + ID: gocloak.StringP("Parent-ID"), + Path: gocloak.StringP("/Parent"), + Name: gocloak.StringP("Parent"), }, }) + mockCreateChildGroup(mKeycloak, c, "Parent-ID", "foo-gmbh", "/Parent/foo-gmbh", "foo-id") + mockGetUser(mKeycloak, c, "user", "1") + mockAddUser(mKeycloak, c, "1", "foo-id") + + g, err := c.PutGroup(context.TODO(), NewGroup("Parent", "foo-gmbh").WithMembers("user")) require.NoError(t, err) + require.Equal(t, "/Parent/foo-gmbh", g.Path()) assert.Len(t, g.Members, 1) } @@ -96,14 +114,8 @@ func TestPutGroup_multiple_matching_groups(t *testing.T) { mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{ - { - ID: gocloak.StringP("test-id"), - Name: gocloak.StringP("foo-gmbh-test"), - }, - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, + newGocloakGroup("test-id", "foo-gmbh-test"), + newGocloakGroup("foo-id", "foo-gmbh"), }) mockGetGroupMembers(mKeycloak, c, "foo-id", []*gocloak.User{ @@ -117,12 +129,7 @@ func TestPutGroup_multiple_matching_groups(t *testing.T) { mockAddUser(mKeycloak, c, "3", "foo-id") mockAddUser(mKeycloak, c, "2", "foo-id") - g, err := c.PutGroup(context.TODO(), Group{ - Name: "foo-gmbh", - Members: []string{ - "user", "user2", "user3", - }, - }) + g, err := c.PutGroup(context.TODO(), NewGroup("foo-gmbh").WithMembers("user", "user2", "user3")) require.NoError(t, err) assert.Len(t, g.Members, 3) } @@ -140,10 +147,7 @@ func TestPutGroup_multiple_matching_users(t *testing.T) { mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{ - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, + newGocloakGroup("foo-id", "foo-gmbh"), }) mockGetGroupMembers(mKeycloak, c, "foo-id", []*gocloak.User{}) mockGetUsers(mKeycloak, c, "user", []*gocloak.User{ @@ -152,10 +156,7 @@ func TestPutGroup_multiple_matching_users(t *testing.T) { }) mockAddUser(mKeycloak, c, "1", "foo-id") - g, err := c.PutGroup(context.TODO(), Group{ - Name: "foo-gmbh", - Members: []string{"user"}, - }) + g, err := c.PutGroup(context.TODO(), NewGroup("foo-gmbh").WithMembers("user")) require.NoError(t, err) assert.Len(t, g.Members, 1) } @@ -174,10 +175,7 @@ func TestPutGroup_remove(t *testing.T) { mockLogin(mKeycloak, c) mockGetGroups(mKeycloak, c, "foo-gmbh", []*gocloak.Group{ - { - ID: gocloak.StringP("foo-id"), - Name: gocloak.StringP("foo-gmbh"), - }, + newGocloakGroup("foo-id", "foo-gmbh"), }) mockGetGroupMembers(mKeycloak, c, "foo-id", []*gocloak.User{ @@ -196,12 +194,7 @@ func TestPutGroup_remove(t *testing.T) { mockAddUser(mKeycloak, c, "2", "foo-id") mockRemoveUser(mKeycloak, c, "4", "foo-id") - g, err := c.PutGroup(context.TODO(), Group{ - Name: "foo-gmbh", - Members: []string{ - "user", "user2", "user3", - }, - }) + g, err := c.PutGroup(context.TODO(), NewGroup("foo-gmbh").WithMembers("user", "user2", "user3")) require.NoError(t, err) assert.Len(t, g.Members, 3) } diff --git a/keycloak/suite_test.go b/keycloak/suite_test.go index 1138fe4..a028b55 100644 --- a/keycloak/suite_test.go +++ b/keycloak/suite_test.go @@ -1,9 +1,11 @@ package keycloak_test import ( + "strings" + . "github.com/vshn/appuio-keycloak-adapter/keycloak" - gocloak "github.com/Nerzal/gocloak/v10" + gocloak "github.com/Nerzal/gocloak/v11" gomock "github.com/golang/mock/gomock" ) @@ -23,7 +25,9 @@ func mockLogin(mgc *MockGoCloak, c Client) { func mockListGroups(mgc *MockGoCloak, c Client, groups []*gocloak.Group) { mgc.EXPECT(). - GetGroups(gomock.Any(), "token", c.Realm, gocloak.GetGroupsParams{}). + GetGroups(gomock.Any(), "token", c.Realm, gocloak.GetGroupsParams{ + Max: gocloak.IntP(-1), + }). Return(groups, nil). Times(1) } @@ -31,17 +35,30 @@ func mockListGroups(mgc *MockGoCloak, c Client, groups []*gocloak.Group) { func mockGetGroups(mgc *MockGoCloak, c Client, groupName string, groups []*gocloak.Group) { mgc.EXPECT(). GetGroups(gomock.Any(), "token", c.Realm, gocloak.GetGroupsParams{ + Max: gocloak.IntP(-1), Search: gocloak.StringP(groupName), }). Return(groups, nil). Times(1) } -func mockCreateGroup(mgc *MockGoCloak, c Client, groupName, groupID string) { +func mockCreateGroup(mgc *MockGoCloak, c Client, groupName, groupPath, groupID string) { + kcg := gocloak.Group{ + Name: &groupName, + Path: &groupPath, + } mgc.EXPECT(). - CreateGroup(gomock.Any(), "token", c.Realm, gocloak.Group{ - Name: &groupName, - }). + CreateGroup(gomock.Any(), "token", c.Realm, kcg). + Return(groupID, nil). + Times(1) +} +func mockCreateChildGroup(mgc *MockGoCloak, c Client, parentID, groupName, groupPath, groupID string) { + kcg := gocloak.Group{ + Name: &groupName, + Path: &groupPath, + } + mgc.EXPECT(). + CreateChildGroup(gomock.Any(), "token", c.Realm, parentID, kcg). Return(groupID, nil). Times(1) } @@ -71,6 +88,7 @@ func mockGetUsers(mgc *MockGoCloak, c Client, userName string, users []*gocloak. mgc.EXPECT(). GetUsers(gomock.Any(), "token", c.Realm, gocloak.GetUsersParams{ Username: gocloak.StringP(userName), + Max: gocloak.IntP(-1), }). Return(users, nil). Times(1) @@ -89,3 +107,14 @@ func mockRemoveUser(mgc *MockGoCloak, c Client, userID, groupID string) { Return(nil). Times(1) } + +func newGocloakGroup(id string, path ...string) *gocloak.Group { + if len(path) == 0 { + panic("group must have at least one element in path") + } + return &gocloak.Group{ + ID: &id, + Name: gocloak.StringP(path[len(path)-1]), + Path: gocloak.StringP("/" + strings.Join(path, "/")), + } +} diff --git a/main.go b/main.go index 09793f7..a9094ee 100644 --- a/main.go +++ b/main.go @@ -72,9 +72,14 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) ctx := ctrl.SetupSignalHandler() + var roles []string + if *syncRoles != "" { + roles = strings.Split(*syncRoles, ",") + } + mgr, or, err := setupManager( keycloak.NewClient(*host, *realm, *username, *password), - strings.Split(*syncRoles, ","), + roles, ctrl.Options{ Scheme: scheme, MetricsBindAddress: *metricsAddr, @@ -104,33 +109,48 @@ func main() { <-c.Stop().Done() } -func setupManager(kc controllers.KeycloakClient, syncRoles []string, opt ctrl.Options) (ctrl.Manager, *controllers.OrganizationReconciler, error) { +func setupManager(kc controllers.KeycloakClient, syncRoles []string, opt ctrl.Options) (ctrl.Manager, *controllers.PeriodicSyncer, error) { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) if err != nil { return nil, nil, err } or := &controllers.OrganizationReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("keycloak-adapter"), - Keycloak: kc, - SyncClusterRoles: syncRoles, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("keycloak-adapter"), + Keycloak: kc, } if err = or.SetupWithManager(mgr); err != nil { return nil, nil, err } + tr := &controllers.TeamReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("keycloak-adapter"), + Keycloak: kc, + } + if err = tr.SetupWithManager(mgr); err != nil { + return nil, nil, err + } //+kubebuilder:scaffold:builder + ps := &controllers.PeriodicSyncer{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("keycloak-adapter"), + Keycloak: kc, + SyncClusterRoles: syncRoles, + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { return nil, nil, err } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { return nil, nil, err } - return mgr, or, err + return mgr, ps, err } -func setupSync(ctx context.Context, r *controllers.OrganizationReconciler, crontab string, timeout time.Duration) (*cron.Cron, error) { +func setupSync(ctx context.Context, r *controllers.PeriodicSyncer, crontab string, timeout time.Duration) (*cron.Cron, error) { syncLog := ctrl.Log.WithName("sync") c := cron.New() _, err := c.AddFunc(crontab, func() {