Skip to content

Commit c731ac1

Browse files
authored
feat(approval): implement FourEyes approval strategy with Semigranted state (#299)
1 parent 74c3d0d commit c731ac1

21 files changed

+1894
-46
lines changed

approval/api/v1/approval_types.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ type ApprovalSpec struct {
2828
Decider Decider `json:"decider,omitempty"`
2929

3030
// Decisions contains information about who or what changed this approval
31-
Decisions []Decision `json:"decisions,omitempty"`
31+
// +kubebuilder:default={}
32+
Decisions []Decision `json:"decisions"`
3233

3334
// Strategy defines the strategy that was used to approve the request
3435
// +kubebuilder:validation:Enum=Auto;Simple;FourEyes
3536
// +kubebuilder:default=Auto
3637
Strategy ApprovalStrategy `json:"strategy"`
3738

3839
// State defines the state of the approval
39-
// +kubebuilder:validation:Enum=Pending;Granted;Rejected;Suspended
40+
// +kubebuilder:validation:Enum=Pending;Semigranted;Granted;Rejected;Suspended
4041
// +kubebuilder:default=Pending
4142
State ApprovalState `json:"state"`
4243

@@ -56,7 +57,7 @@ type ApprovalStatus struct {
5657
AvailableTransitions AvailableTransitions `json:"availableTransitions,omitempty"`
5758

5859
// LastState defines the last state of the approval
59-
// +kubebuilder:validation:Enum=Pending;Granted;Rejected;Suspended
60+
// +kubebuilder:validation:Enum=Pending;Semigranted;Granted;Rejected;Suspended
6061
// +kubebuilder:default=Pending
6162
LastState ApprovalState `json:"lastState,omitempty"`
6263

approval/api/v1/approvalrequest_types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ type ApprovalRequestSpec struct {
2727
Decider Decider `json:"decider,omitempty"`
2828

2929
// Decisions contains information about who or what changed this approval
30-
Decisions []Decision `json:"decisions,omitempty"`
30+
// +kubebuilder:default={}
31+
Decisions []Decision `json:"decisions"`
3132

3233
// Strategy defines the strategy that was used to approve the request
3334
// +kubebuilder:validation:Enum=Auto;Simple;FourEyes

approval/api/v1/builder/builder.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,21 @@ func (b *approvalBuilder) Build(ctx context.Context) (finalResult ApprovalResult
168168

169169
if approvalReq.Spec.Strategy == v1.ApprovalStrategyAuto {
170170
approvalReq.Spec.State = v1.ApprovalStateGranted
171+
if len(approvalReq.Spec.Decisions) == 0 {
172+
approvalReq.Spec.Decisions = append(approvalReq.Spec.Decisions, v1.Decision{
173+
Name: "System",
174+
Comment: v1.AutoApprovedComment,
175+
ResultingState: v1.ApprovalStateGranted,
176+
})
177+
}
171178
}
179+
180+
v1.SetApprovalLabels(approvalReq, approvalReq.Spec.Target,
181+
approvalReq.Spec.Requester.TeamName,
182+
approvalReq.Spec.Decider.TeamName,
183+
approvalReq.Spec.Action,
184+
string(approvalReq.Spec.Strategy))
185+
172186
return nil
173187
}
174188

approval/api/v1/builder/builder_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ var _ = Describe("Approval Builder", Ordered, func() {
117117
g.Expect(ar.Spec.Strategy).To(BeEquivalentTo("Auto"))
118118
g.Expect(ar.Spec.State).To(BeEquivalentTo("Granted"))
119119

120+
By("Checking the AUTO-approved decision was added")
121+
g.Expect(ar.Spec.Decisions).To(HaveLen(1))
122+
g.Expect(ar.Spec.Decisions[0].Name).To(Equal("System"))
123+
g.Expect(ar.Spec.Decisions[0].Comment).To(Equal(approvalv1.AutoApprovedComment))
124+
g.Expect(ar.Spec.Decisions[0].ResultingState).To(Equal(approvalv1.ApprovalStateGranted))
125+
126+
By("Checking the filtering labels were set on the ApprovalRequest")
127+
g.Expect(ar.Labels[approvalv1.TargetKindLabelKey]).To(Equal("testresource"))
128+
g.Expect(ar.Labels[approvalv1.TargetNameLabelKey]).To(Equal("apisub"))
129+
g.Expect(ar.Labels[approvalv1.RequesterTeamLabelKey]).To(Equal("max"))
130+
g.Expect(ar.Labels[approvalv1.DeciderTeamLabelKey]).To(Equal(""))
131+
g.Expect(ar.Labels[approvalv1.ActionLabelKey]).To(Equal(""))
132+
g.Expect(ar.Labels[approvalv1.ApprovalStrategyLabelKey]).To(Equal("auto"))
133+
120134
testutil.ExpectConditionToBeFalse(g, meta.FindStatusCondition(builder.GetOwner().GetConditions(), ConditionTypeApprovalGranted), "Pending")
121135

122136
appr := &approvalv1.Approval{}
@@ -213,6 +227,18 @@ var _ = Describe("Approval Builder", Ordered, func() {
213227
// Verify that the strategy was overridden to Auto
214228
Expect(ar.Spec.Strategy).To(Equal(approvalv1.ApprovalStrategyAuto))
215229
Expect(ar.Spec.State).To(Equal(approvalv1.ApprovalStateGranted))
230+
231+
By("Checking the AUTO-approved decision was added for trusted requester")
232+
Expect(ar.Spec.Decisions).To(HaveLen(1))
233+
Expect(ar.Spec.Decisions[0].Name).To(Equal("System"))
234+
Expect(ar.Spec.Decisions[0].Comment).To(Equal(approvalv1.AutoApprovedComment))
235+
Expect(ar.Spec.Decisions[0].ResultingState).To(Equal(approvalv1.ApprovalStateGranted))
236+
237+
By("Checking the filtering labels reflect the overridden Auto strategy")
238+
Expect(ar.Labels[approvalv1.TargetKindLabelKey]).To(Equal("testresource"))
239+
Expect(ar.Labels[approvalv1.TargetNameLabelKey]).To(Equal("apisub"))
240+
Expect(ar.Labels[approvalv1.RequesterTeamLabelKey]).To(Equal("trustedteam"))
241+
Expect(ar.Labels[approvalv1.ApprovalStrategyLabelKey]).To(Equal("auto"))
216242
})
217243
})
218244

@@ -261,6 +287,18 @@ var _ = Describe("Approval Builder", Ordered, func() {
261287
res, err := builder.Build(ctx)
262288
Expect(err).NotTo(HaveOccurred())
263289
Expect(res).To(Equal(ApprovalResultGranted)) // There were no changes and Approval is granted
290+
291+
By("Checking the filtering labels on the ApprovalRequest")
292+
ar := &approvalv1.ApprovalRequest{}
293+
err = k8sClient.Get(ctx, client.ObjectKey{
294+
Name: builder.GetApprovalRequest().Name,
295+
Namespace: testNamespace,
296+
}, ar)
297+
Expect(err).ToNot(HaveOccurred())
298+
Expect(ar.Labels[approvalv1.TargetKindLabelKey]).To(Equal("testresource"))
299+
Expect(ar.Labels[approvalv1.TargetNameLabelKey]).To(Equal("apisub"))
300+
Expect(ar.Labels[approvalv1.RequesterTeamLabelKey]).To(Equal("max"))
301+
Expect(ar.Labels[approvalv1.ApprovalStrategyLabelKey]).To(Equal("auto"))
264302
})
265303

266304
It("should handle an already rejected Approval", func() {

approval/api/v1/common_types.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ package v1
66

77
import (
88
"encoding/json"
9+
910
ctypes "github.com/telekom/controlplane/common/pkg/types"
1011

1112
"github.com/pkg/errors"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1214
"k8s.io/apimachinery/pkg/runtime"
1315
)
1416

@@ -20,6 +22,9 @@ const (
2022
ApprovalStrategyFourEyes ApprovalStrategy = "FourEyes"
2123
)
2224

25+
// AutoApprovedComment is the comment added to auto-approved ApprovalRequests.
26+
const AutoApprovedComment = "Auto-approved: The approval strategy does not require manual review."
27+
2328
type ApprovalAction string
2429

2530
const (
@@ -123,4 +128,14 @@ type Decision struct {
123128

124129
// Comment provided by the person making the decision
125130
Comment string `json:"comment,omitempty"`
131+
132+
// Timestamp of when the decision was made
133+
// +optional
134+
Timestamp *metav1.Time `json:"timestamp,omitempty"`
135+
136+
// ResultingState is the state the resource transitioned to as a result of this decision.
137+
// Automatically set by the defaulting webhook to match Spec.State when not provided.
138+
// +kubebuilder:validation:Required
139+
// +kubebuilder:validation:Enum=Pending;Semigranted;Granted;Rejected;Suspended;Expired
140+
ResultingState ApprovalState `json:"resultingState"`
126141
}

approval/api/v1/labels.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2025 Deutsche Telekom IT GmbH
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package v1
6+
7+
import (
8+
"github.com/telekom/controlplane/common/pkg/config"
9+
"github.com/telekom/controlplane/common/pkg/types"
10+
"github.com/telekom/controlplane/common/pkg/util/labelutil"
11+
)
12+
13+
// Label keys for filtering ApprovalRequest and Approval resources.
14+
var (
15+
TargetKindLabelKey = config.BuildLabelKey("target.kind")
16+
TargetNameLabelKey = config.BuildLabelKey("target.name")
17+
RequesterTeamLabelKey = config.BuildLabelKey("requester.team")
18+
DeciderTeamLabelKey = config.BuildLabelKey("decider.team")
19+
ActionLabelKey = config.BuildLabelKey("action")
20+
ApprovalStrategyLabelKey = config.BuildLabelKey("approval.strategy")
21+
)
22+
23+
// SetApprovalLabels sets filtering labels on an ApprovalRequest or Approval resource.
24+
// These labels allow discovering resources without knowing their exact (hashed) name.
25+
func SetApprovalLabels(obj types.Object, target types.TypedObjectRef, requester, decider, action, strategy string) {
26+
labels := obj.GetLabels()
27+
if labels == nil {
28+
labels = map[string]string{}
29+
}
30+
labels[TargetKindLabelKey] = labelutil.NormalizeLabelValue(target.Kind)
31+
labels[TargetNameLabelKey] = labelutil.NormalizeLabelValue(target.Name)
32+
labels[RequesterTeamLabelKey] = labelutil.NormalizeLabelValue(requester)
33+
labels[DeciderTeamLabelKey] = labelutil.NormalizeLabelValue(decider)
34+
labels[ActionLabelKey] = labelutil.NormalizeLabelValue(action)
35+
labels[ApprovalStrategyLabelKey] = labelutil.NormalizeLabelValue(strategy)
36+
obj.SetLabels(labels)
37+
}

approval/api/v1/zz_generated.deepcopy.go

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

approval/config/crd/bases/approval.cp.ei.telekom.de_approvalrequests.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ spec:
103103
type: string
104104
type: object
105105
decisions:
106+
default: []
106107
description: Decisions contains information about who or what changed
107108
this approval
108109
items:
@@ -116,8 +117,25 @@ spec:
116117
name:
117118
description: Name of the person making the decision
118119
type: string
120+
resultingState:
121+
description: |-
122+
ResultingState is the state the resource transitioned to as a result of this decision.
123+
Automatically set by the defaulting webhook to match Spec.State when not provided.
124+
enum:
125+
- Pending
126+
- Semigranted
127+
- Granted
128+
- Rejected
129+
- Suspended
130+
- Expired
131+
type: string
132+
timestamp:
133+
description: Timestamp of when the decision was made
134+
format: date-time
135+
type: string
119136
required:
120137
- name
138+
- resultingState
121139
type: object
122140
type: array
123141
requester:
@@ -230,6 +248,7 @@ spec:
230248
type: object
231249
required:
232250
- action
251+
- decisions
233252
- requester
234253
- state
235254
- strategy

approval/config/crd/bases/approval.cp.ei.telekom.de_approvals.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ spec:
121121
type: string
122122
type: object
123123
decisions:
124+
default: []
124125
description: Decisions contains information about who or what changed
125126
this approval
126127
items:
@@ -134,8 +135,25 @@ spec:
134135
name:
135136
description: Name of the person making the decision
136137
type: string
138+
resultingState:
139+
description: |-
140+
ResultingState is the state the resource transitioned to as a result of this decision.
141+
Automatically set by the defaulting webhook to match Spec.State when not provided.
142+
enum:
143+
- Pending
144+
- Semigranted
145+
- Granted
146+
- Rejected
147+
- Suspended
148+
- Expired
149+
type: string
150+
timestamp:
151+
description: Timestamp of when the decision was made
152+
format: date-time
153+
type: string
137154
required:
138155
- name
156+
- resultingState
139157
type: object
140158
type: array
141159
requester:
@@ -200,6 +218,7 @@ spec:
200218
description: State defines the state of the approval
201219
enum:
202220
- Pending
221+
- Semigranted
203222
- Granted
204223
- Rejected
205224
- Suspended
@@ -248,6 +267,7 @@ spec:
248267
type: object
249268
required:
250269
- action
270+
- decisions
251271
- requester
252272
- state
253273
- strategy
@@ -332,6 +352,7 @@ spec:
332352
description: LastState defines the last state of the approval
333353
enum:
334354
- Pending
355+
- Semigranted
335356
- Granted
336357
- Rejected
337358
- Suspended
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2025 Deutsche Telekom IT GmbH
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
apiVersion: approval.cp.ei.telekom.de/v1
6+
kind: ApprovalRequest
7+
metadata:
8+
labels:
9+
app.kubernetes.io/name: approval
10+
app.kubernetes.io/managed-by: kustomize
11+
cp.ei.telekom.de/environment: default
12+
name: foo-bar-v1--my-foureyes-app
13+
spec:
14+
requester:
15+
email: workspace-b@telekom.de
16+
name: Workspace-b
17+
18+
target:
19+
kind: ApiExposure
20+
name: foo-bar-v1
21+
namespace: default
22+
source:
23+
kind: ApiSubscription
24+
name: my-app--foo-bar-v1
25+
namespace: default
26+
27+
strategy: FourEyes
28+
state: Pending
29+
reason: "I need to access the API foo-bar-v1 (requires two approvals)"

0 commit comments

Comments
 (0)