Repository: crossplane-contrib/provider-gitlab
Pull request #144 (2024-05-09 20:12:18)

feat: SamlGroupLinks
### Description of your changes

This PR adds SamlGroupLink functionality. This allows users to tie Saml groups to Gitlab groups using the samlGroupSync feature for Gitlab EE. 

https://docs.gitlab.com/ee/user/group/saml_sso/group_sync.html

I have:

- [x] Read and followed Crossplane's [contribution process].
- [ ] Run `make reviewable test` to ensure this PR is ready for review. 
=> fails

### How has this code been tested

1. Unit tests have been created to test the controller functionality for observe, connect, create, delete
2. Tested in RKE2 cluster. I was able to create and delete saml group sync's on various groups. 

jholt96: Resolved previous lint Issues and ran the golangci-lint local with no errors
MisterMX: @jholt96 I think there is still an issue with the linter. Can you check again?

Patch:
diff --git a/apis/groups/v1alpha1/referencers.go b/apis/groups/v1alpha1/referencers.go
index ebee5e7..14e90e3 100644
--- a/apis/groups/v1alpha1/referencers.go
+++ b/apis/groups/v1alpha1/referencers.go
@@ -217,3 +217,31 @@ func (mg *Group) ResolveReferences(ctx context.Context, c client.Reader) error {
 
 	return nil
 }
+
+// ResolveReferences of this SamlGroupLink
+func (mg *SamlGroupLink) ResolveReferences(ctx context.Context, c client.Reader) error {
+	r := reference.NewAPIResolver(c, mg)
+
+	// resolve spec.forProvider.groupIdRef
+	rsp, err := r.Resolve(ctx, reference.ResolutionRequest{
+		CurrentValue: fromPtrValue(mg.Spec.ForProvider.GroupID),
+		Reference:    mg.Spec.ForProvider.GroupIDRef,
+		Selector:     mg.Spec.ForProvider.GroupIDSelector,
+		To:           reference.To{Managed: &Group{}, List: &GroupList{}},
+		Extract:      reference.ExternalName(),
+	})
+
+	if err != nil {
+		return errors.Wrap(err, "spec.forProvider.groupId")
+	}
+
+	resolvedID, err := toPtrValue(rsp.ResolvedValue)
+	if err != nil {
+		return errors.Wrap(err, "spec.forProvider.groupId")
+	}
+
+	mg.Spec.ForProvider.GroupID = resolvedID
+	mg.Spec.ForProvider.GroupIDRef = rsp.ResolvedReference
+
+	return nil
+}
diff --git a/apis/groups/v1alpha1/register.go b/apis/groups/v1alpha1/register.go
index a80d9f8..248bba6 100644
--- a/apis/groups/v1alpha1/register.go
+++ b/apis/groups/v1alpha1/register.go
@@ -53,6 +53,14 @@ var (
 	MemberKubernetesGroupVersionKind = SchemeGroupVersion.WithKind(MemberKind)
 )
 
+// SamlGroupLink type metadata
+var (
+	SamlGroupLinkKind             = reflect.TypeOf(SamlGroupLink{}).Name()
+	SamlGroupLinkGroupKind        = schema.GroupKind{Group: KubernetesGroup, Kind: SamlGroupLinkKind}.String()
+	SamlGroupLinkKindAPIVersion   = SamlGroupLinkKind + "." + SchemeGroupVersion.String()
+	SamlGroupLinkGroupVersionKind = SchemeGroupVersion.WithKind(SamlGroupLinkKind)
+)
+
 // Deploy Token type metadata
 var (
 	DeployTokenKind             = reflect.TypeOf(DeployToken{}).Name()
@@ -83,4 +91,6 @@ func init() {
 	SchemeBuilder.Register(&AccessToken{}, &AccessTokenList{})
 	SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{})
 	SchemeBuilder.Register(&Variable{}, &VariableList{})
+	SchemeBuilder.Register(&SamlGroupLink{}, &SamlGroupLinkList{})
+
 }
diff --git a/apis/groups/v1alpha1/samlgrouplink_types.go b/apis/groups/v1alpha1/samlgrouplink_types.go
new file mode 100644
index 0000000..e709b1b
--- /dev/null
+++ b/apis/groups/v1alpha1/samlgrouplink_types.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+)
+
+// SamlGroupLinkParameters define the desired state of a Gitlab Group Saml Link
+// https://docs.gitlab.com/ee/api/groups.html#saml-group-links
+type SamlGroupLinkParameters struct {
+	// GroupID is the ID of the group to create the deploy token in.
+	// +optional
+	// +immutable
+	GroupID *int `json:"groupId,omitempty"`
+
+	// GroupIDRef is a reference to a group to retrieve its groupId
+	// +optional
+	// +immutable
+	GroupIDRef *xpv1.Reference `json:"groupIdRef,omitempty"`
+
+	// GroupIDSelector selects reference to a group to retrieve its groupId.
+	// +optional
+	GroupIDSelector *xpv1.Selector `json:"groupIdSelector,omitempty"`
+
+	// name is the name of the saml group to attach to the gitlab group
+	// +immutable
+	Name *string `json:"name"`
+
+	// accessLevel is the defined role for members of the SAML group
+	// +immutable
+	AccessLevel AccessLevelValue `json:"accessLevel"`
+
+	// memberRoleID is the defined member role assigned to members of the group
+	// +optional
+	// +immutable
+	MemberRoleID *int `json:"memberRoleId,omitempty"`
+}
+
+// SamlGroupLinkObservation represents a Group Saml Link.
+type SamlGroupLinkObservation struct {
+	Name string `json:"name,omitempty"`
+}
+
+// A SamlGroupLinkSpec defines the desired state of a Gitlab SAML group sync.
+type SamlGroupLinkSpec struct {
+	xpv1.ResourceSpec `json:",inline"`
+	ForProvider       SamlGroupLinkParameters `json:"forProvider"`
+}
+
+// A SamlGroupLinkStatus represents the observed state of a Gitlab SAML group sync.
+type SamlGroupLinkStatus struct {
+	xpv1.ResourceStatus `json:",inline"`
+	AtProvider          SamlGroupLinkObservation `json:"atProvider,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// A SamlGroupLink is a managed resource that represents a Gitlab saml group sync connection
+// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
+// +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status"
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:printcolumn:name="EXTERNAL-NAME",type="string",JSONPath=".status.atProvider.name"
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,gitlab}
+type SamlGroupLink struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   SamlGroupLinkSpec   `json:"spec"`
+	Status SamlGroupLinkStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// SamlGroupLinkList contains a list of group items
+type SamlGroupLinkList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []SamlGroupLink `json:"items"`
+}
diff --git a/apis/groups/v1alpha1/zz_generated.deepcopy.go b/apis/groups/v1alpha1/zz_generated.deepcopy.go
index d1dedb8..9318d7e 100644
--- a/apis/groups/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/groups/v1alpha1/zz_generated.deepcopy.go
@@ -827,6 +827,154 @@ func (in *MemberStatus) DeepCopy() *MemberStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLink) DeepCopyInto(out *SamlGroupLink) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLink.
+func (in *SamlGroupLink) DeepCopy() *SamlGroupLink {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLink)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SamlGroupLink) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLinkList) DeepCopyInto(out *SamlGroupLinkList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]SamlGroupLink, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLinkList.
+func (in *SamlGroupLinkList) DeepCopy() *SamlGroupLinkList {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLinkList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *SamlGroupLinkList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLinkObservation) DeepCopyInto(out *SamlGroupLinkObservation) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLinkObservation.
+func (in *SamlGroupLinkObservation) DeepCopy() *SamlGroupLinkObservation {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLinkObservation)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLinkParameters) DeepCopyInto(out *SamlGroupLinkParameters) {
+	*out = *in
+	if in.GroupID != nil {
+		in, out := &in.GroupID, &out.GroupID
+		*out = new(int)
+		**out = **in
+	}
+	if in.GroupIDRef != nil {
+		in, out := &in.GroupIDRef, &out.GroupIDRef
+		*out = new(v1.Reference)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.GroupIDSelector != nil {
+		in, out := &in.GroupIDSelector, &out.GroupIDSelector
+		*out = new(v1.Selector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Name != nil {
+		in, out := &in.Name, &out.Name
+		*out = new(string)
+		**out = **in
+	}
+	if in.MemberRoleID != nil {
+		in, out := &in.MemberRoleID, &out.MemberRoleID
+		*out = new(int)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLinkParameters.
+func (in *SamlGroupLinkParameters) DeepCopy() *SamlGroupLinkParameters {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLinkParameters)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLinkSpec) DeepCopyInto(out *SamlGroupLinkSpec) {
+	*out = *in
+	in.ResourceSpec.DeepCopyInto(&out.ResourceSpec)
+	in.ForProvider.DeepCopyInto(&out.ForProvider)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLinkSpec.
+func (in *SamlGroupLinkSpec) DeepCopy() *SamlGroupLinkSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLinkSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SamlGroupLinkStatus) DeepCopyInto(out *SamlGroupLinkStatus) {
+	*out = *in
+	in.ResourceStatus.DeepCopyInto(&out.ResourceStatus)
+	out.AtProvider = in.AtProvider
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SamlGroupLinkStatus.
+func (in *SamlGroupLinkStatus) DeepCopy() *SamlGroupLinkStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(SamlGroupLinkStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *SharedWithGroups) DeepCopyInto(out *SharedWithGroups) {
 	*out = *in
diff --git a/apis/groups/v1alpha1/zz_generated.managed.go b/apis/groups/v1alpha1/zz_generated.managed.go
index 8c284ea..0a4235b 100644
--- a/apis/groups/v1alpha1/zz_generated.managed.go
+++ b/apis/groups/v1alpha1/zz_generated.managed.go
@@ -260,6 +260,66 @@ func (mg *Member) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
 	mg.Spec.WriteConnectionSecretToReference = r
 }
 
+// GetCondition of this SamlGroupLink.
+func (mg *SamlGroupLink) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return mg.Status.GetCondition(ct)
+}
+
+// GetDeletionPolicy of this SamlGroupLink.
+func (mg *SamlGroupLink) GetDeletionPolicy() xpv1.DeletionPolicy {
+	return mg.Spec.DeletionPolicy
+}
+
+// GetManagementPolicies of this SamlGroupLink.
+func (mg *SamlGroupLink) GetManagementPolicies() xpv1.ManagementPolicies {
+	return mg.Spec.ManagementPolicies
+}
+
+// GetProviderConfigReference of this SamlGroupLink.
+func (mg *SamlGroupLink) GetProviderConfigReference() *xpv1.Reference {
+	return mg.Spec.ProviderConfigReference
+}
+
+// GetPublishConnectionDetailsTo of this SamlGroupLink.
+func (mg *SamlGroupLink) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+	return mg.Spec.PublishConnectionDetailsTo
+}
+
+// GetWriteConnectionSecretToReference of this SamlGroupLink.
+func (mg *SamlGroupLink) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+	return mg.Spec.WriteConnectionSecretToReference
+}
+
+// SetConditions of this SamlGroupLink.
+func (mg *SamlGroupLink) SetConditions(c ...xpv1.Condition) {
+	mg.Status.SetConditions(c...)
+}
+
+// SetDeletionPolicy of this SamlGroupLink.
+func (mg *SamlGroupLink) SetDeletionPolicy(r xpv1.DeletionPolicy) {
+	mg.Spec.DeletionPolicy = r
+}
+
+// SetManagementPolicies of this SamlGroupLink.
+func (mg *SamlGroupLink) SetManagementPolicies(r xpv1.ManagementPolicies) {
+	mg.Spec.ManagementPolicies = r
+}
+
+// SetProviderConfigReference of this SamlGroupLink.
+func (mg *SamlGroupLink) SetProviderConfigReference(r *xpv1.Reference) {
+	mg.Spec.ProviderConfigReference = r
+}
+
+// SetPublishConnectionDetailsTo of this SamlGroupLink.
+func (mg *SamlGroupLink) SetPublishConnectionDetailsTo(r *xpv1.PublishConnectionDetailsTo) {
+	mg.Spec.PublishConnectionDetailsTo = r
+}
+
+// SetWriteConnectionSecretToReference of this SamlGroupLink.
+func (mg *SamlGroupLink) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
+	mg.Spec.WriteConnectionSecretToReference = r
+}
+
 // GetCondition of this Variable.
 func (mg *Variable) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
 	return mg.Status.GetCondition(ct)
diff --git a/apis/groups/v1alpha1/zz_generated.managedlist.go b/apis/groups/v1alpha1/zz_generated.managedlist.go
index 6a31f61..2897d19 100644
--- a/apis/groups/v1alpha1/zz_generated.managedlist.go
+++ b/apis/groups/v1alpha1/zz_generated.managedlist.go
@@ -56,6 +56,15 @@ func (l *MemberList) GetItems() []resource.Managed {
 	return items
 }
 
+// GetItems of this SamlGroupLinkList.
+func (l *SamlGroupLinkList) GetItems() []resource.Managed {
+	items := make([]resource.Managed, len(l.Items))
+	for i := range l.Items {
+		items[i] = &l.Items[i]
+	}
+	return items
+}
+
 // GetItems of this VariableList.
 func (l *VariableList) GetItems() []resource.Managed {
 	items := make([]resource.Managed, len(l.Items))
diff --git a/package/crds/groups.gitlab.crossplane.io_samlgrouplinks.yaml b/package/crds/groups.gitlab.crossplane.io_samlgrouplinks.yaml
new file mode 100644
index 0000000..46fb715
--- /dev/null
+++ b/package/crds/groups.gitlab.crossplane.io_samlgrouplinks.yaml
@@ -0,0 +1,381 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.13.0
+  name: samlgrouplinks.groups.gitlab.crossplane.io
+spec:
+  group: groups.gitlab.crossplane.io
+  names:
+    categories:
+    - crossplane
+    - managed
+    - gitlab
+    kind: SamlGroupLink
+    listKind: SamlGroupLinkList
+    plural: samlgrouplinks
+    singular: samlgrouplink
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: READY
+      type: string
+    - jsonPath: .status.conditions[?(@.type=='Synced')].status
+      name: SYNCED
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: AGE
+      type: date
+    - jsonPath: .status.atProvider.name
+      name: EXTERNAL-NAME
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: A SamlGroupLink is a managed resource that represents a Gitlab
+          saml group sync connection
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: A SamlGroupLinkSpec defines the desired state of a Gitlab
+              SAML group sync.
+            properties:
+              deletionPolicy:
+                default: Delete
+                description: 'DeletionPolicy specifies what will happen to the underlying
+                  external when this managed resource is deleted - either "Delete"
+                  or "Orphan" the external resource. This field is planned to be deprecated
+                  in favor of the ManagementPolicies field in a future release. Currently,
+                  both could be set independently and non-default values would be
+                  honored if the feature flag is enabled. See the design doc for more
+                  information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223'
+                enum:
+                - Orphan
+                - Delete
+                type: string
+              forProvider:
+                description: SamlGroupLinkParameters define the desired state of a
+                  Gitlab Group Saml Link https://docs.gitlab.com/ee/api/groups.html#saml-group-links
+                properties:
+                  accessLevel:
+                    description: accessLevel is the defined role for members of the
+                      SAML group
+                    type: integer
+                  groupId:
+                    description: GroupID is the ID of the group to create the deploy
+                      token in.
+                    type: integer
+                  groupIdRef:
+                    description: GroupIDRef is a reference to a group to retrieve
+                      its groupId
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  groupIdSelector:
+                    description: GroupIDSelector selects reference to a group to retrieve
+                      its groupId.
+                    properties:
+                      matchControllerRef:
+                        description: MatchControllerRef ensures an object with the
+                          same controller reference as the selecting object is selected.
+                        type: boolean
+                      matchLabels:
+                        additionalProperties:
+                          type: string
+                        description: MatchLabels ensures an object with matching labels
+                          is selected.
+                        type: object
+                      policy:
+                        description: Policies for selection.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    type: object
+                  memberRoleId:
+                    description: memberRoleID is the defined member role assigned
+                      to members of the group
+                    type: integer
+                  name:
+                    description: name is the name of the saml group to attach to the
+                      gitlab group
+                    type: string
+                required:
+                - accessLevel
+                - name
+                type: object
+              managementPolicies:
+                default:
+                - '*'
+                description: 'THIS IS A BETA FIELD. It is on by default but can be
+                  opted out through a Crossplane feature flag. ManagementPolicies
+                  specify the array of actions Crossplane is allowed to take on the
+                  managed and external resources. This field is planned to replace
+                  the DeletionPolicy field in a future release. Currently, both could
+                  be set independently and non-default values would be honored if
+                  the feature flag is enabled. If both are custom, the DeletionPolicy
+                  field will be ignored. See the design doc for more information:
+                  https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223
+                  and this one: https://github.com/crossplane/crossplane/blob/444267e84783136daa93568b364a5f01228cacbe/design/one-pager-ignore-changes.md'
+                items:
+                  description: A ManagementAction represents an action that the Crossplane
+                    controllers can take on an external resource.
+                  enum:
+                  - Observe
+                  - Create
+                  - Update
+                  - Delete
+                  - LateInitialize
+                  - '*'
+                  type: string
+                type: array
+              providerConfigRef:
+                default:
+                  name: default
+                description: ProviderConfigReference specifies how the provider that
+                  will be used to create, observe, update, and delete this managed
+                  resource should be configured.
+                properties:
+                  name:
+                    description: Name of the referenced object.
+                    type: string
+                  policy:
+                    description: Policies for referencing.
+                    properties:
+                      resolution:
+                        default: Required
+                        description: Resolution specifies whether resolution of this
+                          reference is required. The default is 'Required', which
+                          means the reconcile will fail if the reference cannot be
+                          resolved. 'Optional' means this reference will be a no-op
+                          if it cannot be resolved.
+                        enum:
+                        - Required
+                        - Optional
+                        type: string
+                      resolve:
+                        description: Resolve specifies when this reference should
+                          be resolved. The default is 'IfNotPresent', which will attempt
+                          to resolve the reference only when the corresponding field
+                          is not present. Use 'Always' to resolve the reference on
+                          every reconcile.
+                        enum:
+                        - Always
+                        - IfNotPresent
+                        type: string
+                    type: object
+                required:
+                - name
+                type: object
+              publishConnectionDetailsTo:
+                description: PublishConnectionDetailsTo specifies the connection secret
+                  config which contains a name, metadata and a reference to secret
+                  store config to which any connection details for this managed resource
+                  should be written. Connection details frequently include the endpoint,
+                  username, and password required to connect to the managed resource.
+                properties:
+                  configRef:
+                    default:
+                      name: default
+                    description: SecretStoreConfigRef specifies which secret store
+                      config should be used for this ConnectionSecret.
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  metadata:
+                    description: Metadata is the metadata for connection secret.
+                    properties:
+                      annotations:
+                        additionalProperties:
+                          type: string
+                        description: Annotations are the annotations to be added to
+                          connection secret. - For Kubernetes secrets, this will be
+                          used as "metadata.annotations". - It is up to Secret Store
+                          implementation for others store types.
+                        type: object
+                      labels:
+                        additionalProperties:
+                          type: string
+                        description: Labels are the labels/tags to be added to connection
+                          secret. - For Kubernetes secrets, this will be used as "metadata.labels".
+                          - It is up to Secret Store implementation for others store
+                          types.
+                        type: object
+                      type:
+                        description: Type is the SecretType for the connection secret.
+                          - Only valid for Kubernetes Secret Stores.
+                        type: string
+                    type: object
+                  name:
+                    description: Name is the name of the connection secret.
+                    type: string
+                required:
+                - name
+                type: object
+              writeConnectionSecretToRef:
+                description: WriteConnectionSecretToReference specifies the namespace
+                  and name of a Secret to which any connection details for this managed
+                  resource should be written. Connection details frequently include
+                  the endpoint, username, and password required to connect to the
+                  managed resource. This field is planned to be replaced in a future
+                  release in favor of PublishConnectionDetailsTo. Currently, both
+                  could be set independently and connection details would be published
+                  to both without affecting each other.
+                properties:
+                  name:
+                    description: Name of the secret.
+                    type: string
+                  namespace:
+                    description: Namespace of the secret.
+                    type: string
+                required:
+                - name
+                - namespace
+                type: object
+            required:
+            - forProvider
+            type: object
+          status:
+            description: A SamlGroupLinkStatus represents the observed state of a
+              Gitlab SAML group sync.
+            properties:
+              atProvider:
+                description: SamlGroupLinkObservation represents a Group Saml Link.
+                properties:
+                  name:
+                    type: string
+                type: object
+              conditions:
+                description: Conditions of the resource.
+                items:
+                  description: A Condition that may apply to a resource.
+                  properties:
+                    lastTransitionTime:
+                      description: LastTransitionTime is the last time this condition
+                        transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    message:
+                      description: A Message containing details about this condition's
+                        last transition from one status to another, if any.
+                      type: string
+                    reason:
+                      description: A Reason for this condition's last transition from
+                        one status to another.
+                      type: string
+                    status:
+                      description: Status of this condition; is it currently True,
+                        False, or Unknown?
+                      type: string
+                    type:
+                      description: Type of this condition. At most one of each condition
+                        type may apply to a resource at any point in time.
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/pkg/clients/groups/fake/fake.go b/pkg/clients/groups/fake/fake.go
index 30a53da..9b702a1 100644
--- a/pkg/clients/groups/fake/fake.go
+++ b/pkg/clients/groups/fake/fake.go
@@ -48,6 +48,10 @@ type MockClient struct {
 	MockCreateGroupAccessToken func(gid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error)
 	MockRevokeGroupAccessToken func(gid interface{}, accessToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
 
+	MockGetGroupSAMLLink    func(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error)
+	MockAddGroupSAMLLink    func(pid interface{}, opt *gitlab.AddGroupSAMLLinkOptions, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error)
+	MockDeleteGroupSAMLLink func(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
 	MockListGroupVariables  func(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
 	MockGetGroupVariable    func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
 	MockCreateGroupVariable func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
@@ -166,3 +170,18 @@ func (c *MockClient) RemoveVariable(gid interface{}, key string, options ...gitl
 func (c *MockClient) ListUsers(opt *gitlab.ListUsersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.User, *gitlab.Response, error) {
 	return c.MockListUsers(opt)
 }
+
+// GetGroupSAMLLink calls the underlying MockGetGroupSAMLLink method.
+func (c *MockClient) GetGroupSAMLLink(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+	return c.MockGetGroupSAMLLink(pid, samlGroupName)
+}
+
+// AddGroupSAMLLink call the underlying MockAddGroupSAMLLink method.
+func (c *MockClient) AddGroupSAMLLink(pid interface{}, opt *gitlab.AddGroupSAMLLinkOptions, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+	return c.MockAddGroupSAMLLink(pid, opt)
+}
+
+// DeleteGroupSAMLLink calls the underlying MockDeleteGroupSAMLLink method.
+func (c *MockClient) DeleteGroupSAMLLink(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+	return c.MockDeleteGroupSAMLLink(pid, samlGroupName)
+}
diff --git a/pkg/clients/groups/samlgrouplink.go b/pkg/clients/groups/samlgrouplink.go
new file mode 100644
index 0000000..c6187fb
--- /dev/null
+++ b/pkg/clients/groups/samlgrouplink.go
@@ -0,0 +1,74 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package groups
+
+import (
+	"strings"
+
+	"github.com/xanzy/go-gitlab"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+)
+
+const (
+	errorSamlGroupLinkNotFound = "Linked SAML group link not found"
+)
+
+// SamlGroupLinkClient defines Gitlab Saml Group Link Service Operations
+type SamlGroupLinkClient interface {
+	GetGroupSAMLLink(gid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error)
+	AddGroupSAMLLink(gid interface{}, opt *gitlab.AddGroupSAMLLinkOptions, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error)
+	DeleteGroupSAMLLink(gid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+}
+
+// IsErrorSamlGroupLinkNotFound helper function to test for errSamlGroupLinkNotFound error.
+func IsErrorSamlGroupLinkNotFound(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(err.Error(), errorSamlGroupLinkNotFound)
+}
+
+// NewSamlGroupLinkClient returns a new Giltab Group Service
+func NewSamlGroupLinkClient(cfg clients.Config) SamlGroupLinkClient {
+	git := clients.NewClient(cfg)
+	return git.Groups
+}
+
+// GenerateAddSamlGroupLinkOptions is used to produce Options for SamlGroupSync creation
+func GenerateAddSamlGroupLinkOptions(p *v1alpha1.SamlGroupLinkParameters) *gitlab.AddGroupSAMLLinkOptions {
+	samlGroupName := &gitlab.AddGroupSAMLLinkOptions{
+		SAMLGroupName: p.Name,
+		AccessLevel:   (*gitlab.AccessLevelValue)(&p.AccessLevel),
+	}
+
+	return samlGroupName
+}
+
+// GenerateAddSamlGroupLinkObservation is used to produce v1alpha1.SamlGroupLinkbObservation
+func GenerateAddSamlGroupLinkObservation(samlGroupLink *gitlab.SAMLGroupLink) v1alpha1.SamlGroupLinkObservation {
+	if samlGroupLink == nil {
+		return v1alpha1.SamlGroupLinkObservation{}
+	}
+
+	output := v1alpha1.SamlGroupLinkObservation{
+		Name: samlGroupLink.Name,
+	}
+
+	return output
+}
diff --git a/pkg/controller/groups/samlgrouplinks/controller.go b/pkg/controller/groups/samlgrouplinks/controller.go
new file mode 100644
index 0000000..7970096
--- /dev/null
+++ b/pkg/controller/groups/samlgrouplinks/controller.go
@@ -0,0 +1,202 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package samlgrouplinks
+
+import (
+	"context"
+
+	"github.com/xanzy/go-gitlab"
+
+	"github.com/crossplane/crossplane-runtime/pkg/controller"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/connection"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	secretstoreapi "github.com/crossplane-contrib/provider-gitlab/apis/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/features"
+)
+
+const (
+	errNotSamlGroupLink       = "managed resource is not a SamlGroupLink custom resource"
+	errGetFailed              = "cannot get Gitlab SamlGroupLink"
+	errCreateFailed           = "cannot create Gitlab SamlGroupLink"
+	errDeleteFailed           = "cannot delete Gitlab SamlGroupLink"
+	errSamlGroupLinktNotFound = "cannot find Gitlab SamlGroupLink"
+	errMissingGroupID         = "missing Spec.ForProvider.GroupID"
+	errMissingExternalName    = "external name annotation not found"
+)
+
+// SetupSamlGroupLink adds a controller that reconciles samlgrouplinks.
+func SetupSamlGroupLink(mgr ctrl.Manager, o controller.Options) error {
+	name := managed.ControllerName(v1alpha1.SamlGroupLinkKind)
+	cps := []managed.ConnectionPublisher{managed.NewAPISecretPublisher(mgr.GetClient(), mgr.GetScheme())}
+
+	if o.Features.Enabled(features.EnableAlphaExternalSecretStores) {
+		cps = append(cps, connection.NewDetailsManager(mgr.GetClient(), secretstoreapi.StoreConfigGroupVersionKind))
+	}
+
+	reconcilerOpts := []managed.ReconcilerOption{
+		managed.WithExternalConnecter(&connector{kube: mgr.GetClient(), newGitlabClientFn: groups.NewSamlGroupLinkClient}),
+		managed.WithInitializers(),
+		managed.WithPollInterval(o.PollInterval),
+		managed.WithLogger(o.Logger.WithValues("controller", name)),
+		managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))),
+		managed.WithConnectionPublishers(cps...),
+	}
+	if o.Features.Enabled(features.EnableAlphaManagementPolicies) {
+		reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies())
+	}
+
+	r := managed.NewReconciler(mgr,
+		resource.ManagedKind(v1alpha1.SamlGroupLinkGroupVersionKind),
+		reconcilerOpts...)
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1alpha1.SamlGroupLink{}).
+		Complete(r)
+}
+
+type connector struct {
+	kube              client.Client
+	newGitlabClientFn func(cfg clients.Config) groups.SamlGroupLinkClient
+}
+
+func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) {
+	cr, ok := mg.(*v1alpha1.SamlGroupLink)
+	if !ok {
+		return nil, errors.New(errNotSamlGroupLink)
+	}
+
+	cfg, err := clients.GetConfig(ctx, c.kube, cr)
+	if err != nil {
+		return nil, err
+	}
+	return &external{kube: c.kube, client: c.newGitlabClientFn(*cfg)}, nil
+}
+
+type external struct {
+	kube   client.Client
+	client groups.SamlGroupLinkClient
+}
+
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+	cr, ok := mg.(*v1alpha1.SamlGroupLink)
+	if !ok {
+		return managed.ExternalObservation{}, errors.New(errNotSamlGroupLink)
+	}
+
+	samlGroupName := meta.GetExternalName(cr)
+
+	if samlGroupName == "" {
+		return managed.ExternalObservation{ResourceExists: false}, nil
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalObservation{}, errors.New(errMissingGroupID)
+	}
+
+	groupLink, _, err := e.client.GetGroupSAMLLink(*cr.Spec.ForProvider.GroupID, samlGroupName)
+
+	if err != nil {
+		return managed.ExternalObservation{}, errors.Wrap(resource.Ignore(groups.IsErrorSamlGroupLinkNotFound, err), errGetFailed)
+	}
+
+	cr.Status.AtProvider = groups.GenerateAddSamlGroupLinkObservation(groupLink)
+	cr.Status.SetConditions(xpv1.Available())
+
+	return managed.ExternalObservation{
+		ResourceExists:          true,
+		ResourceUpToDate:        isSamlGroupLinkUpToDate(&cr.Spec.ForProvider, groupLink),
+		ResourceLateInitialized: false,
+	}, nil
+}
+
+func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) {
+	cr, ok := mg.(*v1alpha1.SamlGroupLink)
+	if !ok {
+		return managed.ExternalCreation{}, errors.New(errNotSamlGroupLink)
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalCreation{}, errors.New(errMissingGroupID)
+	}
+
+	samlGroupLink, _, err := e.client.AddGroupSAMLLink(
+		*cr.Spec.ForProvider.GroupID,
+		groups.GenerateAddSamlGroupLinkOptions(&cr.Spec.ForProvider),
+		gitlab.WithContext(ctx),
+	)
+	if err != nil {
+		return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed)
+	}
+
+	meta.SetExternalName(cr, samlGroupLink.Name)
+
+	return managed.ExternalCreation{}, nil
+}
+
+func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
+	// not able to update SamlGroupLink
+	return managed.ExternalUpdate{}, nil
+}
+
+func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
+	cr, ok := mg.(*v1alpha1.SamlGroupLink)
+	if !ok {
+		return errors.New(errNotSamlGroupLink)
+	}
+	if cr.Spec.ForProvider.GroupID == nil {
+		return errors.New(errMissingGroupID)
+	}
+
+	samlGroupName := meta.GetExternalName(cr)
+
+	if samlGroupName == "" {
+		return errors.New(errMissingExternalName)
+	}
+
+	_, err := e.client.DeleteGroupSAMLLink(
+		*cr.Spec.ForProvider.GroupID,
+		samlGroupName,
+		nil,
+		gitlab.WithContext(ctx),
+	)
+	return errors.Wrap(err, errDeleteFailed)
+}
+
+func isSamlGroupLinkUpToDate(p *v1alpha1.SamlGroupLinkParameters, g *gitlab.SAMLGroupLink) bool {
+	if !cmp.Equal(int(p.AccessLevel), int(g.AccessLevel)) {
+		return false
+	}
+
+	if !cmp.Equal(*p.Name, g.Name) {
+		return false
+	}
+	return true
+}
diff --git a/pkg/controller/groups/setup.go b/pkg/controller/groups/setup.go
index 5b34aed..203a45a 100644
--- a/pkg/controller/groups/setup.go
+++ b/pkg/controller/groups/setup.go
@@ -25,6 +25,7 @@ import (
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/deploytokens"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/groups"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/members"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/samlgrouplinks"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/variables"
 )
 
@@ -36,6 +37,7 @@ func Setup(mgr ctrl.Manager, o controller.Options) error {
 		accesstokens.SetupAccessToken,
 		deploytokens.SetupDeployToken,
 		variables.SetupVariable,
+		samlgrouplinks.SetupSamlGroupLink,
 	} {
 		if err := setup(mgr, o); err != nil {
 			return err


Test patch:
diff --git a/pkg/controller/groups/samlgrouplinks/controller_test.go b/pkg/controller/groups/samlgrouplinks/controller_test.go
new file mode 100644
index 0000000..6de33e0
--- /dev/null
+++ b/pkg/controller/groups/samlgrouplinks/controller_test.go
@@ -0,0 +1,488 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package samlgrouplinks
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	"github.com/xanzy/go-gitlab"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups/fake"
+)
+
+var (
+	unexpecedItem resource.Managed
+
+	errBoom     = errors.New("boom")
+	name        = "Saml-example"
+	groupID     = 1234
+	accessLevel = gitlab.AccessLevelValue(10)
+)
+
+type SamlGroupLinkModifier func(*v1alpha1.SamlGroupLink)
+
+func withConditions(c ...xpv1.Condition) SamlGroupLinkModifier {
+	return func(cr *v1alpha1.SamlGroupLink) { cr.Status.ConditionedStatus.Conditions = c }
+}
+func withGroupID() SamlGroupLinkModifier {
+	return func(r *v1alpha1.SamlGroupLink) { r.Spec.ForProvider.GroupID = &groupID }
+}
+
+func withStatus(s v1alpha1.SamlGroupLinkObservation) SamlGroupLinkModifier {
+	return func(r *v1alpha1.SamlGroupLink) { r.Status.AtProvider = s }
+}
+
+func withAccessLevel(i int) SamlGroupLinkModifier {
+	return func(r *v1alpha1.SamlGroupLink) { r.Spec.ForProvider.AccessLevel = v1alpha1.AccessLevelValue(i) }
+}
+
+func withSpec(s v1alpha1.SamlGroupLinkParameters) SamlGroupLinkModifier {
+	return func(r *v1alpha1.SamlGroupLink) { r.Spec.ForProvider = s }
+}
+
+func withExternalName(n string) SamlGroupLinkModifier {
+	return func(r *v1alpha1.SamlGroupLink) { meta.SetExternalName(r, n) }
+}
+
+func samlGroupLink(m ...SamlGroupLinkModifier) *v1alpha1.SamlGroupLink {
+	cr := &v1alpha1.SamlGroupLink{}
+	for _, f := range m {
+		f(cr)
+	}
+	return cr
+}
+
+type args struct {
+	samlGroupLink groups.SamlGroupLinkClient
+	kube          client.Client
+	cr            resource.Managed
+}
+
+func TestConnect(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalClient
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotSamlGroupLink),
+			},
+		},
+		"ProviderConfigRefNotGivenError": {
+			args: args{
+				cr:   samlGroupLink(),
+				kube: &test.MockClient{MockGet: test.NewMockGetFn(nil)},
+			},
+			want: want{
+				cr:  samlGroupLink(),
+				err: errors.New("providerConfigRef is not given"),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			c := &connector{kube: tc.kube, newGitlabClientFn: nil}
+			o, err := c.Connect(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestObserve(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalObservation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotSamlGroupLink),
+			},
+		},
+		"NoExternalName": {
+			args: args{
+				cr: samlGroupLink(),
+			},
+			want: want{
+				cr:     samlGroupLink(),
+				err:    nil,
+				result: managed.ExternalObservation{ResourceExists: false},
+			},
+		},
+		"NoGroupID": {
+			args: args{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+				err:    errors.New(errMissingGroupID),
+				result: managed.ExternalObservation{},
+			},
+		},
+		"FailedGetRequest": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockGetGroupSAMLLink: func(gid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+						return nil, &gitlab.Response{Response: &http.Response{StatusCode: 400}}, errBoom
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withAccessLevel(10),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withAccessLevel(10),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+				),
+				result: managed.ExternalObservation{ResourceExists: false},
+				err:    errors.Wrap(errBoom, errGetFailed),
+			},
+		},
+		"ErrGet404": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockGetGroupSAMLLink: func(gid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+						return nil, &gitlab.Response{Response: &http.Response{StatusCode: 404}}, errors.New("Linked SAML group link not found")
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withAccessLevel(10),
+
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withAccessLevel(10),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+				),
+				result: managed.ExternalObservation{ResourceExists: false},
+				err:    nil,
+			},
+		},
+		"SuccessfulAvailable": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockGetGroupSAMLLink: func(gid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+						return &gitlab.SAMLGroupLink{Name: name, AccessLevel: accessLevel}, &gitlab.Response{}, nil
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withConditions(xpv1.Available()),
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+					withStatus(v1alpha1.SamlGroupLinkObservation{Name: name}),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          true,
+					ResourceUpToDate:        true,
+					ResourceLateInitialized: false,
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.samlGroupLink}
+			o, err := e.Observe(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+
+}
+
+func TestDelete(t *testing.T) {
+	type want struct {
+		cr  resource.Managed
+		err error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotSamlGroupLink),
+			},
+		},
+		"NoExternalName": {
+			args: args{
+				cr: samlGroupLink(
+					withGroupID(),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+				),
+				err: errors.New(errMissingExternalName),
+			},
+		},
+		"NoGroupID": {
+			args: args{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+				err: errors.New(errMissingGroupID),
+			},
+		},
+		"FailedDeletion": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockDeleteGroupSAMLLink: func(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, errBoom
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+				err: errors.Wrap(errBoom, errDeleteFailed),
+			},
+		},
+		"SuccessfulDeletion": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockDeleteGroupSAMLLink: func(pid interface{}, samlGroupName string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+				err: nil,
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.samlGroupLink}
+			err := e.Delete(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCreate(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalCreation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotSamlGroupLink),
+			},
+		},
+		"NoGroupID": {
+			args: args{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withExternalName(name),
+				),
+				err:    errors.New(errMissingGroupID),
+				result: managed.ExternalCreation{},
+			},
+		},
+		"FailedCreation": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockAddGroupSAMLLink: func(pid interface{}, opt *gitlab.AddGroupSAMLLinkOptions, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+						return &gitlab.SAMLGroupLink{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+				err: errors.Wrap(errBoom, errCreateFailed),
+			},
+		},
+		"SuccessfulCreation": {
+			args: args{
+				samlGroupLink: &fake.MockClient{
+					MockAddGroupSAMLLink: func(pid interface{}, opt *gitlab.AddGroupSAMLLinkOptions, options ...gitlab.RequestOptionFunc) (*gitlab.SAMLGroupLink, *gitlab.Response, error) {
+						return &gitlab.SAMLGroupLink{Name: name}, &gitlab.Response{}, nil
+					},
+				},
+				cr: samlGroupLink(
+					withGroupID(),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+			},
+			want: want{
+				cr: samlGroupLink(
+					withGroupID(),
+					withExternalName(name),
+					withSpec(v1alpha1.SamlGroupLinkParameters{GroupID: &groupID, Name: &name}),
+					withAccessLevel(10),
+				),
+				err:    nil,
+				result: managed.ExternalCreation{},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.samlGroupLink}
+			o, err := e.Create(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}