Repository: crossplane-contrib/provider-gitlab
Pull request #135 (2024-04-18 14:11:57)

feat: add GroupAccessToken
### Description of your changes

This PR adds GroupAccessTokens 

I have:

- [x] Read and followed Crossplane's [contribution process].
- [ ] Run `make reviewable test` to ensure this PR is ready for review.

=> fails - even on master

### How has this code been tested

running in our cluster, created some unit tests

[contribution process]: https://git.io/fj2m9

MisterMX: Thanks for the PR @markussiebert. Could you squash your changes into a single commit?
markussiebert: > Thanks for the PR @markussiebert. Could you squash your changes into a single commit?

done!

Patch:
diff --git a/apis/groups/v1alpha1/accesstoken_types.go b/apis/groups/v1alpha1/accesstoken_types.go
new file mode 100644
index 0000000..261da88
--- /dev/null
+++ b/apis/groups/v1alpha1/accesstoken_types.go
@@ -0,0 +1,108 @@
+/*
+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"
+)
+
+// AccessTokenParameters define the desired state of a Gitlab access token
+// https://docs.gitlab.com/ee/api/access_tokens.html
+type AccessTokenParameters 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"`
+
+	// Expiration date of the access token. The date cannot be set later than the maximum allowable lifetime of an access token.
+	// If not set, the maximum allowable lifetime of a personal access token is 365 days.
+	// Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+	// +immutable
+	ExpiresAt *metav1.Time `json:"expiresAt,omitempty"`
+
+	// Access level for the group. Default is 40.
+	// Valid values are 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), and 50 (Owner).
+	// +optional
+	// +immutable
+	AccessLevel *AccessLevelValue `json:"accessLevel,omitempty"`
+
+	// Scopes indicates the access token scopes.
+	// Must be at least one of read_repository, read_registry, write_registry,
+	// read_package_registry, or write_package_registry.
+	// +immutable
+	Scopes []string `json:"scopes"`
+
+	// Name of the group access token
+	// +required
+	Name string `json:"name"`
+}
+
+// AccessTokenObservation represents a access token.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/group_access_tokens.html
+type AccessTokenObservation struct {
+	TokenID *int `json:"id,omitempty"`
+}
+
+// A AccessTokenSpec defines the desired state of a Gitlab group.
+type AccessTokenSpec struct {
+	xpv1.ResourceSpec `json:",inline"`
+	ForProvider       AccessTokenParameters `json:"forProvider"`
+}
+
+// A AccessTokenStatus represents the observed state of a Gitlab group.
+type AccessTokenStatus struct {
+	xpv1.ResourceStatus `json:",inline"`
+	AtProvider          AccessTokenObservation `json:"atProvider,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// A AccessToken is a managed resource that represents a Gitlab group access token
+// +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:subresource:status
+// +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,gitlab}
+type AccessToken struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   AccessTokenSpec   `json:"spec"`
+	Status AccessTokenStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// AccessTokenList contains a list of group items
+type AccessTokenList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []AccessToken `json:"items"`
+}
diff --git a/apis/groups/v1alpha1/referencers.go b/apis/groups/v1alpha1/referencers.go
index e02b491..ebee5e7 100644
--- a/apis/groups/v1alpha1/referencers.go
+++ b/apis/groups/v1alpha1/referencers.go
@@ -127,6 +127,34 @@ func (mg *DeployToken) ResolveReferences(ctx context.Context, c client.Reader) e
 	return nil
 }
 
+// ResolveReferences of this Access Token
+func (mg *AccessToken) 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
+}
+
 // ResolveReferences of this Group.
 func (mg *Group) ResolveReferences(ctx context.Context, c client.Reader) error {
 	r := reference.NewAPIResolver(c, mg)
diff --git a/apis/groups/v1alpha1/register.go b/apis/groups/v1alpha1/register.go
index c785ce1..a80d9f8 100644
--- a/apis/groups/v1alpha1/register.go
+++ b/apis/groups/v1alpha1/register.go
@@ -61,6 +61,14 @@ var (
 	DeployTokenGroupVersionKind = SchemeGroupVersion.WithKind(DeployTokenKind)
 )
 
+// Access Token type metadata
+var (
+	AccessTokenKind             = reflect.TypeOf(AccessToken{}).Name()
+	AccessTokenGroupKind        = schema.GroupKind{Group: KubernetesGroup, Kind: AccessTokenKind}.String()
+	AccessTokenKindAPIVersion   = AccessTokenKind + "." + SchemeGroupVersion.String()
+	AccessTokenGroupVersionKind = SchemeGroupVersion.WithKind(AccessTokenKind)
+)
+
 // Variable type metadata
 var (
 	VariableKind             = reflect.TypeOf(Variable{}).Name()
@@ -72,6 +80,7 @@ var (
 func init() {
 	SchemeBuilder.Register(&Group{}, &GroupList{})
 	SchemeBuilder.Register(&Member{}, &MemberList{})
+	SchemeBuilder.Register(&AccessToken{}, &AccessTokenList{})
 	SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{})
 	SchemeBuilder.Register(&Variable{}, &VariableList{})
 }
diff --git a/apis/groups/v1alpha1/zz_generated.deepcopy.go b/apis/groups/v1alpha1/zz_generated.deepcopy.go
index 84990e7..d1dedb8 100644
--- a/apis/groups/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/groups/v1alpha1/zz_generated.deepcopy.go
@@ -25,6 +25,163 @@ import (
 	runtime "k8s.io/apimachinery/pkg/runtime"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessToken) DeepCopyInto(out *AccessToken) {
+	*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 AccessToken.
+func (in *AccessToken) DeepCopy() *AccessToken {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessToken)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AccessToken) 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 *AccessTokenList) DeepCopyInto(out *AccessTokenList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]AccessToken, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTokenList.
+func (in *AccessTokenList) DeepCopy() *AccessTokenList {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessTokenList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AccessTokenList) 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 *AccessTokenObservation) DeepCopyInto(out *AccessTokenObservation) {
+	*out = *in
+	if in.TokenID != nil {
+		in, out := &in.TokenID, &out.TokenID
+		*out = new(int)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTokenObservation.
+func (in *AccessTokenObservation) DeepCopy() *AccessTokenObservation {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessTokenObservation)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessTokenParameters) DeepCopyInto(out *AccessTokenParameters) {
+	*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.ExpiresAt != nil {
+		in, out := &in.ExpiresAt, &out.ExpiresAt
+		*out = (*in).DeepCopy()
+	}
+	if in.AccessLevel != nil {
+		in, out := &in.AccessLevel, &out.AccessLevel
+		*out = new(AccessLevelValue)
+		**out = **in
+	}
+	if in.Scopes != nil {
+		in, out := &in.Scopes, &out.Scopes
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTokenParameters.
+func (in *AccessTokenParameters) DeepCopy() *AccessTokenParameters {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessTokenParameters)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessTokenSpec) DeepCopyInto(out *AccessTokenSpec) {
+	*out = *in
+	in.ResourceSpec.DeepCopyInto(&out.ResourceSpec)
+	in.ForProvider.DeepCopyInto(&out.ForProvider)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTokenSpec.
+func (in *AccessTokenSpec) DeepCopy() *AccessTokenSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessTokenSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessTokenStatus) DeepCopyInto(out *AccessTokenStatus) {
+	*out = *in
+	in.ResourceStatus.DeepCopyInto(&out.ResourceStatus)
+	in.AtProvider.DeepCopyInto(&out.AtProvider)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTokenStatus.
+func (in *AccessTokenStatus) DeepCopy() *AccessTokenStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessTokenStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *CustomAttribute) DeepCopyInto(out *CustomAttribute) {
 	*out = *in
diff --git a/apis/groups/v1alpha1/zz_generated.managed.go b/apis/groups/v1alpha1/zz_generated.managed.go
index 716c797..8c284ea 100644
--- a/apis/groups/v1alpha1/zz_generated.managed.go
+++ b/apis/groups/v1alpha1/zz_generated.managed.go
@@ -20,6 +20,66 @@ package v1alpha1
 
 import xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
 
+// GetCondition of this AccessToken.
+func (mg *AccessToken) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return mg.Status.GetCondition(ct)
+}
+
+// GetDeletionPolicy of this AccessToken.
+func (mg *AccessToken) GetDeletionPolicy() xpv1.DeletionPolicy {
+	return mg.Spec.DeletionPolicy
+}
+
+// GetManagementPolicies of this AccessToken.
+func (mg *AccessToken) GetManagementPolicies() xpv1.ManagementPolicies {
+	return mg.Spec.ManagementPolicies
+}
+
+// GetProviderConfigReference of this AccessToken.
+func (mg *AccessToken) GetProviderConfigReference() *xpv1.Reference {
+	return mg.Spec.ProviderConfigReference
+}
+
+// GetPublishConnectionDetailsTo of this AccessToken.
+func (mg *AccessToken) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+	return mg.Spec.PublishConnectionDetailsTo
+}
+
+// GetWriteConnectionSecretToReference of this AccessToken.
+func (mg *AccessToken) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+	return mg.Spec.WriteConnectionSecretToReference
+}
+
+// SetConditions of this AccessToken.
+func (mg *AccessToken) SetConditions(c ...xpv1.Condition) {
+	mg.Status.SetConditions(c...)
+}
+
+// SetDeletionPolicy of this AccessToken.
+func (mg *AccessToken) SetDeletionPolicy(r xpv1.DeletionPolicy) {
+	mg.Spec.DeletionPolicy = r
+}
+
+// SetManagementPolicies of this AccessToken.
+func (mg *AccessToken) SetManagementPolicies(r xpv1.ManagementPolicies) {
+	mg.Spec.ManagementPolicies = r
+}
+
+// SetProviderConfigReference of this AccessToken.
+func (mg *AccessToken) SetProviderConfigReference(r *xpv1.Reference) {
+	mg.Spec.ProviderConfigReference = r
+}
+
+// SetPublishConnectionDetailsTo of this AccessToken.
+func (mg *AccessToken) SetPublishConnectionDetailsTo(r *xpv1.PublishConnectionDetailsTo) {
+	mg.Spec.PublishConnectionDetailsTo = r
+}
+
+// SetWriteConnectionSecretToReference of this AccessToken.
+func (mg *AccessToken) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
+	mg.Spec.WriteConnectionSecretToReference = r
+}
+
 // GetCondition of this DeployToken.
 func (mg *DeployToken) 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 5d6da2d..6a31f61 100644
--- a/apis/groups/v1alpha1/zz_generated.managedlist.go
+++ b/apis/groups/v1alpha1/zz_generated.managedlist.go
@@ -20,6 +20,15 @@ package v1alpha1
 
 import resource "github.com/crossplane/crossplane-runtime/pkg/resource"
 
+// GetItems of this AccessTokenList.
+func (l *AccessTokenList) 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 DeployTokenList.
 func (l *DeployTokenList) GetItems() []resource.Managed {
 	items := make([]resource.Managed, len(l.Items))
diff --git a/package/crds/groups.gitlab.crossplane.io_accesstokens.yaml b/package/crds/groups.gitlab.crossplane.io_accesstokens.yaml
new file mode 100644
index 0000000..161a5ea
--- /dev/null
+++ b/package/crds/groups.gitlab.crossplane.io_accesstokens.yaml
@@ -0,0 +1,388 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.13.0
+  name: accesstokens.groups.gitlab.crossplane.io
+spec:
+  group: groups.gitlab.crossplane.io
+  names:
+    categories:
+    - crossplane
+    - managed
+    - gitlab
+    kind: AccessToken
+    listKind: AccessTokenList
+    plural: accesstokens
+    singular: accesstoken
+  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
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: A AccessToken is a managed resource that represents a Gitlab
+          group access token
+        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 AccessTokenSpec defines the desired state of a Gitlab group.
+            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: AccessTokenParameters define the desired state of a Gitlab
+                  access token https://docs.gitlab.com/ee/api/access_tokens.html
+                properties:
+                  accessLevel:
+                    description: Access level for the group. Default is 40. Valid
+                      values are 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer),
+                      and 50 (Owner).
+                    type: integer
+                  expiresAt:
+                    description: Expiration date of the access token. The date cannot
+                      be set later than the maximum allowable lifetime of an access
+                      token. If not set, the maximum allowable lifetime of a personal
+                      access token is 365 days. Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+                    format: date-time
+                    type: string
+                  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
+                  name:
+                    description: Name of the group access token
+                    type: string
+                  scopes:
+                    description: Scopes indicates the access token scopes. Must be
+                      at least one of read_repository, read_registry, write_registry,
+                      read_package_registry, or write_package_registry.
+                    items:
+                      type: string
+                    type: array
+                required:
+                - name
+                - scopes
+                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 AccessTokenStatus represents the observed state of a Gitlab
+              group.
+            properties:
+              atProvider:
+                description: "AccessTokenObservation represents a access token. \n
+                  GitLab API docs: https://docs.gitlab.com/ee/api/group_access_tokens.html"
+                properties:
+                  id:
+                    type: integer
+                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/accesstoken.go b/pkg/clients/groups/accesstoken.go
new file mode 100644
index 0000000..3460948
--- /dev/null
+++ b/pkg/clients/groups/accesstoken.go
@@ -0,0 +1,65 @@
+/*
+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"
+)
+
+// AccessTokenClient defines Gitlab Group service operations
+type AccessTokenClient interface {
+	GetGroupAccessToken(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error)
+	CreateGroupAccessToken(pid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error)
+	RevokeGroupAccessToken(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+}
+
+// IsErrorGroupAccessTokenNotFound helper function to test for errGroupAccessTokenNotFound error.
+func IsErrorGroupAccessTokenNotFound(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(err.Error(), errGroupNotFound)
+}
+
+// NewAccessTokenClient returns a new Gitlab GroupAccessToken service
+func NewAccessTokenClient(cfg clients.Config) AccessTokenClient {
+	git := clients.NewClient(cfg)
+	return git.GroupAccessTokens
+}
+
+// GenerateCreateGroupAccessTokenOptions generates project creation options
+func GenerateCreateGroupAccessTokenOptions(name string, p *v1alpha1.AccessTokenParameters) *gitlab.CreateGroupAccessTokenOptions {
+	accesstoken := &gitlab.CreateGroupAccessTokenOptions{
+		Name:   &name,
+		Scopes: &p.Scopes,
+	}
+
+	if p.ExpiresAt != nil {
+		accesstoken.ExpiresAt = (*gitlab.ISOTime)(&p.ExpiresAt.Time)
+	}
+
+	if p.AccessLevel != nil {
+		accesstoken.AccessLevel = (*gitlab.AccessLevelValue)(p.AccessLevel)
+	}
+
+	return accesstoken
+}
diff --git a/pkg/clients/groups/fake/fake.go b/pkg/clients/groups/fake/fake.go
index 21ee7c5..30a53da 100644
--- a/pkg/clients/groups/fake/fake.go
+++ b/pkg/clients/groups/fake/fake.go
@@ -44,6 +44,10 @@ type MockClient struct {
 	MockCreateGroupDeployToken func(gid interface{}, opt *gitlab.CreateGroupDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
 	MockDeleteGroupDeployToken func(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
 
+	MockGetGroupAccessToken    func(gid interface{}, accessToken int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error)
+	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)
+
 	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)
@@ -118,6 +122,21 @@ func (c *MockClient) DeleteGroupDeployToken(gid interface{}, deployToken int, op
 	return c.MockDeleteGroupDeployToken(gid, deployToken)
 }
 
+// GetGroupAccessToken calls the underlying MockGetGroupDeployToken method.
+func (c *MockClient) GetGroupAccessToken(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+	return c.MockGetGroupAccessToken(gid, deployToken)
+}
+
+// CreateGroupAccessToken calls the underlying MockCreateGroupDeployToken method.
+func (c *MockClient) CreateGroupAccessToken(gid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+	return c.MockCreateGroupAccessToken(gid, opt)
+}
+
+// RevokeGroupAccessToken calls the underlying MockDeleteGroupDeployToken method.
+func (c *MockClient) RevokeGroupAccessToken(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+	return c.MockRevokeGroupAccessToken(gid, deployToken)
+}
+
 // ListVariables calls the underlying MockListGroupVariables method.
 func (c *MockClient) ListVariables(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error) {
 	return c.MockListGroupVariables(gid, opt)
diff --git a/pkg/controller/groups/accesstokens/controller.go b/pkg/controller/groups/accesstokens/controller.go
new file mode 100644
index 0000000..673bee3
--- /dev/null
+++ b/pkg/controller/groups/accesstokens/controller.go
@@ -0,0 +1,223 @@
+/*
+Copyright 2020 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 accesstokens
+
+import (
+	"context"
+	"strconv"
+	"time"
+
+	"github.com/xanzy/go-gitlab"
+
+	"github.com/crossplane/crossplane-runtime/pkg/controller"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	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/meta"
+	"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 (
+	errNotAccessToken       = "managed resource is not a Gitlab accesstoken custom resource"
+	errExternalNameNotInt   = "custom resource external name is not an integer"
+	errFailedParseID        = "cannot parse Access Token ID to int"
+	errGetFailed            = "cannot get Gitlab accesstoken"
+	errCreateFailed         = "cannot create Gitlab accesstoken"
+	errDeleteFailed         = "cannot delete Gitlab accesstoken"
+	errAccessTokentNotFound = "cannot find Gitlab accesstoken"
+	errMissingGroupID       = "missing Spec.ForProvider.GroupID"
+)
+
+// SetupAccessToken adds a controller that reconciles GroupAccessTokens.
+func SetupAccessToken(mgr ctrl.Manager, o controller.Options) error {
+	name := managed.ControllerName(v1alpha1.AccessTokenKind)
+
+	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.NewAccessTokenClient}),
+		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.AccessTokenGroupVersionKind),
+		reconcilerOpts...)
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1alpha1.AccessToken{}).
+		Complete(r)
+}
+
+type connector struct {
+	kube              client.Client
+	newGitlabClientFn func(cfg clients.Config) groups.AccessTokenClient
+}
+
+func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) {
+	cr, ok := mg.(*v1alpha1.AccessToken)
+	if !ok {
+		return nil, errors.New(errNotAccessToken)
+	}
+	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.AccessTokenClient
+}
+
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+	cr, ok := mg.(*v1alpha1.AccessToken)
+	if !ok {
+		return managed.ExternalObservation{}, errors.New(errNotAccessToken)
+	}
+
+	externalName := meta.GetExternalName(cr)
+	if externalName == "" {
+		return managed.ExternalObservation{}, nil
+	}
+
+	accessTokenID, err := strconv.Atoi(externalName)
+	if err != nil {
+		return managed.ExternalObservation{}, errors.Wrap(err, errFailedParseID)
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalObservation{}, errors.New(errMissingGroupID)
+	}
+
+	at, res, err := e.client.GetGroupAccessToken(*cr.Spec.ForProvider.GroupID, accessTokenID)
+	if err != nil {
+		if clients.IsResponseNotFound(res) {
+			return managed.ExternalObservation{}, nil
+		}
+		return managed.ExternalObservation{}, errors.Wrap(err, errAccessTokentNotFound)
+	}
+
+	current := cr.Spec.ForProvider.DeepCopy()
+	lateInitializeGroupAccessToken(&cr.Spec.ForProvider, at)
+
+	cr.Status.SetConditions(xpv1.Available())
+
+	return managed.ExternalObservation{
+		ResourceExists:          true,
+		ResourceUpToDate:        true,
+		ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider),
+	}, nil
+}
+
+func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) {
+	cr, ok := mg.(*v1alpha1.AccessToken)
+	if !ok {
+		return managed.ExternalCreation{}, errors.New(errNotAccessToken)
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalCreation{}, errors.New(errMissingGroupID)
+	}
+
+	at, _, err := e.client.CreateGroupAccessToken(
+		*cr.Spec.ForProvider.GroupID,
+		groups.GenerateCreateGroupAccessTokenOptions(cr.Name, &cr.Spec.ForProvider),
+		gitlab.WithContext(ctx),
+	)
+
+	if err != nil {
+		return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed)
+	}
+
+	meta.SetExternalName(cr, strconv.Itoa(at.ID))
+	return managed.ExternalCreation{
+		ConnectionDetails: managed.ConnectionDetails{
+			"token": []byte(at.Token),
+		},
+	}, nil
+}
+
+func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
+	// it's not possible to update a GroupAccessToken
+	return managed.ExternalUpdate{}, nil
+}
+
+func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
+	cr, ok := mg.(*v1alpha1.AccessToken)
+	if !ok {
+		return errors.New(errNotAccessToken)
+	}
+
+	accessTokenID, err := strconv.Atoi(meta.GetExternalName(cr))
+
+	if err != nil {
+		return errors.New(errExternalNameNotInt)
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return errors.New(errMissingGroupID)
+	}
+	_, err = e.client.RevokeGroupAccessToken(
+		*cr.Spec.ForProvider.GroupID,
+		accessTokenID,
+		gitlab.WithContext(ctx),
+	)
+
+	return errors.Wrap(err, errDeleteFailed)
+}
+
+// lateInitializeGroupAccessToken fills the empty fields in the access token spec with the
+// values seen in gitlab access token.
+func lateInitializeGroupAccessToken(in *v1alpha1.AccessTokenParameters, accessToken *gitlab.GroupAccessToken) { // nolint:gocyclo
+	if accessToken == nil {
+		return
+	}
+
+	if in.AccessLevel == nil {
+		in.AccessLevel = (*v1alpha1.AccessLevelValue)(&accessToken.AccessLevel)
+	}
+
+	if in.ExpiresAt == nil && accessToken.ExpiresAt != nil {
+		in.ExpiresAt = &metav1.Time{Time: time.Time(*accessToken.ExpiresAt)}
+	}
+}
diff --git a/pkg/controller/groups/setup.go b/pkg/controller/groups/setup.go
index 57dceac..5b34aed 100644
--- a/pkg/controller/groups/setup.go
+++ b/pkg/controller/groups/setup.go
@@ -21,6 +21,7 @@ import (
 
 	"github.com/crossplane/crossplane-runtime/pkg/controller"
 
+	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/accesstokens"
 	"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"
@@ -32,6 +33,7 @@ func Setup(mgr ctrl.Manager, o controller.Options) error {
 	for _, setup := range []func(ctrl.Manager, controller.Options) error{
 		groups.SetupGroup,
 		members.SetupMember,
+		accesstokens.SetupAccessToken,
 		deploytokens.SetupDeployToken,
 		variables.SetupVariable,
 	} {


Test patch:
diff --git a/pkg/controller/groups/accesstokens/controller_test.go b/pkg/controller/groups/accesstokens/controller_test.go
new file mode 100644
index 0000000..e26172c
--- /dev/null
+++ b/pkg/controller/groups/accesstokens/controller_test.go
@@ -0,0 +1,617 @@
+// /*
+// 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 accesstokens
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+	"testing"
+	"time"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"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/xanzy/go-gitlab"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"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 (
+	errBoom        = errors.New("boom")
+	id             = 0
+	wrongIDstr     = "fr"
+	accessTokenID  = 1234
+	sAccessTokenID = strconv.Itoa(accessTokenID)
+	invalidInput   resource.Managed
+	expiresAt      = time.Now().AddDate(0, 6, 0)
+	accessLevel    = 40
+	name           = "Access Token Name"
+	token          = "Token"
+	accessTokenObj = gitlab.GroupAccessToken{
+		ID:          accessTokenID,
+		Name:        name,
+		ExpiresAt:   (*gitlab.ISOTime)(&expiresAt),
+		Token:       token,
+		Scopes:      []string{"scope1", "scope2"},
+		AccessLevel: 40, // Access level. Valid values are 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), and 50 (Owner). Defaults to 40.
+	}
+
+	extNameAnnotation = map[string]string{meta.AnnotationKeyExternalName: fmt.Sprint(accessTokenID)}
+)
+
+type args struct {
+	accessTokenClient groups.AccessTokenClient
+	kube              client.Client
+	cr                resource.Managed
+}
+
+type accessTokenModifier func(*v1alpha1.AccessToken)
+
+func withConditions(c ...xpv1.Condition) accessTokenModifier {
+	return func(r *v1alpha1.AccessToken) { r.Status.ConditionedStatus.Conditions = c }
+}
+
+func withSpec(fp v1alpha1.AccessTokenParameters) accessTokenModifier {
+	return func(r *v1alpha1.AccessToken) { r.Spec.ForProvider = fp }
+}
+
+func withExternalName(accessTokenID string) accessTokenModifier {
+	return func(r *v1alpha1.AccessToken) { meta.SetExternalName(r, accessTokenID) }
+}
+
+func withAnnotations(a map[string]string) accessTokenModifier {
+	return func(p *v1alpha1.AccessToken) { meta.AddAnnotations(p, a) }
+}
+
+func accessToken(m ...accessTokenModifier) *v1alpha1.AccessToken {
+	cr := &v1alpha1.AccessToken{}
+	for _, f := range m {
+		f(cr)
+	}
+	return cr
+}
+
+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: invalidInput,
+			},
+			want: want{
+				cr:  invalidInput,
+				err: errors.New(errNotAccessToken),
+			},
+		},
+		"NoExternalName": {
+			args: args{
+				cr: accessToken(),
+			},
+			want: want{
+				cr:     accessToken(),
+				result: managed.ExternalObservation{},
+				err:    nil,
+			},
+		},
+		"ExternalNameNotID": {
+			args: args{
+				cr: accessToken(withExternalName(wrongIDstr)),
+			},
+			want: want{
+				cr:     accessToken(withExternalName(wrongIDstr)),
+				result: managed.ExternalObservation{},
+				err:    errors.Wrap(getConversionError(), errFailedParseID),
+			},
+		},
+		"NoGroupID": {
+			args: args{
+				cr: accessToken(withExternalName(sAccessTokenID)),
+			},
+			want: want{
+				cr:     accessToken(withExternalName(sAccessTokenID)),
+				result: managed.ExternalObservation{},
+				err:    errors.New(errMissingGroupID),
+			},
+		},
+		"ErrGetAccessToken": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return nil, nil, errBoom
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+				result: managed.ExternalObservation{},
+				err:    errors.Wrap(errBoom, errAccessTokentNotFound),
+			},
+		},
+		"GetErr404": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return nil, &gitlab.Response{
+							Response: &http.Response{StatusCode: http.StatusNotFound},
+						}, errBoom
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{GroupID: &id}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{GroupID: &id}),
+				),
+				result: managed.ExternalObservation{},
+				err:    nil,
+			},
+		},
+		"AccessTokenDoNotExist": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return nil, nil, errBoom
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+				result: managed.ExternalObservation{},
+				err:    errors.Wrap(errBoom, errAccessTokentNotFound),
+			},
+		},
+		"ResourceLateInitializedFalse": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return &gitlab.GroupAccessToken{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID:     &id,
+						AccessLevel: (*v1alpha1.AccessLevelValue)(&accessLevel),
+						ExpiresAt:   &v1.Time{Time: expiresAt},
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withConditions(xpv1.Available()),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID:     &id,
+						AccessLevel: (*v1alpha1.AccessLevelValue)(&accessLevel),
+						ExpiresAt:   &v1.Time{Time: expiresAt},
+					}),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          true,
+					ResourceUpToDate:        true,
+					ResourceLateInitialized: false,
+				},
+			},
+		},
+		"ResourceLateInitializedTrue": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return &gitlab.GroupAccessToken{
+							ExpiresAt:   accessTokenObj.ExpiresAt,
+							AccessLevel: *gitlab.AccessLevel(accessTokenObj.AccessLevel),
+						}, &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withConditions(xpv1.Available()),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID:     &id,
+						ExpiresAt:   &v1.Time{Time: expiresAt},
+						AccessLevel: (*v1alpha1.AccessLevelValue)(&accessLevel),
+					}),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          true,
+					ResourceUpToDate:        true,
+					ResourceLateInitialized: true,
+				},
+			},
+		},
+		"TokenUpToDate": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockGetGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return &gitlab.GroupAccessToken{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID:     &id,
+						AccessLevel: (*v1alpha1.AccessLevelValue)(&accessLevel),
+						ExpiresAt:   &v1.Time{Time: expiresAt},
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withConditions(xpv1.Available()),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID:     &id,
+						AccessLevel: (*v1alpha1.AccessLevelValue)(&accessLevel),
+						ExpiresAt:   &v1.Time{Time: expiresAt},
+					}),
+				),
+				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.accessTokenClient}
+			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 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: invalidInput,
+			},
+			want: want{
+				cr:     invalidInput,
+				err:    errors.New(errNotAccessToken),
+				result: managed.ExternalCreation{},
+			},
+		},
+		"NoGroupID": {
+			args: args{
+				cr: accessToken(withExternalName(sAccessTokenID)),
+			},
+			want: want{
+				cr:     accessToken(withExternalName(sAccessTokenID)),
+				result: managed.ExternalCreation{},
+				err:    errors.New(errMissingGroupID),
+			},
+		},
+		"CreationFailedErr": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockCreateGroupAccessToken: func(pid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return nil, nil, errBoom
+					},
+				},
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withExternalName(sAccessTokenID),
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+				),
+				result: managed.ExternalCreation{},
+				err:    errors.Wrap(errBoom, errCreateFailed),
+			},
+		},
+		"NoExternalName": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockCreateGroupAccessToken: func(pid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return &gitlab.GroupAccessToken{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName("0"),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName("0"),
+				),
+				err: errors.Wrap(errBoom, errCreateFailed),
+			},
+		},
+		"CreationSuccessful": {
+			args: args{
+				kube: &test.MockClient{
+					MockUpdate: test.NewMockUpdateFn(nil),
+				},
+				accessTokenClient: &fake.MockClient{
+					MockCreateGroupAccessToken: func(pid interface{}, opt *gitlab.CreateGroupAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupAccessToken, *gitlab.Response, error) {
+						return &accessTokenObj, &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withAnnotations(extNameAnnotation),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName(sAccessTokenID),
+				),
+				result: managed.ExternalCreation{
+					ConnectionDetails: managed.ConnectionDetails{"token": []byte(token)},
+				},
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.accessTokenClient}
+			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)
+			}
+		})
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalUpdate
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessfulUpdate": {
+			args: args{
+				cr: accessToken(),
+			},
+			want: want{
+				cr:     accessToken(),
+				result: managed.ExternalUpdate{},
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.accessTokenClient}
+			o, err := e.Update(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: invalidInput,
+			},
+			want: want{
+				cr:  invalidInput,
+				err: errors.New(errNotAccessToken),
+			},
+		},
+		"FailedDeletionExternalNameNotInt": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockRevokeGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName("test"),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName("test"),
+				),
+				err: errors.New(errExternalNameNotInt),
+			},
+		},
+		"FailedDeletion": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockRevokeGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, errBoom
+					},
+				},
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName(strconv.Itoa(0)),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName(strconv.Itoa(0)),
+				),
+				err: errors.Wrap(errBoom, errDeleteFailed),
+			},
+		},
+		"SuccessfulDeletion": {
+			args: args{
+				accessTokenClient: &fake.MockClient{
+					MockRevokeGroupAccessToken: func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName(strconv.Itoa(accessTokenID)),
+				),
+			},
+			want: want{
+				cr: accessToken(
+					withSpec(v1alpha1.AccessTokenParameters{
+						GroupID: &id,
+					}),
+					withExternalName(strconv.Itoa(accessTokenID)),
+				),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{client: tc.accessTokenClient}
+			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 getConversionError() error {
+	_, err := strconv.Atoi(wrongIDstr)
+	return err
+}