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.
Files changed (23) hide show
  1. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  2. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -0
  3. package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
  4. package/core/workflow/definitions_module/access/Process.references.ts +27 -0
  5. package/core/workflow/definitions_module/http/Processes.controller.ts +23 -14
  6. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +3 -0
  7. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -0
  8. package/core/workflow/generated/deps.ts +2 -0
  9. package/core/workflow/index.ts +0 -1
  10. package/core/workflow/internal/http.ts +18 -17
  11. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
  12. package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
  13. package/core/workflow/runtime_module/http/Events.controller.ts +5 -3
  14. package/core/workflow/runtime_module/http/Instances.controller.ts +25 -15
  15. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +3 -0
  16. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +4 -0
  17. package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -1
  18. package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
  19. package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -12
  20. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +8 -3
  21. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +41 -9
  22. package/package.json +1 -1
  23. 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.129.0" }, capabilities: { experimentalApi: true },
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 { Controller, Created, Get, Patch, Post, Put, type HttpContext } from "../../../http";
3
- import { authorize, body, optionalInteger, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
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 come from `WorkflowAccess`. */
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 authorize(ctx, "processes.read");
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 authorize(ctx, "processes.manage", { processKey: key });
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 authorize(ctx, "processes.read", { processKey: key });
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 authorize(ctx, "processes.manage", { processKey: key });
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 authorize(ctx, "processes.manage", { processKey: key });
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 authorize(ctx, "processes.publish", { processKey: key });
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 authorize(ctx, "processes.read", { processKey: key });
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 authorize(ctx, "processes.read", { processKey: key });
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
- async tools(ctx: HttpContext) {
90
- await authorize(ctx, "processes.read");
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]],
@@ -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 { ForbiddenError, UnauthorizedError, type HttpContext } from "../../http";
2
- import { WorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "../access/WorkflowAccess";
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
- * Asks the application's {@link WorkflowAccess} who calls and whether the action
8
- * is allowed. Without a registered access every route answers 403.
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
- /** Whom the caller's tasks are addressed to, from the application's {@link WorkflowAccess}. */
20
- export async function assigneesOf(ctx: HttpContext, actor: WorkflowActor): Promise<readonly string[]> {
21
- const access = ctx.services.tryResolve(WorkflowAccess);
22
- return access === undefined ? [] : await access.assignees(ctx, actor);
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 { Controller, Post, type HttpContext } from "../../../http";
3
- import { authorize, body, optionalString, param } from "../../internal/http";
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 authorize(ctx, "events.emit", { partition });
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 { Controller, Created, Get, Post, type HttpContext } from "../../../http";
3
- import { WorkflowError } from "../../contracts/WorkflowError";
4
- import { authorize, body, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
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 come from `WorkflowAccess` with the instance's partition. */
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 authorize(ctx, "instances.start", { processKey, partition });
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 partition = optionalString(ctx.query("partition"), "partition");
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
- partition,
36
- processKey,
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 authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 { Controller, Get, Post, type HttpContext } from "../../../http";
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 { assigneesOf, authorize, body, optionalString, pagingQuery, param } from "../../internal/http";
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-001 §5): those addressed to any of
11
- * `WorkflowAccess.assignees` or claimed by the caller. Another person's task
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 authorize(ctx, "tasks.work", { partition: partition ?? null });
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
- assignees: await assigneesOf(ctx, actor),
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
- ...(status === undefined ? {} : { status: status.split(",") as TaskStatus[] }),
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 assignees or claimed by them. */
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 authorize(ctx, "tasks.work", { processKey: task.processKey, partition: task.partition, instanceId: task.instanceId, taskId: task.id });
62
- const assignees = await assigneesOf(ctx, actor);
63
- if (!assignees.includes(task.assignee) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
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 any of these (`role:support`, `user:42`), plus those the actor claimed. */
27
- readonly assignees: readonly string[];
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 assignees, oldest first. */
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 = [...(query.status ?? ["open", "claimed"])];
23
- if (statuses.length === 0 || statuses.some((status) => !STATUSES.has(status))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
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
- if (assignees.length === 0 && mine === undefined) return { items: [], total: 0 };
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 = mine === undefined
29
- ? source.where((t) => t.assignee.in(assignees))
30
- : assignees.length === 0
31
- ? source.where((t) => t.claimedById.eq(mine))
32
- : source.where((t) => t.assignee.in(assignees).or(t.claimedById.eq(mine)));
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.129.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
- }