Skip to content
Merged
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
3 changes: 2 additions & 1 deletion app/controllers/src/shared/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export function mapApprovalRuleDataToApi(rule: ApprovalRuleData): ApprovalRuleAp
return {
type: rule.type,
groupId: rule.groupId,
minCount: rule.minCount
minCount: rule.minCount,
...(rule.requireHighPrivilege !== undefined && {requireHighPrivilege: rule.requireHighPrivilege})
}
case ApprovalRuleType.AND:
return {
Expand Down
21 changes: 17 additions & 4 deletions app/controllers/src/workflows/workflows.mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,15 +396,16 @@ export function mapCanVoteResponseToApi(response: CanVoteResponse): CanVoteRespo
const cantVoteReason = mapCantVoteReasonToApi(response)

return {
canVote: response.canVote === true,
canVote: response.canVote,
voteStatus: response.status,
cantVoteReason
cantVoteReason,
requireHighPrivilege: response.canVote ? response.requireHighPrivilege : undefined
}
}

function mapCantVoteReasonToApi(response: CanVoteResponse): string | undefined {
if (response.canVote === true) return undefined
switch (response.canVote.reason) {
if (response.canVote) return undefined
switch (response.reason) {
case "workflow_expired":
return "WORKFLOW_EXPIRED"
case "workflow_cancelled":
Expand All @@ -417,6 +418,8 @@ function mapCantVoteReasonToApi(response: CanVoteResponse): string | undefined {
return "WORKFLOW_TEMPLATE_NOT_ACTIVE"
case "entity_not_eligible_to_vote":
return "NO_PERMISSIONS"
case "inconsistent_memberships":
return "INCONSISTENT_MEMBERSHIPS"
}
}

Expand Down Expand Up @@ -690,6 +693,15 @@ export function generateErrorResponseForCastVote(
return new ConflictException(
generateErrorPayload(errorCode, `${context}: Workflow has been updated concurrently`)
)
case "entity_not_supported":
case "step_up_context_missing":
case "step_up_operation_mismatch":
case "step_up_resource_mismatch":
return new ForbiddenException(generateErrorPayload(errorCode, `${context}: High privilege token required`))
case "token_not_found":
return new InternalServerErrorException(
generateErrorPayload("UNKNOWN_ERROR", `${context}: Failed to consume token`)
)
case "approval_rule_and_rule_must_have_rules":
case "approval_rule_group_rule_invalid_group_id":
case "approval_rule_group_rule_invalid_min_count":
Expand Down Expand Up @@ -776,6 +788,7 @@ export function generateErrorResponseForCastVote(
case "workflow_action_missing_http_method":
case "workflow_action_headers_invalid":
case "agent_name_cannot_be_uuid":
case "inconsistent_memberships":
Logger.error(`${context}: Found internal data inconsistency: ${error}`)
return new InternalServerErrorException(
generateErrorPayload("UNKNOWN_ERROR", `${context}: internal data inconsistency`)
Expand Down
30 changes: 28 additions & 2 deletions app/domain/src/approval-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface PrivateGroupRequirementRule {
type: ApprovalRuleType.GROUP_REQUIREMENT
groupId: string
minCount: number
requireHighPrivilege?: boolean
}

export type AndRule = Readonly<PrivateAndRule>
Expand All @@ -41,6 +42,13 @@ interface ApprovalRuleLogic {
* @returns The voting group ids.
*/
getVotingGroupIds(): ReadonlyArray<string>

/**
* Determines if a high privilege token is explicitly required to cast a vote for the given groups.
* @param groupIds The groups the entity wants to cast a vote for.
* @returns true if high privilege is required, false otherwise.
*/
isHighPrivilegeRequired(groupIds: ReadonlyArray<string>): boolean
}

export type ApprovalRuleValidationError = PrefixUnion<"approval_rule", UnprefixedApprovalRuleValidationError>
Expand Down Expand Up @@ -93,10 +101,17 @@ export class ApprovalRuleFactory {
return left("approval_rule_group_rule_invalid_min_count")
if (data.minCount < 1) return left("approval_rule_group_rule_invalid_min_count")

let requireHighPrivilege: boolean | undefined = undefined
if (data.requireHighPrivilege !== undefined) {
if (typeof data.requireHighPrivilege !== "boolean") return left("approval_rule_malformed_content")
requireHighPrivilege = data.requireHighPrivilege
}

return right({
type: ApprovalRuleType.GROUP_REQUIREMENT,
groupId: data.groupId,
minCount: data.minCount
minCount: data.minCount,
...(requireHighPrivilege !== undefined && {requireHighPrivilege})
})
}

Expand Down Expand Up @@ -131,7 +146,8 @@ export class ApprovalRuleFactory {
private static decorateApprovalRuleData(data: ApprovalRuleData): ApprovalRule {
return {
...data,
getVotingGroupIds: () => getVotingGroupIds(data)
getVotingGroupIds: () => getVotingGroupIds(data),
isHighPrivilegeRequired: (groupIds: ReadonlyArray<string>) => isHighPrivilegeRequired(data, groupIds)
}
}
}
Expand Down Expand Up @@ -173,3 +189,13 @@ function getVotingGroupIds(rule: ApprovalRuleData): ReadonlyArray<string> {
return rule.rules.flatMap(getVotingGroupIds)
}
}

function isHighPrivilegeRequired(rule: ApprovalRuleData, groupIds: ReadonlyArray<string>): boolean {
switch (rule.type) {
case ApprovalRuleType.GROUP_REQUIREMENT:
return groupIds.includes(rule.groupId) && rule.requireHighPrivilege === true
case ApprovalRuleType.AND:
case ApprovalRuleType.OR:
return rule.rules.some(r => isHighPrivilegeRequired(r, groupIds))
}
}
75 changes: 66 additions & 9 deletions app/domain/src/workflow-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,22 @@ export type WorkflowTemplateCantVoteReason =
| "entity_not_in_required_group"
| "workflow_template_not_active"
| "entity_not_eligible_to_vote"
| "inconsistent_memberships"

interface WorkflowTemplateLogic {
/**
* Checks if an entity is eligible to vote on a workflow created from this template.
*
* @param memberships The group memberships of the entity.
* @param entityRoles The roles and permissions currently assigned to the entity.
* @param votedForGroups Optional list of specific group IDs being voted for.
* @returns Either a reason why the entity cannot vote, or a success object.
*/
canVote(
memberships: ReadonlyArray<MembershipWithGroupRef>,
entityRoles: ReadonlyArray<UnconstrainedBoundRole>
): Either<WorkflowTemplateCantVoteReason, true>
entityRoles: ReadonlyArray<UnconstrainedBoundRole>,
votedForGroups?: ReadonlyArray<string>
): Either<WorkflowTemplateCantVoteReason, {canVote: true; requireHighPrivilege: boolean}>
}

export type WorkflowTemplateValidationError =
Expand Down Expand Up @@ -213,8 +223,9 @@ export class WorkflowTemplateFactory {
...workflowTemplateData,
canVote: (
memberships: ReadonlyArray<MembershipWithGroupRef>,
entityRoles: ReadonlyArray<UnconstrainedBoundRole>
) => canVote(workflowTemplateData, memberships, entityRoles)
entityRoles: ReadonlyArray<UnconstrainedBoundRole>,
votedForGroups?: ReadonlyArray<string>
) => canVote(workflowTemplateData, memberships, entityRoles, votedForGroups)
}
})
)
Expand Down Expand Up @@ -306,14 +317,44 @@ function validateLatestIsActive(
return E.right(undefined)
}

/**
* Checks if an entity (user or agent) is eligible to vote on a workflow based on the template.
*
* The check involves:
* 1. Template status: Must be ACTIVE or allow voting on DEPRECATED.
* 2. Permission check: Entity must have the 'vote' permission for this template.
* 3. Group membership: Entity must belong to at least one group defined in the template's approval rule.
* 4. Privilege check: Determines if a high-privilege token (step-up) is required for the vote.
*
* @param workflowTemplate The template data being checked.
* @param memberships The list of groups the entity belongs to, with their associated references.
* @param entityRoles The roles and permissions currently assigned to the entity.
* @param votedForGroups Optional list of specific group IDs the entity is casting a vote for.
* @returns Either a reason why the entity cannot vote, or a success object indicating if high-privilege is required.
*/
function canVote(
workflowTemplate: WorkflowTemplateData,
memberships: ReadonlyArray<MembershipWithGroupRef>,
entityRoles: ReadonlyArray<UnconstrainedBoundRole>
): Either<WorkflowTemplateCantVoteReason, true> {
if (workflowTemplate.status !== WorkflowTemplateStatus.ACTIVE && !workflowTemplate.allowVotingOnDeprecatedTemplate) {
entityRoles: ReadonlyArray<UnconstrainedBoundRole>,
votedForGroups?: ReadonlyArray<string>
): Either<WorkflowTemplateCantVoteReason, {canVote: true; requireHighPrivilege: boolean}> {
if (workflowTemplate.status !== WorkflowTemplateStatus.ACTIVE && !workflowTemplate.allowVotingOnDeprecatedTemplate)
return left("workflow_template_not_active")
}

// If memberships is empty, the entity cannot vote because they are not in any required group
if (memberships.length === 0) return left("entity_not_in_required_group")

// Infer entityType from the first membership
const firstMembership = memberships[0]

if (firstMembership === undefined) return left("entity_not_in_required_group")

const entityType = firstMembership.getEntityType()
const entityId = firstMembership.getEntityId()

// Validate that all memberships belong to the same entity
const consistentMemberships = memberships.every(m => m.getEntityType() === entityType && m.getEntityId() === entityId)
if (!consistentMemberships) return left("inconsistent_memberships")

// Check if entity has vote permission for this workflow template (hierarchical check)
const hasVotePermission = hasVotePermissionForWorkflowTemplate(workflowTemplate, entityRoles)
Expand All @@ -326,9 +367,25 @@ function canVote(

if (!hasValidMembership) return left("entity_not_in_required_group")

return right(true)
const targetGroups = votedForGroups ?? memberships.map(m => m.groupId)
const requireHighPrivilege =
entityType === "agent" ? false : workflowTemplate.approvalRule.isHighPrivilegeRequired(targetGroups)

return right({
canVote: true,
requireHighPrivilege
})
}

/**
* Helper function that checks if an entity has the 'vote' permission for a specific workflow template.
* The check is hierarchical: it searches for the permission at the organization level,
* the space level, or the individual template level.
*
* @param workflowTemplate The template containing 'id' and 'spaceId'.
* @param entityRoles The roles assigned to the entity.
* @returns True if the entity has a role that allows voting on this template.
*/
function hasVotePermissionForWorkflowTemplate(
workflowTemplate: WorkflowTemplateData,
entityRoles: ReadonlyArray<UnconstrainedBoundRole>
Expand Down
9 changes: 5 additions & 4 deletions app/domain/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,20 @@ export const WORKFLOW_TERMINAL_STATUSES = [WorkflowStatus.APPROVED, WorkflowStat
export function canVoteOnWorkflow(
workflow: DecoratedWorkflow<{workflowTemplate: true}>,
memberships: ReadonlyArray<MembershipWithGroupRef>,
entityRoles: ReadonlyArray<UnconstrainedBoundRole>
): Either<CantVoteReason, true> {
entityRoles: ReadonlyArray<UnconstrainedBoundRole>,
votedForGroups?: ReadonlyArray<string>
): Either<CantVoteReason, {canVote: true; requireHighPrivilege: boolean}> {
if (WORKFLOW_TERMINAL_STATUSES.includes(workflow.status))
return left(generateCantVoteReasonForTerminalStatus(workflow.status))

// Check if workflow has expired
const now = new Date(Date.now())
if (workflow.expiresAt < now) return left("workflow_expired")

const templateCanVoteResult = workflow.workflowTemplate.canVote(memberships, entityRoles)
const templateCanVoteResult = workflow.workflowTemplate.canVote(memberships, entityRoles, votedForGroups)
if (isLeft(templateCanVoteResult)) return templateCanVoteResult

return right(true)
return templateCanVoteResult
}

function generateCantVoteReasonForTerminalStatus(status: WorkflowStatus): CantVoteReason {
Expand Down
42 changes: 28 additions & 14 deletions app/domain/test/workflows-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ describe("WorkflowTemplate - canVote method", () => {
const membershipG1Admin = createMembership(group1Id, "user-1")
const membershipG1Owner = createMembership(group1Id, "user-1")
const membershipG1Auditor = createMembership(group1Id, "user-1")
const membershipG2Approver = createMembership(group2Id, "user-2")
const membershipG3Approver = createMembership(group3Id, "user-3")
const membershipUnrelatedApprover = createMembership(unrelatedGroupId, "user-4")
const membershipG2Approver = createMembership(group2Id, "user-1")
const membershipG3Approver = createMembership(group3Id, "user-1")
const membershipUnrelatedApprover = createMembership(unrelatedGroupId, "user-1")

describe("good cases", () => {
it("should return true for GROUP_REQUIREMENT rule when user is in the required group with APPROVER role", () => {
Expand All @@ -63,7 +63,7 @@ describe("WorkflowTemplate - canVote method", () => {
// When: canVote is called
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be true
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for GROUP_REQUIREMENT rule when user is in the required group with ADMIN role", () => {
Expand All @@ -75,7 +75,7 @@ describe("WorkflowTemplate - canVote method", () => {
// When: canVote is called
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be true
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for GROUP_REQUIREMENT rule when user is in the required group with OWNER role", () => {
Expand All @@ -87,7 +87,7 @@ describe("WorkflowTemplate - canVote method", () => {
// When: canVote is called
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for GROUP_REQUIREMENT rule when user has multiple memberships including the required one with an allowed role", () => {
Expand All @@ -99,7 +99,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for AND rule when user is in the first required group with an allowed role", () => {
Expand All @@ -111,7 +111,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for AND rule when user is in the second required group with an allowed role", () => {
Expand All @@ -123,7 +123,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for AND rule when user is in both required groups with allowed roles", () => {
Expand All @@ -135,7 +135,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for OR rule when user is in the first required group with an allowed role", () => {
Expand All @@ -147,7 +147,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for OR rule when user is in the second required group with an allowed role", () => {
Expand All @@ -159,7 +159,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for OR rule when user is in both required groups with allowed roles", () => {
Expand All @@ -171,7 +171,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})

it("should return true for a nested AND/OR rule when user is in a group from the AND part with an allowed role", () => {
Expand All @@ -186,7 +186,7 @@ describe("WorkflowTemplate - canVote method", () => {
const voterRoles = [createVoterRole(workflowTemplate.id)]
const result = workflowTemplate.canVote(memberships, voterRoles)
// Expect: the result to be Right(true)
expect(result).toBeRightOf(true)
expect(result).toBeRightOf({canVote: true, requireHighPrivilege: false})
})
})

Expand Down Expand Up @@ -226,6 +226,20 @@ describe("WorkflowTemplate - canVote method", () => {
expect(result).toBeLeftOf("entity_not_eligible_to_vote")
})

it("should return inconsistent_memberships when memberships belong to different entities", () => {
// Given: a rule and memberships for different users
const rule = createGroupRequirementRule(group1Id)
const workflowTemplate = getWorkflowTemplate(rule)
const memberships = [createMembership(group1Id, "user-1"), createMembership(group2Id, "user-2")]
const voterRoles = [createVoterRole(workflowTemplate.id)]

// When: canVote is called
const result = workflowTemplate.canVote(memberships, voterRoles)

// Expect: the result to be Left(inconsistent_memberships)
expect(result).toBeLeftOf("inconsistent_memberships")
})

it("should return ENTITY_NOT_IN_REQUIRED_GROUP for GROUP_REQUIREMENT rule when user has multiple memberships, none being the required one with an allowed role", () => {
// Given: a GROUP_REQUIREMENT rule and a user in other groups with allowed roles
const rule = createGroupRequirementRule(group1Id)
Expand Down
Loading