Skip to content

Commit 191b9f0

Browse files
committed
Add permissions to serialized workflow resolved from policies
1 parent 63ff892 commit 191b9f0

File tree

4 files changed

+128
-9
lines changed

4 files changed

+128
-9
lines changed

src/components/authorization/policy/executor/user-resource-privileges.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,7 @@ export class UserResourcePrivileges<
110110
relation: ChildListsKey<TResourceStatic>,
111111
): boolean;
112112
can(action: AnyAction, prop?: SecuredResourceKey<TResourceStatic>) {
113-
const perm = this.policyExecutor.resolve({
114-
action,
115-
session: this.session,
116-
resource: this.resource,
117-
prop,
118-
});
113+
const perm = this.resolve({ action, prop });
119114
return perm === true || perm === false
120115
? perm
121116
: perm.isAllowed({
@@ -125,6 +120,14 @@ export class UserResourcePrivileges<
125120
});
126121
}
127122

123+
resolve(params: Omit<ResolveParams, 'session' | 'resource'>) {
124+
return this.policyExecutor.resolve({
125+
...params,
126+
session: this.session,
127+
resource: this.resource,
128+
});
129+
}
130+
128131
verifyCan(action: ResourceAction): void;
129132
verifyCan(
130133
action: PropAction,

src/components/workflow/dto/serialized-workflow.dto.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createUnionType, Field, ObjectType } from '@nestjs/graphql';
22
import { cacheable } from '@seedcompany/common';
3+
import { stripIndent } from 'common-tags';
34
import * as uuid from 'uuid';
4-
import { DataObject, ID, IdField } from '~/common';
5+
import { DataObject, ID, IdField, Role } from '~/common';
56
import { Workflow } from '../define-workflow';
67
import { DynamicState } from '../transitions/dynamic-state';
78
import { TransitionType } from './workflow-transition.dto';
@@ -61,6 +62,40 @@ export class SerializedWorkflowNotifier extends DataObject {
6162
readonly label: string;
6263
}
6364

65+
@ObjectType('WorkflowTransitionPermission', {
66+
description: stripIndent`
67+
A permission for a transition.
68+
69+
This will either have \`readEvent\` or \`execute\` as a boolean,
70+
specifying the action this permission defines.
71+
If this is true, there could still be a condition that must be met,
72+
described by the \`condition\` field.
73+
`,
74+
})
75+
export class SerializedWorkflowTransitionPermission extends DataObject {
76+
@Field(() => Role)
77+
role: Role;
78+
79+
@Field({
80+
description:
81+
'The action for this permission is conditional, described by this field.',
82+
nullable: true,
83+
})
84+
condition?: string;
85+
86+
@Field({
87+
description: 'Can this role read historical events for this transition?',
88+
nullable: true,
89+
})
90+
readEvent?: boolean;
91+
92+
@Field({
93+
description: 'Can this role execute this transition?',
94+
nullable: true,
95+
})
96+
execute?: boolean;
97+
}
98+
6499
@ObjectType('WorkflowTransition')
65100
export class SerializedWorkflowTransition extends DataObject {
66101
@IdField()
@@ -86,6 +121,9 @@ export class SerializedWorkflowTransition extends DataObject {
86121

87122
@Field(() => [SerializedWorkflowNotifier])
88123
readonly notifiers: readonly SerializedWorkflowNotifier[];
124+
125+
@Field(() => [SerializedWorkflowTransitionPermission])
126+
readonly permissions: readonly SerializedWorkflowTransitionPermission[];
89127
}
90128

91129
@ObjectType('Workflow')
@@ -99,7 +137,12 @@ export class SerializedWorkflow extends DataObject {
99137
@Field(() => [SerializedWorkflowTransition])
100138
readonly transitions: readonly SerializedWorkflowTransition[];
101139

102-
static from<W extends Workflow>(workflow: W): SerializedWorkflow {
140+
static from<W extends Workflow>(
141+
workflow: W,
142+
getPermissions: (
143+
transition: W['transition'],
144+
) => readonly SerializedWorkflowTransitionPermission[],
145+
): SerializedWorkflow {
103146
const serializeState = (state: Workflow['state']) => {
104147
const { value, label } = workflow.states.entry(state);
105148
return { value, label };
@@ -134,6 +177,7 @@ export class SerializedWorkflow extends DataObject {
134177
notifiers: (transition.notifiers ?? []).map((notifier) => ({
135178
label: notifier.description,
136179
})),
180+
permissions: getPermissions(transition),
137181
})),
138182
};
139183
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { setOf } from '@seedcompany/common';
2+
import { DateTime } from 'luxon';
3+
import { ID, Role, Session } from '~/common';
4+
import { Privileges, UserResourcePrivileges } from '../authorization';
5+
import { Permission } from '../authorization/policy/builder/perm-granter';
6+
import { Condition } from '../authorization/policy/conditions';
7+
import { Workflow } from './define-workflow';
8+
import { SerializedWorkflowTransitionPermission as SerializedTransitionPermission } from './dto/serialized-workflow.dto';
9+
import { TransitionCondition } from './workflow.granter';
10+
11+
export const transitionPermissionSerializer =
12+
<W extends Workflow>(workflow: W, privileges: Privileges) =>
13+
(transition: W['transition']): readonly SerializedTransitionPermission[] => {
14+
const all = [...Role].flatMap((role) => {
15+
const session: Session = {
16+
token: 'system',
17+
issuedAt: DateTime.now(),
18+
userId: 'anonymous' as ID,
19+
anonymous: false,
20+
roles: [`global:${role}`],
21+
};
22+
const p = privileges.for(session, workflow.eventResource);
23+
const readEvent = resolve(p, 'read', transition.key);
24+
const execute = resolve(p, 'create', transition.key);
25+
return [
26+
{
27+
role,
28+
readEvent: readEvent !== false,
29+
condition: renderCondition(readEvent),
30+
},
31+
{
32+
role,
33+
execute: execute !== false,
34+
condition: renderCondition(execute),
35+
},
36+
];
37+
});
38+
39+
// Remove roles that are never applicable.
40+
const applicableRoles = setOf(
41+
all.flatMap((p) => (p.readEvent || p.execute ? p.role : [])),
42+
);
43+
return all.filter((p) => applicableRoles.has(p.role));
44+
};
45+
46+
const resolve = (
47+
p: UserResourcePrivileges<any>,
48+
action: string,
49+
transitionKey: ID,
50+
): Permission => {
51+
const c = p.resolve({
52+
action,
53+
calculatedAsCondition: true,
54+
optimizeConditions: true,
55+
});
56+
57+
if (typeof c === 'boolean') {
58+
return c;
59+
}
60+
// Cheaply resolve transition condition.
61+
if (c instanceof TransitionCondition) {
62+
return c.allowedTransitionKeys.has(transitionKey);
63+
}
64+
65+
return c;
66+
};
67+
const renderCondition = (c: boolean | Condition) =>
68+
typeof c === 'boolean' ? undefined : Condition.id(c);

src/components/workflow/workflow.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ExecuteTransitionInput as ExecuteTransitionInputFn,
88
SerializedWorkflow,
99
} from './dto';
10+
import { transitionPermissionSerializer } from './permission.serializer';
1011
import { withTransitionKey } from './workflow.granter';
1112

1213
type ExecuteTransitionInput = ReturnType<
@@ -124,7 +125,10 @@ export const WorkflowService = <W extends Workflow>(workflow: W) => {
124125
}
125126

126127
serialize() {
127-
return SerializedWorkflow.from(this.workflow);
128+
return SerializedWorkflow.from(
129+
this.workflow,
130+
transitionPermissionSerializer(this.workflow, this.privileges),
131+
);
128132
}
129133
}
130134

0 commit comments

Comments
 (0)