bazis 0.129.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -0
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
- package/core/workflow/definitions_module/access/Process.references.ts +27 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +23 -14
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +3 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -0
- package/core/workflow/generated/deps.ts +2 -0
- package/core/workflow/index.ts +0 -1
- package/core/workflow/internal/http.ts +18 -17
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
- package/core/workflow/runtime_module/http/Events.controller.ts +5 -3
- package/core/workflow/runtime_module/http/Instances.controller.ts +25 -15
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +3 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +4 -0
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -1
- package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -12
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +8 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +41 -9
- package/package.json +1 -1
- package/core/workflow/access/WorkflowAccess.ts +0 -52
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.130.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { AccessModule } from "../../access";
|
|
1
2
|
import { HOSTED_SERVICE, Module, scoped, singleton } from "../../di";
|
|
2
3
|
import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
|
|
3
4
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
5
|
+
import { definitionsPermissions } from "./access/Definitions.permissions";
|
|
6
|
+
import { ProcessReferences } from "./access/Process.references";
|
|
4
7
|
import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
|
|
5
8
|
import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
|
|
6
9
|
import { ProcessesController } from "./http/Processes.controller";
|
|
@@ -15,12 +18,16 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
15
18
|
* declared in code ([passport](MODULE.md)).
|
|
16
19
|
*/
|
|
17
20
|
@Module({
|
|
21
|
+
imports: [AccessModule],
|
|
22
|
+
permissions: definitionsPermissions,
|
|
23
|
+
references: { "workflow.process": ProcessReferences },
|
|
18
24
|
ormBazis: { context: DefinitionsDbContext, entities: [WfProcess, WfVersion], ownedStore: definitionsStore, registerRepositories: false },
|
|
19
25
|
providers: [
|
|
20
26
|
singleton(WorkflowClock, SystemWorkflowClock),
|
|
21
27
|
singleton(WorkflowTools),
|
|
22
28
|
singleton(CodeProcessCatalog),
|
|
23
29
|
scoped(IWorkflowDefinitions, WorkflowDefinitions),
|
|
30
|
+
scoped(ProcessReferences),
|
|
24
31
|
singleton(HOSTED_SERVICE, CodeProcessPublisher),
|
|
25
32
|
],
|
|
26
33
|
controllers: [ProcessesController],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { definePermissions } from "../../../access";
|
|
2
|
+
|
|
3
|
+
const process = { ref: "workflow.process", column: "key" };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The rights of the process catalog (WORKFLOW-002 §6.2). A process is one per
|
|
7
|
+
* application, so these have no partition until `WorkflowPartitions` connects
|
|
8
|
+
* processes to partitions (stage 4).
|
|
9
|
+
*/
|
|
10
|
+
export const definitionsPermissions = definePermissions({
|
|
11
|
+
"workflow.processes.read": {
|
|
12
|
+
title: { en: "See processes", ru: "Видеть процессы" },
|
|
13
|
+
on: { process },
|
|
14
|
+
},
|
|
15
|
+
"workflow.processes.manage": {
|
|
16
|
+
title: { en: "Edit processes", ru: "Редактировать процессы" },
|
|
17
|
+
on: { process },
|
|
18
|
+
},
|
|
19
|
+
"workflow.processes.publish": {
|
|
20
|
+
title: { en: "Publish processes", ru: "Публиковать процессы" },
|
|
21
|
+
on: { process },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { References, type ReferenceItem, type ReferencePage } from "../../../access";
|
|
2
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
3
|
+
|
|
4
|
+
/** Reference kind `workflow.process`: what an administrator picks in a condition. */
|
|
5
|
+
export class ProcessReferences extends References {
|
|
6
|
+
constructor(private readonly db: DefinitionsDbContext) {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
|
|
11
|
+
return new Set((await this.describe(ids)).map((item) => item.id));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
|
|
15
|
+
if (ids.length === 0) return [];
|
|
16
|
+
const rows = await this.db.processes.asNoTracking().where((p) => p.key.in([...ids])).toList();
|
|
17
|
+
return rows.map((row) => ({ id: row.key, title: row.title }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async search(text: string, _context: unknown, page: ReferencePage): Promise<readonly ReferenceItem[]> {
|
|
21
|
+
const found = text.trim();
|
|
22
|
+
let source = this.db.processes.asNoTracking();
|
|
23
|
+
if (found !== "") source = source.where((p) => p.key.contains(found, { ignoreCase: true }).or(p.title.contains(found, { ignoreCase: true })));
|
|
24
|
+
const rows = await source.orderBy((p) => p.key).skip(page.offset).take(page.limit).toList();
|
|
25
|
+
return rows.map((row) => ({ id: row.key, title: row.title }));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { can } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Created, Get, Patch, Post, Put, type HttpContext } from "../../../http";
|
|
4
|
+
import { body, callerOf, optionalInteger, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
|
|
4
5
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
5
6
|
import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
|
|
6
7
|
|
|
7
|
-
/** The process catalog over HTTP (WORKFLOW-001 §3); rights
|
|
8
|
+
/** The process catalog over HTTP (WORKFLOW-001 §3); rights are `workflow.processes.*` (WORKFLOW-002 §6.2). */
|
|
8
9
|
@Controller("workflow")
|
|
9
10
|
export class ProcessesController {
|
|
10
11
|
constructor(private readonly definitions: IWorkflowDefinitions) {}
|
|
11
12
|
|
|
12
13
|
@Get("processes")
|
|
14
|
+
@Authorize(can("workflow.processes.read"))
|
|
13
15
|
async list(ctx: HttpContext) {
|
|
14
|
-
await
|
|
16
|
+
const { access } = await callerOf(ctx);
|
|
15
17
|
const archived = ctx.query("archived");
|
|
16
18
|
if (archived !== undefined && archived !== "true" && archived !== "false") throw new WorkflowError("invalid_input", "archived must be true or false");
|
|
17
|
-
const page = await this.definitions.list({ ...pagingQuery(ctx), archived: archived === undefined ? undefined : archived === "true", search: optionalString(ctx.query("search"), "search") });
|
|
19
|
+
const page = await this.definitions.list({ ...pagingQuery(ctx), access: await access.filter("workflow.processes.read"), archived: archived === undefined ? undefined : archived === "true", search: optionalString(ctx.query("search"), "search") });
|
|
18
20
|
return { data: page.items, meta: { total: page.total } };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
@Post("processes")
|
|
24
|
+
@Authorize(can("workflow.processes.manage"))
|
|
22
25
|
async create(ctx: HttpContext) {
|
|
23
26
|
const request = await body(ctx);
|
|
24
27
|
const key = requiredString(request.key, "key", 64);
|
|
25
|
-
const actor = await
|
|
28
|
+
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
26
29
|
const process = await this.definitions.create({
|
|
27
30
|
key,
|
|
28
31
|
title: requiredString(request.title, "title"),
|
|
@@ -33,17 +36,19 @@ export class ProcessesController {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
@Get("processes/:key")
|
|
39
|
+
@Authorize(can("workflow.processes.read"))
|
|
36
40
|
async get(ctx: HttpContext) {
|
|
37
41
|
const key = param(ctx, "key");
|
|
38
|
-
await
|
|
42
|
+
await callerOf(ctx, "workflow.processes.read", { process: key });
|
|
39
43
|
return await this.definitions.get(key);
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
@Patch("processes/:key")
|
|
47
|
+
@Authorize(can("workflow.processes.manage"))
|
|
43
48
|
async update(ctx: HttpContext) {
|
|
44
49
|
const key = param(ctx, "key");
|
|
45
50
|
const request = await body(ctx);
|
|
46
|
-
const actor = await
|
|
51
|
+
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
47
52
|
if (request.archived !== undefined && typeof request.archived !== "boolean") throw new WorkflowError("invalid_input", "archived must be a boolean");
|
|
48
53
|
return await this.definitions.update(key, {
|
|
49
54
|
...(request.title === undefined ? {} : { title: requiredString(request.title, "title") }),
|
|
@@ -54,40 +59,44 @@ export class ProcessesController {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
@Put("processes/:key/draft")
|
|
62
|
+
@Authorize(can("workflow.processes.manage"))
|
|
57
63
|
async saveDraft(ctx: HttpContext) {
|
|
58
64
|
const key = param(ctx, "key");
|
|
59
65
|
const request = await body(ctx);
|
|
60
|
-
const actor = await
|
|
66
|
+
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
61
67
|
return await this.definitions.saveDraft(key, request.document, actor);
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
@Post("processes/:key/publish")
|
|
71
|
+
@Authorize(can("workflow.processes.publish"))
|
|
65
72
|
async publish(ctx: HttpContext) {
|
|
66
73
|
const key = param(ctx, "key");
|
|
67
|
-
const actor = await
|
|
74
|
+
const { actor } = await callerOf(ctx, "workflow.processes.publish", { process: key });
|
|
68
75
|
const version = await this.definitions.publish(key, actor);
|
|
69
76
|
return Created(`${ctx.path.replace(/\/publish$/, "")}/versions/${version.version}`, version);
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
@Get("processes/:key/versions")
|
|
80
|
+
@Authorize(can("workflow.processes.read"))
|
|
73
81
|
async versions(ctx: HttpContext) {
|
|
74
82
|
const key = param(ctx, "key");
|
|
75
|
-
await
|
|
83
|
+
await callerOf(ctx, "workflow.processes.read", { process: key });
|
|
76
84
|
return { data: await this.definitions.versions(key) };
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
@Get("processes/:key/versions/:version")
|
|
88
|
+
@Authorize(can("workflow.processes.read"))
|
|
80
89
|
async version(ctx: HttpContext) {
|
|
81
90
|
const key = param(ctx, "key");
|
|
82
|
-
await
|
|
91
|
+
await callerOf(ctx, "workflow.processes.read", { process: key });
|
|
83
92
|
const version = optionalInteger(param(ctx, "version"), "version");
|
|
84
93
|
if (version === undefined) throw new WorkflowError("invalid_input", "version must be an integer");
|
|
85
94
|
return await this.definitions.version(key, version);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
@Get("tools")
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
@Authorize(can("workflow.processes.read"))
|
|
99
|
+
async tools() {
|
|
91
100
|
return { data: this.definitions.tools() };
|
|
92
101
|
}
|
|
93
102
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AccessFilter } from "../../../access";
|
|
1
2
|
import type { AgentToolContract } from "../../../agent";
|
|
2
3
|
import type { ProcessDocument, ProcessIssue } from "../../../../library/workflow";
|
|
3
4
|
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
@@ -53,6 +54,8 @@ export interface UpdateProcess {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface ProcessListQuery extends WorkflowPaging {
|
|
57
|
+
/** Which processes the caller may see, from `IAccess`; without it the calling code has decided. */
|
|
58
|
+
readonly access?: AccessFilter;
|
|
56
59
|
readonly archived?: boolean;
|
|
57
60
|
/** Part of the key or title, case-insensitive. */
|
|
58
61
|
readonly search?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { grantsToWhere } from "../../../access";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { UniqueViolationError } from "../../../orm";
|
|
3
4
|
import type { AgentToolContract } from "../../../agent";
|
|
@@ -38,7 +39,10 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
38
39
|
|
|
39
40
|
async list(query: ProcessListQuery = {}): Promise<WorkflowPage<ProcessSummary>> {
|
|
40
41
|
const { limit, offset } = pagingOf(query);
|
|
42
|
+
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
43
|
+
if (where === "none") return { items: [], total: 0 };
|
|
41
44
|
let source = this.db.processes.asNoTracking();
|
|
45
|
+
if (where !== "all") source = source.where(() => where);
|
|
42
46
|
if (query.archived === true) source = source.where((p) => p.archivedAt.isNotNull());
|
|
43
47
|
if (query.archived === false) source = source.where((p) => p.archivedAt.isNull());
|
|
44
48
|
if (query.search !== undefined && query.search.trim() !== "") {
|
|
@@ -5,6 +5,7 @@ import { DbContextOptions } from "../../../library/orm/DbContextOptions";
|
|
|
5
5
|
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
6
6
|
import { EventBus } from "../../kernel/events/EventBus";
|
|
7
7
|
import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
8
|
+
import { ProcessReferences } from "../definitions_module/access/Process.references";
|
|
8
9
|
import { CodeProcessCatalog } from "../definitions_module/code/CodeProcessCatalog";
|
|
9
10
|
import { CodeProcessPublisher } from "../definitions_module/code/CodeProcessPublisher";
|
|
10
11
|
import { ProcessesController } from "../definitions_module/http/Processes.controller";
|
|
@@ -37,6 +38,7 @@ registerFrameworkClassDeps([
|
|
|
37
38
|
[EventsController, [IWorkflowRuntime]],
|
|
38
39
|
[InstancesController, [IWorkflowRuntime]],
|
|
39
40
|
[ProcessesController, [IWorkflowDefinitions]],
|
|
41
|
+
[ProcessReferences, [DefinitionsDbContext]],
|
|
40
42
|
[RuntimeDbContext, [DbContextOptions]],
|
|
41
43
|
[Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
|
|
42
44
|
[TaskEvents, [TasksDbContext]],
|
package/core/workflow/index.ts
CHANGED
|
@@ -50,7 +50,6 @@ export { workflowRuntimeConfig } from "./runtime_module/config/Runtime.config";
|
|
|
50
50
|
export type { HistoryType } from "./runtime_module/model/WfHistory.model";
|
|
51
51
|
export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstance.model";
|
|
52
52
|
export type { TokenStatus } from "./runtime_module/model/WfToken.model";
|
|
53
|
-
export { WorkflowAccess, DenyWorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "./access/WorkflowAccess";
|
|
54
53
|
export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
|
|
55
54
|
export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
|
|
56
55
|
export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
|
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
|
|
2
|
+
import { ForbiddenError, type HttpContext } from "../../http";
|
|
3
3
|
import type { WorkflowActor } from "../contracts/types";
|
|
4
4
|
import { WorkflowError } from "../contracts/WorkflowError";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export async function authorize(ctx: HttpContext, action: WorkflowAction, target: WorkflowAccessTarget = {}): Promise<WorkflowActor> {
|
|
11
|
-
const access = ctx.services.tryResolve(WorkflowAccess);
|
|
12
|
-
if (access === undefined) throw new ForbiddenError("Workflow HTTP is closed: register a WorkflowAccess implementation");
|
|
13
|
-
const actor = await access.actor(ctx);
|
|
14
|
-
if (actor === undefined) throw new UnauthorizedError();
|
|
15
|
-
if (!await access.can(ctx, action, target)) throw new ForbiddenError();
|
|
16
|
-
return actor;
|
|
6
|
+
/** Who calls a route: their rights and how they are recorded as an actor. */
|
|
7
|
+
export interface WorkflowCaller {
|
|
8
|
+
readonly access: SubjectAccess;
|
|
9
|
+
readonly actor: WorkflowActor;
|
|
17
10
|
}
|
|
18
11
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The caller of a route; 401 without a subject. With `permission` and `object`
|
|
14
|
+
* also checks the object the route's `can` could not know: 403 without the
|
|
15
|
+
* right. The actor's id is the first owner id, so that `{ own: true }` means
|
|
16
|
+
* the instances the subject started.
|
|
17
|
+
*/
|
|
18
|
+
export async function callerOf(ctx: HttpContext, permission?: string, object?: AccessObject): Promise<WorkflowCaller> {
|
|
19
|
+
const access = await ctx.services.resolve(IAccess).for(ctx);
|
|
20
|
+
if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
|
|
21
|
+
const sub = access.subject.sub;
|
|
22
|
+
const [id = sub.slice(sub.lastIndexOf(":") + 1)] = await access.ownerIds();
|
|
23
|
+
return { access, actor: { kind: "user", id } };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** A route parameter as a string. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AccessModule } from "../../access";
|
|
1
2
|
import { Module, scoped, singleton } from "../../di";
|
|
2
3
|
import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
|
|
3
4
|
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
5
|
+
import { runtimePermissions } from "./access/Runtime.permissions";
|
|
4
6
|
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
5
7
|
import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
6
8
|
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
@@ -21,7 +23,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
21
23
|
* validator by registering its own `WorkflowInputValidator`.
|
|
22
24
|
*/
|
|
23
25
|
@Module({
|
|
24
|
-
imports: [WorkflowDefinitionsModule],
|
|
26
|
+
imports: [AccessModule, WorkflowDefinitionsModule],
|
|
27
|
+
permissions: runtimePermissions,
|
|
25
28
|
config: [workflowRuntimeConfig],
|
|
26
29
|
ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
|
|
27
30
|
providers: [
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { definePermissions } from "../../../access";
|
|
2
|
+
|
|
3
|
+
const process = { ref: "workflow.process", column: "processKey" };
|
|
4
|
+
|
|
5
|
+
/** The rights of running processes (WORKFLOW-002 §6.2). */
|
|
6
|
+
export const runtimePermissions = definePermissions({
|
|
7
|
+
"workflow.instances.start": {
|
|
8
|
+
title: { en: "Start processes", ru: "Запускать процессы" },
|
|
9
|
+
scope: "request",
|
|
10
|
+
on: { process },
|
|
11
|
+
},
|
|
12
|
+
"workflow.instances.read": {
|
|
13
|
+
title: { en: "See instances", ru: "Видеть экземпляры" },
|
|
14
|
+
scope: { column: "partition" },
|
|
15
|
+
on: { process },
|
|
16
|
+
owner: "startedById",
|
|
17
|
+
},
|
|
18
|
+
"workflow.instances.manage": {
|
|
19
|
+
title: { en: "Cancel and retry instances", ru: "Отменять и повторять экземпляры" },
|
|
20
|
+
scope: { column: "partition" },
|
|
21
|
+
on: { process },
|
|
22
|
+
},
|
|
23
|
+
"workflow.events.emit": {
|
|
24
|
+
title: { en: "Send events to processes", ru: "Отправлять события процессам" },
|
|
25
|
+
scope: "request",
|
|
26
|
+
on: { event: { type: "string" } },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { can } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Post, type HttpContext } from "../../../http";
|
|
4
|
+
import { body, callerOf, optionalString, param } from "../../internal/http";
|
|
4
5
|
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
5
6
|
|
|
6
7
|
/** Events from outside — a webhook, another service — over HTTP (WORKFLOW-001 §4). */
|
|
@@ -9,11 +10,12 @@ export class EventsController {
|
|
|
9
10
|
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
10
11
|
|
|
11
12
|
@Post(":eventKey")
|
|
13
|
+
@Authorize(can("workflow.events.emit"))
|
|
12
14
|
async emit(ctx: HttpContext) {
|
|
13
15
|
const eventKey = param(ctx, "eventKey");
|
|
14
16
|
const request = await body(ctx);
|
|
15
17
|
const partition = optionalString(request.partition, "partition") ?? null;
|
|
16
|
-
const actor = await
|
|
18
|
+
const { actor } = await callerOf(ctx, "workflow.events.emit", { scope: partition, event: eventKey });
|
|
17
19
|
const result = await this.runtime.emit(eventKey, request.payload ?? {}, {
|
|
18
20
|
correlation: optionalString(request.correlation, "correlation") ?? null,
|
|
19
21
|
partition,
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { can, type AccessObject } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../../../http";
|
|
4
|
+
import { body, callerOf, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
|
|
5
5
|
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
6
|
-
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
6
|
+
import { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
|
|
7
7
|
|
|
8
|
-
/** Instances over HTTP (WORKFLOW-001 §4); rights
|
|
8
|
+
/** Instances over HTTP (WORKFLOW-001 §4); rights are `workflow.instances.*` (WORKFLOW-002 §6.2). */
|
|
9
9
|
@Controller("workflow/instances")
|
|
10
10
|
export class InstancesController {
|
|
11
11
|
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
12
12
|
|
|
13
13
|
@Post()
|
|
14
|
+
@Authorize(can("workflow.instances.start"))
|
|
14
15
|
async start(ctx: HttpContext) {
|
|
15
16
|
const request = await body(ctx);
|
|
16
17
|
const processKey = requiredString(request.processKey, "processKey", 64);
|
|
17
18
|
const partition = optionalString(request.partition, "partition") ?? null;
|
|
18
|
-
const actor = await
|
|
19
|
+
const { actor } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey });
|
|
19
20
|
const instance = await this.runtime.start(processKey, {
|
|
20
21
|
input: request.input,
|
|
21
22
|
partition,
|
|
@@ -26,14 +27,14 @@ export class InstancesController {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
@Get()
|
|
30
|
+
@Authorize(can("workflow.instances.read"))
|
|
29
31
|
async list(ctx: HttpContext) {
|
|
30
|
-
const
|
|
31
|
-
const processKey = optionalString(ctx.query("processKey"), "processKey");
|
|
32
|
-
await authorize(ctx, "instances.read", { partition: partition ?? null, ...(processKey === undefined ? {} : { processKey }) });
|
|
32
|
+
const { access } = await callerOf(ctx);
|
|
33
33
|
const page = await this.runtime.instances({
|
|
34
34
|
...pagingQuery(ctx),
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
access: await access.filter("workflow.instances.read"),
|
|
36
|
+
partition: optionalString(ctx.query("partition"), "partition"),
|
|
37
|
+
processKey: optionalString(ctx.query("processKey"), "processKey"),
|
|
37
38
|
status: optionalString(ctx.query("status"), "status") as InstanceStatus | undefined,
|
|
38
39
|
businessKey: optionalString(ctx.query("businessKey"), "businessKey"),
|
|
39
40
|
startedById: optionalString(ctx.query("startedById"), "startedById"),
|
|
@@ -42,33 +43,42 @@ export class InstancesController {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
@Get(":id")
|
|
46
|
+
@Authorize(can("workflow.instances.read"))
|
|
45
47
|
async get(ctx: HttpContext) {
|
|
46
48
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
47
|
-
await
|
|
49
|
+
await callerOf(ctx, "workflow.instances.read", objectOf(instance));
|
|
48
50
|
return instance;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
@Get(":id/history")
|
|
54
|
+
@Authorize(can("workflow.instances.read"))
|
|
52
55
|
async history(ctx: HttpContext) {
|
|
53
56
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
54
|
-
await
|
|
57
|
+
await callerOf(ctx, "workflow.instances.read", objectOf(instance));
|
|
55
58
|
return { data: await this.runtime.history(instance.id) };
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
@Post(":id/cancel")
|
|
62
|
+
@Authorize(can("workflow.instances.manage"))
|
|
59
63
|
async cancel(ctx: HttpContext) {
|
|
60
64
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
61
65
|
const request = await body(ctx);
|
|
62
|
-
const actor = await
|
|
66
|
+
const { actor } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
|
|
63
67
|
return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor });
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
@Post(":id/retry")
|
|
71
|
+
@Authorize(can("workflow.instances.manage"))
|
|
67
72
|
async retry(ctx: HttpContext) {
|
|
68
73
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
69
|
-
const actor = await
|
|
74
|
+
const { actor } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
|
|
70
75
|
return await this.runtime.retry(instance.id, { actor });
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
contextOnlyController(InstancesController);
|
|
74
79
|
|
|
80
|
+
/** The instance as the object of a permission. */
|
|
81
|
+
function objectOf(instance: InstanceSummary): AccessObject {
|
|
82
|
+
return { scope: instance.partition, process: instance.processKey, startedById: instance.startedBy.id };
|
|
83
|
+
}
|
|
84
|
+
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AccessFilter } from "../../../access";
|
|
1
2
|
import type { FormIssue } from "../../../../library/workflow";
|
|
2
3
|
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
3
4
|
import type { HistoryType } from "../model/WfHistory.model";
|
|
@@ -75,6 +76,8 @@ export interface EmitResult {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export interface InstanceListQuery extends WorkflowPaging {
|
|
79
|
+
/** Which instances the caller may see, from `IAccess`; without it the calling code has decided. */
|
|
80
|
+
readonly access?: AccessFilter;
|
|
78
81
|
readonly partition?: string;
|
|
79
82
|
readonly status?: InstanceStatus;
|
|
80
83
|
readonly processKey?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { grantsToWhere } from "../../../access";
|
|
1
2
|
import type { FormIssue, FormSpec } from "../../../../library/workflow";
|
|
2
3
|
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
3
4
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
@@ -134,7 +135,10 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
134
135
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
135
136
|
const { limit, offset } = pagingOf(query);
|
|
136
137
|
if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
|
|
138
|
+
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
139
|
+
if (where === "none") return { items: [], total: 0 };
|
|
137
140
|
let source = this.db.instances.asNoTracking();
|
|
141
|
+
if (where !== "all") source = source.where(() => where);
|
|
138
142
|
if (query.partition !== undefined) source = source.where((i) => i.partition.eq(query.partition!));
|
|
139
143
|
if (query.status !== undefined) source = source.where((i) => i.status.eq(query.status!));
|
|
140
144
|
if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AccessModule } from "../../access";
|
|
1
2
|
import { Module, scoped } from "../../di";
|
|
2
3
|
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
3
4
|
import { withEventSubscribers } from "../../kernel";
|
|
4
5
|
import { WorkflowRuntimeModule } from "../runtime_module/WorkflowRuntime.module";
|
|
6
|
+
import { tasksPermissions } from "./access/Tasks.permissions";
|
|
5
7
|
import { TaskEvents } from "./events/TaskEvents";
|
|
6
8
|
import { TasksController } from "./http/Tasks.controller";
|
|
7
9
|
import { TasksDbContext, tasksStore } from "./model/Tasks.dbContext";
|
|
@@ -13,7 +15,8 @@ import { WorkflowTasks } from "./services/WorkflowTasks.service";
|
|
|
13
15
|
* Tasks for people: the inbox of `userTask` steps ([passport](MODULE.md)).
|
|
14
16
|
*/
|
|
15
17
|
@Module({
|
|
16
|
-
imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule],
|
|
18
|
+
imports: [AccessModule, WorkflowDefinitionsModule, WorkflowRuntimeModule],
|
|
19
|
+
permissions: tasksPermissions,
|
|
17
20
|
ormBazis: { context: TasksDbContext, entities: [WfTask], ownedStore: tasksStore, registerRepositories: false },
|
|
18
21
|
providers: [
|
|
19
22
|
scoped(IWorkflowTasks, WorkflowTasks),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { definePermissions } from "../../../access";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The right to work on tasks (WORKFLOW-002 §6.2). Which tasks are the
|
|
5
|
+
* subject's is decided by their addressees, not by an owner column.
|
|
6
|
+
*/
|
|
7
|
+
export const tasksPermissions = definePermissions({
|
|
8
|
+
"workflow.tasks.work": {
|
|
9
|
+
title: { en: "Work on tasks", ru: "Работать с задачами" },
|
|
10
|
+
scope: { column: "partition" },
|
|
11
|
+
on: { process: { ref: "workflow.process", column: "processKey" } },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
2
|
+
import { can } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Get, Post, type HttpContext } from "../../../http";
|
|
3
4
|
import type { WorkflowActor } from "../../contracts/types";
|
|
4
5
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
5
|
-
import {
|
|
6
|
+
import { body, callerOf, optionalString, pagingQuery, param } from "../../internal/http";
|
|
6
7
|
import type { TaskStatus } from "../model/WfTask.model";
|
|
7
8
|
import { IWorkflowTasks, type TaskDetails } from "../services/IWorkflowTasks.service";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* The caller's tasks (WORKFLOW-
|
|
11
|
-
* `
|
|
12
|
-
* answers 404, as if it did not exist.
|
|
11
|
+
* The caller's tasks (WORKFLOW-002 §6.2): those addressed to any of the
|
|
12
|
+
* subject's `principals` in the task's partition or claimed by the caller.
|
|
13
|
+
* Another person's task answers 404, as if it did not exist.
|
|
13
14
|
*/
|
|
14
15
|
@Controller("workflow/tasks")
|
|
15
16
|
export class TasksController {
|
|
16
17
|
constructor(private readonly tasks: IWorkflowTasks) {}
|
|
17
18
|
|
|
18
19
|
@Get()
|
|
20
|
+
@Authorize(can("workflow.tasks.work"))
|
|
19
21
|
async list(ctx: HttpContext) {
|
|
20
22
|
const partition = optionalString(ctx.query("partition"), "partition");
|
|
21
|
-
const actor = await
|
|
23
|
+
const { access, actor } = await callerOf(ctx);
|
|
22
24
|
const status = ctx.query("status");
|
|
25
|
+
const statuses = status === undefined ? {} : { status: status.split(",") as TaskStatus[] };
|
|
23
26
|
const page = await this.tasks.list({
|
|
24
27
|
...pagingQuery(ctx),
|
|
25
|
-
|
|
28
|
+
...statuses,
|
|
29
|
+
// Addressees are per partition and counted now, not when the task was created.
|
|
30
|
+
addressees: await access.principals(partition === undefined ? await this.tasks.partitions(statuses) : [partition]),
|
|
26
31
|
actor,
|
|
27
|
-
|
|
32
|
+
access: await access.filter("workflow.tasks.work"),
|
|
28
33
|
partition,
|
|
29
34
|
processKey: optionalString(ctx.query("processKey"), "processKey"),
|
|
30
35
|
});
|
|
@@ -32,35 +37,39 @@ export class TasksController {
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
@Get(":id")
|
|
40
|
+
@Authorize(can("workflow.tasks.work"))
|
|
35
41
|
async get(ctx: HttpContext) {
|
|
36
42
|
return (await this.visible(ctx)).task;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
@Post(":id/claim")
|
|
46
|
+
@Authorize(can("workflow.tasks.work"))
|
|
40
47
|
async claim(ctx: HttpContext) {
|
|
41
48
|
const { task, actor } = await this.visible(ctx);
|
|
42
49
|
return await this.tasks.claim(task.id, actor);
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
@Post(":id/release")
|
|
53
|
+
@Authorize(can("workflow.tasks.work"))
|
|
46
54
|
async release(ctx: HttpContext) {
|
|
47
55
|
const { task, actor } = await this.visible(ctx);
|
|
48
56
|
return await this.tasks.release(task.id, actor);
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
@Post(":id/complete")
|
|
60
|
+
@Authorize(can("workflow.tasks.work"))
|
|
52
61
|
async complete(ctx: HttpContext) {
|
|
53
62
|
const { task, actor } = await this.visible(ctx);
|
|
54
63
|
const request = await body(ctx);
|
|
55
64
|
return await this.tasks.complete(task.id, request.output ?? {}, actor);
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
/** The task if the caller may see it: addressed to one of their
|
|
67
|
+
/** The task if the caller may see it: addressed to one of their principals or claimed by them. */
|
|
59
68
|
private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor }> {
|
|
60
69
|
const task = await this.tasks.get(param(ctx, "id"));
|
|
61
|
-
const actor = await
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
70
|
+
const { access, actor } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
|
|
71
|
+
const addressees = await access.principals([task.partition]);
|
|
72
|
+
if (!addressees.some(({ principals }) => principals.includes(task.assignee)) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
|
|
64
73
|
return { task, actor };
|
|
65
74
|
}
|
|
66
75
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FormSpec } from "../../../../library/workflow";
|
|
2
|
+
import type { AccessFilter, ScopePrincipals } from "../../../access";
|
|
2
3
|
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
3
4
|
import type { TaskStatus } from "../model/WfTask.model";
|
|
4
5
|
|
|
@@ -23,10 +24,12 @@ export interface TaskDetails {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface TaskListQuery extends WorkflowPaging {
|
|
26
|
-
/** Tasks addressed to
|
|
27
|
-
readonly
|
|
27
|
+
/** Tasks addressed to these (`role:support`, `user:42`) in their partition, plus those the actor claimed. */
|
|
28
|
+
readonly addressees: readonly ScopePrincipals[];
|
|
28
29
|
/** Whose claimed tasks count as theirs. */
|
|
29
30
|
readonly actor?: WorkflowActor;
|
|
31
|
+
/** Which tasks the caller may see, from `IAccess`; without it the calling code has decided. */
|
|
32
|
+
readonly access?: AccessFilter;
|
|
30
33
|
/** `open` and `claimed` by default. */
|
|
31
34
|
readonly status?: readonly TaskStatus[];
|
|
32
35
|
readonly partition?: string;
|
|
@@ -40,8 +43,10 @@ export interface TaskListQuery extends WorkflowPaging {
|
|
|
40
43
|
* by another person), `wrong_status`, `invalid_input` with the form issues.
|
|
41
44
|
*/
|
|
42
45
|
export abstract class IWorkflowTasks {
|
|
43
|
-
/** The tasks of these
|
|
46
|
+
/** The tasks of these addressees, oldest first. */
|
|
44
47
|
abstract list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>>;
|
|
48
|
+
/** The partitions that have tasks in these statuses (`open` and `claimed` by default): where to ask for addressees. */
|
|
49
|
+
abstract partitions(query?: { readonly status?: readonly TaskStatus[] }): Promise<readonly (string | null)[]>;
|
|
45
50
|
abstract get(taskId: string): Promise<TaskDetails>;
|
|
46
51
|
/** Takes an open task: nobody else can complete it until it is released. */
|
|
47
52
|
abstract claim(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { grantsToWhere, type ScopePrincipals } from "../../../access";
|
|
1
2
|
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
2
3
|
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
3
4
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
@@ -19,17 +20,22 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
19
20
|
|
|
20
21
|
async list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>> {
|
|
21
22
|
const { limit, offset } = pagingOf(query);
|
|
22
|
-
const statuses =
|
|
23
|
-
|
|
24
|
-
const assignees = [...query.assignees];
|
|
23
|
+
const statuses = statusesOf(query.status);
|
|
24
|
+
const groups = groupByPrincipals(query.addressees);
|
|
25
25
|
const mine = query.actor?.id;
|
|
26
|
-
|
|
26
|
+
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
27
|
+
if (where === "none" || (groups.length === 0 && mine === undefined)) return { items: [], total: 0 };
|
|
27
28
|
let source = this.db.tasks.asNoTracking().where((t) => t.status.in(statuses));
|
|
28
|
-
source =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
if (where !== "all") source = source.where(() => where);
|
|
30
|
+
source = source.where((t) => {
|
|
31
|
+
const theirs = groups.map(({ scopes, principals }) => {
|
|
32
|
+
const named = scopes.filter((scope): scope is string => scope !== null);
|
|
33
|
+
const partition = !scopes.includes(null) ? t.partition.in(named) : named.length === 0 ? t.partition.isNull() : t.partition.isNull().or(t.partition.in(named));
|
|
34
|
+
return t.assignee.in(principals).and(partition);
|
|
35
|
+
});
|
|
36
|
+
if (mine !== undefined) theirs.push(t.claimedById.eq(mine));
|
|
37
|
+
return theirs.reduce((left, right) => left.or(right));
|
|
38
|
+
});
|
|
33
39
|
if (query.partition !== undefined) source = source.where((t) => t.partition.eq(query.partition!));
|
|
34
40
|
if (query.processKey !== undefined) source = source.where((t) => t.processKey.eq(query.processKey!));
|
|
35
41
|
const total = await source.count();
|
|
@@ -37,6 +43,12 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
37
43
|
return { items: rows.map(detailsOf), total };
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
async partitions(query: { readonly status?: readonly TaskStatus[] } = {}): Promise<readonly (string | null)[]> {
|
|
47
|
+
const statuses = statusesOf(query.status);
|
|
48
|
+
const rows = await this.db.database.querySqlRaw(`SELECT DISTINCT "partition" FROM "bazis_wf_task_items" WHERE "status" IN (${statuses.map((_, index) => `{${index}}`).join(", ")})`, ...statuses);
|
|
49
|
+
return rows.map((row) => row.partition as string | null);
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
async get(taskId: string): Promise<TaskDetails> {
|
|
41
53
|
return detailsOf(await this.task(taskId, false));
|
|
42
54
|
}
|
|
@@ -109,6 +121,26 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
function statusesOf(status: readonly TaskStatus[] | undefined): TaskStatus[] {
|
|
125
|
+
const statuses = [...(status ?? ["open", "claimed"] as const)];
|
|
126
|
+
if (statuses.length === 0 || statuses.some((item) => !STATUSES.has(item))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
|
|
127
|
+
return statuses;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Partitions with the same addressees go together: usually all of them, so the query stays short. */
|
|
131
|
+
function groupByPrincipals(addressees: readonly ScopePrincipals[]): { scopes: (string | null)[]; principals: string[] }[] {
|
|
132
|
+
const groups = new Map<string, { scopes: (string | null)[]; principals: string[] }>();
|
|
133
|
+
for (const { scope, principals } of addressees) {
|
|
134
|
+
if (principals.length === 0) continue;
|
|
135
|
+
const list = [...new Set(principals)].sort();
|
|
136
|
+
const key = JSON.stringify(list);
|
|
137
|
+
const group = groups.get(key);
|
|
138
|
+
if (group === undefined) groups.set(key, { scopes: [scope], principals: list });
|
|
139
|
+
else group.scopes.push(scope);
|
|
140
|
+
}
|
|
141
|
+
return [...groups.values()];
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
function notFound(taskId: string): WorkflowError {
|
|
113
145
|
return new WorkflowError("not_found", `Task ${taskId} does not exist`);
|
|
114
146
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.130.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { HttpContext } from "../../http";
|
|
2
|
-
import type { WorkflowActor } from "../contracts/types";
|
|
3
|
-
|
|
4
|
-
export type WorkflowAction =
|
|
5
|
-
| "processes.read"
|
|
6
|
-
| "processes.manage"
|
|
7
|
-
| "processes.publish"
|
|
8
|
-
| "instances.start"
|
|
9
|
-
| "instances.read"
|
|
10
|
-
| "instances.manage"
|
|
11
|
-
| "events.emit"
|
|
12
|
-
| "tasks.work";
|
|
13
|
-
|
|
14
|
-
/** What an action is about; the fields known for the route. */
|
|
15
|
-
export interface WorkflowAccessTarget {
|
|
16
|
-
readonly processKey?: string;
|
|
17
|
-
readonly partition?: string | null;
|
|
18
|
-
readonly instanceId?: string;
|
|
19
|
-
readonly taskId?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Rights and identity of the workflow HTTP (WORKFLOW-001 §6). The framework's
|
|
24
|
-
* controllers do not know how the application signs people in, so they ask this
|
|
25
|
-
* port. It is the DI token too: register the application's implementation in any
|
|
26
|
-
* module, `DI.singleton(DI.classProvider(WorkflowAccess, AppWorkflowAccess))`.
|
|
27
|
-
* Without one every workflow route answers 403. The services never ask it: the
|
|
28
|
-
* application's code that calls them decides itself.
|
|
29
|
-
*/
|
|
30
|
-
export abstract class WorkflowAccess {
|
|
31
|
-
/** Who makes the request; `undefined` answers 401. */
|
|
32
|
-
abstract actor(ctx: HttpContext): WorkflowActor | undefined | Promise<WorkflowActor | undefined>;
|
|
33
|
-
|
|
34
|
-
/** Whether the actor may do `action` on `target`; `false` answers 403. */
|
|
35
|
-
abstract can(ctx: HttpContext, action: WorkflowAction, target: WorkflowAccessTarget): boolean | Promise<boolean>;
|
|
36
|
-
|
|
37
|
-
/** Whom the user's tasks are addressed to: `user:<id>`, `role:<code>`. */
|
|
38
|
-
assignees(_ctx: HttpContext, actor: WorkflowActor): readonly string[] | Promise<readonly string[]> {
|
|
39
|
-
return [`user:${actor.id}`];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The default: nothing is allowed until the application says otherwise. */
|
|
44
|
-
export class DenyWorkflowAccess extends WorkflowAccess {
|
|
45
|
-
actor(): undefined {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
can(): boolean {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|