Skip to content

Commit 61d6b12

Browse files
authored
Batch action owner restriction (#684)
* Add batch action runtime owner restriction * Owner is optional * Copilot * Allow master tenant to create cross-owner batch actions * Copilot
1 parent d97ccb5 commit 61d6b12

5 files changed

Lines changed: 133 additions & 39 deletions

File tree

broker/oapi/open-api.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2516,7 +2516,7 @@ paths:
25162516
$ref: '#/components/schemas/Error'
25172517
post:
25182518
summary: Create a new batch action
2519-
description: Creates a batch action for one owner. Master access must provide `symbol`; Okapi access defaults to the tenant's primary symbol when omitted.
2519+
description: Creates a batch action. Master access without `symbol` creates an unrestricted action; Okapi access without `symbol` defaults to the tenant's primary symbol.
25202520
tags:
25212521
- scheduler-api
25222522
parameters:

broker/scheduler/api/api_handler.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (h SchedulerApiHandler) PostBatchActions(w http.ResponseWriter, r *http.Req
123123
return
124124
}
125125

126-
owner, ok := h.resolveConcreteOwner(ctx, w, r, params.Symbol)
126+
owner, ok := h.resolveBatchActionOwner(ctx, w, r, params.Symbol)
127127
if !ok {
128128
return
129129
}
@@ -325,8 +325,9 @@ func (h SchedulerApiHandler) resolveOwnerScope(ctx common.ExtendedContext, w htt
325325
return owners, true
326326
}
327327

328-
// resolveConcreteOwner resolves the single owner required when creating a task.
329-
func (h SchedulerApiHandler) resolveConcreteOwner(ctx common.ExtendedContext, w http.ResponseWriter, r *http.Request, symbol *string) (string, bool) {
328+
// resolveBatchActionOwner resolves the owner for a new batch action. An empty
329+
// owner represents unrestricted master access.
330+
func (h SchedulerApiHandler) resolveBatchActionOwner(ctx common.ExtendedContext, w http.ResponseWriter, r *http.Request, symbol *string) (string, bool) {
330331
t, err := h.tenantResolver.Resolve(ctx, r, symbol)
331332
if err != nil {
332333
brokerapi.AddBadRequestError(ctx, w, err)
@@ -337,10 +338,6 @@ func (h SchedulerApiHandler) resolveConcreteOwner(ctx common.ExtendedContext, w
337338
brokerapi.AddBadRequestError(ctx, w, err)
338339
return "", false
339340
}
340-
if owner == "" {
341-
brokerapi.AddBadRequestError(ctx, w, errors.New("symbol must be specified when creating a batch action with master access"))
342-
return "", false
343-
}
344341
return owner, true
345342
}
346343

broker/scheduler/api/api_handler_test.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -409,17 +409,48 @@ func TestPostBatchActions_ActionParamsPersisted(t *testing.T) {
409409
repo.AssertExpectations(t)
410410
}
411411

412-
func TestPostBatchActions_MasterWithoutSymbolReturnsBadRequest(t *testing.T) {
412+
func TestPostBatchActions_MasterWithoutSymbolCreatesUnrestrictedAction(t *testing.T) {
413413
repo := new(MockSchedRepo)
414+
repo.On("SaveScheduledTask", mock.MatchedBy(func(p sched_db.SaveScheduledTaskParams) bool {
415+
return p.Owner == "" &&
416+
p.ActionData.BatchActionData != nil &&
417+
p.ActionData.BatchActionData.Owner == ""
418+
})).Return(saveScheduledTaskReturn, nil)
419+
414420
h := newHandler(repo)
415-
req := newReq(http.MethodPost, `{"actionName":"email-pullslips","batchQuery":"title=test","schedule":"`+validRrule+`"}`)
421+
req := newReq(http.MethodPost, `{"actionName":"request-aging","batchQuery":"title=test","schedule":"`+validRrule+`","actionParams":{"interval":"24h"}}`)
416422
rr := httptest.NewRecorder()
417423

418424
h.PostBatchActions(rr, req, schedoapi.PostBatchActionsParams{})
419425

420-
assert.Equal(t, http.StatusBadRequest, rr.Code)
421-
assert.Contains(t, rr.Body.String(), "symbol must be specified")
422-
repo.AssertNotCalled(t, "SaveScheduledTask", mock.Anything)
426+
assert.Equal(t, http.StatusCreated, rr.Code)
427+
var resp schedoapi.BatchAction
428+
assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
429+
assert.Equal(t, schedoapi.RequestAging, resp.ActionName)
430+
repo.AssertExpectations(t)
431+
}
432+
433+
func TestPostBatchActions_OkapiWithoutSymbolUsesPrimaryMappedOwner(t *testing.T) {
434+
const mappedOwner = "ISIL:DK-DIKU"
435+
repo := new(MockSchedRepo)
436+
repo.On("SaveScheduledTask", mock.MatchedBy(func(p sched_db.SaveScheduledTaskParams) bool {
437+
return p.Owner == mappedOwner &&
438+
p.ActionData.BatchActionData != nil &&
439+
p.ActionData.BatchActionData.Owner == mappedOwner
440+
})).Return(saveScheduledTaskReturn, nil)
441+
resolver := tenant.NewResolver().
442+
WithTenantToSymbol("ISIL:DK-{tenant}").
443+
WithIllRepo(new(testmocks.MockIllRepositorySuccess))
444+
h := NewSchedulerApiHandler(10, repo, nil, resolver)
445+
req := httptest.NewRequest(http.MethodPost, "/broker/batch_actions",
446+
strings.NewReader(`{"actionName":"request-aging","batchQuery":"title=test","schedule":"`+validRrule+`","actionParams":{"interval":"24h"}}`))
447+
req.Header.Set(tenant.OkapiTenantHeader, "diku")
448+
rr := httptest.NewRecorder()
449+
450+
h.PostBatchActions(rr, req, schedoapi.PostBatchActionsParams{})
451+
452+
assert.Equal(t, http.StatusCreated, rr.Code)
453+
repo.AssertExpectations(t)
423454
}
424455

425456
func TestPostBatchActions_MissingBody(t *testing.T) {

broker/scheduler/service/batch_action.go

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sched_service
22

33
import (
4+
"fmt"
45
"strconv"
56
"time"
67

@@ -34,19 +35,67 @@ func (s *BatchActionService) BatchAction(ctx common.ExtendedContext, event event
3435
}
3536
func (s *BatchActionService) batchAction(ctx common.ExtendedContext, event events.Event) (events.EventStatus, *events.EventResult) {
3637
ctx = ctx.WithArgs(ctx.LoggerArgs().WithComponent(BATCH_COMP))
37-
if event.EventData.BatchActionData != nil {
38-
switch event.EventData.BatchActionData.ActionName {
39-
case string(schedoapi.EmailPullslips):
40-
return s.emailSenderService.EmailPullslip(ctx, event)
41-
case string(schedoapi.RequestAging):
42-
return s.RequestAging(ctx, event)
43-
default:
44-
ctx.Logger().Error("unknown batch action", "actionName", event.EventData.BatchActionData.ActionName, "event", event)
45-
return events.NewErrorResult("cannot process event", "unknown batch action")
46-
}
38+
if event.EventData.BatchActionData == nil {
39+
ctx.Logger().Error("batch action data is empty", "eventId", event.ID)
40+
return events.NewErrorResult("cannot process event", "batch action data is empty")
41+
}
42+
43+
var action func(common.ExtendedContext, events.Event) (events.EventStatus, *events.EventResult)
44+
switch event.EventData.BatchActionData.ActionName {
45+
case string(schedoapi.EmailPullslips):
46+
action = s.emailSenderService.EmailPullslip
47+
case string(schedoapi.RequestAging):
48+
action = s.RequestAging
49+
default:
50+
ctx.Logger().Error("unknown batch action",
51+
"actionName", event.EventData.BatchActionData.ActionName,
52+
"eventId", event.ID,
53+
"taskId", event.EventData.BatchActionData.TaskId)
54+
return events.NewErrorResult("cannot process event", "unknown batch action")
55+
}
56+
57+
restrictedSelector, err := addBatchActionOwnerRestriction(
58+
event.EventData.BatchActionData.Selector,
59+
event.EventData.BatchActionData.Owner,
60+
)
61+
if err != nil {
62+
return events.NewErrorResult("invalid batch action data", err.Error())
63+
}
64+
65+
// Keep the event stored by the event bus unchanged while ensuring every
66+
// action handler receives the owner-restricted selector.
67+
batchActionData := *event.EventData.BatchActionData
68+
batchActionData.Selector = restrictedSelector
69+
event.EventData.BatchActionData = &batchActionData
70+
return action(ctx, event)
71+
}
72+
73+
func addBatchActionOwnerRestriction(selector string, owner string) (string, error) {
74+
if selector == "" {
75+
return "", fmt.Errorf("selector is empty")
76+
}
77+
if owner == "" {
78+
return selector, nil
79+
}
80+
81+
qb, err := cqlbuilder.NewQueryFromString(selector)
82+
if err != nil {
83+
return "", err
84+
}
85+
restrictedSelector, err := qb.And().
86+
BeginClause().
87+
Search("side").Term(string(prservice.SideLending)).
88+
And().Search("supplier_symbol_exact").Term(owner).
89+
Or().
90+
BeginClause().Search("side").Term(string(prservice.SideBorrowing)).
91+
And().Search("requester_symbol_exact").Term(owner).
92+
EndClause().
93+
EndClause().
94+
Build()
95+
if err != nil {
96+
return "", err
4797
}
48-
ctx.Logger().Error("batch action data is empty", "event", event.ID)
49-
return events.NewErrorResult("cannot process event", "batch action data is empty")
98+
return restrictedSelector.String(), nil
5099
}
51100

52101
func (s *BatchActionService) RequestAging(ctx common.ExtendedContext, event events.Event) (events.EventStatus, *events.EventResult) {
@@ -58,10 +107,6 @@ func (s *BatchActionService) RequestAging(ctx common.ExtendedContext, event even
58107
if batchActionData.Selector == "" {
59108
return events.NewErrorResult("cannot process event", "selector is empty")
60109
}
61-
if batchActionData.Owner == "" {
62-
return events.NewErrorResult("cannot process event", "owner is empty")
63-
}
64-
65110
intervalString, ok := event.EventData.CustomData["interval"].(string)
66111
if !ok || intervalString == "" {
67112
return events.NewErrorResult("cannot process event", "interval is missing or not a string")

broker/scheduler/service/batch_action_test.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,37 @@ func TestBatchAction_RequestAgingDispatches(t *testing.T) {
191191
assert.True(t, repo.listCalled)
192192
}
193193

194+
func TestBatchAction_AddsOwnerRestrictionBeforeDispatch(t *testing.T) {
195+
repo := &mockEmailPrRepo{}
196+
svc := NewBatchActionService(&mockBatchActionEventBus{}, repo, nil)
197+
event := requestAgingEvent("state = REQ", map[string]any{"interval": "24h"})
198+
199+
status, _ := svc.batchAction(testCtx, event)
200+
201+
assert.Equal(t, events.EventStatusSuccess, status)
202+
assert.Equal(t,
203+
"(state = $3 AND ((side = $4 AND supplier_symbol = $5) OR (side = $6 AND requester_symbol = $7))) AND updated_at <= $8",
204+
repo.gotQuery.GetWhereClause(),
205+
)
206+
// The restriction is added to the dispatched copy, not persisted into the
207+
// scheduled event payload where repeated runs could accumulate predicates.
208+
assert.Equal(t, "state = REQ", event.EventData.BatchActionData.Selector)
209+
}
210+
211+
func TestBatchAction_MissingOwnerDispatchesWithoutRestriction(t *testing.T) {
212+
repo := &mockEmailPrRepo{}
213+
svc := NewBatchActionService(&mockBatchActionEventBus{}, repo, nil)
214+
event := requestAgingEvent("state = REQ", map[string]any{"interval": "24h"})
215+
event.EventData.BatchActionData.Owner = ""
216+
217+
status, result := svc.batchAction(testCtx, event)
218+
219+
assert.Equal(t, events.EventStatusSuccess, status)
220+
assert.Nil(t, result.EventError)
221+
assert.True(t, repo.listCalled)
222+
assert.Equal(t, "state = $3 AND updated_at <= $4", repo.gotQuery.GetWhereClause())
223+
}
224+
194225
func TestRequestAging_ValidationErrors(t *testing.T) {
195226
tests := []struct {
196227
name string
@@ -211,16 +242,6 @@ func TestRequestAging_ValidationErrors(t *testing.T) {
211242
wantMsg: "cannot process event",
212243
wantCause: "selector is empty",
213244
},
214-
{
215-
name: "empty owner",
216-
event: func() events.Event {
217-
event := requestAgingEvent("cql.allRecords=1", map[string]any{"interval": "24h"})
218-
event.EventData.BatchActionData.Owner = ""
219-
return event
220-
}(),
221-
wantMsg: "cannot process event",
222-
wantCause: "owner is empty",
223-
},
224245
{
225246
name: "missing custom data",
226247
event: requestAgingEvent("cql.allRecords=1", nil),

0 commit comments

Comments
 (0)