Skip to content

Commit 99d473f

Browse files
authored
feat: time-restricted AccessRequests (#221)
* add TTL to AccessRequest spec * implement AccessRequest TTL logic
1 parent ea9cc49 commit 99d473f

File tree

9 files changed

+235
-6
lines changed

9 files changed

+235
-6
lines changed

api/clusters/v1alpha1/accessrequest_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ type AccessRequestSpec struct {
4141
// Exactly one of Token or OIDC must be set.
4242
// +optional
4343
OIDC *OIDCConfig `json:"oidc,omitempty"`
44+
45+
// TTL is the desired time-to-live for the granted access.
46+
// The AccessRequest will be automatically deleted after the TTL has expired.
47+
// Note that this value refers to the creation time of the AccessRequest, not the time the access was granted.
48+
// Leave nil for unlimited TTL.
49+
// +optional
50+
TTL *metav1.Duration `json:"ttl,omitempty"`
4451
}
4552

4653
type TokenConfig struct {

api/clusters/v1alpha1/zz_generated.deepcopy.go

Lines changed: 8 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/crds/manifests/clusters.openmcp.cloud_accessrequests.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,13 @@ spec:
400400
type: object
401401
type: array
402402
type: object
403+
ttl:
404+
description: |-
405+
TTL is the desired time-to-live for the granted access.
406+
The AccessRequest will be automatically deleted after the TTL has expired.
407+
Note that this value refers to the creation time of the AccessRequest, not the time the access was granted.
408+
Leave nil for unlimited TTL.
409+
type: string
403410
type: object
404411
x-kubernetes-validations:
405412
- message: clusterRef may not be removed once set

internal/controllers/accessrequest/controller.go

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package accessrequest
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
apierrors "k8s.io/apimachinery/pkg/api/errors"
89
ctrl "sigs.k8s.io/controller-runtime"
910
"sigs.k8s.io/controller-runtime/pkg/client"
11+
"sigs.k8s.io/controller-runtime/pkg/event"
1012
"sigs.k8s.io/controller-runtime/pkg/predicate"
1113
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1214

@@ -60,6 +62,7 @@ func (r *AccessRequestReconciler) Reconcile(ctx context.Context, req reconcile.R
6062
UpdateStatus(ctx, r.PlatformCluster.Client(), rr)
6163
}
6264

65+
//nolint:gocyclo
6366
func (r *AccessRequestReconciler) reconcile(ctx context.Context, req reconcile.Request) ReconcileResult {
6467
log := logging.FromContextOrPanic(ctx)
6568
// get AccessRequest resource
@@ -89,10 +92,40 @@ func (r *AccessRequestReconciler) reconcile(ctx context.Context, req reconcile.R
8992
}
9093
}
9194

95+
// ignore AccessRequests that are in deletion
96+
if !ar.GetDeletionTimestamp().IsZero() {
97+
log.Info("Ignoring AccessRequest, because it is in deletion")
98+
return ReconcileResult{}
99+
}
100+
92101
rr := ReconcileResult{
93102
Object: ar,
94103
}
95104

105+
// check if TTL is set and has expired
106+
if ar.Spec.TTL != nil {
107+
isExpired, expirationTime := hasExpiredTTL(ar)
108+
if isExpired {
109+
log.Info("AccessRequest TTL has expired, deleting AccessRequest", "expirationTime", expirationTime, "creationTime", ar.GetCreationTimestamp(), "ttl", ar.Spec.TTL.Duration)
110+
if err := r.PlatformCluster.Client().Delete(ctx, ar); err != nil {
111+
rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting AccessRequest after TTL expiration: %w", err), cconst.ReasonPlatformClusterInteractionProblem)
112+
return rr
113+
}
114+
// don't update the status after deletion, could become a race condition
115+
rr.Object = nil
116+
return rr
117+
}
118+
log.Debug("AccessRequest TTL not yet expired", "expirationTime", expirationTime)
119+
// compute requeue after duration, which is the remaining time until expiration plus one second
120+
rr.Result.RequeueAfter = time.Until(expirationTime) + time.Second
121+
}
122+
123+
// check if this reconciliation was just a TTL check and no further action is required
124+
if ctrlutils.HasLabel(ar, clustersv1alpha1.ProviderLabel) && ctrlutils.HasLabel(ar, clustersv1alpha1.ProfileLabel) && ar.Spec.ClusterRef != nil {
125+
log.Info("AccessRequest already has provider and profile labels and a clusterRef, no further action required")
126+
return rr
127+
}
128+
96129
// get Cluster that the request refers to
97130
c := &clustersv1alpha1.Cluster{}
98131
if ar.Spec.ClusterRef != nil {
@@ -190,10 +223,14 @@ func (r *AccessRequestReconciler) SetupWithManager(mgr ctrl.Manager) error {
190223
For(&clustersv1alpha1.AccessRequest{}).
191224
WithEventFilter(predicate.And(
192225
// ignore resources that already have the provider AND profile label set
193-
predicate.Not(predicate.And(
194-
ctrlutils.HasLabelPredicate(clustersv1alpha1.ProviderLabel, ""),
195-
ctrlutils.HasLabelPredicate(clustersv1alpha1.ProfileLabel, ""),
196-
)),
226+
// unless the TTL is set, in which case we need to check for expiration
227+
predicate.Or(
228+
predicate.Not(predicate.And(
229+
ctrlutils.HasLabelPredicate(clustersv1alpha1.ProviderLabel, ""),
230+
ctrlutils.HasLabelPredicate(clustersv1alpha1.ProfileLabel, ""),
231+
)),
232+
hasTTLPredicate(),
233+
),
197234
ctrlutils.LabelSelectorPredicate(r.Config.Selector.Completed()),
198235
predicate.Or(
199236
ctrlutils.GotAnnotationPredicate(apiconst.OperationAnnotation, apiconst.OperationAnnotationValueReconcile),
@@ -203,3 +240,44 @@ func (r *AccessRequestReconciler) SetupWithManager(mgr ctrl.Manager) error {
203240
)).
204241
Complete(r)
205242
}
243+
244+
// hasTTLPredicate returns true if the AccessRequest is not in deletion and has a TTL set.
245+
func hasTTLPredicate() predicate.Predicate {
246+
hasTTL := func(obj client.Object) bool {
247+
ar, ok := obj.(*clustersv1alpha1.AccessRequest)
248+
if !ok {
249+
return false
250+
}
251+
if !ar.DeletionTimestamp.IsZero() {
252+
return false // no need to reconcile already deleted objects here
253+
}
254+
return ar.Spec.TTL != nil
255+
}
256+
return predicate.Funcs{
257+
UpdateFunc: func(tue event.TypedUpdateEvent[client.Object]) bool {
258+
return hasTTL(tue.ObjectNew)
259+
},
260+
CreateFunc: func(tce event.TypedCreateEvent[client.Object]) bool {
261+
return hasTTL(tce.Object)
262+
},
263+
DeleteFunc: func(tde event.TypedDeleteEvent[client.Object]) bool {
264+
return false
265+
},
266+
GenericFunc: func(tge event.TypedGenericEvent[client.Object]) bool {
267+
return hasTTL(tge.Object)
268+
},
269+
}
270+
}
271+
272+
// hasExpiredTTL returns true if the AccessRequest has a TTL set and the TTL has expired.
273+
// If the AccessRequest has a TTL set, the second return value is the expiration time, otherwise it is the zero time.
274+
func hasExpiredTTL(ar *clustersv1alpha1.AccessRequest) (bool, time.Time) {
275+
if ar == nil {
276+
return false, time.Time{}
277+
}
278+
if ar.Spec.TTL == nil {
279+
return false, time.Time{}
280+
}
281+
expirationTime := ar.GetCreationTimestamp().Add(ar.Spec.TTL.Duration)
282+
return time.Now().After(expirationTime), expirationTime
283+
}

internal/controllers/accessrequest/controller_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package accessrequest_test
22

33
import (
4+
"time"
5+
46
. "github.com/onsi/ginkgo/v2"
57
. "github.com/onsi/gomega"
68

9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
711
"k8s.io/apimachinery/pkg/runtime"
812
"sigs.k8s.io/controller-runtime/pkg/client"
913
"sigs.k8s.io/controller-runtime/pkg/reconcile"
@@ -145,4 +149,53 @@ var _ = Describe("AccessRequest Controller", func() {
145149
Expect(ar.Spec.ClusterRef).To(BeNil())
146150
})
147151

152+
Context("TTL", func() {
153+
154+
It("should not delete an AccessRequest without TTL", func() {
155+
env := testutils.NewEnvironmentBuilder().WithFakeClient(scheme).WithInitObjectPath("testdata", "test-06").WithReconcilerConstructor(arReconciler).Build()
156+
ar := &clustersv1alpha1.AccessRequest{}
157+
Expect(env.Client().Get(env.Ctx, ctrlutils.ObjectKey("mc-access-no-ttl", "bar"), ar)).To(Succeed())
158+
arCopy := ar.DeepCopy()
159+
env.ShouldReconcile(testutils.RequestFromObject(ar))
160+
Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)).To(Succeed())
161+
Expect(ar.Labels).To(Equal(arCopy.Labels))
162+
Expect(ar.Annotations).To(Equal(arCopy.Annotations))
163+
Expect(ar.DeletionTimestamp.IsZero()).To(BeTrue())
164+
Expect(ar.Spec).To(Equal(arCopy.Spec))
165+
Expect(ar.Status).To(Equal(arCopy.Status))
166+
})
167+
168+
It("should delete an AccessRequest with expired TTL", func() {
169+
env := testutils.NewEnvironmentBuilder().WithFakeClient(scheme).WithInitObjectPath("testdata", "test-06").WithReconcilerConstructor(arReconciler).Build()
170+
ar := &clustersv1alpha1.AccessRequest{}
171+
Expect(env.Client().Get(env.Ctx, ctrlutils.ObjectKey("mc-access-ttl", "bar"), ar)).To(Succeed())
172+
env.ShouldReconcile(testutils.RequestFromObject(ar))
173+
Eventually(func() error {
174+
return env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)
175+
}).Should(MatchError(apierrors.IsNotFound, "IsNotFound"))
176+
})
177+
178+
It("should not delete an AccessRequest with non-expired TTL", func() {
179+
env := testutils.NewEnvironmentBuilder().WithFakeClient(scheme).WithInitObjectPath("testdata", "test-06").WithReconcilerConstructor(arReconciler).Build()
180+
ar := &clustersv1alpha1.AccessRequest{}
181+
Expect(env.Client().Get(env.Ctx, ctrlutils.ObjectKey("mc-access-ttl", "bar"), ar)).To(Succeed())
182+
183+
// modify creation timestamp to be recent so that TTL is not expired yet
184+
now := metav1.Now()
185+
ar.SetCreationTimestamp(now)
186+
Expect(env.Client().Update(env.Ctx, ar)).To(Succeed())
187+
188+
arCopy := ar.DeepCopy()
189+
rr := env.ShouldReconcile(testutils.RequestFromObject(ar))
190+
Expect(rr.RequeueAfter).To(BeNumerically("~", time.Hour+time.Second, time.Second))
191+
Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)).To(Succeed())
192+
Expect(ar.Labels).To(Equal(arCopy.Labels))
193+
Expect(ar.Annotations).To(Equal(arCopy.Annotations))
194+
Expect(ar.DeletionTimestamp.IsZero()).To(BeTrue())
195+
Expect(ar.Spec).To(Equal(arCopy.Spec))
196+
Expect(ar.Status).To(Equal(arCopy.Status))
197+
})
198+
199+
})
200+
148201
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
apiVersion: clusters.openmcp.cloud/v1alpha1
2+
kind: AccessRequest
3+
metadata:
4+
name: mc-access-no-ttl
5+
namespace: bar
6+
labels:
7+
clusters.openmcp.cloud/profile: default
8+
clusters.openmcp.cloud/provider: asdf
9+
creationTimestamp: "2025-01-01T00:00:00Z"
10+
spec:
11+
clusterRef:
12+
name: my-cluster
13+
namespace: foo
14+
permissions:
15+
- rules:
16+
- apiGroups:
17+
- "*"
18+
resources:
19+
- "*"
20+
verbs:
21+
- "*"
22+
status:
23+
phase: Pending
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
apiVersion: clusters.openmcp.cloud/v1alpha1
2+
kind: AccessRequest
3+
metadata:
4+
name: mc-access-ttl
5+
namespace: bar
6+
labels:
7+
clusters.openmcp.cloud/profile: default
8+
clusters.openmcp.cloud/provider: asdf
9+
creationTimestamp: "2025-01-01T00:00:00Z"
10+
spec:
11+
ttl: "1h"
12+
clusterRef:
13+
name: my-cluster
14+
namespace: foo
15+
permissions:
16+
- rules:
17+
- apiGroups:
18+
- "*"
19+
resources:
20+
- "*"
21+
verbs:
22+
- "*"
23+
status:
24+
phase: Pending
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: clusters.openmcp.cloud/v1alpha1
2+
kind: Cluster
3+
metadata:
4+
name: my-cluster
5+
namespace: foo
6+
spec:
7+
profile: default
8+
kubernetes:
9+
version: "1.32"
10+
purposes:
11+
- test
12+
tenancy: Exclusive
13+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
apiVersion: clusters.openmcp.cloud/v1alpha1
2+
kind: ClusterProfile
3+
metadata:
4+
generation: 1
5+
name: default
6+
spec:
7+
environment: default
8+
providerConfigRef:
9+
name: foobar
10+
providerRef:
11+
name: asdf
12+
supportedVersions:
13+
- version: 1.32.2
14+
- version: 1.31.7
15+
- deprecated: true
16+
version: 1.31.6
17+
- deprecated: true
18+
version: 1.31.5

0 commit comments

Comments
 (0)