Skip to content

Commit c760285

Browse files
refactor(resource-policies): restore generic evaluator
1 parent 2880565 commit c760285

15 files changed

Lines changed: 530 additions & 22 deletions

File tree

apps/sim/lib/credential-groups/application/workflow-access-policy.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,30 @@
11
import type { WorkflowExecutionAuthority } from '@sim/auth/principal'
22
import { z } from 'zod'
33
import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits'
4+
import { WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY } from '@/lib/resource-policies/conditions/workflow-mode'
5+
import { evaluateResourcePolicy } from '@/lib/resource-policies/evaluator'
6+
import { workflowResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals'
7+
import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry'
48
import type { ResourcePolicyCodec } from '@/lib/resource-policies/types'
59

610
export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_SID = 'WorkflowCredentialAccess'
7-
export const CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION = 'credential_groups.credentials.use' as const
8-
export const CREDENTIAL_GROUP_WORKFLOW_MODE_CONDITION_KEY = 'sim:WorkflowMode' as const
11+
export { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION }
12+
export const CREDENTIAL_GROUP_WORKFLOW_MODE_CONDITION_KEY =
13+
WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY
914

1015
const canonicalIdSchema = z
1116
.string()
1217
.min(1)
1318
.max(128)
1419
.refine((value) => value === value.trim(), 'Resource policy IDs must be canonical')
1520

16-
const credentialGroupWorkflowPrincipalSchema = z
17-
.object({
18-
type: z.literal('workflow'),
19-
workflowId: canonicalIdSchema,
20-
})
21-
.strict()
22-
2321
const credentialGroupWorkflowAccessStatementSchema = z
2422
.object({
2523
sid: z.literal(CREDENTIAL_GROUP_WORKFLOW_ACCESS_SID),
2624
effect: z.literal('allow'),
2725
actions: z.tuple([z.literal(CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION)]),
2826
principals: z
29-
.array(credentialGroupWorkflowPrincipalSchema)
27+
.array(workflowResourcePolicyPrincipalSchema)
3028
.min(1)
3129
.max(CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT)
3230
.superRefine((principals, ctx) => {
@@ -178,16 +176,17 @@ export function evaluateCredentialGroupWorkflowAccess(input: {
178176
actorEnrollmentId?: string
179177
currentWorkflow: WorkflowExecutionAuthority
180178
}): CredentialGroupWorkflowAccessDecision {
181-
const allowedWorkflowIds = decodeCredentialGroupWorkflowAccessPolicy(
182-
input.document,
183-
input.credentialGroupId
184-
)
179+
const document = credentialGroupWorkflowAccessPolicyCodec.parse(input.document, {
180+
type: 'credential_group',
181+
id: input.credentialGroupId,
182+
})
183+
const policyDecision = evaluateResourcePolicy({
184+
document,
185+
action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION,
186+
facts: { currentWorkflow: input.currentWorkflow },
187+
})
188+
if (policyDecision.decision === 'deny') return 'deny'
185189
if (input.actorEnrollmentId === input.selectedEnrollmentId) return 'actor'
186-
if (
187-
input.currentWorkflow.mode === 'deployment' &&
188-
allowedWorkflowIds.includes(input.currentWorkflow.workflowId)
189-
) {
190-
return 'workflow'
191-
}
190+
if (policyDecision.decision === 'allow') return 'workflow'
192191
return 'deny'
193192
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export {
2+
getResourcePolicyConditionDefinition,
3+
RESOURCE_POLICY_CONDITION_DEFINITIONS,
4+
requireResourcePolicyConditionDefinition,
5+
} from '@/lib/resource-policies/conditions/registry'
6+
export type {
7+
ResourcePolicyConditionDefinition,
8+
ResourcePolicyConditionEvaluationFacts,
9+
ResourcePolicyConditionKey,
10+
ResourcePolicyConditionOperator,
11+
ResourcePolicyConditionSelector,
12+
} from '@/lib/resource-policies/conditions/types'
13+
export { RESOURCE_POLICY_CONDITION_OPERATORS } from '@/lib/resource-policies/conditions/types'
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import {
7+
RESOURCE_POLICY_CONDITION_DEFINITIONS,
8+
requireResourcePolicyConditionDefinition,
9+
} from '@/lib/resource-policies/conditions'
10+
11+
describe('resource policy condition registry', () => {
12+
it('registers workflow mode resolution and selector metadata together', () => {
13+
const definition = RESOURCE_POLICY_CONDITION_DEFINITIONS['sim:WorkflowMode']
14+
expect(definition.operators).toEqual(['StringEquals'])
15+
expect(definition.selector).toEqual({
16+
type: 'static',
17+
options: [
18+
{ value: 'draft', label: 'Draft' },
19+
{ value: 'deployment', label: 'Deployed' },
20+
],
21+
})
22+
expect(
23+
definition.resolve({
24+
currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' },
25+
})
26+
).toBe('deployment')
27+
})
28+
29+
it('fails fast for an unregistered condition key', () => {
30+
expect(() => requireResourcePolicyConditionDefinition('sim:Unknown')).toThrow(
31+
'condition key sim:Unknown is not registered'
32+
)
33+
})
34+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type {
2+
ResourcePolicyConditionDefinition,
3+
ResourcePolicyConditionKey,
4+
} from '@/lib/resource-policies/conditions/types'
5+
import { workflowModeResourcePolicyConditionDefinition } from '@/lib/resource-policies/conditions/workflow-mode'
6+
7+
export const RESOURCE_POLICY_CONDITION_DEFINITIONS = Object.freeze({
8+
'sim:WorkflowMode': workflowModeResourcePolicyConditionDefinition,
9+
} as const satisfies Record<ResourcePolicyConditionKey, ResourcePolicyConditionDefinition>)
10+
11+
export function getResourcePolicyConditionDefinition(
12+
key: ResourcePolicyConditionKey
13+
): ResourcePolicyConditionDefinition {
14+
return RESOURCE_POLICY_CONDITION_DEFINITIONS[key]
15+
}
16+
17+
export function requireResourcePolicyConditionDefinition(
18+
key: string
19+
): ResourcePolicyConditionDefinition {
20+
if (!Object.hasOwn(RESOURCE_POLICY_CONDITION_DEFINITIONS, key)) {
21+
throw new Error(`Resource policy condition key ${key} is not registered`)
22+
}
23+
return RESOURCE_POLICY_CONDITION_DEFINITIONS[key as ResourcePolicyConditionKey]
24+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export const RESOURCE_POLICY_CONDITION_OPERATORS = ['StringEquals'] as const
2+
3+
export type ResourcePolicyConditionOperator = (typeof RESOURCE_POLICY_CONDITION_OPERATORS)[number]
4+
5+
export interface ResourcePolicyConditionEvaluationFacts {
6+
currentWorkflow?: {
7+
workflowId: string
8+
mode: 'draft' | 'deployment'
9+
}
10+
}
11+
12+
export interface ResourcePolicyConditionOption {
13+
value: string
14+
label: string
15+
}
16+
17+
export type ResourcePolicyConditionSelector = {
18+
type: 'static'
19+
options: readonly ResourcePolicyConditionOption[]
20+
}
21+
22+
export interface ResourcePolicyConditionDefinition {
23+
key: ResourcePolicyConditionKey
24+
label: string
25+
operators: readonly ResourcePolicyConditionOperator[]
26+
selector: ResourcePolicyConditionSelector
27+
resolve(facts: ResourcePolicyConditionEvaluationFacts): string | undefined
28+
}
29+
30+
export type ResourcePolicyConditionKey = 'sim:WorkflowMode'
31+
32+
export function defineResourcePolicyCondition(
33+
definition: ResourcePolicyConditionDefinition
34+
): ResourcePolicyConditionDefinition {
35+
if (!definition.label.trim()) {
36+
throw new Error(`Resource policy condition ${definition.key} requires a label`)
37+
}
38+
if (definition.operators.length === 0) {
39+
throw new Error(`Resource policy condition ${definition.key} requires an operator`)
40+
}
41+
if (definition.selector.options.length === 0) {
42+
throw new Error(`Resource policy condition ${definition.key} requires selector options`)
43+
}
44+
return Object.freeze(definition)
45+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineResourcePolicyCondition } from '@/lib/resource-policies/conditions/types'
2+
3+
export const WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY = 'sim:WorkflowMode' as const
4+
5+
export const workflowModeResourcePolicyConditionDefinition = defineResourcePolicyCondition({
6+
key: WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY,
7+
label: 'Workflow mode',
8+
operators: ['StringEquals'],
9+
selector: {
10+
type: 'static',
11+
options: [
12+
{ value: 'draft', label: 'Draft' },
13+
{ value: 'deployment', label: 'Deployed' },
14+
],
15+
},
16+
resolve: (facts) => facts.currentWorkflow?.mode,
17+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { evaluateResourcePolicy } from '@/lib/resource-policies/evaluator'
7+
import type { ResourcePolicyDocument, ResourcePolicyStatement } from '@/lib/resource-policies/types'
8+
9+
const ALLOW: ResourcePolicyStatement = {
10+
sid: 'AllowWorkflow',
11+
effect: 'allow',
12+
actions: ['credential_groups.credentials.use'],
13+
principals: [{ type: 'workflow', workflowId: 'workflow-1' }],
14+
condition: { StringEquals: { 'sim:WorkflowMode': 'deployment' } },
15+
}
16+
17+
function document(
18+
statements: readonly ResourcePolicyStatement[]
19+
): ResourcePolicyDocument<'credential_group'> {
20+
return {
21+
version: 1,
22+
resource: { type: 'credential_group', id: 'group-1' },
23+
statements,
24+
}
25+
}
26+
27+
describe('resource policy evaluator', () => {
28+
it('matches registered principals and conditions', () => {
29+
expect(
30+
evaluateResourcePolicy({
31+
document: document([ALLOW]),
32+
action: 'credential_groups.credentials.use',
33+
facts: { currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' } },
34+
})
35+
).toEqual({ decision: 'allow', statementSid: 'AllowWorkflow' })
36+
expect(
37+
evaluateResourcePolicy({
38+
document: document([ALLOW]),
39+
action: 'credential_groups.credentials.use',
40+
facts: { currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' } },
41+
})
42+
).toEqual({ decision: 'implicit_deny' })
43+
})
44+
45+
it('gives matching denies precedence over allows', () => {
46+
expect(
47+
evaluateResourcePolicy({
48+
document: document([{ ...ALLOW, sid: 'DenyWorkflow', effect: 'deny' }, ALLOW]),
49+
action: 'credential_groups.credentials.use',
50+
facts: { currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' } },
51+
})
52+
).toEqual({ decision: 'deny', statementSid: 'DenyWorkflow' })
53+
})
54+
55+
it('fails fast on an empty condition', () => {
56+
expect(() =>
57+
evaluateResourcePolicy({
58+
document: document([{ ...ALLOW, condition: {} }]),
59+
action: 'credential_groups.credentials.use',
60+
facts: { currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' } },
61+
})
62+
).toThrow('condition must not be empty')
63+
})
64+
})
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
type ResourcePolicyConditionEvaluationFacts,
3+
requireResourcePolicyConditionDefinition,
4+
} from '@/lib/resource-policies/conditions'
5+
import {
6+
matchResourcePolicyPrincipal,
7+
type ResourcePolicyPrincipalEvaluationFacts,
8+
} from '@/lib/resource-policies/principals'
9+
import { getResourcePolicyDefinition } from '@/lib/resource-policies/registry'
10+
import type {
11+
ResourcePolicyAction,
12+
ResourcePolicyDocument,
13+
ResourcePolicyResourceType,
14+
ResourcePolicyStatement,
15+
} from '@/lib/resource-policies/types'
16+
17+
export type ResourcePolicyDecision =
18+
| { decision: 'allow'; statementSid: string }
19+
| { decision: 'deny'; statementSid: string }
20+
| { decision: 'implicit_deny' }
21+
22+
export interface ResourcePolicyEvaluationFacts
23+
extends ResourcePolicyPrincipalEvaluationFacts,
24+
ResourcePolicyConditionEvaluationFacts {}
25+
26+
interface EvaluateResourcePolicyInput<ResourceType extends ResourcePolicyResourceType> {
27+
document: ResourcePolicyDocument<ResourceType>
28+
action: ResourcePolicyAction
29+
facts: ResourcePolicyEvaluationFacts
30+
}
31+
32+
function conditionMatches(
33+
statement: ResourcePolicyStatement,
34+
definition: ReturnType<typeof getResourcePolicyDefinition>,
35+
facts: ResourcePolicyConditionEvaluationFacts
36+
): boolean {
37+
if (!statement.condition) return true
38+
const operators = Object.entries(statement.condition)
39+
if (operators.length === 0) throw new Error('Resource policy condition must not be empty')
40+
41+
for (const [operator, entries] of operators) {
42+
if (!entries || Object.keys(entries).length === 0) {
43+
throw new Error(`Resource policy condition operator ${operator} must not be empty`)
44+
}
45+
for (const [key, expected] of Object.entries(entries)) {
46+
const condition = requireResourcePolicyConditionDefinition(key)
47+
if (!definition.conditionKeys.includes(condition.key)) {
48+
throw new Error(`Condition key ${key} does not apply to this resource`)
49+
}
50+
if (!condition.operators.includes(operator as 'StringEquals')) {
51+
throw new Error(`Condition operator ${operator} does not apply to ${key}`)
52+
}
53+
if (typeof expected !== 'string') {
54+
throw new Error(`Condition ${key} requires a string value`)
55+
}
56+
if (condition.resolve(facts) !== expected) return false
57+
}
58+
}
59+
return true
60+
}
61+
62+
function statementMatches<ResourceType extends ResourcePolicyResourceType>(
63+
statement: ResourcePolicyStatement,
64+
input: EvaluateResourcePolicyInput<ResourceType>
65+
): boolean {
66+
const definition = getResourcePolicyDefinition(input.document.resource.type)
67+
for (const action of statement.actions) {
68+
if (!definition.actions.includes(action)) {
69+
throw new Error(`Action ${action} does not apply to ${input.document.resource.type}`)
70+
}
71+
}
72+
if (!statement.actions.includes(input.action)) return false
73+
if (statement.principals.length === 0) {
74+
throw new Error(`Resource policy statement ${statement.sid} must contain a principal`)
75+
}
76+
const principalMatches = statement.principals.some((principal) => {
77+
if (!definition.principalTypes.includes(principal.type)) {
78+
throw new Error(`Principal ${principal.type} does not apply to this resource`)
79+
}
80+
return matchResourcePolicyPrincipal(principal, input.facts)
81+
})
82+
return principalMatches && conditionMatches(statement, definition, input.facts)
83+
}
84+
85+
export function evaluateResourcePolicy<ResourceType extends ResourcePolicyResourceType>(
86+
input: EvaluateResourcePolicyInput<ResourceType>
87+
): ResourcePolicyDecision {
88+
const definition = getResourcePolicyDefinition(input.document.resource.type)
89+
if (!definition.actions.includes(input.action)) {
90+
throw new Error(`Action ${input.action} does not apply to ${input.document.resource.type}`)
91+
}
92+
for (const statement of input.document.statements) {
93+
if (statement.effect !== 'allow' && statement.effect !== 'deny') {
94+
throw new Error(`Resource policy statement ${statement.sid} has an invalid effect`)
95+
}
96+
}
97+
98+
for (const statement of input.document.statements) {
99+
if (statement.effect === 'deny' && statementMatches(statement, input)) {
100+
return { decision: 'deny', statementSid: statement.sid }
101+
}
102+
}
103+
for (const statement of input.document.statements) {
104+
if (statement.effect === 'allow' && statementMatches(statement, input)) {
105+
return { decision: 'allow', statementSid: statement.sid }
106+
}
107+
}
108+
return { decision: 'implicit_deny' }
109+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export {
2+
getResourcePolicyPrincipalDefinition,
3+
matchResourcePolicyPrincipal,
4+
RESOURCE_POLICY_PRINCIPAL_DEFINITIONS,
5+
requireResourcePolicyPrincipalDefinition,
6+
} from '@/lib/resource-policies/principals/registry'
7+
export type {
8+
ResourcePolicyPrincipal,
9+
ResourcePolicyPrincipalDefinition,
10+
ResourcePolicyPrincipalEvaluationFacts,
11+
ResourcePolicyPrincipalSelector,
12+
ResourcePolicyPrincipalType,
13+
WorkflowResourcePolicyPrincipal,
14+
} from '@/lib/resource-policies/principals/types'
15+
export { workflowResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workflow'

0 commit comments

Comments
 (0)