Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apis/cmk/cmk-ui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,12 @@ components:
$ref: "#/components/schemas/CreatedAt"
updatedAt:
$ref: "#/components/schemas/UpdatedAt"
additionalInfo:
type: array
items:
$ref: "#/components/schemas/WorkflowAdditionalInfo"
description: Optional informational messages or warnings about the workflow
readOnly: true
WorkflowTransition:
type: object
required:
Expand Down Expand Up @@ -2655,6 +2661,35 @@ components:
description: The target score required for approval.
type: integer
example: 2
WorkflowAdditionalInfo:
type: object
description: Additional informational message or warning about a workflow
readOnly: true
required:
- code
- severity
- message
properties:
code:
type: string
description: Machine-readable code identifying the type of information
enum:
- INSUFFICIENT_APPROVERS
- WORKFLOW_ELIGIBILITY_CHECK_FAILED
- INITIATOR_INELIGIBLE
example: "INSUFFICIENT_APPROVERS"
severity:
type: string
description: Severity level of the information
enum:
- WARNING
- INFO
- ERROR
example: "WARNING"
message:
type: string
description: Human-readable message explaining the information
example: "The number of eligible approvers is currently insufficient to meet the minimum approval criteria."
#
# Labels
#
Expand Down
420 changes: 230 additions & 190 deletions internal/api/cmkapi/cmkapi.go

Large diffs are not rendered by default.

74 changes: 68 additions & 6 deletions internal/api/transform/workflow/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,65 @@ import (

var ErrExpiryGreaterThanMaximum = errors.New("expiry exceeds maximum")

const (
// AdditionalInfoMessageInsufficientApprovers is the message for the insufficient approvers warning
AdditionalInfoMessageInsufficientApprovers = "The number of eligible approvers is currently" +
" insufficient to meet the minimum approval criteria."
// AdditionalInfoMessageEligibilityCheckError is the message when eligibility verification fails
AdditionalInfoMessageEligibilityCheckError = "Unable to verify workflow eligibility. " +
"The approval system may be temporarily unavailable. Please try again later or contact support."
// AdditionalInfoMessageInitiatorIneligible is the message when initiator is no longer eligible to confirm
AdditionalInfoMessageInitiatorIneligible = "The workflow initiator is no longer eligible to confirm this workflow."
)

// buildEligibilityAdditionalInfo creates additional info items based on eligibility status
func buildEligibilityAdditionalInfo(
insufficientApprovers bool,
initiatorIneligible bool,
eligibilityErr error,
) *[]cmkapi.WorkflowAdditionalInfo {
var apiInfoItems []cmkapi.WorkflowAdditionalInfo

// If eligibility check failed, show only the error (takes precedence over warnings)
if eligibilityErr != nil {
apiInfoItems = append(apiInfoItems, cmkapi.WorkflowAdditionalInfo{
Code: cmkapi.WorkflowAdditionalInfoCodeWORKFLOWELIGIBILITYCHECKFAILED,
Severity: cmkapi.WorkflowAdditionalInfoSeverityERROR,
Message: AdditionalInfoMessageEligibilityCheckError,
})
} else {
// No error - show all applicable warnings
if initiatorIneligible {
apiInfoItems = append(apiInfoItems, cmkapi.WorkflowAdditionalInfo{
Code: cmkapi.WorkflowAdditionalInfoCodeINITIATORINELIGIBLE,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: AdditionalInfoMessageInitiatorIneligible,
})
}
if insufficientApprovers {
apiInfoItems = append(apiInfoItems, cmkapi.WorkflowAdditionalInfo{
Code: cmkapi.WorkflowAdditionalInfoCodeINSUFFICIENTAPPROVERS,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: AdditionalInfoMessageInsufficientApprovers,
})
}
}

if len(apiInfoItems) > 0 {
return &apiInfoItems
}
return nil
}

// ToAPI converts a workflow model to an API workflow presentation.
// eligibilityErr should be passed if there was an error checking approver eligibility.
// initiatorIneligible should be true if the initiator is no longer eligible to confirm the workflow.
func ToAPI(
ctx context.Context,
w model.Workflow,
insufficientApprovers bool,
initiatorIneligible bool,
eligibilityErr error,
identityManager identitymanagement.IdentityManagement,
) (*cmkapi.Workflow, error) {
err := sanitise.Sanitize(&w)
Expand All @@ -42,6 +97,13 @@ func ToAPI(
return nil, err
}

// Build metadata with additional info
metadata := &cmkapi.WorkflowMetadata{
CreatedAt: ptr.PointTo(w.CreatedAt),
UpdatedAt: ptr.PointTo(w.UpdatedAt),
AdditionalInfo: buildEligibilityAdditionalInfo(insufficientApprovers, initiatorIneligible, eligibilityErr),
}

return &cmkapi.Workflow{
Id: ptr.PointTo(w.ID),
InitiatorID: w.InitiatorID,
Expand All @@ -55,11 +117,8 @@ func ToAPI(
ArtifactID: w.ArtifactID,
Parameters: ptr.PointTo(w.Parameters),
FailureReason: ptr.PointTo(w.FailureReason),
Metadata: ptr.PointTo(cmkapi.WorkflowMetadata{
CreatedAt: ptr.PointTo(w.CreatedAt),
UpdatedAt: ptr.PointTo(w.UpdatedAt),
}),
ExpiresAt: w.ExpiryDate,
Metadata: metadata,
ExpiresAt: w.ExpiryDate,
}, nil
}

Expand All @@ -71,9 +130,12 @@ func ToAPIDetailed(
approverGroups []*model.Group,
transitions []wfMechanism.Transition,
approvalSummary *wfMechanism.ApprovalSummary,
insufficientApprovers bool,
initiatorIneligible bool,
eligibilityErr error,
identityManager identitymanagement.IdentityManagement,
) (*cmkapi.DetailedWorkflow, error) {
base, err := ToAPI(ctx, w, identityManager)
base, err := ToAPI(ctx, w, insufficientApprovers, initiatorIneligible, eligibilityErr, identityManager)
if err != nil {
return nil, err
}
Expand Down
171 changes: 170 additions & 1 deletion internal/api/transform/workflow/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package workflow_test
import (
"context"
"database/sql"
"errors"
"testing"
"time"

"github.com/google/uuid"
"github.com/openkcm/common-sdk/pkg/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openkcm/cmk/internal/api/cmkapi"
"github.com/openkcm/cmk/internal/api/transform/workflow"
Expand All @@ -20,6 +22,10 @@ import (
"github.com/openkcm/cmk/utils/ptr"
)

const testStateWaitConfirmation = "WAIT_CONFIRMATION"

var errSCIMUnavailable = errors.New("SCIM service unavailable")

func TestWorkflow_ToAPI(t *testing.T) {
workflowMutator := testutils.NewMutator(func() model.Workflow {
return model.Workflow{
Expand Down Expand Up @@ -88,7 +94,9 @@ func TestWorkflow_ToAPI(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := cmkcontext.InjectClientData(t.Context(), &auth.ClientData{Identifier: "User-ID"}, nil)
apiWorkflow, err := workflow.ToAPI(ctx, tt.dbWorkflow, testpluginregistry.NewMockIDMService())
apiWorkflow, err := workflow.ToAPI(ctx,
tt.dbWorkflow, false, false, nil,
testpluginregistry.NewMockIDMService())

if tt.errorExpected {
assert.Error(t, err)
Expand Down Expand Up @@ -317,3 +325,164 @@ func TestWorkflow_ApproverToAPI(t *testing.T) {
})
}
}

func TestWorkflow_ToAPI_EligibilityMetadata(t *testing.T) {
workflowMutator := testutils.NewMutator(func() model.Workflow {
return model.Workflow{
ID: uuid.New(),
InitiatorID: uuid.NewString(),
State: "WAIT_APPROVAL",
ActionType: "LINK",
ArtifactType: "SYSTEM",
ArtifactID: uuid.New(),
Parameters: "ENABLED",
}
})

tests := []struct {
name string
dbWorkflow model.Workflow
insufficientApprovers bool
initiatorIneligible bool
eligibilityErr error
expectedInfoCount int
expectedInfos []cmkapi.WorkflowAdditionalInfo
}{
{
name: "no eligibility issues",
dbWorkflow: workflowMutator(),
insufficientApprovers: false,
initiatorIneligible: false,
eligibilityErr: nil,
expectedInfoCount: 0,
expectedInfos: nil,
},
{
name: "insufficient approvers - warning",
dbWorkflow: workflowMutator(),
insufficientApprovers: true,
initiatorIneligible: false,
eligibilityErr: nil,
expectedInfoCount: 1,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeINSUFFICIENTAPPROVERS,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: workflow.AdditionalInfoMessageInsufficientApprovers,
},
},
},
{
name: "eligibility check failed - error",
dbWorkflow: workflowMutator(),
insufficientApprovers: false,
initiatorIneligible: false,
eligibilityErr: errSCIMUnavailable,
expectedInfoCount: 1,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeWORKFLOWELIGIBILITYCHECKFAILED,
Severity: cmkapi.WorkflowAdditionalInfoSeverityERROR,
Message: workflow.AdditionalInfoMessageEligibilityCheckError,
},
},
},
{
name: "both error and insufficient - error takes precedence, warnings suppressed",
dbWorkflow: workflowMutator(),
insufficientApprovers: true,
initiatorIneligible: false,
eligibilityErr: errSCIMUnavailable,
expectedInfoCount: 1,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeWORKFLOWELIGIBILITYCHECKFAILED,
Severity: cmkapi.WorkflowAdditionalInfoSeverityERROR,
Message: workflow.AdditionalInfoMessageEligibilityCheckError,
},
},
},
{
name: "initiator ineligible - warning",
dbWorkflow: workflowMutator(func(w *model.Workflow) {
w.State = testStateWaitConfirmation
}),
insufficientApprovers: false,
initiatorIneligible: true,
eligibilityErr: nil,
expectedInfoCount: 1,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeINITIATORINELIGIBLE,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: workflow.AdditionalInfoMessageInitiatorIneligible,
},
},
},
{
name: "both initiator ineligible and insufficient approvers - both warnings shown",
dbWorkflow: workflowMutator(func(w *model.Workflow) {
w.State = testStateWaitConfirmation
}),
insufficientApprovers: true,
initiatorIneligible: true,
eligibilityErr: nil,
expectedInfoCount: 2,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeINITIATORINELIGIBLE,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: workflow.AdditionalInfoMessageInitiatorIneligible,
},
{
Code: cmkapi.WorkflowAdditionalInfoCodeINSUFFICIENTAPPROVERS,
Severity: cmkapi.WorkflowAdditionalInfoSeverityWARNING,
Message: workflow.AdditionalInfoMessageInsufficientApprovers,
},
},
},
{
name: "eligibility error takes precedence over all warnings",
dbWorkflow: workflowMutator(func(w *model.Workflow) {
w.State = testStateWaitConfirmation
}),
insufficientApprovers: true,
initiatorIneligible: true,
eligibilityErr: errSCIMUnavailable,
expectedInfoCount: 1,
expectedInfos: []cmkapi.WorkflowAdditionalInfo{
{
Code: cmkapi.WorkflowAdditionalInfoCodeWORKFLOWELIGIBILITYCHECKFAILED,
Severity: cmkapi.WorkflowAdditionalInfoSeverityERROR,
Message: workflow.AdditionalInfoMessageEligibilityCheckError,
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := cmkcontext.InjectClientData(t.Context(), &auth.ClientData{Identifier: "User-ID"}, nil)
apiWorkflow, err := workflow.ToAPI(ctx, tt.dbWorkflow,
tt.insufficientApprovers, tt.initiatorIneligible, tt.eligibilityErr,
testpluginregistry.NewMockIDMService())
require.NoError(t, err)
require.NotNil(t, apiWorkflow)
require.NotNil(t, apiWorkflow.Metadata)

if tt.expectedInfoCount == 0 {
assert.Nil(t, apiWorkflow.Metadata.AdditionalInfo)
} else {
require.NotNil(t, apiWorkflow.Metadata.AdditionalInfo)
require.Len(t, *apiWorkflow.Metadata.AdditionalInfo, tt.expectedInfoCount)

actualInfos := *apiWorkflow.Metadata.AdditionalInfo
for i, expectedInfo := range tt.expectedInfos {
assert.Equal(t, expectedInfo.Code, actualInfos[i].Code, "Code mismatch at index %d", i)
assert.Equal(t, expectedInfo.Severity, actualInfos[i].Severity, "Severity mismatch at index %d", i)
assert.Equal(t, expectedInfo.Message, actualInfos[i].Message, "Message mismatch at index %d", i)
}
}
})
}
}
16 changes: 16 additions & 0 deletions internal/apierrors/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ var workflow = []errs.ExposedErrors[*APIError]{
Status: http.StatusInternalServerError,
},
},
{
InternalErrorChain: []error{manager.ErrCheckWorkflowEligibility},
ExposedError: &APIError{
Code: "CHECK_WORKFLOW_ELIGIBILITY",
Message: "failed to check workflow eligibility from identity management",
Status: http.StatusInternalServerError,
},
},
{
InternalErrorChain: []error{manager.ErrWorkflowNotAllowed},
ExposedError: &APIError{
Expand Down Expand Up @@ -188,6 +196,14 @@ var workflow = []errs.ExposedErrors[*APIError]{
Status: http.StatusBadRequest,
},
},
{
InternalErrorChain: []error{workflowpkg.ErrApproverNoLongerEligible},
ExposedError: &APIError{
Code: "APPROVER_NO_LONGER_ELIGIBLE",
Message: "approver has been removed from the admin group and cannot vote",
Status: http.StatusForbidden,
},
},
{
InternalErrorChain: []error{workflowpkg.ErrTransitionExecution},
ExposedError: &APIError{
Expand Down
Loading
Loading