Skip to content

Commit afad622

Browse files
authored
Merge pull request #77 from thand-io/self-approvals
added support for self approvals
2 parents 2f9ee8b + 9492503 commit afad622

8 files changed

Lines changed: 953 additions & 37 deletions

File tree

.github/workflows/test-and-build.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ jobs:
208208
with:
209209
go-version: ${{ env.GO_VERSION }}
210210

211+
- name: Cache Go modules
212+
uses: actions/cache@v3
213+
with:
214+
path: |
215+
~/.cache/go-build
216+
~/go/pkg/mod
217+
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
218+
restore-keys: |
219+
${{ runner.os }}-go-
220+
211221
- name: Get version from auto-tag
212222
id: version
213223
run: |

examples/workflows/thand.approvals.example.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ workflows:
3636
with:
3737

3838
approvals: 1 # number of approvals required
39-
39+
selfApprove: false # can the requester approve their own request
4040
# 1. The thand notifier simplifies multiple workflow steps down
4141
# to a single call.
4242
# Slack - Sends request to channel

internal/daemon/elevate.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,50 @@ func (s *Server) getElevateAuthOAuth2(c *gin.Context) {
375375

376376
func (s *Server) resumeWorkflow(c *gin.Context, workflow *models.WorkflowTask) {
377377

378+
// Get user context
379+
if !s.Config.IsServer() {
380+
s.getErrorPage(c, http.StatusBadRequest, "Cannot process elevation request")
381+
return
382+
}
383+
384+
// Get user context
385+
// TODO: Validate the provider that we're using?
386+
_, foundUser, err := s.getUser(c)
387+
388+
if err != nil {
389+
logrus.WithError(err).Error("failed to get user")
390+
s.getErrorPage(c, http.StatusUnauthorized, "Unauthorized: unable to get user for elevation", err)
391+
return
392+
}
393+
394+
if foundUser == nil {
395+
s.getErrorPage(c, http.StatusUnauthorized, "Unauthorized: user not found for elevation")
396+
return
397+
}
398+
399+
if foundUser.User == nil {
400+
s.getErrorPage(c, http.StatusUnauthorized, "Unauthorized: user information is missing for elevation")
401+
return
402+
}
403+
404+
// Lets check if the workflow has a cloudevent input to process
405+
event := workflow.GetInputAsCloudEvent()
406+
407+
if event != nil {
408+
409+
// Extensions only support basic types so we need to set the user identity as a string
410+
event.SetExtension(models.VarsContextUser, foundUser.User.GetIdentity())
411+
412+
if len(event.FieldErrors) > 0 {
413+
logrus.WithField("errors", event.FieldErrors).
414+
Error("failed to set user extension on cloudevent")
415+
s.getErrorPage(c, http.StatusBadRequest, "Failed to set user extension on cloudevent")
416+
return
417+
}
418+
419+
workflow.SetInput(event)
420+
}
421+
378422
// Provide no input to resume the workflow as it'll use the saved state
379423
// inputs are only for signals
380424
workflowTask, err := s.Workflows.ResumeWorkflow(

internal/models/workflow_ctx.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import (
66
"maps"
77
"time"
88

9+
cloudevents "github.com/cloudevents/sdk-go/v2"
910
swctx "github.com/serverlessworkflow/sdk-go/v3/impl/ctx"
1011
"github.com/serverlessworkflow/sdk-go/v3/impl/utils"
1112
"github.com/serverlessworkflow/sdk-go/v3/model"
1213
"github.com/sirupsen/logrus"
14+
"github.com/thand-io/agent/internal/common"
1315
)
1416

1517
func NewWorkflowContext(workflow *Workflow) (*WorkflowTask, error) {
@@ -235,6 +237,36 @@ func (ctx *WorkflowTask) GetInputAsMap() map[string]any {
235237
return map[string]any{"input": ctx.Input}
236238
}
237239

240+
func (ctx *WorkflowTask) GetInputAsCloudEvent() *cloudevents.Event {
241+
ctx.mu.Lock()
242+
defer ctx.mu.Unlock()
243+
244+
var event cloudevents.Event
245+
if err := common.ConvertInterfaceToInterface(ctx.Input, &event); err != nil {
246+
logrus.WithError(err).Error("failed to unmarshal cloudevent from workflow input")
247+
return nil
248+
}
249+
250+
if len(event.ID()) == 0 {
251+
logrus.Error("cloudevent validation failed: missing ID")
252+
return nil
253+
}
254+
if event.Time().IsZero() {
255+
logrus.Error("cloudevent validation failed: missing Time")
256+
return nil
257+
}
258+
if len(event.Source()) == 0 {
259+
logrus.Error("cloudevent validation failed: missing Source")
260+
return nil
261+
}
262+
if len(event.Type()) == 0 {
263+
logrus.Error("cloudevent validation failed: missing Type")
264+
return nil
265+
}
266+
267+
return &event
268+
}
269+
238270
func (ctx *WorkflowTask) GetContextAsMap() map[string]any {
239271
ctx.mu.Lock()
240272
defer ctx.mu.Unlock()

internal/workflows/manager/manager.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,8 @@ func (m *WorkflowManager) ResumeWorkflow(
294294

295295
// Lets signal the workflow to continue
296296
err = temporalClient.SignalWorkflow(
297-
ctx, result.WorkflowID, models.TemporalEmptyRunId, models.TemporalResumeSignalName, result)
297+
ctx, result.WorkflowID, models.TemporalEmptyRunId,
298+
models.TemporalResumeSignalName, result)
298299

299300
if err != nil {
300301
return nil, fmt.Errorf("failed to signal workflow: %w", err)

internal/workflows/tasks/providers/thand/approvals.go

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package thand
33
import (
44
"errors"
55
"fmt"
6+
"slices"
7+
"time"
68

79
cloudevents "github.com/cloudevents/sdk-go/v2"
810
"github.com/serverlessworkflow/sdk-go/v3/model"
@@ -21,8 +23,26 @@ func (t *thandTask) executeApprovalsTask(
2123
call *taskModel.ThandTask,
2224
input any) (any, error) {
2325

26+
elevationRequest, err := workflowTask.GetContextAsElevationRequest()
27+
28+
if err != nil {
29+
logrus.WithError(err).WithFields(logrus.Fields{
30+
"taskName": taskName,
31+
}).Error("Failed to get elevation request from context")
32+
33+
return nil, err
34+
}
35+
2436
var notifyReq NotifyRequest
25-
common.ConvertInterfaceToInterface(call.With, &notifyReq)
37+
err = common.ConvertInterfaceToInterface(call.With, &notifyReq)
38+
39+
if err != nil {
40+
logrus.WithError(err).WithFields(logrus.Fields{
41+
"taskName": taskName,
42+
}).Error("Failed to parse notification request")
43+
44+
return nil, err
45+
}
2646

2747
if !notifyReq.IsValid() {
2848
return nil, errors.New("invalid notification request")
@@ -82,6 +102,10 @@ func (t *thandTask) executeApprovalsTask(
82102
return nil, err
83103
}
84104

105+
defaultFlowState := model.FlowDirective{
106+
Value: taskName, // loop back to await more approvals
107+
}
108+
85109
// Set the context to hold all the approvals
86110
/*
87111
output:
@@ -97,37 +121,76 @@ func (t *thandTask) executeApprovalsTask(
97121

98122
workflowContext := workflowTask.GetContextAsMap()
99123

100-
approvals, ok := workflowContext["approvals"].([]any)
124+
approvals, ok := workflowContext["approvals"].(map[string]any)
101125

102126
if !ok {
103-
approvals = []any{}
127+
approvals = map[string]any{}
104128
}
105129

106130
var approvalData map[string]any
107131

108132
if approvalEvent, ok := approval.(*cloudevents.Event); ok {
109133

110134
approvalEvent.DataAs(&approvalData)
135+
extensions := approvalEvent.Extensions()
136+
137+
userIdentity, userExists := extensions[models.VarsContextUser].(string)
138+
139+
if !userExists {
140+
logrus.Warn("Approval event missing user extension")
141+
return &defaultFlowState, nil
142+
}
143+
144+
// Check if self-approval is disabled and the approver is the requester or one of the elevated identities
145+
if !notifyReq.SelfApprove {
146+
requesterIdentity := elevationRequest.User.GetIdentity()
147+
148+
// Check if approver is the requester
149+
if userIdentity == requesterIdentity {
150+
logrus.WithFields(logrus.Fields{
151+
"taskName": taskName,
152+
"userIdentity": userIdentity,
153+
"requesterIdentity": requesterIdentity,
154+
}).Warn("Self-approval is disabled; ignoring approval from requester")
155+
156+
// Return to the default flow state to await more approvals
157+
return &defaultFlowState, nil
158+
}
159+
160+
// Check if approver is one of the identities being elevated
161+
if slices.Contains(elevationRequest.Identities, userIdentity) {
162+
logrus.WithFields(logrus.Fields{
163+
"taskName": taskName,
164+
"userIdentity": userIdentity,
165+
}).Warn("Self-approval is disabled; ignoring approval from identity being elevated")
166+
167+
// Return to the default flow state to await more approvals
168+
return &defaultFlowState, nil
169+
}
170+
}
111171

112172
if approved, exists := approvalData["approved"]; exists {
113-
approvals = append(approvals, map[string]any{
114-
"approved": approved,
115-
})
173+
approvals[userIdentity] = map[string]any{
174+
"approved": approved,
175+
"timestamp": time.Now().UTC().Format(time.RFC3339),
176+
}
116177
}
117178
}
118179

119180
workflowTask.SetContextKeyValue("approvals", approvals)
120181

121182
/*
122183
# If anyone rejects then reject the entire request
123-
# otherwise if there is more than one approval then
124-
# authorize
184+
# otherwise if the required number of approvals is met then authorize
185+
# Approvals are stored as a map[identity]approval_data structure
125186
- case1:
126-
when: any($context.approvals[]; .approved == false)
187+
when: any($context.approvals | to_entries[]; .value.approved == false)
127188
then: denied
128189
- case2:
129-
when: '[$context.approvals[] | select(.approved == true)] | length >= 1'
190+
when: '[$context.approvals | to_entries[] | select(.value.approved == true)] | length >= N'
130191
then: authorize
192+
- default:
193+
then: loop back to task to await more approvals
131194
*/
132195

133196
approvedState, foundApprovedState := call.On.GetString("approved")
@@ -138,7 +201,43 @@ func (t *thandTask) executeApprovalsTask(
138201
}
139202

140203
// Create the switch task to handle approval or rejection
141-
flowDirective, err := runner.SwitchTaskHandler(
204+
flowDirective, err := t.evaluateApprovalSwitch(
205+
workflowTask,
206+
taskName,
207+
approvals,
208+
notifyReq.Approvals,
209+
approvedState,
210+
deniedState,
211+
)
212+
213+
if err != nil {
214+
logrus.WithError(err).WithFields(logrus.Fields{
215+
"taskName": taskName,
216+
}).Error("Failed to execute switch task for approval logic")
217+
218+
return nil, err
219+
}
220+
221+
logrus.WithFields(logrus.Fields{
222+
"taskName": taskName,
223+
"flowDirective": flowDirective.Value,
224+
}).Info("Completed Thand approvals task")
225+
226+
return flowDirective, nil
227+
}
228+
229+
// evaluateApprovalSwitch evaluates the approval logic using a switch task
230+
// to determine if the request should be approved, denied, or loop back for more approvals
231+
func (t *thandTask) evaluateApprovalSwitch(
232+
workflowTask *models.WorkflowTask,
233+
taskName string,
234+
approvals map[string]any,
235+
requiredApprovals int,
236+
approvedState string,
237+
deniedState string,
238+
) (*model.FlowDirective, error) {
239+
240+
return runner.SwitchTaskHandler(
142241
workflowTask,
143242
map[string]any{
144243
"approvals": approvals,
@@ -149,7 +248,7 @@ func (t *thandTask) executeApprovalsTask(
149248
{
150249
"case1": model.SwitchCase{
151250
When: &model.RuntimeExpression{
152-
Value: "any($context.approvals[]; .approved == false)",
251+
Value: "any($context.approvals | to_entries[]; .value.approved == false)",
153252
},
154253
Then: &model.FlowDirective{
155254
Value: deniedState, // go to denied state
@@ -159,7 +258,7 @@ func (t *thandTask) executeApprovalsTask(
159258
{
160259
"case2": model.SwitchCase{
161260
When: &model.RuntimeExpression{
162-
Value: fmt.Sprintf("[$context.approvals[] | select(.approved == true)] | length >= %d", notifyReq.Approvals),
261+
Value: fmt.Sprintf("[$context.approvals | to_entries[] | select(.value.approved == true)] | length >= %d", requiredApprovals),
163262
},
164263
Then: &model.FlowDirective{
165264
Value: approvedState, // proceed to the next state
@@ -176,19 +275,4 @@ func (t *thandTask) executeApprovalsTask(
176275
},
177276
},
178277
})
179-
180-
if err != nil {
181-
logrus.WithError(err).WithFields(logrus.Fields{
182-
"taskName": taskName,
183-
}).Error("Failed to execute switch task for approval logic")
184-
185-
return nil, err
186-
}
187-
188-
logrus.WithFields(logrus.Fields{
189-
"taskName": taskName,
190-
"flowDirective": flowDirective.Value,
191-
}).Info("Completed Thand approvals task")
192-
193-
return flowDirective, nil
194278
}

0 commit comments

Comments
 (0)