diff --git a/.specify/feature.json b/.specify/feature.json index 9305fb44e..fa81f8e0a 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/012-output-converters" + "feature_directory": "specs/013-user-tasks" } diff --git a/AGENTS.md b/AGENTS.md index 0a1a9fbc1..5dca79ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable: For additional context about technologies to be used, project structure, -shell commands, and other important information, read `specs/012-weaver-grounding-tools/plan.md`. +shell commands, and other important information, read `specs/013-user-tasks/plan.md`. ## Active Technologies diff --git a/CONTEXT.md b/CONTEXT.md index 8e19b678c..c449ddc6c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,3 +43,129 @@ _Avoid_: Assignment error, converter log message **Bound Value**: The value delivered only to the destination of an Output Binding after any configured Output Converter has run. It may be null only when the destination permits null. _Avoid_: Activity output + +**External Identity**: +A protocol-neutral identity asserted by an Identity Provider and identified within that provider's namespace. +_Avoid_: Elsa User, external user + +**External Identity Key**: +The immutable combination of target Elsa tenant, Connection Key, validated issuer namespace, and stable subject used to distinguish an External Identity. Host-wide connection deployment does not collapse Elsa User tenancy. +_Avoid_: Email address, user name + +**External Identity Link**: +The association between an External Identity and the Elsa User that receives Elsa-specific roles, permissions, and tenant access. +_Avoid_: External user, federated user + +**Local Credential**: +Optional Elsa-managed authentication material, such as a password, associated with an Elsa User independently of External Identity Links. +_Avoid_: Elsa User, External Identity + +**Unlinked Identity Policy**: +The selected rule that decides what Elsa may do when an authenticated External Identity has no External Identity Link. +_Avoid_: Provisioning mode, authorization policy + +**Elsa Permission**: +A string-named capability required by Elsa functionality and carried by an authenticated principal. +_Avoid_: External claim, role + +**Permission Grant Source**: +A deferred extension concept for contributing Elsa Permissions. It is not a v1 External Authentication Studio configuration surface. +_Avoid_: Role mapping, raw claim pass-through + +**External User Matcher**: +A trusted deployed extension selected by the matcher-based Unlinked Identity Policy to propose an existing Elsa User from bounded, ephemeral external claims. Ambiguous results and matcher errors reject authentication. +_Avoid_: Role matcher, permission mapper, automatic email linking + +**Permission Descriptor**: +Optional module-provided metadata that describes an Elsa Permission without determining validity. External Authentication v1 does not use it for claim-permission mapping. +_Avoid_: Permission catalog, permission registry + +**External Authentication Session**: +The bounded Elsa sign-in session established from one successful external authentication and its resulting claim snapshot. +_Avoid_: Identity-provider session, Elsa access token + +**Upstream Logout**: +An optional logout operation that also asks the Identity Provider to end its session, when supported by the connection's Protocol Adapter. +_Avoid_: Elsa logout, session revocation + +**Break-glass Authentication**: +A deployment-controlled recovery method kept independent of ordinary external sign-in so administrators can repair authentication after lockout. +_Avoid_: Backup provider, normal login method + +**Elsa User**: +An account governed by Elsa's authorization model. An Elsa User may be associated with multiple External Identities. +_Avoid_: External Identity, identity-provider user + +**Adapter Descriptor**: +The protocol-neutral description of a Protocol Adapter's connection settings, validation, presentation, and capabilities. +_Avoid_: Connection settings, custom form + +**Secret Binding**: +A non-secret reference that tells Elsa how to resolve a sensitive connection value without storing or disclosing that value as connection data. +_Avoid_: Client secret, secret value + +**Managed Secret**: +A Secret Binding whose lifecycle is managed through an Elsa-integrated secret store. Studio may replace or remove it but never reveal it. +_Avoid_: Inline secret, connection field + +**External Secret**: +A read-only Secret Binding resolved from deployment configuration or another externally operated resolver. Studio may show its configured/resolvable state but does not own its value or lifecycle. +_Avoid_: Managed Secret, plaintext setting + +**Preferred Login Method**: +The enabled Login Method emphasized and ordered first by the chooser. Preference never causes an automatic redirect; the chooser remains visible. +_Avoid_: Automatic login, forced provider + +**Connection Health**: +The observed operational condition of an Identity Provider Connection, independent of whether administrators intend it to be enabled. +_Avoid_: Enabled state, validity + +**Connection Validity**: +Whether an Identity Provider Connection has structurally acceptable settings and resolvable required secrets. +_Avoid_: Connection Health, enabled state + +**Provider Trust Setting**: +A connection-controlled rule for locating and validating the Identity Provider and its assertions. +_Avoid_: Broker security invariant + +**Broker Security Invariant**: +An Elsa-owned protection for the broker and its clients that connection administrators cannot weaken through Studio. +_Avoid_: Provider Trust Setting + +## User Tasks + +**User Task Definition**: +The design-time configuration of human work in a workflow. It is evaluated and materialized when the activity executes. +_Avoid_: User Task Instance, standalone task + +**User Task Instance**: +A durable runtime work item created by a committed User Task bookmark and completed by one accountable participant or a configured terminal outcome. +_Avoid_: User Task Definition, generic RunTask + +**Participant Reference**: +An opaque, tenant-scoped `{ provider, type, id }` reference to a host-owned user or group. It never implies an Elsa Identity record. +_Avoid_: Elsa User ID, username + +**Candidate**: +A participant eligible to claim an Available User Task. Candidacy grants safe-summary visibility, not protected task content. +_Avoid_: Assignee, requester + +**Assignee**: +The single participant accountable for an Assigned User Task and permitted to access its protected response surface. +_Avoid_: Candidate, manager + +**Requester**: +An optional informational participant shown for context and search. Requester status grants no task access. +_Avoid_: Assignee, task owner + +**Task Action**: +A stable literal outcome key and its materialized display label. `Timeout` and `Cancelled` are reserved actions. +_Avoid_: Button text, arbitrary workflow command + +**Task Health**: +An operational warning or blocking resolution problem independent of User Task lifecycle status. +_Avoid_: Task status, workflow incident + +**Guest Invitation**: +A bounded, one-time candidacy for an external participant that becomes a task-scoped guest session only after configured verification. +_Avoid_: Elsa user invitation, bearer account diff --git a/Directory.Packages.props b/Directory.Packages.props index 24137c54f..d23afa120 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -200,6 +200,7 @@ + diff --git a/Elsa.sln b/Elsa.sln index 0936205ac..309b50448 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -489,6 +489,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.ExternalAuthentication EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Api.Common.UnitTests", "test\unit\Elsa.Api.Common.UnitTests\Elsa.Api.Common.UnitTests.csproj", "{D81CC1F3-6312-4E77-BA16-8044F2553F3C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks", "src\modules\Elsa.UserTasks\Elsa.UserTasks.csproj", "{2FF198D8-FE05-4893-92C8-A373C82C8DEF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore", "src\modules\Elsa.UserTasks.Persistence.EFCore\Elsa.UserTasks.Persistence.EFCore.csproj", "{3A89A26D-E9AF-4A75-B9F0-C93018727869}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.Sqlite", "src\modules\Elsa.UserTasks.Persistence.EFCore.Sqlite\Elsa.UserTasks.Persistence.EFCore.Sqlite.csproj", "{4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.SqlServer", "src\modules\Elsa.UserTasks.Persistence.EFCore.SqlServer\Elsa.UserTasks.Persistence.EFCore.SqlServer.csproj", "{C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.PostgreSql", "src\modules\Elsa.UserTasks.Persistence.EFCore.PostgreSql\Elsa.UserTasks.Persistence.EFCore.PostgreSql.csproj", "{408BBF9A-6CF2-4241-A20B-0190B03D48B1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.MySql", "src\modules\Elsa.UserTasks.Persistence.EFCore.MySql\Elsa.UserTasks.Persistence.EFCore.MySql.csproj", "{BB2982B8-708B-422E-B1B3-541240F01A49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.Oracle", "src\modules\Elsa.UserTasks.Persistence.EFCore.Oracle\Elsa.UserTasks.Persistence.EFCore.Oracle.csproj", "{F2714082-9CE1-4DAE-9421-AE59477F950A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.VNext", "src\modules\Elsa.UserTasks.Persistence.VNext\Elsa.UserTasks.Persistence.VNext.csproj", "{385BB2E8-33F3-4127-BBD6-33F06938EE1C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{AB07AAEB-2C7A-1088-880A-08DB82DA218D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.UnitTests", "test\unit\Elsa.UserTasks.UnitTests\Elsa.UserTasks.UnitTests.csproj", "{FB045674-6D63-4B49-A060-73D580E0FAC0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.UnitTests", "test\unit\Elsa.UserTasks.Persistence.EFCore.UnitTests\Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj", "{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -2297,6 +2321,126 @@ Global {D81CC1F3-6312-4E77-BA16-8044F2553F3C}.Release|x64.Build.0 = Release|Any CPU {D81CC1F3-6312-4E77-BA16-8044F2553F3C}.Release|x86.ActiveCfg = Release|Any CPU {D81CC1F3-6312-4E77-BA16-8044F2553F3C}.Release|x86.Build.0 = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|x64.Build.0 = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Debug|x86.Build.0 = Debug|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|Any CPU.Build.0 = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|x64.ActiveCfg = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|x64.Build.0 = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|x86.ActiveCfg = Release|Any CPU + {2FF198D8-FE05-4893-92C8-A373C82C8DEF}.Release|x86.Build.0 = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|x64.Build.0 = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Debug|x86.Build.0 = Debug|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|Any CPU.Build.0 = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|x64.ActiveCfg = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|x64.Build.0 = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|x86.ActiveCfg = Release|Any CPU + {3A89A26D-E9AF-4A75-B9F0-C93018727869}.Release|x86.Build.0 = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|x64.Build.0 = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Debug|x86.Build.0 = Debug|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|Any CPU.Build.0 = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|x64.ActiveCfg = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|x64.Build.0 = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|x86.ActiveCfg = Release|Any CPU + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1}.Release|x86.Build.0 = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|x64.ActiveCfg = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|x64.Build.0 = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|x86.ActiveCfg = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Debug|x86.Build.0 = Debug|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|Any CPU.Build.0 = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|x64.ActiveCfg = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|x64.Build.0 = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|x86.ActiveCfg = Release|Any CPU + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B}.Release|x86.Build.0 = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|x64.ActiveCfg = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|x64.Build.0 = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|x86.ActiveCfg = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Debug|x86.Build.0 = Debug|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|Any CPU.Build.0 = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|x64.ActiveCfg = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|x64.Build.0 = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|x86.ActiveCfg = Release|Any CPU + {408BBF9A-6CF2-4241-A20B-0190B03D48B1}.Release|x86.Build.0 = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|x64.Build.0 = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Debug|x86.Build.0 = Debug|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|Any CPU.Build.0 = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|x64.ActiveCfg = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|x64.Build.0 = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|x86.ActiveCfg = Release|Any CPU + {BB2982B8-708B-422E-B1B3-541240F01A49}.Release|x86.Build.0 = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|x64.Build.0 = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Debug|x86.Build.0 = Debug|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|Any CPU.Build.0 = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|x64.ActiveCfg = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|x64.Build.0 = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|x86.ActiveCfg = Release|Any CPU + {F2714082-9CE1-4DAE-9421-AE59477F950A}.Release|x86.Build.0 = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|x64.Build.0 = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Debug|x86.Build.0 = Debug|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|Any CPU.Build.0 = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|x64.ActiveCfg = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|x64.Build.0 = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|x86.ActiveCfg = Release|Any CPU + {385BB2E8-33F3-4127-BBD6-33F06938EE1C}.Release|x86.Build.0 = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|x64.Build.0 = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Debug|x86.Build.0 = Debug|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|Any CPU.Build.0 = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|x64.ActiveCfg = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|x64.Build.0 = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|x86.ActiveCfg = Release|Any CPU + {FB045674-6D63-4B49-A060-73D580E0FAC0}.Release|x86.Build.0 = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|x64.Build.0 = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Debug|x86.Build.0 = Debug|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|Any CPU.Build.0 = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x64.ActiveCfg = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x64.Build.0 = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x86.ActiveCfg = Release|Any CPU + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2490,6 +2634,16 @@ Global {DCFAE70F-67B8-4F84-A28D-55E1FF6F92DC} = {18453B51-25EB-4317-A4B3-B10518252E92} {F94712B2-9237-403F-B8F4-C6F73D1DFED9} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {D81CC1F3-6312-4E77-BA16-8044F2553F3C} = {18453B51-25EB-4317-A4B3-B10518252E92} + {2FF198D8-FE05-4893-92C8-A373C82C8DEF} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {3A89A26D-E9AF-4A75-B9F0-C93018727869} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {4A494EBB-702E-4FC7-AFCB-02ECD2613AA1} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {C298ED71-A1AA-4DB0-B4F8-B2F19133EC0B} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {408BBF9A-6CF2-4241-A20B-0190B03D48B1} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {BB2982B8-708B-422E-B1B3-541240F01A49} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {F2714082-9CE1-4DAE-9421-AE59477F950A} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {385BB2E8-33F3-4127-BBD6-33F06938EE1C} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} + {FB045674-6D63-4B49-A060-73D580E0FAC0} = {AB07AAEB-2C7A-1088-880A-08DB82DA218D} + {A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E} = {AB07AAEB-2C7A-1088-880A-08DB82DA218D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/doc/adr/0026-identity-neutral-user-task-participants.md b/doc/adr/0026-identity-neutral-user-task-participants.md new file mode 100644 index 000000000..07be44ddc --- /dev/null +++ b/doc/adr/0026-identity-neutral-user-task-participants.md @@ -0,0 +1,21 @@ +# 26. Use identity-neutral participant references for User Tasks + +Date: 2026-08-17 + +## Status + +Accepted + +## Context + +Elsa Core does not own a universal user model. `Elsa.Identity` is one optional host feature, while embedded deployments may use an application database, Entra ID, LDAP, or another identity platform. Coupling task assignment to one user table would make the User Tasks module unusable or duplicative for those hosts. + +## Decision + +User Tasks store opaque participant references composed of tenant, provider namespace, participant type, and external ID, with an optional non-authoritative display snapshot. The module has no required `Elsa.Identity` dependency or foreign key. + +Authentication mapping, live group membership, directory lookup, and task authorization are replaceable host contracts. The built-in adapter maps namespaced claims. Directory resolution failure does not invalidate an exact live participant claim or fault task activation. + +## Consequences + +Hosts can integrate their existing identities without synchronization into Elsa. Every authorization and query path must remain tenant- and namespace-aware, and participant display may degrade to an opaque ID when no directory is available. Database constraints cannot enforce existence in an external identity system; policy and integration tests enforce relationships instead. diff --git a/doc/adr/0027-project-user-tasks-from-committed-bookmarks.md b/doc/adr/0027-project-user-tasks-from-committed-bookmarks.md new file mode 100644 index 000000000..f6a008652 --- /dev/null +++ b/doc/adr/0027-project-user-tasks-from-committed-bookmarks.md @@ -0,0 +1,21 @@ +# 27. Project User Tasks from committed workflow bookmarks + +Date: 2026-08-17 + +## Status + +Accepted + +## Context + +A User Task must both suspend workflow execution durably and support efficient task-inbox queries. Writing a bookmark and a separate task store as an uncoordinated dual write can leave an invisible suspended workflow or an orphan task. Making the task table the workflow source of truth would bypass Elsa's established bookmark execution semantics. + +## Decision + +The User Task activity creates a dedicated bookmark whose payload contains the materialized task definition. After the workflow commit succeeds, a projector idempotently creates the task record from the committed bookmark. Terminal task operations persist an idempotent transitional operation and asynchronously resume the bookmark with a dedicated stimulus. Bookmark removal finalizes the task. + +A bounded, multi-node-safe reconciler scans committed User Task bookmarks and task operations to recreate missed projections, retry stale terminal delivery, and diagnose or finalize orphan records. Materialization key, bookmark ID, expected revision, and operation ID provide uniqueness and race control. + +## Consequences + +Workflow state remains authoritative for suspension and resumption, while the task store can be indexed independently for secure queues. Task availability may be briefly eventually consistent after workflow commit, and the module must operate a projector, operation outbox, and reconciler. Every provider must preserve the same idempotency and compare-and-swap guarantees. diff --git a/doc/adr/toc.md b/doc/adr/toc.md index 9d089bb04..1e7f596e8 100644 --- a/doc/adr/toc.md +++ b/doc/adr/toc.md @@ -25,3 +25,5 @@ * [23. Separate authentication UI composition from security administration](0023-separate-authentication-ui-composition-from-security-administration.md) * [24. Use exact OIDC discovery and deployment-derived callbacks](0024-use-exact-oidc-discovery-and-deployment-derived-callbacks.md) * [25. Two-axis authorization model with open resources and open verbs](0025-two-axis-authorization-model.md) +* [26. Use Identity-Neutral Participant References for User Tasks](0026-identity-neutral-user-task-participants.md) +* [27. Project User Tasks from Committed Workflow Bookmarks](0027-project-user-tasks-from-committed-bookmarks.md) diff --git a/doc/user-tasks.md b/doc/user-tasks.md new file mode 100644 index 000000000..8db70d9c9 --- /dev/null +++ b/doc/user-tasks.md @@ -0,0 +1,60 @@ +# User Tasks + +User Tasks add durable, workflow-bound human work to Elsa. A workflow suspends at the `UserTask` activity, authorized workers discover and act on the task through `/user-tasks`, and the workflow resumes with a typed action and validated form result. + +## Identity integration + +The module does not require `Elsa.Identity`. It stores opaque, tenant-scoped participant references: + +```json +{ + "tenantId": "acme", + "provider": "entra", + "type": "User", + "id": "external-subject-id" +} +``` + +The default adapter maps namespaced claims from `ClaimsPrincipal`. Embedded hosts can replace `IUserTaskIdentityResolver`, `IUserTaskAccessPolicy`, and `IUserTaskParticipantDirectory` to integrate their own authentication, authorization, users, and groups. Directory lookup enriches display and snapshot groups; it is never required for an exact live claim match. + +Every operation requires both its module permission and a relationship to the task. Candidate workers can read safe queue metadata and claim. Only the assignee and tenant-scoped managers can read protected instructions, task data, and form content. Releasing a task revokes that access immediately. + +## Lifecycle + +Direct assignments start as `Assigned`; candidate or invitation work starts as `Available`; a task with no route to a worker starts as manager-only `Unassigned`. Completion, timeout, and cancellation first enter a transitional state while the workflow bookmark is resumed, then finalize as `Completed`, `TimedOut`, or `Cancelled`. + +Claims and terminal actions use optimistic concurrency. Completion and cancellation require a client operation ID, making same-payload retries idempotent while rejecting divergent reuse. A post-commit bookmark projector and paged reconciler repair interrupted projection and resume delivery without duplicating tasks. + +## Forms and actions + +Action keys are stable literals; their labels may be expressions. `Timeout` and `Cancelled` are reserved. An optional `IUserTaskFormProvider` resolves and pins a provider-neutral form reference when the task activates, then validates and normalizes the completion payload. An unresolved form creates a blocking manager health issue. Tasks without a form accept an action only. + +Protected task, form, and completion payloads are limited to 256 KiB by default. Put files in an external object/document provider and submit references rather than file bytes. + +## Persistence + +The Core feature includes an in-memory repository for development and tests. Production hosts can select the EF Core User Tasks package for SQLite, SQL Server, PostgreSQL, MySQL, or Oracle, or use the VNext persistence adapter. The provider-neutral API and activity contract do not change with the store. + +Terminal tasks are retained indefinitely by default. Configurable cleanup may purge terminal aggregates and their protected data, but never open tasks. Audit events remain append-only and contain safe metadata only. + +## HTTP surface + +Clients read `GET /user-tasks/capabilities` before rendering navigation, then work through `/user-tasks` for the queue and `/user-tasks/{id}` for a single task. Every command carries an `operationId` that the client mints once per submission and reuses on retry, so a double click or a network retry is recognised as the same command rather than accepted as a second one. + +Terminal commands (complete, cancel, retry resolution) answer `202`: the workflow resumes out of band and the client observes the final state by requery or invalidation. Conflicts answer `409`, semantic input errors `422`, and anonymous invitation traffic is throttled with `429`. Every failure body is `{ code, message }` with display-safe copy — never exception text or payload fragments. + +Masked form fields never travel with the task detail. A client that needs one calls `POST /user-tasks/{id}/reveal`, which requires protected access, requires the form provider to have marked the field revealable, and writes the disclosure to the audit trail. + +## Guest invitations + +Guest invitations are task-scoped and do not create Elsa users. Core hashes the one-time token, retains retry material only in a Data Protection-encrypted transient outbox, and delegates delivery and challenge verification to host services. The first successful verification claims the task, revokes sibling invitations, and issues a bounded guest session. Anonymous errors are generic and rate-limited; guests cannot release or reassign work. + +The transient delivery outbox encrypts pending secrets with ASP.NET Core Data Protection, so the module needs an `IDataProtectionProvider`. Web hosts register one by default; a non-web host that enables invitations must call `AddDataProtection()` itself. + +A guest presents its session as `Authorization: UserTaskSession ` against `/user-task-sessions/current` and `/user-task-sessions/current/complete`. The session identifies the task, so no task ID appears in the route and a guest can never address a task other than the one its invitation was issued for. Completion is intersected with the action allowlist pinned at issuance, and every session for a task is revoked as soon as that task closes. + +## Studio and custom applications + +Elsa Studio provides Workflows → User Tasks with Assigned to me, Available, History, and manager-only All and Needs Attention views. Studio is a reference workbench, not a required runtime dependency. Custom applications use the same REST APIs and server-computed capability projections. Realtime messages are invalidations only; clients always requery authorized data. + +See [the feature specification](../specs/013-user-tasks/spec.md), [REST contract](../specs/013-user-tasks/contracts/rest-api.md), and [quickstart](../specs/013-user-tasks/quickstart.md) for the complete design and examples. diff --git a/specs/013-user-tasks/checklists/accessibility.md b/specs/013-user-tasks/checklists/accessibility.md new file mode 100644 index 000000000..6ad4f80e0 --- /dev/null +++ b/specs/013-user-tasks/checklists/accessibility.md @@ -0,0 +1,10 @@ +# Accessibility Checklist + +- [x] A11Y001 Queue and detail actions are keyboard operable. +- [x] A11Y002 Status, health, priority, and overdue state do not rely on color alone. +- [x] A11Y003 Desktop split view has a route-equivalent narrow/mobile detail experience. +- [x] A11Y004 Loading, empty, error, conflict, and asynchronous accepted states have announced text. +- [x] A11Y005 Forms preserve provider labels, descriptions, validation messages, and focus management. +- [x] A11Y006 Claim/release/complete/reassign controls expose names and disabled reasons. +- [x] A11Y007 Realtime invalidations do not unexpectedly move focus or replace in-progress input. +- [x] A11Y008 Guest verification reveals generic but actionable accessible feedback. diff --git a/specs/013-user-tasks/checklists/requirements.md b/specs/013-user-tasks/checklists/requirements.md new file mode 100644 index 000000000..bb98182b4 --- /dev/null +++ b/specs/013-user-tasks/checklists/requirements.md @@ -0,0 +1,12 @@ +# Requirements Quality Checklist + +- [x] CHK001 Product outcome and actors are explicit. +- [x] CHK002 User stories are prioritized and independently testable. +- [x] CHK003 Functional requirements use stable identifiers and testable MUST language. +- [x] CHK004 Identity neutrality and host integration seams are explicit. +- [x] CHK005 Lifecycle, race, idempotency, and reconciliation behavior are explicit. +- [x] CHK006 Safe versus protected disclosure is explicit. +- [x] CHK007 REST, persistence, Studio, invitation, forms, retention, and scale boundaries are covered. +- [x] CHK008 V1 exclusions and existing RunTask compatibility are explicit. +- [x] CHK009 No unresolved `[NEEDS CLARIFICATION]` markers remain after the product interview. +- [x] CHK010 Success criteria are measurable and implementation-neutral. diff --git a/specs/013-user-tasks/checklists/security.md b/specs/013-user-tasks/checklists/security.md new file mode 100644 index 000000000..c8c0eca3a --- /dev/null +++ b/specs/013-user-tasks/checklists/security.md @@ -0,0 +1,12 @@ +# Security Checklist + +- [x] SEC001 Authentication is host-owned and identity storage is decoupled. +- [x] SEC002 Permission and task-relationship checks are both required. +- [x] SEC003 Tenant scope is part of participant identity and every query boundary. +- [x] SEC004 Candidate, released-user, terminal-history, and manager disclosure rules are specified. +- [x] SEC005 Exclusions and manager override reason/audit behavior are specified. +- [x] SEC006 Protected payloads are bounded, excluded from search/audit, and purged consistently. +- [x] SEC007 Invitation secrets are hashed, delivery retry material is encrypted and transient, and anonymous errors are generic/rate-limited. +- [x] SEC008 Guest sessions are task-scoped, revocable, capability-limited, and bounded. +- [x] SEC009 Mutation concurrency, operation idempotency, and terminal races are specified. +- [x] SEC010 List authorization prevents row and count leakage. diff --git a/specs/013-user-tasks/contracts/forms-contract.md b/specs/013-user-tasks/contracts/forms-contract.md new file mode 100644 index 000000000..0f77564ac --- /dev/null +++ b/specs/013-user-tasks/contracts/forms-contract.md @@ -0,0 +1,12 @@ +# Forms Contract + +`FormReference` contains `provider`, `id`, and an optional version selector. The replaceable `IUserTaskFormProvider` resolves a reference during activation, returns a version-pinned render descriptor, and validates/normalizes completion data. + +- A selector such as `latest` is resolved once and stored as a concrete version on the task. +- Resolution failure creates a blocking manager-only health issue; it never silently substitutes a different form. +- Repair retries the original reference. V1 provides no live editing or repinning of an open task. +- Workers receive only the provider-neutral render descriptor after assignment. Studio delegates rendering to a registered renderer and shows an unsupported-provider state otherwise. +- Submitted data is bounded to 256 KiB by default, validated by the same provider and pinned version, normalized, then stored and supplied to the workflow result. +- A task without a form accepts no arbitrary data; completion consists only of a configured action. +- Form data is protected, excluded from search and audit payloads, and retained/purged with the task. +- V1 has no native form builder or draft protocol. diff --git a/specs/013-user-tasks/contracts/identity-contract.md b/specs/013-user-tasks/contracts/identity-contract.md new file mode 100644 index 000000000..ae60a3f34 --- /dev/null +++ b/specs/013-user-tasks/contracts/identity-contract.md @@ -0,0 +1,32 @@ +# Identity and Authorization Contract + +## Participant value + +`ParticipantReference` is an opaque value with required `tenantId`, `provider`, `type` (`User` or `Group`), and `id`; `displayName` is an optional non-authoritative snapshot. Equality uses the four required fields with host-configurable normalization. A configured default provider may expand a designer-entered bare ID. Core never verifies existence during task activation and never persists an Elsa Identity foreign key. + +## Host interfaces + +- `IUserTaskIdentityResolver`: maps the current authenticated principal to one or more exact participant references and tenant context. +- `IUserTaskAccessPolicy`: evaluates an operation against module permission, task relationship, exclusions, management scope, and optional guest capabilities. +- `IUserTaskParticipantDirectory`: searches display entries, resolves display snapshots, and optionally enumerates group membership for snapshot mode. + +The built-in identity resolver maps namespaced `ClaimsPrincipal` claims. All interfaces are replaceable. Directory failure affects display or task health; it does not make participant values invalid. + +## Assignment membership + +- `Live` (default): candidate group checks use current principal references/claims. An exact reference match remains authoritative even if the directory cannot resolve it. +- `Snapshot`: groups are expanded at activation and stored as snapshot members. Failure to enumerate creates a blocking manager-only health issue. +- Explicit user exclusions deny assignment and protected access. Manager override is disabled by default; when enabled it requires `manage:user-tasks` and a reason. +- `Requester` is context only and grants no capability. + +## Operation permissions + +`read:user-tasks`, `claim:user-tasks`, `complete:user-tasks`, `assign:user-tasks`, `update:user-tasks`, `cancel:user-tasks`, `invite:user-tasks`, `manage:user-tasks`, and `lookup:user-task-participants` are independent permissions. Permission alone is insufficient: ordinary callers also need the corresponding task relationship. Managers are tenant-scoped. Guest sessions carry an allowlist of capabilities for one task. + +## Disclosure + +Safe summary: title, summary, reference, tags, task type, workflow context, status/health, assignee display, priority, due/created times, and caller capabilities. Protected detail: instructions, task data, pinned form, and completion data. Candidates receive summary before claim; assignees receive protected detail; release revokes it immediately. After termination, only the completer and managers retain protected detail. Prior assignees see safe summary and audit history. + +## Default-deny rules + +Cross-tenant references never match. Unknown relationship, missing tenant, malformed reference, failed policy integration, revoked guest session, and stale capability decisions deny access. List authorization must be applied in the query path so unauthorized rows and totals cannot leak. diff --git a/specs/013-user-tasks/contracts/invitations-contract.md b/specs/013-user-tasks/contracts/invitations-contract.md new file mode 100644 index 000000000..e52620ac8 --- /dev/null +++ b/specs/013-user-tasks/contracts/invitations-contract.md @@ -0,0 +1,17 @@ +# Guest Invitation Contract + +## Issuance and delivery + +Activity definitions and authorized managers may create multiple invitations. Each invitation has a challenge provider/configuration, allowed actions, and bounded expiry (default: earlier of seven days or `DueAt`). Core creates an unguessable one-time secret, stores only its hash, and gives raw material once to `IUserTaskInvitationDispatcher`. Retry uses a protected transient outbox encrypted through ASP.NET Core Data Protection; successful or expired delivery removes the entry. + +## Verification + +Anonymous endpoints disclose only generic invitation copy before verification, use rate limiting, and return indistinguishable failures for missing, expired, consumed, or invalid invitations. `IUserTaskInvitationVerifier` performs the configured challenge. Bearer-only verification is permitted only when explicitly enabled by the activity. + +The first successful verification atomically: + +1. claims the still-open task for a generated guest participant; +2. consumes the winning invitation and revokes its siblings; +3. issues a revocable, task-scoped session through `IUserTaskGuestSessionIssuer`. + +The guest session expires at task close or its own host-bounded TTL and grants only configured read/complete capabilities. Guests cannot release, reassign, invite, update, cancel, or manage. A manager recovers abandoned guest work by reassignment or reissue. No raw challenge response, invitation secret, or protected task data is written to audit events. diff --git a/specs/013-user-tasks/contracts/persistence-contract.md b/specs/013-user-tasks/contracts/persistence-contract.md new file mode 100644 index 000000000..9e1b99f6e --- /dev/null +++ b/specs/013-user-tasks/contracts/persistence-contract.md @@ -0,0 +1,266 @@ +# User Tasks Persistence Contract + +**Feature**: `013-user-tasks` +**Status**: Approved for implementation +**Persistence precedent**: `Elsa.Secrets` module and its EF Core provider packages + +This contract defines the storage boundary and provider-parity requirements. It describes logical storage units rather than prescribing one physical database design, but every provider must preserve the same behavior and invariants. + +## Package and registration boundary + +The module is split as follows: + +```text +Elsa.UserTasks +├── contracts, models, activity, manager, in-memory repository +├── claims resolver, access policy seam, projection/reconciliation +├── endpoints, permissions, notifications, invitation orchestration +└── no EF Core or Elsa.Identity dependency + +Elsa.UserTasks.Persistence.EFCore +├── UserTasksElsaDbContext +├── entity configurations and repository +└── EFCoreUserTasksPersistenceFeature + +Elsa.UserTasks.Persistence.EFCore.Sqlite +Elsa.UserTasks.Persistence.EFCore.SqlServer +Elsa.UserTasks.Persistence.EFCore.PostgreSql +Elsa.UserTasks.Persistence.EFCore.MySql +Elsa.UserTasks.Persistence.EFCore.Oracle +├── provider configuration and design-time factory +├── provider-specific migration assembly +└── provider shell feature + +Elsa.UserTasks.Persistence.VNext +├── IPersistenceSchemaProvider descriptor +└── document-store adapter with equivalent semantics +``` + +The Core feature registers an in-memory repository for development/tests. EF Core and VNext packages replace the repository through feature composition, following the `Elsa.Secrets` pattern. Providers must not add a dependency on `Elsa.Identity`. + +## Logical storage units + +All units are tenant-scoped. `TenantId` is either an explicit host tenant value or the configured default tenant; it is never taken from an untrusted arbitrary query string. + +### `UserTasks` + +One row/document per User Task instance. + +Required identity and workflow fields: + +- `Id`, `TenantId`. +- `WorkflowDefinitionId`, `WorkflowInstanceId`, `ActivityInstanceId`, `BookmarkId`. +- `MaterializationKey`, the normalized tenant/workflow-instance/activity-instance tuple. + +Safe query and presentation fields: + +- `Title`, `Summary`, `Reference`, `TaskType`, `Tags` (bounded safe JSON or normalized tag representation). +- Requester provider/type/ID and optional display snapshot. +- `Priority`, `DueAt`, `CreatedAt`, `UpdatedAt`, `AssignedAt`, `CompletedAt`. +- `Status`, `IsOverdue` (computed or persisted consistently), and health summary. + +Assignment fields: + +- Assignee provider/type/ID and optional display snapshot. +- `MembershipResolutionMode`. +- Safe configuration flags such as exclusion-override enabled and timeout/cancellation enabled. + +Protected and operational fields: + +- `InstructionsJson`/`TaskDataJson`, encrypted or provider-protected bounded JSON. +- Requested and pinned form reference metadata, protected where provider-private. +- Terminal action key, protected normalized completion data, and completion actor reference. +- `Revision`, an integer optimistic-concurrency token. +- `CreatedFromBookmarkRevision` or equivalent workflow marker when needed for reconciliation. + +Protected fields are never part of search indexes, list projections, events, notifications, or authorization scopes. + +### `UserTaskCandidates` + +Materialized candidate relationships used for authorized list and claim queries: + +- `Id`, `TenantId`, `TaskId`. +- Participant `Provider`, `Type`, and `ParticipantId`. +- Optional display snapshot. +- Source (`DirectUser`, `DirectGroup`, `SnapshotMember`, or `Invitation`) and source group reference where relevant. +- Created/updated timestamps. + +The canonical uniqueness key is tenant, task, provider, participant type, participant ID, and source identity. Duplicate bookmark notifications must not duplicate candidate rows. + +For live groups, the group candidate is retained and evaluated against current actor claims. For snapshot groups, the expanded user rows are retained with the original group reference for audit. Exclusions are stored separately or with an unambiguous relationship marker and are checked before candidate eligibility. + +### `UserTaskSnapshotMembers` and `UserTaskExclusions` + +These optional normalized units make snapshot membership and exclusion checks queryable without deserializing protected task data: + +- Tenant/task identifiers. +- Provider, participant type, participant ID, and optional source group. +- Creation timestamp and safe source metadata. + +An implementation may combine these with `UserTaskCandidates` when it preserves the same uniqueness and authorization semantics. + +### `UserTaskEvents` + +Append-only audit timeline: + +- `Id`, `TenantId`, `TaskId`, and monotonically increasing task `Revision`. +- `EventType`, `OccurredAt`, operation ID, actor provider/type/ID. +- Safe reason and bounded safe metadata JSON. V1 has no generic completion comment. + +Events never contain instructions, task data, forms, completion data, invitation secrets, guest session tokens, or provider-private payloads. Event writes occur in the same transaction as the state transition. + +### `UserTaskOperations` + +Idempotency and asynchronous transition state: + +- `Id`, `TenantId`, `TaskId`, and client `OperationId`. +- Operation kind (`Claim`, `Release`, `Assign`, `ScheduleUpdate`, `Complete`, `Timeout`, `Cancel`, `RetryResolution`, or invitation verification). +- Expected revision, canonical request hash, state, attempts, and created/updated/completed timestamps. +- Safe outcome/status metadata and a protected completion payload when the operation is a completion request. + +Unique key: `(TenantId, TaskId, OperationId)`. A repeated operation with the same request hash returns the existing state. A different request hash is a conflict. + +### `UserTaskInvitations` + +One-time guest invitation state: + +- `Id`, `TenantId`, `TaskId`, and optional sibling/replacement group ID. +- Recipient reference or opaque destination metadata, never an Elsa.Identity foreign key. +- Cryptographically strong token hash and challenge/verifier provider name/configuration. +- `Status` (`Pending`, `Dispatched`, `Verified`, `Consumed`, `Revoked`, or `Expired`). +- `ExpiresAt`, `IssuedAt`, `VerifiedAt`, `ConsumedAt`, and `RevokedAt`. + +Only the token hash is retained in the aggregate. A raw token must not be recoverable from ordinary reads or logs. + +### `UserTaskInvitationDeliveries` + +Encrypted transient delivery outbox: + +- `Id`, `TenantId`, `TaskId`, `InvitationId`, and dispatcher/provider name. +- Encrypted token payload, delivery metadata, attempt count, state, `AvailableAt`, expiry, and last error code. +- Idempotency key and timestamps. + +The encrypted payload is decrypted only for a dispatch attempt and returned once to the dispatcher boundary. Delivery failures are represented by safe error codes. Cleanup is independent of terminal task retention and must respect in-flight retries. + +### `UserTaskGuestSessions` + +Task-scoped guest capability: + +- `Id`, `TenantId`, `TaskId`, `InvitationId`. +- Hashed session token, guest participant reference, granted capability set, issued/expiry timestamps, and revocation timestamp. + +The raw session token is returned only at issuance. The hash is unique and all queries are task- and tenant-scoped. A guest session cannot release, reassign, or access another task. + +## Required indexes and constraints + +Names may vary by provider, but the following logical constraints are mandatory: + +- Primary key for every unit on its opaque `Id`. +- Unique `(TenantId, MaterializationKey)` on `UserTasks`. +- Unique `(TenantId, BookmarkId)` on `UserTasks`. +- Indexes for tenant/status; tenant/assignee provider/type/ID; tenant/priority; tenant/due time; workflow definition; workflow instance; activity instance; created/completed time; and revision/health where used by reconciliation. +- Candidate lookup index on tenant/provider/type/participant ID/task status. +- Unique event ordering `(TaskId, Revision)` or an equivalent monotonic event sequence. +- Unique operation key `(TenantId, TaskId, OperationId)` and an index for stale pending operations. +- Unique invitation token hash and indexes for task/status/expiry. +- Unique guest session token hash and indexes for task/expiry/revocation. + +Safe search may use normalized columns or a bounded safe search projection. It must cover title, summary, reference, tags, task type, workflow/correlation fields, and safe requester fields without inspecting protected JSON. Cursor pagination uses a stable `(sortValue, Id)` key; counts are optional. + +## Repository contracts and transaction behavior + +```csharp +public interface IUserTaskRepository +{ + Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default); + Task QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default); + Task FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default); + Task FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default); + Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default); + Task AddProjectionAsync(UserTaskProjection projection, CancellationToken cancellationToken = default); +} +``` + +The concrete repository may expose child-unit operations through a unit-of-work abstraction. It must support: + +1. Authorized, store-translatable list scopes that do not load protected payloads. +2. Atomic compare-and-swap by task ID and expected revision. +3. Atomic candidate/snapshot/exclusion updates. +4. Atomic append of the lifecycle event and operation record with the state transition. +5. Atomic insertion of a workflow-resumption outbox marker for asynchronous completion, timeout, and cancellation. +6. At-least-once delivery with idempotent operation and bookmark keys. + +If a database transaction is unavailable, the provider must provide equivalent compare-and-swap and durable ordering guarantees and document the recovery behavior. A mutation must never report success while dropping its event, operation, or required outbox marker. + +## Projection and reconciliation storage behavior + +The workflow runtime is the source of truth for bookmark existence and workflow resumption. User Tasks is a durable projection with recovery responsibilities. + +- `WorkflowBookmarksPersisted` projects committed materializations after the workflow transaction commits. +- Projection is idempotent by `(TenantId, MaterializationKey)` and `BookmarkId`. +- A missing projection is recreated from the committed bookmark payload in bounded pages. +- A stale `Completing`, `TimingOut`, or `Cancelling` operation is requeued or finalized from its durable operation marker. +- A task whose bookmark no longer exists is finalized as `Completed` only when a matching pending operation marker proves the intended resumption; otherwise it is finalized as `Cancelled`. +- Ambiguous, cross-tenant, or malformed records are retained for manager diagnostics and reported as health issues. +- Reconciliation is safe to run on multiple nodes. A distributed lock or compare-and-swap lease prevents duplicate work, while duplicate delivery remains harmless. + +## EF Core provider contract + +`Elsa.UserTasks.Persistence.EFCore` provides a dedicated `UserTasksElsaDbContext : ElsaDbContextBase`, entity configurations, repository, and `EFCoreUserTasksPersistenceFeature` depending on `UserTasksFeature`. Configuration must: + +- Use the Elsa schema abstraction (`IElsaDbContextSchema`) and provider-appropriate migration assembly. +- Configure bounded string lengths, UTC `DateTimeOffset` values, string enum storage, JSON/protected payload columns, and the required keys/indexes. +- Mark `Revision` as an optimistic concurrency token. An update with zero rows affected is a revision conflict, not a successful no-op. +- Keep protected payload properties out of safe projections and indexes. +- Use one transaction for state, candidates, events, operations, and outbox markers whenever the provider supports it. + +Provider packages must provide: + +- `Elsa.UserTasks.Persistence.EFCore.Sqlite` with SQLite configuration, design-time context factory, migrations, and shell feature. +- `Elsa.UserTasks.Persistence.EFCore.SqlServer` with SQL Server configuration, design-time context factory, migrations, and shell feature. +- `Elsa.UserTasks.Persistence.EFCore.PostgreSql` with PostgreSQL configuration, design-time context factory, migrations, and shell feature. +- `Elsa.UserTasks.Persistence.EFCore.MySql` with MySQL configuration, design-time context factory, migrations, and shell feature. +- `Elsa.UserTasks.Persistence.EFCore.Oracle` with Oracle configuration, design-time context factory, migrations, and shell feature. + +The provider differences are dialect and column-type details only. All providers must expose the same lifecycle transitions, revision conflicts, idempotency behavior, visibility scope, retention behavior, and projection/reconciliation semantics. SQLite stores UTC timestamps in its provider-compatible ISO-8601 representation; other providers use their native UTC-capable representation. JSON may use provider-native JSON types or text, but round-trip semantics and payload limits are identical. + +Each provider migration must create its complete User Tasks schema, including child units, constraints, indexes, and any required schema marker. Migration tests must apply from an empty database and verify restart durability and indexed query behavior. + +## VNext provider parity + +`Elsa.UserTasks.Persistence.VNext` registers an `IPersistenceSchemaProvider` describing the same logical storage units, fields, indexes, JSON payload limits, and uniqueness constraints under the `Elsa.UserTasks` schema namespace. Its document-store adapter uses compare-and-swap document versions for `Revision` and supports equivalent indexed queries for safe fields. + +The VNext adapter must preserve: + +- tenant/materialization/bookmark uniqueness; +- candidate, exclusion, and snapshot membership semantics; +- append-only events; +- operation idempotency and stale-operation scans; +- encrypted invitation delivery outbox and hashed invitation/session tokens; +- terminal-only retention purge; and +- the same protected-data and authorization boundaries. + +No provider may make Elsa.Identity a prerequisite or silently weaken tenant isolation. + +## Retention and cleanup + +- Terminal task and event retention is indefinite by default. +- An opt-in purge job deletes only terminal tasks and their associated terminal events after the configured age. +- Open tasks (`Unassigned`, `Available`, `Assigned`, and transitional states) are never purged. +- Expired/revoked invitations, guest sessions, and delivered invitation outbox entries may be purged under separate bounded policies. +- Purge is tenant-scoped, paged, observable, and safe to retry. It must not remove a task with a pending operation, unresolved health issue requiring recovery, or open workflow bookmark. +- Purge never writes raw payloads to logs or audit records. + +## Verification obligations + +The provider test suite must cover: + +- empty-database migration and restart/reload round trips; +- indexed tenant/status/assignment/due/workflow/cursor queries without protected payload loading; +- unique materialization and bookmark projection under duplicate delivery; +- concurrent claim, completion, timeout, cancellation, and revision conflicts; +- repeated and divergent operation IDs; +- event ordering and append-only behavior; +- invitation token hash, sibling revocation, delivery retry, guest-session expiry, and no raw-token persistence; +- reconciliation of missing projections, stale operations, and orphaned bookmarks; and +- terminal-only retention cleanup. diff --git a/specs/013-user-tasks/contracts/rest-api.md b/specs/013-user-tasks/contracts/rest-api.md new file mode 100644 index 000000000..91d2cf81a --- /dev/null +++ b/specs/013-user-tasks/contracts/rest-api.md @@ -0,0 +1,77 @@ +# REST API Contract + +Base path: `/user-tasks`. Authenticated endpoints use Elsa endpoint permission metadata plus `IUserTaskAccessPolicy`. JSON uses camel case. IDs and cursors are opaque. Enum-valued fields (`status`, `healthSeverity`) are emitted as strings so a client never depends on ordinal values. + +## Capabilities + +`GET /user-tasks/capabilities` returns the tenant- and actor-scoped feature descriptor: + +```json +{ + "enabled": true, "canList": true, "canRead": true, "canReadAll": false, + "canClaim": true, "canComplete": true, "canRelease": true, "canAssign": false, + "canUpdate": false, "canCancel": false, "canCreateGuestLinks": false, + "canViewProtected": true, "participantPicker": false, + "realtime": true, "pollingIntervalSeconds": 30 +} +``` + +The descriptor is advisory: it decides what a client renders, never what the server allows. It is gated on `read:user-tasks`, so an actor without read access receives `403` and clients treat that as "hide the feature". + +`GET /user-tasks/{id}/capabilities` returns the per-task projection: `taskId`, `revision`, `allowedActions`, `canReadProtected`, and `canManage`. + +## Query + +`GET /user-tasks?scope=assigned|available|history|all|needsAttention&cursor=&limit=&sort=&direction=&status=&priorityFrom=&priorityTo=&due=&from=&to=&workflowDefinitionId=&workflowInstanceId=&reference=&taskType=&search=&includeTotalCount=` + +Returns `{ items, nextCursor, totalCount? }`. Default/max limit: 50/200. Stable sorts: created, due, priority, title, updated, each with an ID tiebreaker. + +- `scope` is part of the authorization predicate, not a display filter. `all` and `needsAttention` require manager scope and answer `403` — not an empty page — for anyone else. +- `status` is repeatable. Unknown values are dropped rather than rejected, so a stale bookmark still loads. +- `due` is a derived filter (`overdue`, `today`, `thisWeek`, `noDueDate`); `from`/`to` carry an explicit range. + +Items contain only the safe summary and the caller's capabilities: id, title, summary, reference, tags, task type, status, priority, assignee display, a count-only `candidateSummary`, due/overdue, created/updated/assigned/completed times, workflow definition name and version, a safe workflow instance reference, health (managers only), `allowedActions`, and `revision`. + +`GET /user-tasks/{id}` returns the flat summary plus protected fields and a `disclosure` block when authorized. `GET /user-tasks/{id}/events?cursor=&limit=` returns safe append-only audit entries (`id`, `kind`, `summary`, `occurredAt`, `actorDisplayName`); it returns an empty page for callers without protected access rather than revealing who acted. + +`POST /user-tasks/{id}/reveal` with `{ fieldKey }` discloses one masked form field. The field must be marked `masked` and `canReveal` by its form provider, the caller must hold protected access, and the reveal is written to the audit trail. Masked values are never included in the ordinary detail response. + +`GET /user-task-participants?search=&type=&cursor=&limit=` requires `lookup:user-task-participants`; absence of a directory returns an empty page, never an identity-module error. + +## Commands + +Every command accepts an `operationId`. Clients mint one per user submission and reuse it across retries, so a retry is recognised as the same command instead of accepted as a second one. + +- `POST /user-tasks/{id}/claim`: `{ expectedRevision, operationId? }` → `200` `{ operationId, status, revision, task }`. +- `POST /user-tasks/{id}/release`: `{ expectedRevision, operationId?, reason? }` → `200`. +- `POST /user-tasks/{id}/assign`: `{ expectedRevision, assignee, operationId?, reason? }` → `200`; managers may assign a non-candidate and it is audited. The assignee's tenant is taken from the caller's own scope and never from the body, so a cross-tenant reference cannot be constructed over the wire. +- `PATCH /user-tasks/{id}`: `{ expectedRevision, priority?, dueAt?, operationId? }` → `200`; no other materialized fields are mutable. +- `POST /user-tasks/{id}/complete`: `{ expectedRevision, operationId, actionKey, data? }` → `202`. +- `POST /user-tasks/{id}/cancel`: `{ expectedRevision, operationId, reason }` → `202` only when enabled. +- `POST /user-tasks/{id}/retry-resolution`: `{ expectedRevision, operationId? }` → `202`; retries the original form or snapshot input. +- `POST /user-tasks/{id}/invitations`: invitation definition → `201` metadata; the secret is delivered through the dispatcher, never returned to API clients. +- `GET /user-tasks/{id}/invitations` → `{ items }` and `DELETE /user-tasks/{id}/invitations/{invitationId}` → `204` require the invite relationship. + +## Guest API + +- `GET /user-task-invitations/{token}` → a generic challenge descriptor `{ challengeType, prompt, requiresCode }`. +- `POST /user-task-invitations/{token}/verify` → a generic failure or `{ sessionCredential, taskId, expiresAt }`. +- `GET /user-task-sessions/current` → the guest-visible task projection. +- `POST /user-task-sessions/current/complete` → `202`, with the same operation and idempotency semantics as the authenticated command. + +The session credential is presented as `Authorization: UserTaskSession `. + +**Documented adjustment.** An earlier draft had guests read and complete through the ordinary `/user-tasks/{id}` resource. The shipped contract addresses the guest surface through the session instead, for two reasons. First, Elsa's endpoint security applies permission metadata at the transport layer through `ConfigurePermissions`; mixing an anonymous session scheme into those routes would have required weakening the gate on the two most sensitive endpoints. Second, the session already identifies exactly one task, so keeping the task ID out of the route removes a task-existence oracle instead of relying on `404` concealment. The guest's authorization semantics are unchanged: `IUserTaskAccessPolicy` still scopes the actor to its own task and intersects completion with the invitation's action allowlist. + +## Errors and idempotency + +Failures return `{ code, message }` with a stable code and display-safe copy. Messages never carry exception text, payload fragments, or infrastructure detail. + +- `401`: no valid authenticated or guest principal. +- `403`: the module permission itself is missing, including a list scope the actor may not use. This is decided at the transport layer before any task is loaded, so it discloses nothing about a specific task. +- `404`: task absent, or present but denied by the relationship check. Both answer with the same status and the same copy, so an ID-guessing caller cannot use a command to prove that a task exists. +- `409`: revision race (`revision-conflict`), invalid lifecycle transition (`terminal`, `transition-in-progress`, `not-claimable`), or an operation ID reused with a different canonical payload (`idempotency-conflict`). +- `422`: invalid action, form validation, payload size, cancellation configuration, or other semantic input. +- `429`: anonymous invitation traffic throttled, partitioned by caller rather than by token. + +Repeated terminal commands with the same task, operation ID, and canonical payload return the existing operation response. Accepted terminal operations always return `202`; clients observe the final state by requery or invalidation. diff --git a/specs/013-user-tasks/contracts/runtime-contract.md b/specs/013-user-tasks/contracts/runtime-contract.md new file mode 100644 index 000000000..29e8504ab --- /dev/null +++ b/specs/013-user-tasks/contracts/runtime-contract.md @@ -0,0 +1,327 @@ +# User Tasks Runtime Contract + +**Feature**: `013-user-tasks` +**Status**: Approved for implementation +**Audience**: Elsa Core module and host integrations + +This contract defines the provider-neutral runtime seams. Names are normative unless explicitly marked as an implementation detail. All asynchronous methods accept cancellation and all identity values are external opaque references. + +## Runtime principles + +- User Tasks are workflow-bound. The activity suspends on a dedicated User Task bookmark and resumes only through `UserTaskStimulus`. +- The existing generic `RunTask` activity and `/tasks/{id}/complete` endpoint are not changed. +- Core owns task state, optimistic concurrency, operation idempotency, protected-data disclosure, projection, reconciliation, and audit events. +- Hosts own authentication, participant identity, group membership, directory display data, policy, form implementations, invitation delivery, invitation verification, and guest session infrastructure. +- Elsa.Identity is optional. No contract accepts an Elsa.Identity user or group entity. + +## Identity-neutral contracts + +### ParticipantReference + +```csharp +public enum UserTaskParticipantType +{ + User, + Group +} + +public sealed record ParticipantReference( + string TenantId, + string Provider, + UserTaskParticipantType Type, + string Id, + string? DisplayName = null); +``` + +`TenantId`, `Provider`, `Type`, and `Id` are required and form the canonical identity. `DisplayName` is a non-authoritative presentation snapshot. Implementations must normalize and compare the canonical fields consistently, but must not rewrite the external ID. + +### Principal mapping + +```csharp +public sealed record UserTaskActor( + ParticipantReference Subject, + IReadOnlyCollection Groups, + string? DisplayName = null); + +public interface IUserTaskIdentityResolver +{ + ValueTask ResolveAsync( + ClaimsPrincipal principal, + CancellationToken cancellationToken = default); +} +``` + +The default implementation is claims-based. Subject, provider/issuer, display-name, and group claim names are configurable. The default subject lookup prefers `NameIdentifier`, then `sub`; the provider falls back to the configured namespace when no issuer is present. Hosts may replace the resolver entirely. + +### Task authorization + +Authorization has two independent gates: the module permission and a task relationship/policy scope. The policy must be usable before protected task data is loaded so list queries cannot leak invisible tasks. + +```csharp +public enum UserTaskAccessOperation +{ + ReadSummary, + ReadProtected, + Claim, + Release, + Assign, + UpdateScheduling, + Complete, + Cancel, + Manage, + IssueInvitation, + RetryResolution +} + +public interface IUserTaskAccessPolicy +{ + Task CreateScopeAsync( + UserTaskActor actor, + UserTaskAccessOperation operation, + CancellationToken cancellationToken = default); + + Task AuthorizeAsync( + UserTask task, + UserTaskActor actor, + UserTaskAccessOperation operation, + CancellationToken cancellationToken = default); +} +``` + +`CreateScopeAsync` must produce a store-translatable tenant/relationship scope. `AuthorizeAsync` is the final check after a task has been loaded. Managers remain tenant-scoped. An inaccessible task is reported as not found at the API boundary. + +### Participant directory + +Directory lookup is optional and never authoritative for authorization. + +```csharp +public interface IUserTaskParticipantDirectory +{ + Task SearchAsync( + UserTaskParticipantQuery query, + CancellationToken cancellationToken = default); + + Task> ResolveDisplayNamesAsync( + IReadOnlyCollection participants, + CancellationToken cancellationToken = default); + + Task> EnumerateGroupMembersAsync( + ParticipantReference group, + CancellationToken cancellationToken = default); +} +``` + +If a directory is not registered, capability discovery reports it as unavailable and Studio falls back to raw namespaced references or expressions. Live group authorization still uses the actor's exact namespaced claims. Snapshot enumeration failure creates a durable manager-only health issue. + +## Form provider contract + +```csharp +public sealed record UserTaskFormReference( + string ProviderName, + string Key, + string? Binding = null, + string? Version = null); + +public sealed record ResolvedUserTaskForm( + UserTaskFormReference Requested, + string PinnedVersion, + IReadOnlyDictionary Metadata); + +public interface IUserTaskFormProvider +{ + string Name { get; } + + Task ResolveAsync( + UserTaskFormReference reference, + CancellationToken cancellationToken = default); + + Task ValidateAndNormalizeAsync( + ResolvedUserTaskForm form, + string actionKey, + JsonElement data, + CancellationToken cancellationToken = default); +} +``` + +The provider resolves and pins the form at task activation. Completion always validates against that pinned version. A missing provider or failed resolution blocks worker completion and is repairable only by retrying the original reference. Server-supplied HTML is not a Core contract and must not be rendered by Studio without a trusted installed renderer. + +Tasks without a form must reject arbitrary completion JSON. A form provider returns normalized data; Core stores it as protected completion data and exposes it only according to task visibility rules. + +## Task manager contract + +The public manager is the only lifecycle orchestration entry point. Repositories and projection stores are not exposed to workflow activities or API endpoints directly. + +```csharp +public interface IUserTaskManager +{ + Task ProjectAsync( + UserTaskMaterialization materialization, + CancellationToken cancellationToken = default); + + Task QueryAsync( + UserTaskQuery query, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task GetAsync( + string tenantId, + string taskId, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task ClaimAsync( + string tenantId, + string taskId, + UserTaskMutationRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task ReleaseAsync( + string tenantId, + string taskId, + UserTaskMutationRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task AssignAsync( + string tenantId, + string taskId, + UserTaskAssignRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task UpdateSchedulingAsync( + string tenantId, + string taskId, + UserTaskSchedulingUpdate request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task CompleteAsync( + string tenantId, + string taskId, + UserTaskCompletionRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task CancelAsync( + string tenantId, + string taskId, + UserTaskCancelRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); + + Task RetryResolutionAsync( + string tenantId, + string taskId, + UserTaskMutationRequest request, + UserTaskActor actor, + CancellationToken cancellationToken = default); +} +``` + +The concrete DTO names may follow repository conventions, but the following fields and semantics are required: + +- `UserTaskMutationRequest`: `ExpectedRevision`, and `OperationId` for retried/asynchronous operations. +- `UserTaskAssignRequest`: target canonical participant, optional reason, `ExpectedRevision`, and `OperationId`. +- `UserTaskSchedulingUpdate`: optional `Priority` and `DueAt`; no other task definition fields may be changed. +- `UserTaskCompletionRequest`: `ExpectedRevision`, `OperationId`, configured `ActionKey`, and optional JSON `Data` only when a form is installed. V1 has no generic completion comment; explanation fields belong to the pinned form. +- `UserTaskCancelRequest`: `ExpectedRevision`, `OperationId`, and mandatory safe `Reason`. + +Claim, release, assignment, and scheduling updates are synchronous state transitions. Completion and manager cancellation persist a transitional operation, enqueue workflow resumption, and return an accepted result while the task is `Completing` or `Cancelling`. Timeout uses the same operation path with `TimingOut` and reserved action `Timeout`. + +## Workflow bookmark and projection contracts + +The activity materializes its evaluated definition into a dedicated bookmark. The bookmark payload is safe to retry and contains no host-only identity object. + +```csharp +public sealed record UserTaskMaterialization( + string TenantId, + string WorkflowDefinitionId, + string WorkflowInstanceId, + string ActivityInstanceId, + string BookmarkId, + UserTaskDefinitionSnapshot Definition, + IReadOnlyCollection SnapshotMembers, + IReadOnlyCollection SnapshotGroups, + DateTimeOffset CreatedAt); + +public sealed record UserTaskStimulus( + string TenantId, + string TaskId, + string OperationId, + string ActionKey, + JsonElement? CompletionData); +``` + +`UserTaskStimulus` is the only completion stimulus for this module. The activity binds the resulting `UserTaskResult` and selected action to its outputs and emits the configured workflow outcome. + +```csharp +public interface IUserTaskProjectionService +{ + Task ProjectCommittedBookmarksAsync( + IReadOnlyCollection materializations, + CancellationToken cancellationToken = default); + + Task FinalizeBookmarkRemovalAsync( + UserTaskBookmarkRemoval removal, + CancellationToken cancellationToken = default); +} + +public interface IUserTaskReconciler +{ + Task ReconcileAsync( + UserTaskReconciliationRequest request, + CancellationToken cancellationToken = default); +} +``` + +The `WorkflowBookmarksPersisted` path calls the projection service only after the workflow commit. Projection is idempotent by the materialization key and bookmark ID. Bookmark removal finalizes a pending matching completion as `Completed`; removal without a pending completion finalizes the task as `Cancelled` and never independently resumes the workflow. + +The bounded reconciler must: + +1. Recreate a missing task projection for every committed User Task bookmark. +2. Requeue stale `Completing`, `TimingOut`, or `Cancelling` operations whose workflow stimulus was not delivered. +3. Finalize task records whose bookmark was removed, subject to the matching operation marker. +4. Report, but not silently delete, ambiguous or cross-tenant records. + +Task creation, operation state, lifecycle event, and transactional outbox marker must commit together where the provider supports a transaction. Delivery is at least once; consumers are idempotent by task and operation ID. + +## Invitation, delivery, and guest-session contracts + +```csharp +public interface IUserTaskInvitationDispatcher +{ + Task DispatchAsync( + UserTaskInvitationDelivery delivery, + CancellationToken cancellationToken = default); +} + +public interface IUserTaskInvitationVerifier +{ + Task VerifyAsync( + UserTaskInvitationChallenge challenge, + CancellationToken cancellationToken = default); +} + +public interface IUserTaskGuestSessionIssuer +{ + Task IssueAsync( + UserTaskInvitation invitation, + CancellationToken cancellationToken = default); +} +``` + +Invitation issuance stores only a hash of the redemption token in the task aggregate. The raw token is returned once to the delivery outbox/dispatcher boundary and is never included in logs, events, API responses, or ordinary persistence reads. The transient delivery outbox stores the encrypted token for retry and decrypts it only for a dispatch attempt. Delivery implementations must use the delivery ID as an idempotency key. + +Verification must use generic failure responses and rate limits. The first valid verification atomically claims the task for the guest, revokes all sibling invitations, and issues a task-scoped guest session with an explicit expiry. A guest session cannot release or reassign the task and cannot be used for another task. + +Invitation expiry defaults to the earlier of seven days or the task due time, when due time exists. Expired, consumed, revoked, and invalid invitations must be indistinguishable at the anonymous API boundary. + +## Notifications and audit + +Core publishes mediator notifications only after the lifecycle transaction commits. Notifications and realtime invalidations contain metadata such as tenant, task ID, status, revision, and changed safe fields; they never contain protected task/form/completion data, invitation tokens, or provider-private metadata. + +Minimum lifecycle notifications are `UserTaskCreated`, `UserTaskChanged`, `UserTaskCompletionAccepted`, `UserTaskCompleted`, `UserTaskTimedOut`, `UserTaskCancelled`, `UserTaskOverdue`, `UserTaskInvitationChanged`, and `UserTaskHealthChanged`. + +`UserTaskEvent` is the durable audit record. Hosts may subscribe to mediator notifications for email, webhooks, or escalations; notification delivery is not a required v1 Core feature. diff --git a/specs/013-user-tasks/contracts/studio-contract.md b/specs/013-user-tasks/contracts/studio-contract.md new file mode 100644 index 000000000..4ea58bf00 --- /dev/null +++ b/specs/013-user-tasks/contracts/studio-contract.md @@ -0,0 +1,254 @@ +# Studio Contract: User Tasks + +The Core/server User Tasks feature is paired with an optional `elsa-studio` module. Studio owns the authenticated task workspace, workflow-designer activity editing, and the guest completion page. The server remains authoritative for task visibility, participant identity, disclosure, capabilities, validation, claims, and completion. + +Studio must not reference `Elsa.Identity` user entities. Participant identifiers are opaque, tenant-scoped references supplied by the host application. A host may provide a participant picker, but the task module remains fully usable when no picker is installed. + +## Information Architecture + +The authenticated module contributes a **User Tasks** entry under **Workflows**: + +```text +Workflows +├── Definitions +├── Instances +└── User Tasks +``` + +Routes: + +- `/workflows/user-tasks` — task workspace and queue. +- `/workflows/user-tasks/{taskId}` — authenticated task detail and response page. +- `/tasks/guest?t={opaqueTaskToken}` — guest task page; it is not linked from the authenticated menu. + +The task list must not offer a generic **Create task** action. Runtime tasks are created by executing a published workflow containing the User Task activity. Designers create task definitions from the workflow designer. + +The menu is displayed only when the remote User Tasks feature is enabled and the current actor has list/read capability. The menu never grants authorization. + +## Task Workspace + +The default route opens the **Assigned to me** tab. Tabs are represented in the URL and preserve browser back/forward behavior: + +| Tab | Query value | Purpose | Required capability | +| --- | --- | --- | --- | +| Assigned to me | `tab=assigned` | Tasks assigned to the current actor, including claimed tasks | `read:user-tasks` | +| Available | `tab=available` | Unassigned tasks for which the actor is an eligible candidate | `read:user-tasks` | +| History | `tab=history` | Terminal tasks completed by the actor and safe history for tasks they acted on | `read:user-tasks` | +| All | `tab=all` | Tenant-visible tasks for operations and support | `manage:user-tasks` | +| Needs Attention | `tab=needs-attention` | Unassigned, blocked-health, overdue, and stale-operation tasks | `manage:user-tasks` | + +Tabs that are not allowed by the capability response are hidden. A deep link to a hidden tab displays the permitted default tab rather than an authorization error from the UI. The API remains authoritative. + +### Queue/list behavior + +The list is server-paged and server-filtered. It follows the existing Studio `MudTable.ServerData` pattern used by Secrets and Workflow Definitions. + +The table displays safe summary fields only: + +- task title and task type/key; +- status and priority; +- assignee or candidate summary, subject to disclosure policy; +- due date and overdue indicator; +- workflow definition name/version and a safe workflow instance reference; +- created/updated timestamps; +- an action menu derived from the row's `allowedActions`. + +The row action menu may contain **Open**, **Claim**, **Release**, **Assign**, **Complete**, **Cancel**, and **Copy link**, but only when both the server capability response and the row's `allowedActions` allow the action. A row click opens the detail route. The list never performs a client-only mutation or assumes that a visible button is authorized. + +The toolbar provides: + +- Search over the server-approved task title, task key/type, and workflow summary fields. +- Status filter using the canonical lifecycle states; overdue is a derived filter. +- Priority filter. +- Due-date filter (`overdue`, `today`, `thisWeek`, `noDueDate`, or explicit range). +- Workflow definition/instance filters when the actor has the corresponding workflow visibility. +- A reset-filters action. + +Search and filters are debounced, cancellable, and applied before paging. Empty states distinguish **No tasks**, **No tasks match these filters**, and **Tasks are unavailable**. Errors preserve the current filters and offer retry. + +### URL state + +The list serializes only non-sensitive view state: + +```text +/workflows/user-tasks?tab=assigned&status=Assigned&priorityFrom=75&due=overdue&search=approval&cursor=opaque&pageSize=25&sort=due&direction=asc +``` + +Supported query keys are `tab`, repeated `status`, `priorityFrom`, `priorityTo`, `due`, `from`, `to`, `search`, `workflowDefinitionId`, `workflowInstanceId`, `cursor`, `pageSize`, `sort`, and `direction`. Unknown or invalid values are ignored and replaced by safe defaults. Tenant, actor identity, form data, access tokens, task payloads, and protected fields must never appear in the URL. + +Changing a filter resets the page to the first page. Reloading or sharing a URL restores the same safe list state, subject to the current actor's capabilities. The server's canonical sort and page-size limits win over client values. + +## Task Detail + +The detail page is a responsive, single-task workspace. It loads a safe task projection and a server-provided capability/action projection; it does not load the raw workflow instance state. + +The desktop layout contains a task header, primary response surface, and contextual summary. On narrow screens it becomes a single column with a sticky bottom action bar. All actions remain keyboard accessible and have accessible names that include the task title where useful. + +Detail tabs are: + +1. **Task** — title, description, due/priority/status, participant summary, and the form or outcome controls. +2. **Workflow** — safe definition and instance context, shown only when the response authorizes it. +3. **History** — claims, assignments, releases, and completion audit entries, shown only when the response authorizes it. + +The Task tab is always the landing tab. Tabs that are not disclosed are omitted rather than rendered disabled. A task that has become completed, cancelled, expired, or stale is rendered read-only with its final status and outcome where disclosure permits. + +### Claim and completion + +- **Claim** is an atomic server operation. On success Studio refreshes the task projection and opens the response surface. +- **Release** returns a claimed task to its eligible queue when allowed. +- **Complete** validates the response both client-side and server-side. The server may require a current claim, an expected task revision, or a participant authorization check. +- Stale task responses produce a conflict state with **Reload task** and a clear explanation; Studio must not overwrite another actor's claim or completion. +- Completion success shows the outcome and workflow continuation state when safe, then returns to the originating list while preserving its URL filters. +- Repeated completion of the same task is treated as an idempotent success only when the submitted response matches the already recorded completion; otherwise Studio displays the server conflict. + +## Protected Disclosure + +User Tasks can contain business data and workflow-derived values. The Studio task module must treat all task projections and form responses as protected data. + +- Render only fields explicitly returned by the server's disclosure projection. +- Never expose bookmark IDs/hashes, workflow input dictionaries, variables, activity execution state, bearer/SAS tokens, internal storage payloads, or provider credentials. +- Do not put form data, participant claims, or protected description text in URLs, browser titles, telemetry, exception messages, clipboard content, or client-side logs. +- Sensitive form fields are masked by default and are not copied into diagnostics or analytics. A server-provided `canReveal`/`disclosure` decision is required before any reveal control is rendered. +- Workflow and history tabs use safe summaries and server-rendered audit values only; Studio must not reconstruct them from generic workflow APIs. +- If the server marks a task or field as `guestVisible: false`, it is absent from the guest page even if it is visible to an authenticated operator. + +## Guest Task Page + +The guest page is a narrow completion surface for a signed, expiring, single-purpose task token. It is not a task inbox and must not require or create an Elsa Identity account. + +The token is supplied as an opaque query value (`t`). Studio passes it to the guest-task API without decoding, persisting, logging, or displaying it. The server resolves tenant, task, actor constraints, expiry, disclosure, and allowed operations. + +Guest behavior: + +- Show only the task title, explicitly guest-visible description/form fields, due/status information, and server-approved outcomes. +- Do not show workflow definition/instance identifiers, participant IDs, claims, internal links, task history, or protected fields. +- Do not provide list navigation, menu navigation, claim/reassign/cancel actions, or arbitrary workflow links. +- Require explicit submit confirmation when completion is irreversible. +- On success, show a confirmation/status page and invalidate or consume the token according to the server response. +- On expired, revoked, already completed, or invalid token, show a generic safe error with no task existence oracle. +- Do not silently retry a state-changing request. Retry is allowed only for an idempotent read. + +## Capabilities and Action Projection + +Studio obtains a User Tasks capability descriptor before displaying navigation or choosing controls. The descriptor is server-generated for the current actor and tenant and may include: + +```json +{ + "enabled": true, + "canList": true, + "canRead": true, + "canReadAll": false, + "canClaim": true, + "canComplete": true, + "canRelease": true, + "canAssign": false, + "canCancel": false, + "canCreateGuestLinks": false, + "canViewProtected": false, + "participantPicker": false, + "realtime": true, + "pollingIntervalSeconds": 30 +} +``` + +The exact capability transport may be part of the User Tasks REST capability endpoint, but the Studio client must not infer permissions from the presence of an endpoint or an Elsa Identity role. Each task row/detail response also supplies `allowedActions`, `disclosure`, and `revision` so that per-task authorization and concurrency are explicit. + +When capabilities are unavailable, Studio fails closed for mutations and hides the User Tasks menu unless the remote feature's safe default explicitly permits read-only discovery. A `403` from any API remains an authorization result, not a client error to suppress. + +## Realtime Invalidation and Polling + +The module may subscribe to a User Tasks SignalR hub when the capability descriptor advertises `realtime`. The hub sends tenant- and actor-authorized invalidation envelopes, not task data: + +```json +{ + "kind": "task.updated", + "taskId": "task-123", + "revision": 7, + "occurredAt": "2026-01-01T12:00:00Z" +} +``` + +Supported invalidation kinds are `task.created`, `task.updated`, `task.claimed`, `task.released`, `task.assigned`, `task.completed`, `task.cancelled`, and `task.expired`. The server enforces tenant and actor visibility before publishing an envelope. The envelope must not contain title, description, participant IDs, form values, workflow input, or protected audit data. + +Studio behavior: + +- Debounce bursts of invalidations into one list reload. +- Reload the visible list when an invalidation can affect its filters; preserve the selected page where possible and reset only when the server indicates that the current page is no longer valid. +- Reload the open detail when its `taskId` is invalidated. Do not apply partial event data to local state. +- If SignalR is unavailable or disconnected, fall back to visibility-aware polling at the server-advertised interval (30 seconds by default), stopping while the document is hidden and when the component is disposed. +- Reconnect with bounded backoff, then perform a full list/detail reload after reconnect to close gaps. +- Guest pages do not subscribe to a task inbox or realtime stream. + +## Workflow Designer Activity + +The User Task activity is contributed to the activity catalog under a human-task category and uses the normal activity descriptor/input/output editor. The activity is blocking: execution creates a task and suspends until an authorized completion resumes it. + +Required designer inputs: + +| Field | Behavior | +| --- | --- | +| Title | Required, expression-capable string; safe display text for the task list/detail. | +| Summary and instructions | Optional expression-capable safe summary and separately protected instructions. | +| Reference, tags, and task type | Optional safe values used for filtering, reporting, and host form/participant policies. | +| Requester | Optional informational participant reference that grants no access. | +| Assignee | Optional expression-capable opaque participant reference. It is not an Elsa Identity user ID. | +| Candidate users and groups | Optional collections of opaque participant references using the canonical user/group kinds. | +| Membership and exclusions | Live or snapshot membership, excluded users, and optional reason-required manager override. | +| Due date | Optional expression-capable UTC timestamp; Studio displays the configured timezone explicitly. | +| Priority | Numeric value from 0 through 100 with default 50. | +| Form/actions | Optional provider-neutral form reference and literal immutable action keys with expression-capable labels. | +| Timeout/cancellation | Explicit enablement for the reserved `Timeout` and `Cancelled` outcomes. | +| Invitations | Optional guest definitions including challenge provider, bounded expiry, allowed actions, and explicit bearer-only opt-in. | + +Outputs: + +- `TaskId` — the durable task identifier. +- `Outcome` — the selected completion outcome. +- `Response` or `FormData` — the validated completion payload according to the activity contract. +- `CompletedBy` and `CompletedAt` — safe completion metadata when configured by the activity. + +All inputs remain expression-capable unless the activity descriptor marks a field as non-expression metadata. The generic Studio editor must preserve expressions and bindings when a participant picker or form editor is not installed. The editor must not fetch or persist resolved participant names as workflow definition values. + +The activity editor should show a clear note that a task is created only when the workflow runs, and that changing the definition does not mutate already-created tasks. + +## Optional Participant Picker + +The User Tasks Studio module may consume a host-provided participant picker. The picker is an optional contribution, not a dependency on Elsa Identity. + +Suggested contribution shape: + +```csharp +public interface IUserTaskParticipantPicker +{ + ValueTask PickAsync( + ParticipantPickerContext context, + CancellationToken cancellationToken = default); +} +``` + +The context includes the current opaque values, allowed participant kinds, task key, tenant scope, and whether multiple selection is allowed. Results contain opaque `kind`/`id` references and optional display labels. Labels are presentation-only; the workflow definition persists the references. If no picker is registered, Studio renders a safe text/reference editor and preserves unknown participant kinds. + +The picker must obey the server's participant lookup authorization, avoid returning hidden users/groups, and never be used to authorize claim or completion actions. + +## Client Contracts and Verification + +The paired Studio module should provide a Refit client whose DTOs mirror the User Tasks REST contract, plus: + +- menu/feature registration following the existing Secrets module pattern; +- list/detail/guest routes above; +- server-paged list loading with cancellation and URL-state restoration; +- task-form validation and conflict handling; +- capability/action/disclosure rendering; +- optional participant-picker registration; +- SignalR invalidation subscription with polling fallback. + +Studio verification must cover: + +- menu gating and hidden tabs for missing capabilities; +- every URL filter, invalid-value normalization, page reset, and browser navigation; +- safe row/detail rendering and absence of protected data in URLs/logs/telemetry; +- claim, release, complete, conflict, already-completed, and server-error states; +- responsive detail and guest-page behavior; +- fallback text participant editing with no picker; +- SignalR debounce/reconnect, polling start/stop, and post-reconnect reload; +- preservation of expressions/bindings and disclosure defaults in the designer. diff --git a/specs/013-user-tasks/data-model.md b/specs/013-user-tasks/data-model.md new file mode 100644 index 000000000..d13c83706 --- /dev/null +++ b/specs/013-user-tasks/data-model.md @@ -0,0 +1,180 @@ +# User Tasks Data Model + +**Feature**: `013-user-tasks` +**Status**: Approved for implementation +**Source of truth**: [`spec.md`](./spec.md) + +This document fixes the vocabulary, aggregate boundaries, lifecycle invariants, and persistence-safe representation for User Tasks. A User Task is always created by a workflow activity; v1 does not support standalone tasks. + +## Domain vocabulary + +| Term | Meaning | +| --- | --- | +| User Task Definition | Configuration stored in a workflow definition. It is evaluated when the activity executes. | +| User Task Instance | Durable runtime work item created from a committed User Task bookmark. | +| Participant Reference | An identity-neutral `{ tenant, provider, type, id }` value. `type` is `user` or `group` for authenticated participants. | +| Candidate | A participant eligible to claim an available task. | +| Assignee | The single participant currently accountable for the task. The assignee is normally a user; an invitation may establish a task-scoped guest assignee. | +| Requester | Optional informational participant. Requester status never grants task access. | +| Action | A stable workflow outcome key and its materialized display label. | +| Form Reference | A provider-neutral reference to a form definition. The concrete version is pinned when the task activates. | +| Task Health | Operational resolution or delivery state independent of lifecycle status. | +| Operation | An idempotent command record used for asynchronous completion, timeout, cancellation, or another retried mutation. | +| Invitation | A single-task guest candidacy secured by a one-time token and a pluggable verification challenge. | +| Guest Session | A bounded, revocable, task-scoped capability issued after invitation verification. | +| Invitation Delivery | An encrypted, transient outbox entry used to deliver a one-time invitation secret. | + +## User Task instance + +The instance is the durable projection of one committed bookmark. It has one workflow owner and one terminal result. + +### Identity and workflow linkage + +- `Id` is a stable opaque task identifier. +- `TenantId` is required for every task and every child record. A host may use a single default tenant, but the storage contract remains tenant-scoped. +- `WorkflowDefinitionId`, `WorkflowInstanceId`, and `ActivityInstanceId` identify the workflow execution that owns the task. +- `BookmarkId` identifies the dedicated User Task bookmark. +- `MaterializationKey` is the normalized tuple `(TenantId, WorkflowInstanceId, ActivityInstanceId)`. It is unique and prevents retries or duplicate bookmark notifications from creating two task instances. +- The existing generic `RunTask` activity and `/tasks/{id}/complete` contract are unrelated and remain unchanged. + +### Safe task fields + +These fields may be returned to a candidate before claim and may be used for authorized search: + +- `Title` (required), `Summary`, `Reference`, `Tags`, and `TaskType`. +- Materialized requester reference and workflow labels/identifiers. +- `Priority`, an integer from 0 through 100; the default is 50. +- `DueAt` in UTC and computed `IsOverdue`. +- `Status`, `CreatedAt`, `AssignedAt`, `CompletedAt`, and `UpdatedAt`. +- Materialized assignee display information when the caller is authorized to see it. + +Protected fields are never searched or returned to an unassigned candidate: + +- `Instructions`. +- `TaskData`. +- The pinned form reference and provider-private form metadata. +- Completion data and other provider-private payloads. + +The default maximum serialized size for each protected task/form/completion payload is 256 KiB. External files are represented by a provider URI or ID; they are not embedded in task JSON. + +### Assignment fields + +- A direct `Assignee` may be materialized at activation. +- `CandidateUsers` and `CandidateGroups` are stored as namespaced participant references. +- `MembershipResolutionMode` is `Live` or `Snapshot`; `Live` is the default. +- `ExcludedUsers` are canonical participant references and are hard exclusions by default. +- `AllowManagerExclusionOverride` is materialized from the activity. If true, a manager may bypass an exclusion only with a mandatory audit reason. +- Invitations are separate child records and do not turn an external recipient into a global Elsa user. + +Display names are optional snapshots used only for presentation. Authorization and uniqueness always use the provider namespace, participant type, and external ID. + +## Participant references and membership + +```text +ParticipantReference +├── TenantId required, storage scope +├── Provider required namespace/issuer +├── Type user | group +├── Id required external identifier +└── DisplayName optional non-authoritative snapshot +``` + +The default claims adapter maps the authenticated principal to a user reference and group references. Hosts may replace that adapter and may use any external directory or authorization policy. Elsa.Identity is not required and no User Tasks table has a foreign key to an Elsa.Identity table. + +### Live membership + +Live tasks persist group references and evaluate the caller's current namespaced claims at list/action time. Directory lookup may enrich names or report health, but an unavailable directory does not invalidate an exact authoritative claim match. + +### Snapshot membership + +At activation, the participant directory enumerates each configured group and persists the resulting user references together with the original group reference. Later organization changes do not alter eligibility. If enumeration is unavailable or fails, the task is still durable but becomes manager-only with a blocking health issue. + +### Exclusions + +Exclusions are checked against canonical references for claiming, assignment, and guest verification. An excluded participant cannot claim or receive the task unless the activity allows manager override and the manager supplies a reason that is written to the audit event. + +## Actions and forms + +### Actions + +Each action has: + +- `Key`: a literal, immutable workflow outcome key. +- `Label`: a materialized display value; the source may be expression-capable. +- Optional safe presentation metadata. + +Action keys are unique within a task. `Timeout` and `Cancelled` are reserved keys and cannot be configured by a designer. A task without an enabled form accepts only the selected configured action and no arbitrary completion JSON. + +### Forms + +A form reference contains `ProviderName`, `Key`, and the requested binding/version information. Activation resolves and pins a concrete provider version. Open tasks never follow a later “latest” form version. + +The installed provider validates and normalizes submission data for the pinned version and selected action. If resolution or pinning fails, the task remains durable but is manager-only with a blocking health issue. Repair retries the original reference; managers cannot replace the live form reference ad hoc. + +## Lifecycle + +### States + +| State | Meaning | Worker visibility | +| --- | --- | --- | +| `Unassigned` | No direct assignee, candidate, or invitation is available. | Manager-only; never an open “everyone” queue. | +| `Available` | At least one candidate or invitation may claim the task. | Eligible candidates see safe summary. | +| `Assigned` | One participant is accountable. | Assignee sees protected content; managers see it within tenant scope. | +| `Completing` | A valid completion operation won the race and bookmark resumption is pending. | No second terminal action is accepted. | +| `TimingOut` | A timeout operation won the race and reserved `Timeout` resumption is pending. | No worker action is accepted. | +| `Cancelling` | A manager cancellation operation won the race and reserved `Cancelled` resumption is pending. | No worker action is accepted. | +| `Completed` | Bookmark resumption committed with a configured action. | Terminal. | +| `TimedOut` | Bookmark resumption committed with reserved `Timeout`. | Terminal. | +| `Cancelled` | The task was cancelled by its workflow/activity or by an enabled manager operation. | Terminal. | + +### Transitions + +| Trigger | Transition and rule | +| --- | --- | +| Activity activation with direct assignee | Create `Assigned`. | +| Activity activation with candidates/invitations | Create `Available`. | +| Activation without any assignment | Create `Unassigned` and a warning/health issue. | +| Eligible claim | Atomically `Available → Assigned`; one winner only. | +| Release | `Assigned → Available`, or `Unassigned` when no eligible candidates/invitations remain. Protected access ends immediately. | +| Manager assign/reassign | Any open task becomes `Assigned`; manager completion still requires assignment to the accountable participant. | +| Authenticated completion | `Assigned → Completing`; enqueue the dedicated User Task bookmark resumption. | +| Due processing with timeout enabled | Open state `→ TimingOut`; resume through reserved `Timeout`; finalize `TimedOut`. | +| Due processing without timeout | Keep the task open, set `IsOverdue`, and publish one idempotent overdue notification. | +| Enabled manager cancellation | Open state `→ Cancelling`; resume through reserved `Cancelled`; finalize `Cancelled`. A reason is required. | +| Workflow/activity bookmark removal | Finalize `Completed` when a matching pending completion exists; otherwise finalize `Cancelled`. It never independently resumes the workflow. | + +Completion, timeout, and cancellation use one optimistic transition path. The first committed transition wins; later requests receive a conflict. Guest verification is a special claim operation: the first valid invitation atomically claims the task and revokes sibling invitations. Guests cannot release tasks. + +### Mutable after activation + +Only priority and due date may be changed through the governed runtime update path. Assignment changes use dedicated claim/release/assign operations. Title, summary, instructions, protected task data, actions, exclusions, membership mode, and the pinned form are immutable for the live task. + +## Operations and idempotency + +An operation is scoped by `(TenantId, TaskId, OperationId)` and stores the canonical request hash, expected revision, operation kind, status, and timestamps. + +- Every mutation requiring a race-safe result supplies `ExpectedRevision`. +- A matching repeated operation ID and request hash returns the existing result/state without performing the command twice. +- Reusing an operation ID with different content is a conflict. +- A completion operation stores normalized completion data only in protected task/operation storage, never in an event or notification payload. +- A stale `Completing`, `TimingOut`, or `Cancelling` operation is retried or completed by reconciliation according to its durable state; it is never silently discarded. + +## Events and health + +`UserTaskEvent` is append-only, tenant-scoped, and ordered by task revision. Events include the event kind, actor participant reference when known, timestamp, operation ID, safe reason, and safe metadata. They never include protected instructions, task data, form data, completion data, invitation secrets, or provider-private payloads. + +At minimum, record `Created`, `Claimed`, `Released`, `Assigned`, `Reassigned`, `CompletionRequested`, `Completed`, `TimedOut`, `Cancelled`, `OverdueNotified`, `InvitationIssued`, `InvitationVerified`, `InvitationRevoked`, and failed privileged operations. + +Health issues are separate from lifecycle status. They identify blocking or advisory problems such as form resolution failure, snapshot enumeration failure, unknown participant display data, or invitation delivery failure. Health data is safe for managers and diagnostics; it cannot disclose tokens or protected payloads. + +## Visibility and retention invariants + +- A candidate receives safe summary only. Protected content is disclosed only to the assignee or a manager. +- Releasing a task revokes protected access immediately. +- A completed task's protected data is visible to the completer and managers; prior assignees retain only summary and safe audit visibility. +- A requester receives no access merely by being the requester. +- Inaccessible task IDs are indistinguishable from missing IDs (`404` at the API boundary). +- All reads and writes are tenant-scoped and must pass both module permission and task relationship/policy checks. +- Terminal retention is indefinite by default. An opt-in purge removes only terminal tasks and their events. Open tasks and their operational records are never purged. +- Expired invitations, revoked guest sessions, and delivered invitation outbox entries may use separate short retention settings, but cleanup must not affect the task aggregate or audit history. + diff --git a/specs/013-user-tasks/plan.md b/specs/013-user-tasks/plan.md new file mode 100644 index 000000000..7a8a815fe --- /dev/null +++ b/specs/013-user-tasks/plan.md @@ -0,0 +1,104 @@ +# Implementation Plan: User Tasks + +**Feature**: `013-user-tasks` | **Date**: 2026-08-17 | **Specification**: [spec.md](spec.md) + +## Summary + +Deliver an identity-neutral human-task bounded context in Elsa Core and a reference task workbench in Elsa Studio. A blocking activity creates a materialized bookmark payload; post-commit projection creates a durable task record. Authorized REST commands atomically mutate task state and terminal commands asynchronously resume the bookmark. Reconciliation closes commit gaps. The core package defaults to in-memory persistence and optional provider packages mirror Elsa.Secrets. + +## Technical Context + +**Language/Version**: C# latest; nullable and implicit usings enabled; Blazor/Razor in Studio +**Primary Dependencies**: Elsa feature/module and workflow runtime infrastructure, FastEndpoints via Elsa endpoint base types, mediator notifications, SignalR, Microsoft.Extensions.Options/Logging, ASP.NET Core Data Protection/rate limiting, EF Core provider packages +**Storage**: In-memory development/test repository; module-specific EF Core context and SQLite, SQL Server, PostgreSQL, MySQL, Oracle packages; VNext document-store adapter +**Testing**: xUnit, existing Elsa testing helpers, SQLite integration fixtures, Studio component/client tests where available +**Targets**: Core `net8.0;net9.0;net10.0`; existing Studio targets +**Performance Envelope**: approximately 100,000 open and millions of terminal tasks per tenant; cursor queries avoid protected JSON and page-number offsets +**Constraints**: no Elsa.Identity dependency; no change to generic RunTask; task/bookmark consistency across failure and cluster races; protected data never disclosed through summary/search/realtime +**Repositories**: Core worktree plus adjacent Elsa Studio repository; local-only delivery gates while GitHub is unavailable + +## Constitution Check + +### Pre-design + +- Separate Core module, EF persistence, provider packages, and Studio module: PASS. +- Provider-neutral contracts and optional host integrations: PASS. +- Activity uses standard attributes and input/output wrappers: PASS. +- Endpoints remain one class per route and asynchronous: PASS. +- Public behavior and architecture are documented before code: PASS. +- Minimum scoped product: PASS; excluded capabilities are explicit. + +### Post-design + +- No persistence or identity infrastructure leaks into domain/API contracts: PASS. +- Every durable race has a revision/idempotency rule and test task: PASS. +- Every protected-data path has an authorization/disclosure rule: PASS. +- Multi-provider persistence follows established Elsa.Secrets boundaries: PASS. +- Local build/test/review gates replace only the explicitly skipped GitHub gates: PASS. + +## Architecture + +```mermaid +flowchart LR + A["UserTask activity"] -->|"bookmark payload"| B["Workflow commit"] + B --> C["Post-commit projector"] + C --> D["IUserTaskRepository"] + D --> E["In-memory / EF Core / VNext"] + F["Studio or host app"] --> G["Authorized REST endpoints"] + G --> H["IUserTaskManager"] + H --> D + H --> I["Identity and policy adapters"] + H --> J["Form and invitation adapters"] + H -->|"terminal operation"| K["Bookmark resumer"] + K --> A + L["Paged reconciler and due scanner"] --> D + L --> K + H --> M["Mediator lifecycle events"] + M --> N["Metadata-free SignalR invalidation"] +``` + +## Package and File Shape + +### Elsa Core repository + +- `src/modules/Elsa.UserTasks`: activity, domain models, contracts, default services, in-memory repository, projection/reconciliation/due workers, permissions, notifications, SignalR hub, endpoints, feature and shell feature. +- `src/modules/Elsa.UserTasks.Persistence.EFCore`: context, configurations, repository, feature. +- `src/modules/Elsa.UserTasks.Persistence.EFCore.{Sqlite,SqlServer,PostgreSql,MySql,Oracle}`: provider configuration, migrations, shell features. +- `src/modules/Elsa.UserTasks.Persistence.VNext`: document-store-backed repository/feature. +- `test/unit/Elsa.UserTasks.UnitTests`: lifecycle, identity, disclosure, form, invitation, idempotency, projection, due, API model tests. +- `test/integration/Elsa.UserTasks.IntegrationTests` where an existing test project pattern supports endpoint/runtime integration. + +### Elsa Studio repository + +- `src/modules/Elsa.Studio.UserTasks`: remote feature, menu, Refit client, models, queue/detail pages and components, optional extension interfaces, guest page, realtime/polling coordinator. +- Existing workflow designer renders the activity through metadata; custom inputs are added only where generic editing is insufficient. + +## Delivery Sequence + +1. Land the approved dossier, contracts, terminology, and traceable task ledger. +2. Implement the domain, state machine, activity/bookmark contract, repository, and focused unit tests. +3. Add post-commit projection, asynchronous completion, reconciliation, due processing, and lifecycle notifications. +4. Add authorized REST query/commands and safe DTO mapping. +5. Add invitations, guest sessions, delivery outbox, anonymous defenses, and tests. +6. Add EF Core/VNext persistence and provider migrations/configuration. +7. Add Studio queue/detail/designer/guest integrations. +8. Run targeted then broad local verification, traceability analysis, and up to five self-review passes. + +## Verification Strategy + +- Unit-test legal and illegal state transitions, exact revision increments, canonical idempotency payloads, claim and terminal races. +- Contract-test query authorization and safe/protected DTO mapping for every relationship. +- Runtime-test task projection after workflow commit, bookmark resume, finalization, and each reconciliation gap. +- SQLite-test durable restart, indexes, tenant isolation, cursor stability, and cleanup. +- Schema-review every provider migration and compile all provider projects. +- Studio-test tab queries, URL state, responsive route behavior, capability actions, conflict refresh, disclosure, realtime fallback, and accessibility semantics. +- Build affected projects first, then both repositories broadly without GitHub-dependent checks. + +## Risk Controls + +- **Dual-write inconsistency**: bookmark is source evidence; projection happens post-commit and a paged reconciler repairs both directions. +- **Authorization leakage**: repository query accepts an authorization scope compiled by policy; summary DTOs cannot carry protected fields. +- **Terminal duplicate resume**: transitional states, expected revision, canonical operation record, and bookmark-removal finalization. +- **Provider schema drift**: one EF model plus provider-specific migrations and a shared conformance suite. +- **Guest secret exposure**: hash at rest, encrypted transient outbox, generic anonymous surface, short bounded sessions. +- **Scope pressure**: v1 exclusions remain acceptance constraints; standalone tasks, drafts, attachments, comments, bulk actions, and native forms are not implementation tasks. diff --git a/specs/013-user-tasks/quickstart.md b/specs/013-user-tasks/quickstart.md new file mode 100644 index 000000000..4ce8df66b --- /dev/null +++ b/specs/013-user-tasks/quickstart.md @@ -0,0 +1,270 @@ +# Quickstart: Elsa User Tasks + +**Status**: Approved +**Last reviewed**: 2026-08-17 + +This walkthrough creates a workflow-bound invoice approval, executes it, lists the candidate task, claims it as an external user, and completes it with a typed action and form payload. The examples use the planned `Elsa.UserTasks` REST contract and do not require `Elsa.Identity`. + +## Prerequisites + +- An Elsa Server with workflow management, workflow runtime, and the User Tasks module enabled. +- A host identity provider that issues a bearer token with a stable subject and optional group claims. +- The `read:user-tasks`, `claim:user-tasks`, and `complete:user-tasks` permissions for the worker token. A manager token additionally needs `assign:user-tasks` and `manage:user-tasks`. +- An installed form provider if the task uses `formReference`. + +The Core module defaults to an in-memory repository for development and tests. A durable host adds the User Tasks EF Core persistence package and its provider-specific shell feature, following the same package split used by `Elsa.Secrets`: + +```csharp +services.AddElsa(elsa => +{ + elsa.UseUserTasks(); +}); + +// Enable the provider-specific User Tasks persistence feature in the host. +// The feature registers the User Task repository, DbContext, mappings, and migrations. +// Use the SQLite, SQL Server, PostgreSQL, MySQL, or Oracle package selected by the host. +``` + +Do not add an `Elsa.Identity` reference merely to run this walkthrough. The default claims resolver reads the configured subject/provider/group claims; a host may replace it with `IUserTaskIdentityResolver` and `IUserTaskAccessPolicy`. + +## Create the workflow + +In Elsa Studio, create a workflow definition named `invoice-review` with a sequence containing one `UserTask` activity: + +```csharp +new UserTask +{ + Id = "ReviewInvoice", + Title = "Review invoice", + Summary = "Approve or reject invoice INV-1042.", + Instructions = "Check the supplier, amount, and purchase order before deciding.", + Reference = "INV-1042", + TaskType = "invoice-approval", + CandidateGroups = + [ + new ParticipantReference( + TenantId: "acme", + Provider: "acme-directory", + Type: UserTaskParticipantType.Group, + Id: "accounts-payable") + ], + Priority = 75, + DueAt = "= now() + duration(\"P2D\")", + FormReference = new UserTaskFormReference + { + ProviderName = "acme-forms", + Key = "invoice-review", + Binding = "version:3" + }, + Actions = + [ + new UserTaskActionDefinition { Key = "approved", Label = "Approve" }, + new UserTaskActionDefinition { Key = "rejected", Label = "Reject" } + ], + TaskData = new + { + invoiceNumber = "INV-1042", + amount = 1250.00, + currency = "EUR", + supplier = "Northwind Supplies" + } +}; +``` + +The C# above describes the activity shape used by the workflow builder. Studio serializes the same values into the workflow definition. `CandidateGroups` stores an opaque external reference; the string `accounts-payable` is not an Elsa group ID. The activity blocks on its User Task bookmark and exposes the selected action as the workflow outcome after completion. + +If a form provider is not installed, omit `FormReference` and complete with an action only. Without a form, arbitrary business data is not accepted by the Core completion endpoint. + +## Execute the workflow + +After publishing the definition, execute it through the existing workflow runtime endpoint. Set `DEFINITION_ID` to the published definition ID returned by the workflow-definition API: + +```bash +export ELSA_BASE_URL="https://localhost:5001" +export DEFINITION_ID="invoice-review" +export WORKER_TOKEN="" + +curl --fail-with-body -i \ + -X POST "$ELSA_BASE_URL/workflow-definitions/$DEFINITION_ID/execute" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "correlationId": "order-1042", + "input": { + "invoiceNumber": "INV-1042" + } + }' +``` + +The response includes the normal workflow state and the `x-elsa-workflow-instance-id` response header. Save that value for the task detail and workflow deep link. + +## List the candidate queue + +The accounts-payable worker supplies a token whose external group claims include `acme-directory/accounts-payable`. The User Tasks API evaluates the host identity policy and returns summary-safe rows: + +```bash +curl --fail-with-body \ + "$ELSA_BASE_URL/user-tasks?scope=available&taskType=invoice-approval&limit=20&includeTotalCount=true" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Accept: application/json" +``` + +An available row has this shape (the exact response may include links and tenant metadata): + +```json +{ + "items": [ + { + "id": "01JUSER-TASK-1042", + "title": "Review invoice", + "summary": "Approve or reject invoice INV-1042.", + "reference": "INV-1042", + "taskType": "invoice-approval", + "status": "Available", + "assignee": null, + "priority": 75, + "dueAt": "2026-08-19T10:30:00Z", + "isOverdue": false, + "allowedActions": ["claim"], + "dataAccess": "summary", + "revision": 1 + } + ], + "totalCount": 1 +} +``` + +The row does not expose `Instructions`, `TaskData`, or the form payload. A worker can search by reference, status, priority, due-date range, workflow definition/instance, and stable cursor/sort parameters. Studio uses the same endpoint for its **Available** tab. + +## Claim the task + +Claiming is a separate, concurrency-safe operation. The `expectedRevision` prevents a stale list row from claiming a task that another worker already took: + +```bash +export TASK_ID="01JUSER-TASK-1042" + +curl --fail-with-body \ + -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/claim" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"expectedRevision":1}' +``` + +A successful claim changes the task to `Assigned`, sets the assignee to the external subject from the token, increments `revision`, and returns `allowedActions` containing `release` and `complete`. + +## Read protected details + +After claiming, the worker can fetch the full detail. The same request made with a different candidate token returns only the safe projection or `404`, depending on the host access policy: + +```bash +curl --fail-with-body \ + "$ELSA_BASE_URL/user-tasks/$TASK_ID" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Accept: application/json" +``` + +The assigned response includes the pinned form reference, protected instructions, task data, workflow definition/instance links, action definitions, revision, and a safe lifecycle timeline. It still omits secrets that the host form provider does not authorize. + +## Complete with a form action + +The form provider validates and normalizes `data`. The `actionKey` must match a configured action; it is also emitted as the User Task activity outcome. The `operationId` makes a network retry idempotent: + +```bash +export OPERATION_ID="6d1cf9d1-22e4-4f4d-a13e-0f9a50b754d8" + +curl --fail-with-body -i \ + -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/complete" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "expectedRevision": 2, + "operationId": "'"$OPERATION_ID"'", + "actionKey": "approved", + "data": { + "purchaseOrder": "PO-1042", + "approvedAmount": 1250.00 + } + }' +``` + +The endpoint returns `202 Accepted` while the task is `Completing`. Poll the task resource until `Completed`: + +```bash +until curl --fail-with-body -s \ + "$ELSA_BASE_URL/user-tasks/$TASK_ID" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + | rg -q '"status"[[:space:]]*:[[:space:]]*"Completed"'; do + sleep 1 +done +``` + +When the bookmark resumes, the workflow receives `UserTaskResult` with `actionKey = "approved"`, the provider-normalized data, the external completing participant, and the completion timestamp. A workflow branch can use the `approved` or `rejected` outcome to continue execution. + +Submitting the same `operationId` and payload again returns the original completion result or an equivalent idempotent response. Reusing it with a different action or data returns `409 Conflict`. + +## Release or reassign + +If the worker cannot finish, release clears the assignee and returns the task to the candidate queue: + +```bash +curl --fail-with-body \ + -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/release" \ + -H "Authorization: Bearer $WORKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"expectedRevision":2}' +``` + +A manager can assign the task to a specific external user. Managers cannot complete on another user's behalf; they must assign it to an accountable user first: + +```bash +export MANAGER_TOKEN="" + +curl --fail-with-body \ + -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/assign" \ + -H "Authorization: Bearer $MANAGER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "expectedRevision": 3, + "assignee": { + "provider": "acme-directory", + "type": "user", + "id": "u-2048" + }, + "reason": "Accounts-payable coverage for the month-end queue." + }' +``` + +## Inspect the manager queue and audit history + +Managers with `manage:user-tasks` can query all task scopes and see the safe event timeline: + +```bash +curl --fail-with-body \ + "$ELSA_BASE_URL/user-tasks?scope=all&status=Assigned&priorityFrom=50&limit=50" \ + -H "Authorization: Bearer $MANAGER_TOKEN" \ + -H "Accept: application/json" + +curl --fail-with-body \ + "$ELSA_BASE_URL/user-tasks/$TASK_ID/events" \ + -H "Authorization: Bearer $MANAGER_TOKEN" \ + -H "Accept: application/json" +``` + +Events include created, claimed, released, assigned, reassigned, completion requested, completed, and canceled transitions with timestamps, external participant references, revisions, and safe reasons. They do not include protected task or form payloads. + +## Validate the integration + +Run these scenarios against an in-memory host first, then repeat with the selected EF Core provider: + +1. Two candidate workers claim the same task; one succeeds and the other receives `409`. +2. A candidate can list a summary but cannot fetch protected data before claiming. +3. A claimed worker can release and another candidate can claim the task. +4. A completion retry with the same `operationId` does not resume the bookmark twice. +5. A stale `expectedRevision` cannot complete or reassign a task. +6. A task with no assignment is manager-only and produces a design/runtime warning. +7. A missing form provider produces a blocking health issue and never renders arbitrary HTML. +8. The workflow receives the configured action outcome and typed result after `Completing` reaches `Completed`. +9. A canceled workflow removes the bookmark and records the User Task as canceled. +10. No request or persistence path requires an `Elsa.Identity` user or group. + +The implementation should turn each scenario into a Core integration or component test and keep this document synchronized with the REST and runtime contracts. diff --git a/specs/013-user-tasks/research.md b/specs/013-user-tasks/research.md new file mode 100644 index 000000000..f280a4092 --- /dev/null +++ b/specs/013-user-tasks/research.md @@ -0,0 +1,131 @@ +# Research: User Tasks + +**Status**: Approved +**Last reviewed**: 2026-08-17 + +This research captures the product and integration decisions behind `Elsa.UserTasks`. The feature is deliberately identity-neutral: Elsa stores references to participants supplied by the host application, but does not create or require Elsa users. + +## Decision: Make User Tasks a workflow-bound inbox projection + +**Rationale**: A User Task must block workflow execution and must also be queryable as durable work from Elsa Studio or a custom application. The activity therefore creates a bookmark, while the User Tasks module projects the committed bookmark into a task record. Completion records the requested result and resumes the bookmark asynchronously. This gives workflow execution and inbox queries separate persistence boundaries without allowing a task row to become an independent workflow execution primitive. + +**Alternatives considered**: + +- Reuse the existing generic `RunTask` activity and `/tasks/{id}/complete`: rejected because it has a different contract and does not provide assignment, participant, form, audit, or queue semantics. +- Create standalone tasks in v1: rejected because the first release must have an unambiguous workflow resume target; standalone work can follow once its lifecycle and retention rules are separately designed. +- Make the task table the source of workflow state: rejected because a database write could claim or complete a task without atomically coordinating the workflow bookmark. + +## Decision: Store opaque, tenant-scoped participant references + +**Rationale**: Camunda models `assignee`, `candidateUsers`, and `candidateGroups` as identifiers resolved by its surrounding identity system. Its current documentation also notes that identifiers are case-sensitive and that group IDs are preferred. Flowable is even more explicit: its runtime does not verify that an assigned user exists, which allows an embedded engine to use an existing identity service. Elsa should adopt this integration boundary directly. + +Elsa persists a participant as `{ provider, type, id }`, where `type` is `user` or `group`. The value is an external reference, not an `Elsa.Identity` foreign key. Claims, directory lookups, display names, and group membership are replaceable host integrations. A display name is a convenience snapshot and never participates in authorization. + +**Sources**: [Camunda user task assignments](https://docs.camunda.io/docs/components/modeler/bpmn/user-tasks/), [Flowable Task API](https://www.flowable.com/open-source/docs/bpmn/ch04-API/), [Flowable user-task assignment](https://www.flowable.com/open-source/docs/userguide-5/). + +**Alternatives considered**: + +- Foreign keys to `Elsa.Identity` users or groups: rejected because integrations may use Entra ID, LDAP, an application database, a SaaS identity provider, or no Elsa user store at all. +- Free-form strings without a provider namespace: rejected because the same subject identifier can exist in multiple issuers or tenants. +- A mandatory directory provider: rejected because authorization must work with claims alone and directory services are often unavailable or eventually consistent. + +## Decision: Separate assignment from task state + +**Rationale**: Camunda Tasklist treats assignment as a distinct concern: a task can be available to candidates, claimed by one worker, released, reassigned, and then completed. Flowable uses the same model: claiming makes one user the assignee and removes the task from the other candidates' personal queues. Elsa therefore exposes dedicated claim, release, assign, and complete operations, rather than treating an assignee field update as completion or as a generic patch. + +The public lifecycle is `Unassigned`, `Available`, `Assigned`, transitional `Completing`, `TimingOut`, and `Cancelling`, and terminal `Completed`, `TimedOut`, and `Cancelled`. A task with no usable assignment is manager-only and raises a designer/runtime warning; it never becomes visible to every authenticated user merely because no candidate was configured. + +**Sources**: [Camunda Tasklist overview](https://docs.camunda.io/docs/components/tasklist/userguide/using-tasklist/), [Camunda user-task lifecycle](https://docs.camunda.io/docs/apis-tools/frontend-development/task-applications/user-task-lifecycle/), [Flowable task lists and claiming](https://www.flowable.com/open-source/docs/bpmn/ch07a-BPMN-Introduction/). + +**Alternatives considered**: + +- Let every candidate complete directly: rejected because it creates race conditions and makes accountability unclear. +- Treat `InProgress` as a required engine state: rejected for v1; hosts can add application-level progress outside the workflow lifecycle. `Assigned` is the accountable state. +- Automatically reassign when a worker disconnects: rejected because leases and absence detection are host-specific and can surprise users. + +## Decision: Give candidates a safe queue projection and protect task content + +**Rationale**: Camunda's Tasklist combines a queue with a selected-task detail view and shows task name, process, assignee, priority, creation date, due date, and follow-up date. Elsa keeps the useful queue/detail pattern but makes the security boundary explicit: an available candidate receives only summary-safe fields and can claim the task; protected instructions, task data, and form metadata are returned only after claim or to an authorized manager. This is a deliberate Elsa security improvement, not a claim that all Camunda deployments behave this way. + +The API returns `dataAccess` and server-computed `allowedActions` so Studio does not infer authorization from the current user interface state. An inaccessible task returns `404`, avoiding existence leaks. + +**Source**: [Camunda Tasklist queue and task details](https://docs.camunda.io/docs/components/tasklist/userguide/using-tasklist/). + +## Decision: Keep priority and due dates with an explicit timeout outcome + +**Rationale**: Camunda supports expression-capable due dates, follow-up dates, and a 0–100 priority with a default of 50; Tasklist can sort by those fields. Elsa keeps an expression-capable `DueAt` and priority because approvals, exception queues, and SLA work need these signals. Every overdue task publishes one idempotent notification. By default it remains actionable; when the designer explicitly enables timeout, a cluster-safe due operation races through `TimingOut` and resumes the workflow with reserved action `Timeout`. Follow-up dates and first-party escalation delivery remain deferred. + +**Sources**: [Camunda user-task scheduling and priority](https://docs.camunda.io/docs/components/modeler/bpmn/user-tasks/), [Camunda priority labels](https://docs.camunda.io/docs/components/tasklist/userguide/defining-task-priorities/). + +**Alternatives considered**: + +- Automatically cancel at the due date: rejected because business processes differ on whether a missed deadline should escalate, remain actionable, or take a rejection path. +- Use an unbounded numeric priority: rejected because a bounded range is easier to validate, render, and sort consistently. +- Include follow-up or first-party escalation delivery in the first domain contract: rejected because it couples task persistence to a delivery system that Elsa does not otherwise provide. The module publishes lifecycle notifications for host subscribers. + +## Decision: Use provider-neutral, version-pinned forms + +**Rationale**: Camunda links forms to user tasks as separately deployed, versioned resources. It supports binding to the latest, a deployment, or a version tag so a running process can pin the form version it expects. Elsa has no first-party form builder to assume, so `FormReference` contains a provider name, key, and version/binding. On activation, the module resolves and pins the concrete version. The provider validates and normalizes submitted data; Core does not merge arbitrary fields into workflow variables. Studio renders only an installed trusted provider renderer and never arbitrary server-provided HTML. + +If the form cannot be resolved or validated, the task records a blocking health issue and is manager-only until configuration is repaired. A manager may retry the original reference; they cannot silently replace the live form on an active task. + +**Sources**: [Camunda form linking](https://docs.camunda.io/docs/components/modeler/forms/utilizing-forms/), [Camunda resource binding](https://docs.camunda.io/docs/components/best-practices/modeling/choosing-the-resource-binding-type/), [Camunda Tasklist form behavior](https://docs.camunda.io/docs/components/tasklist/userguide/using-tasklist/). + +**Alternatives considered**: + +- Build a form designer into Core: rejected because it would make Elsa responsible for a large UI and schema ecosystem before the provider contract is proven. +- Store only an unversioned form key: rejected because a form change could invalidate an active task. +- Render arbitrary HTML returned by a provider: rejected because it creates an unnecessary XSS and trust-boundary risk. +- Persist completion data as arbitrary workflow variables: rejected because user-submitted data needs provider validation and an explicit typed `UserTaskResult` boundary. + +## Decision: Make completion optimistic-concurrency-safe and idempotent + +**Rationale**: A queue is inherently concurrent: two workers may select the same candidate task, or a client may retry after a network timeout. Claim, release, assign, and complete requests require `expectedRevision`. Completion additionally requires a client-generated `operationId`; repeating the same operation is safe, while reusing it with a different payload returns `409`. Completion returns `202 Accepted` while the workflow resume is pending and transitions the task through `Completing`. + +This combines Camunda's explicit assignment/completion operations with Elsa's bookmark-resume boundary. The first committed terminal transition wins; later completion, cancellation, or workflow-cancel attempts receive a conflict or are recorded as an already-finalized no-op according to the operation contract. + +**Sources**: [Camunda lifecycle operations and events](https://docs.camunda.io/docs/apis-tools/frontend-development/task-applications/user-task-lifecycle/), [Camunda task API](https://docs.camunda.io/docs/apis-tools/tasklist-api-rest/controllers/tasklist-api-rest-task-controller/). + +## Decision: Use a rich, authorized queue API and a capability-driven Studio + +**Rationale**: Camunda's task search API supports status, assignment, candidate users/groups, process references, date ranges, priority, variable predicates, include-variable selection, sorting, and cursor pagination. Elsa adopts those high-value query dimensions with opaque cursor pagination and a stable ID tie-breaker. List rows contain bounded metadata; details load protected content only when authorized. + +Studio adds **Assigned to me**, **Available**, **History**, and manager-only **All** and **Needs Attention** views at `/workflows/user-tasks`. The list uses server-side filtering, URL-persisted filters, sorting, and cursor paging. The detail view shows workflow context, protected form/data when allowed, action buttons from `allowedActions`, and a safe audit timeline. A participant picker is optional and capability-driven; raw opaque IDs and expressions remain available when no directory exists. + +**Sources**: [Camunda task search](https://docs.camunda.io/docs/apis-tools/tasklist-api-rest/specifications/search-tasks/), [Camunda Tasklist overview](https://docs.camunda.io/docs/apis-tools/tasklist-api-rest/tasklist-api-rest-overview/). + +**Alternatives considered**: + +- A client-side list loaded from workflow instances: rejected because it cannot enforce task-level scope efficiently or support custom task stores. +- A mandatory user/group picker: rejected because host identity systems may not expose a searchable directory. +- A single page showing every task to every authenticated user: rejected because it leaks business data and makes a manager scope meaningless. + +## Decision: Mirror the Elsa.Secrets persistence shape + +**Rationale**: The module follows the repository's established split: a Core package owns contracts, services, default repository, activity, endpoints, permissions, and an in-memory implementation; persistence packages add provider-specific repositories, shell features, migrations, and mappings. This keeps the default useful for development and tests while allowing durable EF Core providers and future persistence-vNext adapters without changing the REST or activity contract. + +Persist `UserTasks`, normalized `UserTaskCandidates`, and append-only `UserTaskEvents`. Index tenant, status, assignee provider/ID, priority, due date, workflow definition/instance, activity instance, created/completed time, and revision. Store bounded protected task data, completion data, and form references as JSON. Unique keys cover task ID, bookmark ID, and the tenant/workflow-instance/activity-instance materialization key. + +The module has no secret-like cleartext protection requirement; protected task data is governed by task authorization and provider storage guarantees. Terminal retention is indefinite by default, with opt-in purge of terminal tasks and events only. + +## Decision: Keep guest invitations separate from ordinary identity + +**Rationale**: External customer or partner approvals are high-value, but a guest link must not become a general Elsa credential. Guest invitations use the same task aggregate and authorization model, with a single-task, scoped, one-time token and an optional bearer-only permission. The invitation token is hashed at rest; the host owns dispatch and verification. Authenticated workers never need to use invitation APIs. + +This is staged after the authenticated lifecycle so the core task contract can be validated first. It follows the same task queue and completion rules, including revision and operation-idempotency checks. + +**Alternatives considered**: + +- Treat every guest as an Elsa user: rejected because it recreates the identity coupling the module is intended to avoid. +- Put all task data in a bearer URL: rejected because links are routinely copied, logged, or forwarded. +- Make invitation delivery part of Core: rejected because email/SMS/portal delivery is host-specific. + +## What Elsa should and should not copy + +| Copy | Adapt | Do not copy as a v1 dependency | +| --- | --- | --- | +| Camunda's assignment vocabulary, queue/detail layout, priority/due fields, explicit actions, form references, and lifecycle events. | Camunda's candidate visibility: use Elsa's summary-safe projection and host authorization policy rather than coupling to Elsa.Identity. | Camunda Tasklist V1/V2 visibility behavior; its docs describe different candidate semantics across API versions. | +| Flowable's standalone identity boundary and candidate-group claim semantics. | Flowable's rich query vocabulary, adding tenant and workflow bookmark constraints. | Flowable's assumption that the engine's identity component can resolve group membership; Elsa must support claims-only hosts. | +| Temporal's durable human approval pattern and immutable execution history. | Expose mediator notifications and webhooks/events for host integrations. | A signal-only implementation with no durable inbox, task assignment, or Studio list. | + +The Camunda visibility split is documented in [Tasklist access restrictions](https://docs.camunda.io/docs/8.8/components/tasklist/user-task-access-restrictions/). The Temporal comparison comes from its official [human-in-the-loop reference architecture](https://go.temporal.io/platform-hub/ai-engineering/ai-reference-architecture/), which uses Signals/Updates and durable waits rather than a built-in task inbox. diff --git a/specs/013-user-tasks/spec.md b/specs/013-user-tasks/spec.md new file mode 100644 index 000000000..674d45bd8 --- /dev/null +++ b/specs/013-user-tasks/spec.md @@ -0,0 +1,173 @@ +# Feature Specification: User Tasks + +**Feature Branch**: `013-user-tasks` +**Created**: 2026-08-17 +**Status**: Approved for implementation +**Input**: Add an identity-neutral, durable human-task module to Elsa Core and a production-capable task workbench to Elsa Studio. + +## Product Intent + +User Tasks let a workflow pause for a human decision, expose the work through a secure queue, and resume with a typed outcome. Elsa owns task durability and workflow coordination, while the host owns people, groups, authentication, and policy. No required dependency or foreign key may couple the module to `Elsa.Identity`. + +## User Scenarios & Testing + +### User Story 1 - Model and execute a user task (Priority: P1) + +A workflow designer adds a User Task activity, configures visible context, assignment rules, completion actions, and an optional form. At runtime the workflow suspends until an authorized worker completes the task. + +**Why this priority**: Durable workflow suspension and typed human completion are the feature's core value. + +**Independent Test**: Publish and run a workflow containing one User Task, complete it through REST, and verify the selected action and normalized form data resume the correct activity exactly once. + +**Acceptance Scenarios**: + +1. **Given** a task with a direct assignee, **When** the activity executes, **Then** an `Assigned` task and matching bookmark are persisted and only the assignee or a manager can read protected content. +2. **Given** a task with candidates, **When** an eligible worker claims it, **Then** the claim is atomic and competing claimants receive a conflict. +3. **Given** a valid completion request, **When** it is accepted, **Then** the API returns `202`, transitions through `Completing`, resumes the workflow, and eventually records `Completed` once. + +--- + +### User Story 2 - Work from a task inbox (Priority: P1) + +Workers use Elsa Studio to see work assigned to them, discover available work, inspect authorized details, claim or release it, and complete it. Managers monitor all work and repair or reassign tasks that need attention. + +**Why this priority**: A human task that cannot be found and acted upon is not operationally useful. + +**Independent Test**: Seed tasks in each lifecycle state and verify each Studio tab, filter, action, disclosure rule, and mobile detail route against the REST API. + +**Acceptance Scenarios**: + +1. **Given** an authenticated worker, **When** they open User Tasks, **Then** Assigned to me, Available, and History contain only relationship-authorized safe records. +2. **Given** an unclaimed candidate task, **When** a candidate opens it, **Then** only its safe summary is visible until claim succeeds. +3. **Given** a manager, **When** they open All or Needs Attention, **Then** they can diagnose, reassign, retry resolution, update due date or priority, and optionally cancel without completing on another person's behalf. + +--- + +### User Story 3 - Integrate a host-owned identity system (Priority: P1) + +An Elsa integrator maps its authenticated principals and directory to opaque, tenant-scoped participant references and supplies task authorization without adopting Elsa Identity. + +**Why this priority**: Identity neutrality is a hard compatibility boundary for embedded Elsa deployments. + +**Independent Test**: Replace the default claims adapter and directory with test implementations and verify list visibility, live and snapshot groups, exclusions, management scope, and display resolution. + +**Acceptance Scenarios**: + +1. **Given** a namespaced participant reference unknown to the directory, **When** an exact authenticated claim matches it, **Then** live-mode authorization can succeed and activation does not fail. +2. **Given** snapshot membership that cannot be enumerated, **When** the task activates, **Then** it is created with a manager-only blocking health issue. +3. **Given** an excluded participant, **When** they otherwise match assignment, **Then** access is denied unless the activity permits a manager override and an audited reason is supplied. + +--- + +### User Story 4 - Complete a task from an external portal (Priority: P2) + +A workflow invites an external person. The host delivers a one-time token, the guest completes a pluggable verification challenge, and a task-scoped guest session permits completion without creating an Elsa user. + +**Why this priority**: Customer approvals and document requests are high-value workflow scenarios. + +**Independent Test**: Issue multiple invitations, verify one, confirm it atomically claims the task and revokes its siblings, and complete through the guest page with no identity-module dependency. + +**Acceptance Scenarios**: + +1. **Given** an unverified invitation link, **When** it is opened, **Then** the response reveals generic content only. +2. **Given** a valid challenge, **When** verification succeeds, **Then** a bounded task-scoped session is issued, the task is claimed, and competing invitations are revoked. +3. **Given** an expired, consumed, or invalid token, **When** verification is attempted, **Then** the API returns a generic rate-limited failure without revealing task existence. + +--- + +### User Story 5 - Meet deadlines and recover reliably (Priority: P2) + +Operators rely on due-date notifications, optional timeout outcomes, reconciliation, audit history, and provider-backed persistence across restarts and multiple nodes. + +**Why this priority**: Human work is asynchronous and must survive failures and races. + +**Independent Test**: Exercise completion-versus-timeout races on two nodes, restart between bookmark commit and projection, and verify first-writer-wins, idempotent notifications, and reconciliation. + +**Acceptance Scenarios**: + +1. **Given** an overdue task without timeout enabled, **When** the scanner processes it repeatedly, **Then** one overdue notification is published and the task remains open. +2. **Given** timeout enabled, **When** due time wins the terminal transition, **Then** the workflow resumes with reserved action `Timeout` and the task becomes `TimedOut`. +3. **Given** a committed bookmark lacking a task projection, **When** reconciliation runs, **Then** the missing task is recreated without duplication. + +## Edge Cases + +- A task with neither assignee, candidates, nor invitations becomes `Unassigned`, visible only to managers, and records a warning. +- Release immediately revokes protected access and returns to `Available` or `Unassigned`. +- Reuse of an operation ID with the same payload is idempotent; reuse with different content conflicts. +- Completion, timeout, cancellation, and bookmark removal races use expected revision and a transitional state so exactly one terminal result wins. +- A configured form that cannot be resolved blocks worker completion and creates a manager-only health issue; repair retries the original reference. +- “Latest” form references are pinned at activation so later form changes cannot alter an open task. +- Search never examines protected instructions, task data, form data, or completion data. +- Terminal history grants protected data only to the completer and managers; prior assignees receive summary and audit only. +- Unknown display names degrade to opaque participant IDs and a health indication, not workflow failure. +- Invitations expire at the configured bound, defaulting to the earlier of seven days or task due time. + +## Requirements + +### Functional Requirements + +- **FR-001**: The system MUST provide a blocking `UserTask` workflow activity with expression-capable design inputs and typed result outputs. +- **FR-002**: The activity MUST materialize title, summary, reference, tags, task type, requester, priority, due time, instructions, bounded task data, assignment rules, exclusions, actions, form reference, and invitation definitions at activation. +- **FR-003**: Completion action keys MUST be immutable literals; `Timeout` and `Cancelled` are reserved; labels MAY be expressions. +- **FR-004**: Task status MUST follow `Unassigned`, `Available`, `Assigned`, transitional `Completing`/`TimingOut`/`Cancelling`, and terminal `Completed`/`TimedOut`/`Cancelled` states. +- **FR-005**: Claim, release, direct assign, reassign, due-date update, priority update, completion, timeout, and optional cancellation MUST be atomic, revision-checked, idempotent where applicable, and audited. +- **FR-006**: Completion MUST be asynchronous: accept a command, persist operation state, enqueue bookmark resumption, and finalize from bookmark removal or reconciliation. +- **FR-007**: The module MUST project committed User Task bookmarks and reconcile missing projections, stale operations, and orphan task records in bounded pages. +- **FR-008**: The module MUST use opaque participant references composed of tenant, provider namespace, participant type, and external ID, with no required Elsa Identity dependency. +- **FR-009**: The host MUST be able to replace principal mapping, identity resolution, participant lookup, task policy, form, invitation delivery, invitation verification, and guest session services. +- **FR-010**: Authorization MUST require both operation permission and a task relationship; managers remain tenant-scoped and guest sessions remain task-scoped. +- **FR-011**: Candidate users MUST see safe summary only; protected instructions, task data, and forms MUST be disclosed only after assignment and revoked on release. +- **FR-012**: Live group membership MUST use current principal claims; snapshot membership MUST expand at activation and create a manager-only issue if enumeration fails. +- **FR-013**: Explicit exclusions MUST override ordinary assignment; an optional manager override MUST require a reason and audit event. +- **FR-014**: The requester MUST be searchable and visible but MUST confer no access. +- **FR-015**: Forms MUST be provider-neutral, version-pinned at activation, validated and normalized by the provider, and repairable only by retrying the original reference. +- **FR-016**: Tasks without a form MUST accept only a configured action and no arbitrary completion JSON. +- **FR-017**: Due processing MUST publish one idempotent overdue notification; when timeout is enabled it MUST resume with `Timeout` and finalize as `TimedOut`. +- **FR-018**: Manager cancellation MUST be activity-enabled, permission-protected, reason-required, and resume with `Cancelled`. +- **FR-019**: REST APIs under `/user-tasks` MUST provide cursor-based authorized search, details, events, capabilities, claim/release/assign, priority/due update, complete/cancel, resolution retry, participant lookup, and invitation operations. +- **FR-020**: Terminal mutation APIs MUST require `expectedRevision` and `operationId`, return `202` when accepted, `409` for races or divergent operation reuse, and `422` for domain validation. +- **FR-021**: Anonymous invitation APIs MUST use generic errors and rate limiting; raw invitation secrets MUST never be stored outside an encrypted transient delivery outbox and MUST be returned only once to a dispatcher. +- **FR-022**: The first successfully verified invitation MUST atomically claim the task, revoke sibling invitations, and issue a bounded guest session; guests MUST NOT release tasks. +- **FR-023**: Persistence MUST offer an in-memory default plus EF Core packages for SQLite, SQL Server, PostgreSQL, MySQL, and Oracle, and a VNext provider abstraction with equivalent behavior. +- **FR-024**: Storage MUST index normalized query fields and use bounded JSON only for protected task, form, and completion payloads; default payload limit is 256 KiB. +- **FR-025**: Audit events MUST be append-only, tenant-scoped, and exclude raw protected payloads. +- **FR-026**: Terminal retention MUST be indefinite by default with configurable purge that never deletes open tasks. +- **FR-027**: Studio MUST add Workflows → User Tasks with Assigned to me, Available, History, and manager-only All and Needs Attention views. +- **FR-028**: Studio MUST provide desktop queue/detail and mobile detail routes, URL-backed filters, workflow-instance deep links, capability-driven actions, safe disclosure, and no embedded diagram. +- **FR-029**: Studio MUST consume metadata-free SignalR invalidations and requery, with polling fallback. +- **FR-030**: Studio's generic activity editor MUST support all activity fields; participant lookup is optional with raw namespaced references and expressions as fallback. +- **FR-031**: Studio MUST ship a replaceable guest task page that reveals no task-specific information before successful verification. +- **FR-032**: Search MUST be limited to safe title, summary, reference, tags, task type, workflow, and correlation fields and MUST use opaque cursor pagination with stable sorting and optional totals. +- **FR-033**: Existing generic `RunTask` behavior and `/tasks/{id}/complete` MUST remain unchanged. +- **FR-034**: The system MUST publish mediator lifecycle notifications and metadata-free realtime invalidations for external integrations. + +### Key Entities + +- **UserTask**: Durable runtime projection linked to tenant, workflow, activity instance, and bookmark; owns lifecycle, safe/protected content, timing, assignment, form pin, health, revision, and terminal result. +- **ParticipantReference**: Opaque tenant/provider/type/ID identity value with an optional display snapshot. +- **Candidate / Exclusion / SnapshotMember**: Assignment relationships materialized for authorization and querying. +- **UserTaskAction**: Stable completion key and materialized display label. +- **UserTaskOperation**: Idempotency and asynchronous transition record keyed by task and operation ID. +- **UserTaskEvent**: Append-only audit event with actor reference and safe metadata. +- **UserTaskInvitation**: Hashed one-time invitation, challenge policy, expiry, state, and guest claim relationship. +- **GuestSession**: Revocable, task-scoped capabilities and expiry. +- **InvitationDelivery**: Encrypted transient outbox entry for retryable host delivery. + +## Success Criteria + +- **SC-001**: A published workflow can suspend at a User Task and resume exactly once with a typed result across restart and retry tests. +- **SC-002**: Two concurrent claimants or terminal actors yield exactly one winner in all automated race tests. +- **SC-003**: Authorization tests prove no candidate, released worker, unrelated user, cross-tenant manager, or unverified guest can read protected content. +- **SC-004**: Authorized list queries remain cursor-stable at the target envelope of 100,000 open tasks and millions of terminal tasks per tenant without loading protected payloads. +- **SC-005**: All supported persistence providers expose the same schema contract and the SQLite integration suite proves restart durability and indexed query behavior. +- **SC-006**: A worker can locate, claim, and complete a seeded task from Studio on desktop and a narrow viewport without entering an Elsa Identity user ID. +- **SC-007**: Reconciliation restores every intentionally interrupted bookmark/task/operation test fixture without duplicate tasks or workflow resumes. +- **SC-008**: Requirements, contracts, implementation tasks, automated tests, and documentation retain stable traceability identifiers. + +## Assumptions and Scope + +- Hosts already provide authentication and a permission pipeline; the module provides replaceable adapters and safe defaults, not a user database. +- Server time is UTC and persistence providers support optimistic concurrency. +- External files are referenced by URI or provider ID; they are not embedded in task JSON. +- V1 excludes standalone tasks, a native form builder, drafts, comments, attachments, formal delegation, bulk actions, named saved filters, and multi-response tasks. +- GitHub issue, project, pull-request, and remote review gates are intentionally skipped for this delivery because of reported GitHub downtime; all local specification, test, build, and self-review gates remain in force. diff --git a/specs/013-user-tasks/tasks.md b/specs/013-user-tasks/tasks.md new file mode 100644 index 000000000..eeed3bfc8 --- /dev/null +++ b/specs/013-user-tasks/tasks.md @@ -0,0 +1,125 @@ +# Tasks: User Tasks + +Tasks use `[ID] [P?] [Story] Description with file path`. `[P]` tasks may run in parallel when their file ownership does not overlap. + +## Phase 1: Durable specification + +- [x] T001 Create the authoritative PRD in `specs/013-user-tasks/spec.md`. +- [x] T002 [P] Capture official-source decisions in `specs/013-user-tasks/research.md`. +- [x] T003 [P] Define the runtime data model in `specs/013-user-tasks/data-model.md`. +- [x] T004 [P] Define REST, runtime, identity, forms, invitations, persistence, and Studio contracts in `specs/013-user-tasks/contracts/`. +- [x] T005 Add requirements, security, and accessibility gates in `specs/013-user-tasks/checklists/`. +- [x] T006 Record architecture, sequence, risks, and verification in `specs/013-user-tasks/plan.md`. +- [x] T007 Reconcile dossier terminology and run cross-artifact analysis across `specs/013-user-tasks/`. +- [x] T008 Add canonical User Tasks terminology to `CONTEXT.md` and update the active Spec Kit pointer in `AGENTS.md`. + +## Phase 2: Core domain and workflow slice + +- [x] T009 [US1] Scaffold `src/modules/Elsa.UserTasks/Elsa.UserTasks.csproj` and feature/shell registration. +- [x] T010 [P] [US1] Add enums and value models for participants, lifecycle, health, actions, form pins, results, operations, and invitations under `src/modules/Elsa.UserTasks/Models/`. +- [x] T011 [P] [US3] Add replaceable identity, access policy, directory, forms, invitations, manager, repository, clock, and scheduling contracts under `src/modules/Elsa.UserTasks/Contracts/`. +- [x] T012 [US1] Implement the guarded state machine and aggregate invariants in `src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs`. +- [x] T013 [P] [US1] Implement the in-memory repository with tenant-safe authorized cursor queries in `src/modules/Elsa.UserTasks/Repositories/`. +- [x] T014 [P] [US3] Implement default claims resolver and default-deny access policy in `src/modules/Elsa.UserTasks/Services/`. +- [x] T015 [US1] Implement the blocking `UserTask` activity, materialized bookmark payload, stimulus, and typed output under `src/modules/Elsa.UserTasks/Activities/`. +- [x] T016 [US1] Project committed bookmarks and finalize removed bookmarks in `src/modules/Elsa.UserTasks/HostedServices/` and notification handlers. +- [ ] T017 [US5] Add the bounded startup/recurring reconciler for missing projections, stale operations, and orphan records. +- [x] T018 [P] [US5] Add cluster-safe due scanning, idempotent overdue notification, and optional timeout operation. +- [x] T019 [P] Add append-only safe audit and mediator lifecycle notification models/dispatch. +- [x] T020 [P] Add domain, race, disclosure, identity, projection, reconciliation, and due tests in `test/unit/Elsa.UserTasks.UnitTests/`. + +## Phase 3: REST and realtime slice + +- [x] T021 [US2] Add permissions, API DTOs, safe/protected mapping, and canonical API error mapping under `src/modules/Elsa.UserTasks/`. +- [x] T022 [P] [US2] Implement authorized cursor search, detail, events, and capability endpoints under `Endpoints/UserTasks/`. +- [x] T023 [P] [US2] Implement claim, release, assignment, and priority/due update endpoints. +- [x] T024 [P] [US1] Implement asynchronous complete, cancel, and resolution-retry endpoints with operation idempotency. +- [x] T025 [P] [US3] Implement optional participant lookup without Elsa Identity coupling. +- [ ] T026 [US2] Add metadata-free SignalR invalidation and polling-compatible lifecycle notifications. +- [ ] T027 [P] Add endpoint authorization, concealment, validation, cursor, idempotency, and conflict tests. + +## Phase 4: Guest invitations + +- [x] T028 [US4] Implement invitation issuance, token hashing, expiry, sibling revocation, and manager APIs. +- [x] T029 [US4] Implement Data Protection-backed transient delivery outbox and dispatcher retries. +- [x] T030 [US4] Implement generic rate-limited challenge and verification endpoints. +- [x] T031 [US4] Implement atomic guest claim and revocable task-scoped session issuance/validation. +- [x] T032 [P] Add invitation secrecy, expiry, replay, race, guest capability, and recovery tests. + +## Phase 5: Persistence providers + +- [x] T033 [US5] Scaffold `Elsa.UserTasks.Persistence.EFCore` with module DbContext, entity configurations, repository, and feature. +- [x] T034 [P] [US5] Add normalized indexes and bounded JSON mappings for tasks, participants, audit, operations, invitations, guest sessions, and delivery outbox. +- [x] T035 [P] [US5] Add SQLite provider configuration, migration, design-time factory, and shell feature. +- [x] T036 [P] [US5] Add SQL Server provider configuration, migration, design-time factory, and shell feature. +- [x] T037 [P] [US5] Add PostgreSQL provider configuration, migration, design-time factory, and shell feature. +- [x] T038 [P] [US5] Add MySQL provider configuration, migration, design-time factory, and shell feature. +- [x] T039 [P] [US5] Add Oracle provider configuration, migration, design-time factory, and shell feature. +- [x] T040 [P] [US5] Add VNext document-store repository and feature. +- [ ] T041 [US5] Add shared persistence conformance and SQLite restart/index/tenant/cursor tests. + +## Phase 6: Elsa Studio + +- [x] T042 [US2] Scaffold `Elsa.Studio.UserTasks` module, remote feature, service registration, and Workflows menu item. +- [x] T043 [P] [US2] Add Refit client and safe/protected/capability/command models. +- [x] T044 [US2] Implement URL-backed Assigned to me, Available, History, All, and Needs Attention queue views. +- [x] T045 [US2] Implement desktop split detail and mobile detail route with workflow deep link, protected disclosure, timeline, health, and capability actions. +- [x] T046 [P] [US2] Implement claim/release/assign/update/complete/cancel/retry interactions with asynchronous and conflict refresh states. +- [ ] T047 [P] [US1] Add User Task activity editor support and optional replaceable participant lookup picker with raw/expression fallback. +- [ ] T048 [US2] Add metadata-free SignalR requery coordinator and polling fallback without disrupting focus or form input. +- [x] T049 [US4] Add replaceable anonymous guest verification and task completion page. +- [ ] T050 [P] Add Studio client/component tests for tabs, URL filters, disclosure, actions, responsive routing, realtime fallback, and accessibility. + +## Phase 7: Documentation and local gates + +- [x] T051 Add module configuration, identity integration, forms, invitations, hosting, persistence, and upgrade documentation under `doc/`. +- [ ] T052 Add runnable sample workflows and host adapter examples referenced by `specs/013-user-tasks/quickstart.md`. +- [ ] T053 Run affected Core unit/integration tests and all User Tasks persistence conformance tests. +- [x] T054 Run affected Studio tests and builds. +- [ ] T055 Build both repositories broadly across configured target frameworks without GitHub-dependent gates. +- [ ] T056 Re-run requirement-to-task-to-test traceability analysis and resolve every critical/high finding. +- [ ] T057 Run up to five local self-review passes for correctness, security, API compatibility, accessibility, and maintainability. +- [ ] T058 Confirm no unrelated files were changed and record local verification evidence. + +## Dependencies + +T001–T008 precede implementation. Domain T009–T015 precede runtime T016–T019 and REST commands. Auth contracts T011/T014 precede all public queries. Core REST T021–T027 precedes Studio T042–T050. Invitations depend on the core state machine and REST security. EF/VNext depend on repository contracts but can proceed alongside Studio after the Core slice stabilizes. Documentation and final gates follow all slices. + +## Requirement traceability + +| Requirements | Delivery tasks | Primary verification | +| --- | --- | --- | +| FR-001–FR-007, FR-033 | T009–T020 | Activity/runtime, transition-race, projection, bookmark, and reconciliation tests | +| FR-008–FR-014 | T011, T014, T020–T027 | Identity replacement, authorized query, exclusion, disclosure, and tenant-isolation tests | +| FR-015–FR-018 | T012, T015, T018, T020, T024 | Form pin/validation, no-form completion, overdue/timeout, and cancellation tests | +| FR-019–FR-022 | T021–T032 | REST contract, idempotency, invitation secrecy/rate-limit, and guest-session tests | +| FR-023–FR-026 | T033–T041 | Provider compile/migration review and shared persistence conformance suite | +| FR-027–FR-031 | T042–T050 | Studio client/component, responsive, disclosure, guest, and accessibility tests | +| FR-032, FR-034 | T013, T019, T022, T026, T027 | Cursor stability, search safety, notification, and invalidation tests | + +## Verification status + +Checked items were built and exercised by an automated test or a successful multi-target build in this +worktree. The following remain open and are deliberately left unchecked: + +- **T017** — the reconciler's periodic worker is registered, but its repair logic has no test covering + interrupted projection or stale-operation recovery. +- **T026, T048** — Core has no SignalR invalidation hub yet. Studio ships the client and a + visibility-aware polling fallback, so the feature degrades correctly, but the realtime path is not + end to end. +- **T027** — authorization, concealment, idempotency, and conflict behavior are covered at the service + layer. There are no HTTP-level endpoint tests asserting the status-code mapping. +- **T041** — persistence coverage is EF Core/SQLite only. The shared conformance suite across + in-memory, EF, and VNext is not written. +- **T047** — the Studio activity editor contribution and participant-picker integration for the + designer are not implemented. +- **T050** — Studio tests cover the wire contract, URL state, and error mapping. Component-level tests + for tabs, disclosure, responsive routing, realtime fallback, and accessibility are not written. +- **T052** — no runnable sample workflows or host adapter examples yet. +- **T053, T055** — targeted Core and Studio test suites and the affected module, provider, and host + builds pass. A full solution-wide build across every configured target framework has not been run. +- **T056–T058** — final traceability, self-review, and evidence passes are outstanding. + +## Local-only gate note + +GitHub issue/project/branch/PR/review automation is intentionally omitted because the user reported GitHub downtime. This does not waive specification analysis, automated tests, builds, traceability, or self-review. diff --git a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj index e6676c124..972ec240d 100644 --- a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj +++ b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj @@ -25,6 +25,7 @@ + @@ -33,6 +34,8 @@ + + diff --git a/src/apps/Elsa.ModularServer.Web/Program.cs b/src/apps/Elsa.ModularServer.Web/Program.cs index b44a3c436..0b1248f4f 100644 --- a/src/apps/Elsa.ModularServer.Web/Program.cs +++ b/src/apps/Elsa.ModularServer.Web/Program.cs @@ -7,6 +7,8 @@ using Elsa.Diagnostics.ConsoleLogs.Dashboard.ShellFeatures; using Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures; using Elsa.ExternalAuthentication.OpenIdConnect.ShellFeatures; using Elsa.ExternalAuthentication.Secrets.ShellFeatures; +using Elsa.UserTasks.Persistence.EFCore.Sqlite.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; using Elsa.ModularServer.Web; using Elsa.ModularServer.Web.Catalog; using Elsa.Platform.Integration.ShellFeatures; @@ -88,7 +90,9 @@ builder.AddShells(shells => shells typeof(WorkflowRuntimeDashboardFeature), typeof(ConsoleLogsDashboardFeature), typeof(StructuredLogsDashboardFeature), - typeof(WorkflowsApiFeature)); + typeof(WorkflowsApiFeature), + typeof(UserTasksFeature), + typeof(SqliteUserTasksPersistenceShellFeature)); })); services.AddSingleton(); diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/DbContextFactories.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/DbContextFactories.cs new file mode 100644 index 000000000..8e0571746 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/DbContextFactories.cs @@ -0,0 +1,17 @@ +using Elsa.Persistence.EFCore.Abstractions; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Pomelo.EntityFrameworkCore.MySql.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.MySql; + +[UsedImplicitly] +public class UserTasksDbContextFactory : MySqlUserTasksDesignTimeDbContextFactory; +public class MySqlUserTasksDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase where TDbContext : DbContext +{ + protected override void ConfigureServices(IServiceCollection services) => services.AddMySqlEntityModelCreatingHandlers(); + protected override void ConfigureBuilder(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaMySql(GetType().Assembly, connectionString, serverVersion: ServerVersion.Parse("9.0.0")); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Elsa.UserTasks.Persistence.EFCore.MySql.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Elsa.UserTasks.Persistence.EFCore.MySql.csproj new file mode 100644 index 000000000..2f1dcdc2e --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Elsa.UserTasks.Persistence.EFCore.MySql.csproj @@ -0,0 +1,9 @@ + + net8.0;net9.0Provides MySQL EF Core persistence for Elsa user tasks.elsa module user tasks persistence efcore mysql + + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Extensions/MySqlUserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Extensions/MySqlUserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..2f9412323 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Extensions/MySqlUserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore.Features; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.MySql.Extensions; + +public static class MySqlUserTasksPersistenceFeatureExtensions +{ + private static Assembly Assembly => typeof(MySqlUserTasksPersistenceFeatureExtensions).Assembly; + public static EFCoreUserTasksPersistenceFeature UseMySql(this EFCoreUserTasksPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseMySql(Assembly, connectionString, options, configure); + public static EFCoreUserTasksPersistenceFeature UseMySql(this EFCoreUserTasksPersistenceFeature feature, Func connectionStringFunc, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseMySql(Assembly, connectionStringFunc, options, configure); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/FodyWeavers.xml new file mode 100644 index 000000000..8304193f8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/FodyWeavers.xml @@ -0,0 +1,2 @@ + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Migrations/UserTasks/20260817120000_Initial.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Migrations/UserTasks/20260817120000_Initial.cs new file mode 100644 index 000000000..bd4ec2cd9 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/Migrations/UserTasks/20260817120000_Initial.cs @@ -0,0 +1,17 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.MySql.Migrations.UserTasks; + +[DbContext(typeof(UserTasksElsaDbContext))] +[Migration("20260817120000_Initial")] +public partial class Initial : Migration +{ + private readonly IElsaDbContextSchema schema; + public Initial(IElsaDbContextSchema schema) => this.schema = schema ?? throw new ArgumentNullException(nameof(schema)); + protected override void Up(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Up(migrationBuilder, schema.Schema); + protected override void Down(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Down(migrationBuilder, schema.Schema); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/ShellFeatures/MySqlUserTasksPersistenceShellFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/ShellFeatures/MySqlUserTasksPersistenceShellFeature.cs new file mode 100644 index 000000000..6f3cc0c6c --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.MySql/ShellFeatures/MySqlUserTasksPersistenceShellFeature.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using CShells.Features; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.UserTasks.Persistence.EFCore.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.MySql.ShellFeatures; + +[ManifestFeatureCategory("User Tasks")] +[ManifestFeatureCategory("Persistence")] +[ShellFeature(DisplayName = "MySQL User Tasks Persistence", Description = "Provides MySQL persistence for User Tasks", DependsOn = [typeof(UserTasksFeature)])] +[UsedImplicitly] +[ManifestInfrastructure("mysql-database", "database", Reason = "Stores User Tasks data in MySQL.", Providers = new[] { "MySQL" }, ConfigurationKeys = new[] { "ConnectionString" })] +public sealed class MySqlUserTasksPersistenceShellFeature : EFCoreUserTasksPersistenceShellFeatureBase +{ + protected override void ConfigureProvider(DbContextOptionsBuilder builder, Assembly migrationsAssembly, string connectionString, ElsaDbContextOptions? options) => builder.UseElsaMySql(migrationsAssembly, connectionString, options); + protected override void OnConfiguring(IServiceCollection services) + { + services.AddMySqlEntityModelCreatingHandlers(); + base.OnConfiguring(services); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/DbContextFactories.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/DbContextFactories.cs new file mode 100644 index 000000000..abaf63825 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/DbContextFactories.cs @@ -0,0 +1,15 @@ +using Elsa.Persistence.EFCore.Abstractions; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore.Oracle; + +[UsedImplicitly] +public class UserTasksDbContextFactory : OracleUserTasksDesignTimeDbContextFactory; +public class OracleUserTasksDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase where TDbContext : DbContext +{ + protected override void ConfigureBuilder(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaOracle(GetType().Assembly, connectionString, new ElsaDbContextOptions().ConfigureOracle()); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Elsa.UserTasks.Persistence.EFCore.Oracle.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Elsa.UserTasks.Persistence.EFCore.Oracle.csproj new file mode 100644 index 000000000..f6481a8bf --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Elsa.UserTasks.Persistence.EFCore.Oracle.csproj @@ -0,0 +1,9 @@ + + Provides Oracle EF Core persistence for Elsa user tasks.elsa module user tasks persistence efcore oracle + + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Extensions/OracleUserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Extensions/OracleUserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..39ac2edb0 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Extensions/OracleUserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore.Features; +using Oracle.EntityFrameworkCore.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.Oracle.Extensions; + +public static class OracleUserTasksPersistenceFeatureExtensions +{ + private static Assembly Assembly => typeof(OracleUserTasksPersistenceFeatureExtensions).Assembly; + public static EFCoreUserTasksPersistenceFeature UseOracle(this EFCoreUserTasksPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseOracle(Assembly, connectionString, options, configure); + public static EFCoreUserTasksPersistenceFeature UseOracle(this EFCoreUserTasksPersistenceFeature feature, Func connectionStringFunc, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseOracle(Assembly, connectionStringFunc, options, configure); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/FodyWeavers.xml new file mode 100644 index 000000000..8304193f8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/FodyWeavers.xml @@ -0,0 +1,2 @@ + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Migrations/UserTasks/20260817120000_Initial.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Migrations/UserTasks/20260817120000_Initial.cs new file mode 100644 index 000000000..b41b8611e --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/Migrations/UserTasks/20260817120000_Initial.cs @@ -0,0 +1,17 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.Oracle.Migrations.UserTasks; + +[DbContext(typeof(UserTasksElsaDbContext))] +[Migration("20260817120000_Initial")] +public partial class Initial : Migration +{ + private readonly IElsaDbContextSchema schema; + public Initial(IElsaDbContextSchema schema) => this.schema = schema ?? throw new ArgumentNullException(nameof(schema)); + protected override void Up(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Up(migrationBuilder, schema.Schema); + protected override void Down(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Down(migrationBuilder, schema.Schema); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/ShellFeatures/OracleUserTasksPersistenceShellFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/ShellFeatures/OracleUserTasksPersistenceShellFeature.cs new file mode 100644 index 000000000..6145fe785 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Oracle/ShellFeatures/OracleUserTasksPersistenceShellFeature.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using CShells.Features; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.UserTasks.Persistence.EFCore.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore.Oracle.ShellFeatures; + +[ManifestFeatureCategory("User Tasks")] +[ManifestFeatureCategory("Persistence")] +[ShellFeature(DisplayName = "Oracle User Tasks Persistence", Description = "Provides Oracle persistence for User Tasks", DependsOn = [typeof(UserTasksFeature)])] +[UsedImplicitly] +[ManifestInfrastructure("oracle-database", "database", Reason = "Stores User Tasks data in Oracle.", Providers = new[] { "Oracle" }, ConfigurationKeys = new[] { "ConnectionString" })] +public sealed class OracleUserTasksPersistenceShellFeature : EFCoreUserTasksPersistenceShellFeatureBase +{ + protected override void ConfigureProvider(DbContextOptionsBuilder builder, Assembly migrationsAssembly, string connectionString, ElsaDbContextOptions? options) => builder.UseElsaOracle(migrationsAssembly, connectionString, options.ConfigureOracle()); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/DbContextFactories.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/DbContextFactories.cs new file mode 100644 index 000000000..30b1d5891 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/DbContextFactories.cs @@ -0,0 +1,16 @@ +using Elsa.Persistence.EFCore.Abstractions; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.PostgreSql; + +[UsedImplicitly] +public class UserTasksDbContextFactory : PostgreSqlUserTasksDesignTimeDbContextFactory; +public class PostgreSqlUserTasksDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase where TDbContext : DbContext +{ + protected override void ConfigureServices(IServiceCollection services) => services.AddPostgreSqlEntityModelCreatingHandlers(); + protected override void ConfigureBuilder(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaPostgreSql(GetType().Assembly, connectionString); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Elsa.UserTasks.Persistence.EFCore.PostgreSql.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Elsa.UserTasks.Persistence.EFCore.PostgreSql.csproj new file mode 100644 index 000000000..1b4b12738 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Elsa.UserTasks.Persistence.EFCore.PostgreSql.csproj @@ -0,0 +1,8 @@ + + Provides PostgreSQL EF Core persistence for Elsa user tasks.elsa module user tasks persistence efcore postgresql + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Extensions/PostgreSqlUserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Extensions/PostgreSqlUserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..b1fce544e --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Extensions/PostgreSqlUserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore.Features; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.PostgreSql.Extensions; + +public static class PostgreSqlUserTasksPersistenceFeatureExtensions +{ + private static Assembly Assembly => typeof(PostgreSqlUserTasksPersistenceFeatureExtensions).Assembly; + public static EFCoreUserTasksPersistenceFeature UsePostgreSql(this EFCoreUserTasksPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UsePostgreSql(Assembly, connectionString, options, configure); + public static EFCoreUserTasksPersistenceFeature UsePostgreSql(this EFCoreUserTasksPersistenceFeature feature, Func connectionStringFunc, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UsePostgreSql(Assembly, connectionStringFunc, options, configure); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/FodyWeavers.xml new file mode 100644 index 000000000..8304193f8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/FodyWeavers.xml @@ -0,0 +1,2 @@ + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Migrations/UserTasks/20260817120000_Initial.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Migrations/UserTasks/20260817120000_Initial.cs new file mode 100644 index 000000000..e5f7e3320 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/Migrations/UserTasks/20260817120000_Initial.cs @@ -0,0 +1,17 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.PostgreSql.Migrations.UserTasks; + +[DbContext(typeof(UserTasksElsaDbContext))] +[Migration("20260817120000_Initial")] +public partial class Initial : Migration +{ + private readonly IElsaDbContextSchema schema; + public Initial(IElsaDbContextSchema schema) => this.schema = schema ?? throw new ArgumentNullException(nameof(schema)); + protected override void Up(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Up(migrationBuilder, schema.Schema); + protected override void Down(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Down(migrationBuilder, schema.Schema); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/ShellFeatures/PostgreSqlUserTasksPersistenceShellFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/ShellFeatures/PostgreSqlUserTasksPersistenceShellFeature.cs new file mode 100644 index 000000000..8e41bb22f --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.PostgreSql/ShellFeatures/PostgreSqlUserTasksPersistenceShellFeature.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using CShells.Features; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.UserTasks.Persistence.EFCore.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.PostgreSql.ShellFeatures; + +[ManifestFeatureCategory("User Tasks")] +[ManifestFeatureCategory("Persistence")] +[ShellFeature(DisplayName = "PostgreSQL User Tasks Persistence", Description = "Provides PostgreSQL persistence for User Tasks", DependsOn = [typeof(UserTasksFeature)])] +[UsedImplicitly] +[ManifestInfrastructure("postgresql-database", "database", Reason = "Stores User Tasks data in PostgreSQL.", Providers = new[] { "PostgreSQL" }, ConfigurationKeys = new[] { "ConnectionString" })] +public sealed class PostgreSqlUserTasksPersistenceShellFeature : EFCoreUserTasksPersistenceShellFeatureBase +{ + protected override void ConfigureProvider(DbContextOptionsBuilder builder, Assembly migrationsAssembly, string connectionString, ElsaDbContextOptions? options) => builder.UseElsaPostgreSql(migrationsAssembly, connectionString, options); + protected override void OnConfiguring(IServiceCollection services) + { + services.AddPostgreSqlEntityModelCreatingHandlers(); + base.OnConfiguring(services); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/DbContextFactories.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/DbContextFactories.cs new file mode 100644 index 000000000..9016dedbe --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/DbContextFactories.cs @@ -0,0 +1,15 @@ +using Elsa.Persistence.EFCore.Abstractions; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore.SqlServer; + +[UsedImplicitly] +public class UserTasksDbContextFactory : SqlServerUserTasksDesignTimeDbContextFactory; + +public class SqlServerUserTasksDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase where TDbContext : DbContext +{ + protected override void ConfigureBuilder(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaSqlServer(GetType().Assembly, connectionString); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Elsa.UserTasks.Persistence.EFCore.SqlServer.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Elsa.UserTasks.Persistence.EFCore.SqlServer.csproj new file mode 100644 index 000000000..d85f42d51 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Elsa.UserTasks.Persistence.EFCore.SqlServer.csproj @@ -0,0 +1,8 @@ + + Provides SQL Server EF Core persistence for Elsa user tasks.elsa module user tasks persistence efcore sqlserver + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Extensions/SqlServerUserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Extensions/SqlServerUserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..6b1d5fb54 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Extensions/SqlServerUserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore.Features; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.SqlServer.Extensions; + +public static class SqlServerUserTasksPersistenceFeatureExtensions +{ + private static Assembly Assembly => typeof(SqlServerUserTasksPersistenceFeatureExtensions).Assembly; + + public static EFCoreUserTasksPersistenceFeature UseSqlServer(this EFCoreUserTasksPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseSqlServer(Assembly, connectionString, options, configure); + + public static EFCoreUserTasksPersistenceFeature UseSqlServer(this EFCoreUserTasksPersistenceFeature feature, Func connectionStringFunc, ElsaDbContextOptions? options = null, Action? configure = null) => feature.UseSqlServer(Assembly, connectionStringFunc, options, configure); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/FodyWeavers.xml new file mode 100644 index 000000000..8304193f8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/FodyWeavers.xml @@ -0,0 +1,2 @@ + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Migrations/UserTasks/20260817120000_Initial.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Migrations/UserTasks/20260817120000_Initial.cs new file mode 100644 index 000000000..53ec19143 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/Migrations/UserTasks/20260817120000_Initial.cs @@ -0,0 +1,17 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.SqlServer.Migrations.UserTasks; + +[DbContext(typeof(UserTasksElsaDbContext))] +[Migration("20260817120000_Initial")] +public partial class Initial : Migration +{ + private readonly IElsaDbContextSchema schema; + public Initial(IElsaDbContextSchema schema) => this.schema = schema ?? throw new ArgumentNullException(nameof(schema)); + protected override void Up(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Up(migrationBuilder, schema.Schema); + protected override void Down(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Down(migrationBuilder, schema.Schema); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/ShellFeatures/SqlServerUserTasksPersistenceShellFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/ShellFeatures/SqlServerUserTasksPersistenceShellFeature.cs new file mode 100644 index 000000000..1d2785fb5 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.SqlServer/ShellFeatures/SqlServerUserTasksPersistenceShellFeature.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using CShells.Features; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.UserTasks.Persistence.EFCore.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore.SqlServer.ShellFeatures; + +[ManifestFeatureCategory("User Tasks")] +[ManifestFeatureCategory("Persistence")] +[ShellFeature(DisplayName = "SQL Server User Tasks Persistence", Description = "Provides SQL Server persistence for User Tasks", DependsOn = [typeof(UserTasksFeature)])] +[UsedImplicitly] +[ManifestInfrastructure("sqlserver-database", "database", Reason = "Stores User Tasks data in SQL Server.", Providers = new[] { "SQL Server" }, ConfigurationKeys = new[] { "ConnectionString" })] +public sealed class SqlServerUserTasksPersistenceShellFeature : EFCoreUserTasksPersistenceShellFeatureBase +{ + protected override void ConfigureProvider(DbContextOptionsBuilder builder, Assembly migrationsAssembly, string connectionString, ElsaDbContextOptions? options) => builder.UseElsaSqlServer(migrationsAssembly, connectionString, options); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/DbContextFactories.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/DbContextFactories.cs new file mode 100644 index 000000000..a253f50f7 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/DbContextFactories.cs @@ -0,0 +1,24 @@ +using Elsa.Persistence.EFCore.Abstractions; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.Sqlite; + +[UsedImplicitly] +public class UserTasksDbContextFactory : SqliteUserTasksDesignTimeDbContextFactory; + +public class SqliteUserTasksDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase where TDbContext : DbContext +{ + protected override void ConfigureServices(IServiceCollection services) + { + services.AddSqliteEntityModelCreatingHandlers(); + } + + protected override void ConfigureBuilder(DbContextOptionsBuilder builder, string connectionString) + { + builder.UseElsaSqlite(GetType().Assembly, connectionString); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Elsa.UserTasks.Persistence.EFCore.Sqlite.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Elsa.UserTasks.Persistence.EFCore.Sqlite.csproj new file mode 100644 index 000000000..c9dab9158 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Elsa.UserTasks.Persistence.EFCore.Sqlite.csproj @@ -0,0 +1,11 @@ + + + Provides SQLite EF Core persistence for Elsa user tasks. + elsa module user tasks persistence efcore sqlite + + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Extensions/SqliteUserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Extensions/SqliteUserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..375a321d6 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Extensions/SqliteUserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.UserTasks.Persistence.EFCore.Features; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Elsa.UserTasks.Persistence.EFCore.Sqlite.Extensions; + +public static class SqliteUserTasksPersistenceFeatureExtensions +{ + private static Assembly Assembly => typeof(SqliteUserTasksPersistenceFeatureExtensions).Assembly; + + public static EFCoreUserTasksPersistenceFeature UseSqlite( + this EFCoreUserTasksPersistenceFeature feature, + string? connectionString = null, + ElsaDbContextOptions? options = null, + Action? configure = null) + { + connectionString ??= "Data Source=elsa.sqlite.db;Cache=Shared;"; + return feature.UseSqlite(_ => connectionString, options, configure); + } + + public static EFCoreUserTasksPersistenceFeature UseSqlite( + this EFCoreUserTasksPersistenceFeature feature, + Func connectionStringFunc, + ElsaDbContextOptions? options = null, + Action? configure = null) + { + return feature.UseSqlite(Assembly, connectionStringFunc, options, configure); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/FodyWeavers.xml new file mode 100644 index 000000000..bc5a74a23 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Migrations/UserTasks/20260817120000_Initial.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Migrations/UserTasks/20260817120000_Initial.cs new file mode 100644 index 000000000..1736b84e2 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/Migrations/UserTasks/20260817120000_Initial.cs @@ -0,0 +1,23 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Migrations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.Sqlite.Migrations.UserTasks; + +[DbContext(typeof(UserTasksElsaDbContext))] +[Migration("20260817120000_Initial")] +public partial class Initial : Migration +{ + private readonly IElsaDbContextSchema _schema; + + public Initial(IElsaDbContextSchema schema) + { + _schema = schema ?? throw new ArgumentNullException(nameof(schema)); + } + + protected override void Up(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Up(migrationBuilder, _schema.Schema); + + protected override void Down(MigrationBuilder migrationBuilder) => UserTasksSchemaMigration.Down(migrationBuilder, _schema.Schema); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/ShellFeatures/SqliteUserTasksPersistenceShellFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/ShellFeatures/SqliteUserTasksPersistenceShellFeature.cs new file mode 100644 index 000000000..2753f0743 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore.Sqlite/ShellFeatures/SqliteUserTasksPersistenceShellFeature.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using CShells.Features; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.UserTasks.Persistence.EFCore.ShellFeatures; +using Elsa.UserTasks.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.Sqlite.ShellFeatures; + +[ManifestFeatureCategory("User Tasks")] +[ManifestFeatureCategory("Persistence")] +[ShellFeature( + DisplayName = "SQLite User Tasks Persistence", + Description = "Provides SQLite persistence for User Tasks", + DependsOn = [typeof(UserTasksFeature)])] +[UsedImplicitly] +[ManifestInfrastructure("sqlite-database", "database", Reason = "Stores User Tasks data in SQLite.", Providers = new[] { "SQLite" }, ConfigurationKeys = new[] { "ConnectionString" })] +public sealed class SqliteUserTasksPersistenceShellFeature : EFCoreUserTasksPersistenceShellFeatureBase +{ + protected override void ConfigureProvider(DbContextOptionsBuilder builder, Assembly migrationsAssembly, string connectionString, ElsaDbContextOptions? options) + { + builder.UseElsaSqlite(migrationsAssembly, connectionString, options); + } + + protected override void OnConfiguring(IServiceCollection services) + { + services.AddSqliteEntityModelCreatingHandlers(); + base.OnConfiguring(services); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Contracts/IUserTaskPersistenceAdapter.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Contracts/IUserTaskPersistenceAdapter.cs new file mode 100644 index 000000000..91e9e10ba --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Contracts/IUserTaskPersistenceAdapter.cs @@ -0,0 +1,34 @@ +namespace Elsa.UserTasks.Persistence.EFCore.Contracts; + +/// +/// Low-level record adapter used by migration/reconciliation infrastructure. The public aggregate +/// repository is implemented by the same EF repository below. +/// +public interface IUserTaskPersistenceAdapter +{ + Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default); + + Task> QueryAsync( + UserTaskPersistenceQuery query, + CancellationToken cancellationToken = default); + + Task TryAddProjectionAsync( + UserTaskRecord task, + CancellationToken cancellationToken = default); + + Task TrySaveAsync( + UserTaskRecord task, + int expectedRevision, + CancellationToken cancellationToken = default); +} + +public sealed class UserTaskPersistenceQuery +{ + public string TenantId { get; init; } = default!; + public IReadOnlyCollection Statuses { get; init; } = []; + public string? AssigneeProvider { get; init; } + public string? AssigneeType { get; init; } + public string? AssigneeId { get; init; } + public string? Search { get; init; } + public int? Limit { get; init; } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Elsa.UserTasks.Persistence.EFCore.csproj b/src/modules/Elsa.UserTasks.Persistence.EFCore/Elsa.UserTasks.Persistence.EFCore.csproj new file mode 100644 index 000000000..5df5e6f2f --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Elsa.UserTasks.Persistence.EFCore.csproj @@ -0,0 +1,17 @@ + + + + + Provides Entity Framework Core persistence for Elsa user tasks. + + elsa module user tasks persistence efcore + + + + + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Extensions/UserTasksPersistenceFeatureExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Extensions/UserTasksPersistenceFeatureExtensions.cs new file mode 100644 index 000000000..ca1d74990 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Extensions/UserTasksPersistenceFeatureExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.UserTasks.Features; +using Elsa.UserTasks.Persistence.EFCore.Features; + +namespace Elsa.UserTasks.Persistence.EFCore.Extensions; + +public static class UserTasksPersistenceFeatureExtensions +{ + public static UserTasksFeature UseEntityFrameworkCore(this UserTasksFeature feature, Action? configure = null) + { + feature.Module.Configure(configure); + return feature; + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Features/EFCoreUserTasksPersistenceFeature.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Features/EFCoreUserTasksPersistenceFeature.cs new file mode 100644 index 000000000..448464cfe --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Features/EFCoreUserTasksPersistenceFeature.cs @@ -0,0 +1,33 @@ +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Features; +using Elsa.UserTasks.Persistence.EFCore.Contracts; +using Elsa.UserTasks.Persistence.EFCore.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.Features; + +/// +/// Configures the identity-neutral User Tasks repository with EF Core persistence. +/// +[DependsOn(typeof(UserTasksFeature))] +public class EFCoreUserTasksPersistenceFeature(IModule module) + : PersistenceFeatureBase(module) +{ + public override void Apply() + { + base.Apply(); + AddStore(); + AddStore(); + AddStore(); + Services.AddScoped(); + Services.AddScoped(); + + // Guest sessions and invitation deliveries carry secrets that must survive a restart and a + // failover, so the provider replaces the Core in-memory defaults registered by TryAdd. + Services.AddScoped(); + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.EFCore/FodyWeavers.xml new file mode 100644 index 000000000..bc5a74a23 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs new file mode 100644 index 000000000..dfdaaa967 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs @@ -0,0 +1,264 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.UserTasks.Persistence.EFCore.Migrations; + +/// +/// Provider-neutral initial schema operations. Provider migration assemblies call this helper so their +/// generated migration shape stays functionally identical while EF chooses provider column types. +/// +public static class UserTasksSchemaMigration +{ + public static void Up(MigrationBuilder migrationBuilder, string schema) + { + migrationBuilder.EnsureSchema(schema); + + migrationBuilder.CreateTable(name: "UserTasks", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + WorkflowDefinitionId = table.Column(maxLength: 450, nullable: false), + WorkflowDefinitionName = table.Column(maxLength: 500, nullable: true), + WorkflowDefinitionVersion = table.Column(nullable: true), + WorkflowInstanceId = table.Column(maxLength: 450, nullable: false), + WorkflowInstanceReference = table.Column(maxLength: 500, nullable: true), + ActivityInstanceId = table.Column(maxLength: 450, nullable: false), + BookmarkId = table.Column(maxLength: 450, nullable: false), + MaterializationKey = table.Column(maxLength: 900, nullable: false), + Title = table.Column(maxLength: 500, nullable: false), + Summary = table.Column(maxLength: 4000, nullable: true), + Reference = table.Column(maxLength: 500, nullable: true), + TaskType = table.Column(maxLength: 200, nullable: true), + TagsJson = table.Column(nullable: false), + RequesterProvider = table.Column(maxLength: 200, nullable: true), + RequesterType = table.Column(maxLength: 64, nullable: true), + RequesterId = table.Column(maxLength: 450, nullable: true), + RequesterDisplayName = table.Column(maxLength: 500, nullable: true), + Priority = table.Column(nullable: false), + DueAt = table.Column(nullable: true), + IsOverdue = table.Column(nullable: false), + Status = table.Column(maxLength: 32, nullable: false), + TimeoutEnabled = table.Column(nullable: false), + CancellationEnabled = table.Column(nullable: false), + AllowManagerExclusionOverride = table.Column(nullable: false), + MembershipResolutionMode = table.Column(maxLength: 32, nullable: true), + AssigneeProvider = table.Column(maxLength: 200, nullable: true), + AssigneeType = table.Column(maxLength: 64, nullable: true), + AssigneeId = table.Column(maxLength: 450, nullable: true), + AssigneeDisplayName = table.Column(maxLength: 500, nullable: true), + InstructionsJson = table.Column(nullable: false), + TaskDataJson = table.Column(nullable: false), + FormReferenceJson = table.Column(nullable: true), + PinnedFormJson = table.Column(nullable: true), + ActionsJson = table.Column(nullable: true), + InvitationDefinitionsJson = table.Column(nullable: false), + HealthIssuesJson = table.Column(nullable: false), + HealthSeverity = table.Column(maxLength: 32, nullable: true), + HealthCode = table.Column(maxLength: 200, nullable: true), + HealthMessage = table.Column(maxLength: 4000, nullable: true), + CompletionActionKey = table.Column(maxLength: 200, nullable: true), + CompletionDataJson = table.Column(nullable: true), + CompletionActorJson = table.Column(nullable: true), + CreatedAt = table.Column(nullable: false), + UpdatedAt = table.Column(nullable: false), + AssignedAt = table.Column(nullable: true), + CompletedAt = table.Column(nullable: true), + Revision = table.Column(nullable: false), + CreatedFromBookmarkRevision = table.Column(nullable: true) + }, + constraints: table => table.PrimaryKey("PK_UserTasks", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskCandidates", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + Provider = table.Column(maxLength: 200, nullable: false), + ParticipantKey = table.Column(maxLength: 800, nullable: false), + ParticipantType = table.Column(maxLength: 32, nullable: false), + ParticipantId = table.Column(maxLength: 450, nullable: false), + DisplayName = table.Column(maxLength: 500, nullable: true), + Source = table.Column(maxLength: 32, nullable: false), + SourceGroupProvider = table.Column(maxLength: 200, nullable: true), + SourceGroupId = table.Column(maxLength: 450, nullable: true), + CreatedAt = table.Column(nullable: false), + UpdatedAt = table.Column(nullable: false) + }, + constraints: table => table.PrimaryKey("PK_UserTaskCandidates", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskSnapshotMembers", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + Provider = table.Column(maxLength: 200, nullable: false), + ParticipantKey = table.Column(maxLength: 800, nullable: false), + ParticipantType = table.Column(maxLength: 32, nullable: false), + ParticipantId = table.Column(maxLength: 450, nullable: false), + SourceGroupProvider = table.Column(maxLength: 200, nullable: true), + SourceGroupId = table.Column(maxLength: 450, nullable: true), + CreatedAt = table.Column(nullable: false) + }, + constraints: table => table.PrimaryKey("PK_UserTaskSnapshotMembers", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskExclusions", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + Provider = table.Column(maxLength: 200, nullable: false), + ParticipantKey = table.Column(maxLength: 800, nullable: false), + ParticipantType = table.Column(maxLength: 32, nullable: false), + ParticipantId = table.Column(maxLength: 450, nullable: false), + CreatedAt = table.Column(nullable: false) + }, + constraints: table => table.PrimaryKey("PK_UserTaskExclusions", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskEvents", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + Revision = table.Column(nullable: false), + EventType = table.Column(maxLength: 64, nullable: false), + OccurredAt = table.Column(nullable: false), + OperationId = table.Column(maxLength: 450, nullable: true), + ActorProvider = table.Column(maxLength: 200, nullable: true), + ActorType = table.Column(maxLength: 64, nullable: true), + ActorId = table.Column(maxLength: 450, nullable: true), + ActorJson = table.Column(nullable: true), + Reason = table.Column(maxLength: 4000, nullable: true), + MetadataJson = table.Column(nullable: false) + }, + constraints: table => table.PrimaryKey("PK_UserTaskEvents", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskOperations", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + OperationId = table.Column(maxLength: 450, nullable: false), + Kind = table.Column(maxLength: 64, nullable: false), + ExpectedRevision = table.Column(nullable: false), + RequestHash = table.Column(maxLength: 128, nullable: false), + Status = table.Column(maxLength: 32, nullable: false), + Attempts = table.Column(nullable: false), + SafeResultJson = table.Column(nullable: true), + ProtectedPayloadJson = table.Column(nullable: true), + ActionKey = table.Column(maxLength: 200, nullable: true), + ErrorCode = table.Column(maxLength: 200, nullable: true), + CreatedAt = table.Column(nullable: false), + UpdatedAt = table.Column(nullable: false), + CompletedAt = table.Column(nullable: true) + }, + constraints: table => table.PrimaryKey("PK_UserTaskOperations", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskInvitations", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + SiblingGroupId = table.Column(maxLength: 450, nullable: true), + RecipientJson = table.Column(nullable: true), + TokenHash = table.Column(maxLength: 256, nullable: false), + VerifierProvider = table.Column(maxLength: 200, nullable: false), + AllowedActionsJson = table.Column(nullable: false), + ChallengeJson = table.Column(nullable: true), + Status = table.Column(maxLength: 32, nullable: false), + IssuedAt = table.Column(nullable: false), + ExpiresAt = table.Column(nullable: false), + VerifiedAt = table.Column(nullable: true), + ConsumedAt = table.Column(nullable: true), + RevokedAt = table.Column(nullable: true) + }, + constraints: table => table.PrimaryKey("PK_UserTaskInvitations", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskInvitationDeliveries", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + InvitationId = table.Column(maxLength: 450, nullable: false), + DispatcherProvider = table.Column(maxLength: 200, nullable: false), + EncryptedToken = table.Column(nullable: false), + DeliveryMetadataJson = table.Column(nullable: true), + Status = table.Column(maxLength: 32, nullable: false), + Attempts = table.Column(nullable: false), + AvailableAt = table.Column(nullable: false), + ExpiresAt = table.Column(nullable: false), + LastErrorCode = table.Column(maxLength: 128, nullable: true), + CreatedAt = table.Column(nullable: false), + DeliveredAt = table.Column(nullable: true) + }, + constraints: table => table.PrimaryKey("PK_UserTaskInvitationDeliveries", x => x.Id)); + + migrationBuilder.CreateTable(name: "UserTaskGuestSessions", schema: schema, + columns: table => new + { + Id = table.Column(maxLength: 450, nullable: false), + TenantId = table.Column(maxLength: 450, nullable: false), + TaskId = table.Column(maxLength: 450, nullable: false), + InvitationId = table.Column(maxLength: 450, nullable: false), + SessionTokenHash = table.Column(maxLength: 256, nullable: false), + GuestParticipantJson = table.Column(nullable: false), + CapabilitiesJson = table.Column(nullable: false), + IssuedAt = table.Column(nullable: false), + ExpiresAt = table.Column(nullable: false), + RevokedAt = table.Column(nullable: true) + }, + constraints: table => table.PrimaryKey("PK_UserTaskGuestSessions", x => x.Id)); + + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_MaterializationKey", table: "UserTasks", columns: ["TenantId", "MaterializationKey"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_BookmarkId", table: "UserTasks", columns: ["TenantId", "BookmarkId"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_Status", table: "UserTasks", columns: ["TenantId", "Status"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_Assignee", table: "UserTasks", columns: ["TenantId", "AssigneeProvider", "AssigneeType", "AssigneeId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_Priority", table: "UserTasks", columns: ["TenantId", "Priority"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_DueAt", table: "UserTasks", columns: ["TenantId", "DueAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_WorkflowDefinition", table: "UserTasks", columns: ["TenantId", "WorkflowDefinitionId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_WorkflowInstance", table: "UserTasks", columns: ["TenantId", "WorkflowInstanceId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_ActivityInstance", table: "UserTasks", columns: ["TenantId", "ActivityInstanceId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_CreatedAt", table: "UserTasks", columns: ["TenantId", "CreatedAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTasks_Tenant_CompletedAt", table: "UserTasks", columns: ["TenantId", "CompletedAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskCandidates_Tenant_Task_Participant", table: "UserTaskCandidates", columns: ["TenantId", "TaskId", "Provider", "ParticipantType", "ParticipantId", "Source"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskCandidates_Tenant_Participant", table: "UserTaskCandidates", columns: ["TenantId", "Provider", "ParticipantType", "ParticipantId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskCandidates_Tenant_Task", table: "UserTaskCandidates", columns: ["TenantId", "TaskId"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskCandidates_Tenant_Task_ParticipantKey", table: "UserTaskCandidates", columns: ["TenantId", "TaskId", "ParticipantKey"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskSnapshotMembers_Tenant_Task_Participant", table: "UserTaskSnapshotMembers", columns: ["TenantId", "TaskId", "Provider", "ParticipantType", "ParticipantId"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskSnapshotMembers_Tenant_Task_ParticipantKey", table: "UserTaskSnapshotMembers", columns: ["TenantId", "TaskId", "ParticipantKey"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskExclusions_Tenant_Task_Participant", table: "UserTaskExclusions", columns: ["TenantId", "TaskId", "Provider", "ParticipantType", "ParticipantId"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskExclusions_Tenant_Task_ParticipantKey", table: "UserTaskExclusions", columns: ["TenantId", "TaskId", "ParticipantKey"], schema: schema); + // Not unique: audit is append-only and several entries may share a revision, because an audited read + // (a masked-field reveal) records without consuming the aggregate's concurrency token. + migrationBuilder.CreateIndex(name: "IX_UserTaskEvents_Task_Revision", table: "UserTaskEvents", columns: ["TaskId", "Revision"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskEvents_Tenant_Task_OccurredAt", table: "UserTaskEvents", columns: ["TenantId", "TaskId", "OccurredAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskOperations_Tenant_Task_Operation", table: "UserTaskOperations", columns: ["TenantId", "TaskId", "OperationId"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskOperations_Tenant_Status_UpdatedAt", table: "UserTaskOperations", columns: ["TenantId", "Status", "UpdatedAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskInvitations_TokenHash", table: "UserTaskInvitations", column: "TokenHash", schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskInvitations_Tenant_Task_Status", table: "UserTaskInvitations", columns: ["TenantId", "TaskId", "Status"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskInvitations_Tenant_Status_ExpiresAt", table: "UserTaskInvitations", columns: ["TenantId", "Status", "ExpiresAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskInvitationDeliveries_Tenant_Status_AvailableAt", table: "UserTaskInvitationDeliveries", columns: ["TenantId", "Status", "AvailableAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskInvitationDeliveries_Tenant_Invitation", table: "UserTaskInvitationDeliveries", columns: ["TenantId", "InvitationId"], schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskGuestSessions_SessionTokenHash", table: "UserTaskGuestSessions", column: "SessionTokenHash", schema: schema, unique: true); + migrationBuilder.CreateIndex(name: "IX_UserTaskGuestSessions_Tenant_Task_ExpiresAt", table: "UserTaskGuestSessions", columns: ["TenantId", "TaskId", "ExpiresAt"], schema: schema); + } + + public static void Down(MigrationBuilder migrationBuilder, string schema) + { + migrationBuilder.DropTable("UserTaskGuestSessions", schema); + migrationBuilder.DropTable("UserTaskInvitationDeliveries", schema); + migrationBuilder.DropTable("UserTaskInvitations", schema); + migrationBuilder.DropTable("UserTaskOperations", schema); + migrationBuilder.DropTable("UserTaskEvents", schema); + migrationBuilder.DropTable("UserTaskExclusions", schema); + migrationBuilder.DropTable("UserTaskSnapshotMembers", schema); + migrationBuilder.DropTable("UserTaskCandidates", schema); + migrationBuilder.DropTable("UserTasks", schema); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/PersistenceModels.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/PersistenceModels.cs new file mode 100644 index 000000000..f68bc7864 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/PersistenceModels.cs @@ -0,0 +1,218 @@ +using Elsa.UserTasks.Models; + +namespace Elsa.UserTasks.Persistence.EFCore; + +/// +/// EF records intentionally keep JSON payloads and participant references flattened so every relational +/// provider has the same schema. The public User Tasks aggregate is materialized by the repository. +/// +public enum UserTaskPersistenceCandidateSource +{ + DirectUser, + DirectGroup, + SnapshotMember, + Invitation +} + +public sealed class UserTaskRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string WorkflowDefinitionId { get; set; } = default!; + public string? WorkflowDefinitionName { get; set; } + public int? WorkflowDefinitionVersion { get; set; } + public string WorkflowInstanceId { get; set; } = default!; + public string? WorkflowInstanceReference { get; set; } + public string ActivityInstanceId { get; set; } = default!; + public string BookmarkId { get; set; } = default!; + public string MaterializationKey { get; set; } = default!; + public string Title { get; set; } = default!; + public string? Summary { get; set; } + public string? Reference { get; set; } + public string? TaskType { get; set; } + public string TagsJson { get; set; } = "[]"; + public string? RequesterProvider { get; set; } + public string? RequesterType { get; set; } + public string? RequesterId { get; set; } + public string? RequesterDisplayName { get; set; } + public int Priority { get; set; } = 50; + public DateTimeOffset? DueAt { get; set; } + public bool IsOverdue { get; set; } + public UserTaskStatus Status { get; set; } + public bool TimeoutEnabled { get; set; } + public bool CancellationEnabled { get; set; } + public bool AllowManagerExclusionOverride { get; set; } + public UserTaskMembershipResolutionMode? MembershipResolutionMode { get; set; } + public string? AssigneeProvider { get; set; } + public string? AssigneeType { get; set; } + public string? AssigneeId { get; set; } + public string? AssigneeDisplayName { get; set; } + public string InstructionsJson { get; set; } = "null"; + public string TaskDataJson { get; set; } = "null"; + public string? FormReferenceJson { get; set; } + public string? PinnedFormJson { get; set; } + public string? ActionsJson { get; set; } + public string InvitationDefinitionsJson { get; set; } = "[]"; + public string HealthIssuesJson { get; set; } = "[]"; + public UserTaskHealthSeverity? HealthSeverity { get; set; } + public string? HealthCode { get; set; } + public string? HealthMessage { get; set; } + public string? CompletionActionKey { get; set; } + public string? CompletionDataJson { get; set; } + public string? CompletionActorJson { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? AssignedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public int Revision { get; set; } + public long? CreatedFromBookmarkRevision { get; set; } +} + +public sealed class UserTaskCandidateRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string Provider { get; set; } = default!; + public string ParticipantKey { get; set; } = default!; + public UserTaskParticipantType ParticipantType { get; set; } + public string ParticipantId { get; set; } = default!; + public string? DisplayName { get; set; } + public UserTaskPersistenceCandidateSource Source { get; set; } + public string? SourceGroupProvider { get; set; } + public string? SourceGroupId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserTaskSnapshotMemberRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string Provider { get; set; } = default!; + public string ParticipantKey { get; set; } = default!; + public UserTaskParticipantType ParticipantType { get; set; } = UserTaskParticipantType.User; + public string ParticipantId { get; set; } = default!; + public string? SourceGroupProvider { get; set; } + public string? SourceGroupId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserTaskExclusionRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string Provider { get; set; } = default!; + public string ParticipantKey { get; set; } = default!; + public UserTaskParticipantType ParticipantType { get; set; } = UserTaskParticipantType.User; + public string ParticipantId { get; set; } = default!; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserTaskEventRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public int Revision { get; set; } + public string EventType { get; set; } = default!; + public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow; + public string? OperationId { get; set; } + public string? ActorProvider { get; set; } + public string? ActorType { get; set; } + public string? ActorId { get; set; } + public string? ActorJson { get; set; } + public string? Reason { get; set; } + public string MetadataJson { get; set; } = "{}"; +} + +public enum UserTaskPersistenceOperationStatus +{ + Pending, + Enqueued, + Completed, + Failed, + Abandoned +} + +public sealed class UserTaskOperationRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string OperationId { get; set; } = default!; + public string Kind { get; set; } = default!; + public int ExpectedRevision { get; set; } + public string RequestHash { get; set; } = default!; + public UserTaskPersistenceOperationStatus Status { get; set; } + public int Attempts { get; set; } + public string? SafeResultJson { get; set; } + public string? ProtectedPayloadJson { get; set; } + public string? ActionKey { get; set; } + public string? ErrorCode { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CompletedAt { get; set; } +} + +public sealed class UserTaskInvitationRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string? SiblingGroupId { get; set; } + public string? RecipientJson { get; set; } + public string TokenHash { get; set; } = default!; + public string VerifierProvider { get; set; } = default!; + public string AllowedActionsJson { get; set; } = "[]"; + public string? ChallengeJson { get; set; } + public UserTaskInvitationStatus Status { get; set; } + public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? VerifiedAt { get; set; } + public DateTimeOffset? ConsumedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} + +public enum UserTaskPersistenceDeliveryStatus +{ + Pending, + Processing, + Delivered, + Failed, + Expired +} + +public sealed class UserTaskInvitationDeliveryRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string InvitationId { get; set; } = default!; + public string DispatcherProvider { get; set; } = default!; + public string EncryptedToken { get; set; } = default!; + public string? DeliveryMetadataJson { get; set; } + public UserTaskPersistenceDeliveryStatus Status { get; set; } + public int Attempts { get; set; } + public DateTimeOffset AvailableAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } + public string? LastErrorCode { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? DeliveredAt { get; set; } +} + +public sealed class UserTaskGuestSessionRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = default!; + public string TaskId { get; set; } = default!; + public string InvitationId { get; set; } = default!; + public string SessionTokenHash { get; set; } = default!; + public string GuestParticipantJson { get; set; } = default!; + public string CapabilitiesJson { get; set; } = "[]"; + public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs new file mode 100644 index 000000000..4ea91eb98 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs @@ -0,0 +1,184 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Common; +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Persistence.EFCore.Repositories; + +internal static class UserTaskSecretHashing +{ + public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + + public static string CreateToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} + +/// +/// Durable guest session store. Only the credential hash is written, and every read re-checks expiry and +/// revocation so a session cannot outlive its task across a restart or a failover. +/// +public sealed class EFCoreUserTaskGuestSessionIssuer( + Store store, + ISystemClock clock, + IOptions options) : IUserTaskGuestSessionIssuer +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + + public async Task IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default) + { + var now = clock.UtcNow; + var expiresAt = invitation.ExpiresAt <= now.Add(options.Value.GuestSessionLifetime) ? invitation.ExpiresAt : now.Add(options.Value.GuestSessionLifetime); + if (expiresAt <= now) + return new(false, FailureCode: "session-unavailable"); + + var token = UserTaskSecretHashing.CreateToken(); + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + dbContext.UserTaskGuestSessions.Add(new() + { + TenantId = invitation.TenantId, + TaskId = invitation.TaskId, + InvitationId = invitation.Id, + SessionTokenHash = UserTaskSecretHashing.Hash(token), + GuestParticipantJson = JsonSerializer.Serialize(subject, JsonOptions), + CapabilitiesJson = JsonSerializer.Serialize(invitation.AllowedActions, JsonOptions), + IssuedAt = now, + ExpiresAt = expiresAt + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new(true, token, expiresAt, TaskId: invitation.TaskId); + } + + public async Task ResolveAsync(string credential, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(credential)) + return null; + + var hash = UserTaskSecretHashing.Hash(credential); + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var now = clock.UtcNow; + var row = await dbContext.UserTaskGuestSessions.AsNoTracking() + .FirstOrDefaultAsync(x => x.SessionTokenHash == hash && x.RevokedAt == null && x.ExpiresAt > now, cancellationToken); + if (row is null) + return null; + + var subject = JsonSerializer.Deserialize(row.GuestParticipantJson, JsonOptions); + if (subject is null) + return null; + + var actions = JsonSerializer.Deserialize>(row.CapabilitiesJson, JsonOptions) ?? []; + return new(row.TenantId, row.TaskId, subject, actions, row.ExpiresAt); + } + + public async Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + await dbContext.UserTaskGuestSessions + .Where(x => x.TenantId == tenantId && x.TaskId == taskId && x.RevokedAt == null) + .ExecuteUpdateAsync(x => x.SetProperty(p => p.RevokedAt, clock.UtcNow), cancellationToken); + } +} + +/// +/// Durable invitation-delivery outbox. Tokens are encrypted with ASP.NET Core Data Protection before they +/// reach the database, so a table dump never yields a usable invitation link. +/// +public sealed class EFCoreUserTaskInvitationOutbox( + Store store, + IDataProtectionProvider dataProtectionProvider, + ISystemClock clock, + IOptions options) : IUserTaskInvitationOutbox +{ + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelivery.v1"); + + public async Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + dbContext.UserTaskInvitationDeliveries.Add(new() + { + Id = delivery.Id, + TenantId = delivery.TenantId, + TaskId = delivery.TaskId, + InvitationId = delivery.InvitationId, + DispatcherProvider = delivery.DispatcherName, + EncryptedToken = _protector.Protect(delivery.Token), + DeliveryMetadataJson = delivery.Recipient == null ? null : JsonSerializer.Serialize(new { delivery.Recipient }), + Status = UserTaskPersistenceDeliveryStatus.Pending, + AvailableAt = delivery.NotBefore ?? clock.UtcNow, + ExpiresAt = delivery.ExpiresAt, + CreatedAt = clock.UtcNow + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> DequeueDueAsync(int maxCount, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var now = clock.UtcNow; + // Expired secrets are dropped, never delivered late. + await dbContext.UserTaskInvitationDeliveries.Where(x => x.ExpiresAt <= now).ExecuteDeleteAsync(cancellationToken); + + var rows = await dbContext.UserTaskInvitationDeliveries.AsNoTracking() + .Where(x => x.Status == UserTaskPersistenceDeliveryStatus.Pending && x.AvailableAt <= now) + .OrderBy(x => x.AvailableAt) + .Take(Math.Max(1, maxCount)) + .ToListAsync(cancellationToken); + + var deliveries = new List(rows.Count); + foreach (var row in rows) + { + string token; + try + { + token = _protector.Unprotect(row.EncryptedToken); + } + catch (CryptographicException) + { + // A rotated or unavailable key makes the secret unrecoverable; drop it so a manager reissues. + await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == row.Id).ExecuteDeleteAsync(cancellationToken); + continue; + } + + deliveries.Add(new(row.Id, row.TenantId, row.TaskId, row.InvitationId, row.DispatcherProvider, token, row.ExpiresAt) + { + Attempt = row.Attempts, + NotBefore = row.AvailableAt + }); + } + + return deliveries; + } + + public async Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + // Delivery succeeded, so the encrypted secret has no further purpose and is removed outright. + await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == deliveryId).ExecuteDeleteAsync(cancellationToken); + } + + public async Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var row = await dbContext.UserTaskInvitationDeliveries.FirstOrDefaultAsync(x => x.Id == deliveryId, cancellationToken); + if (row is null) + return; + + row.Attempts += 1; + if (row.Attempts > options.Value.InvitationDeliveryRetryDelays.Count) + { + dbContext.UserTaskInvitationDeliveries.Remove(row); + } + else + { + row.AvailableAt = notBefore; + row.LastErrorCode = "dispatch-failed"; + } + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs new file mode 100644 index 000000000..6e7707897 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs @@ -0,0 +1,794 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Persistence.EFCore.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore.Repositories; + +/// +/// EF Core implementation of the public User Tasks repository and the low-level projection adapter. +/// Participant identities are flattened into provider/type/id columns; no identity or foreign-key +/// dependency is introduced by this package. +/// +public sealed class EFCoreUserTaskRepository(Store store) + : IUserTaskRepository, IUserTaskPersistenceAdapter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + public async Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == taskId, cancellationToken); + return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken); + } + + public async Task QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var records = BuildQuery(dbContext, query); + int? totalCount = query.IncludeTotalCount ? await records.CountAsync(cancellationToken) : null; + var limit = Math.Clamp(query.Limit <= 0 ? 50 : query.Limit, 1, 200); + var rows = await ApplyCursor(ApplyOrdering(records, query), query).Take(limit + 1).ToListAsync(cancellationToken); + var hasMore = rows.Count > limit; + var page = rows.Take(limit).ToList(); + + // The query predicate is intentionally SQL-side, but the policy layer still needs the + // normalized candidate/snapshot/exclusion/history relationships to calculate allowed actions. + // Hydrate those relationships in bounded batches after paging rather than issuing one query per + // task or allowing a summary projection with empty candidate collections. + var items = await LoadAggregatesAsync(dbContext, page, cancellationToken); + return new UserTaskQueryResult(items, hasMore ? CreateCursor(page[^1], query.Sort) : null, totalCount); + } + + public async Task FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.MaterializationKey == key, cancellationToken); + return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken); + } + + public async Task FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.BookmarkId == bookmarkId, cancellationToken); + return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken); + } + + public async Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + // The unique index on TokenHash makes this a single seek. Deliberately not tenant-filtered: an + // anonymous holder presents only a secret and must not be trusted to name its own tenant. + var row = await dbContext.UserTaskInvitations.AsNoTracking().FirstOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken); + if (row is null) + return null; + + var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == row.TenantId && x.Id == row.TaskId, cancellationToken); + if (record is null) + return null; + + var task = await LoadAggregateAsync(dbContext, record, cancellationToken); + return (task, ToInvitation(row)); + } + + public async Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var existing = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == task.TenantId && x.Id == task.Id, cancellationToken); + EnsureExpectedRevision(existing, task.Id, expectedRevision); + + Copy(task, existing!); + existing!.Revision = expectedRevision + 1; + existing.UpdatedAt = DateTimeOffset.UtcNow; + await ReplaceChildrenAsync(dbContext, task, cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + task.Revision = existing.Revision; + task.UpdatedAt = existing.UpdatedAt; + } + catch (DbUpdateConcurrencyException exception) + { + await transaction.RollbackAsync(cancellationToken); + // Translated to the repository contract's exception so callers can distinguish a lost + // optimistic-concurrency race from a fault without depending on EF Core. + throw new UserTaskRevisionConflictException(task.Id, expectedRevision, exception); + } + } + + public async Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken)) + return; + + dbContext.UserTasks.Add(ToRecord(task)); + dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCandidateSource.DirectUser))); + dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceCandidateSource.DirectGroup))); + dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x))); + dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x))); + dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x))); + dbContext.UserTaskEvents.AddRange(task.Events.Select(ToEventRecord)); + dbContext.UserTaskOperations.AddRange(task.Operations.Select(ToOperationRecord)); + dbContext.UserTaskInvitations.AddRange(task.Invitations.Select(ToInvitationRecord)); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + if (!await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken)) + throw; + // A concurrent projection won the unique materialization-key race. Projection is idempotent. + } + } + + public async Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + if (!await dbContext.UserTasks.AsNoTracking().AnyAsync(x => x.TenantId == tenantId && x.Id == taskId, cancellationToken)) + return; + + // A plain insert: the aggregate row, and therefore its revision, is deliberately left untouched. + dbContext.UserTaskEvents.Add(ToEventRecord(@event)); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func mutation, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var record = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == taskId, cancellationToken); + if (record is null || record.Revision != expectedRevision) + return false; + + var task = await LoadAggregateAsync(dbContext, record, cancellationToken); + if (!mutation(task)) + return false; + + Copy(task, record); + record.Revision = expectedRevision + 1; + record.UpdatedAt = DateTimeOffset.UtcNow; + await ReplaceChildrenAsync(dbContext, task, cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + await transaction.RollbackAsync(cancellationToken); + return false; + } + } + + async Task IUserTaskPersistenceAdapter.GetAsync(string tenantId, string taskId, CancellationToken cancellationToken) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + return await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == taskId, cancellationToken); + } + + public async Task> QueryAsync(UserTaskPersistenceQuery query, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var records = dbContext.UserTasks.AsNoTracking().Where(x => x.TenantId == query.TenantId); + if (query.Statuses.Count > 0) + records = records.Where(x => query.Statuses.Contains(x.Status)); + if (!string.IsNullOrWhiteSpace(query.AssigneeProvider)) + records = records.Where(x => x.AssigneeProvider == query.AssigneeProvider); + if (!string.IsNullOrWhiteSpace(query.AssigneeType)) + records = records.Where(x => x.AssigneeType == query.AssigneeType); + if (!string.IsNullOrWhiteSpace(query.AssigneeId)) + records = records.Where(x => x.AssigneeId == query.AssigneeId); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + records = records.Where(x => x.Title.Contains(search) || (x.Summary != null && x.Summary.Contains(search)) || (x.Reference != null && x.Reference.Contains(search)) || (x.TaskType != null && x.TaskType.Contains(search))); + } + + var limit = query.Limit is > 0 ? Math.Min(query.Limit.Value, 200) : 100; + return await records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenByDescending(x => x.Priority).ThenBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(limit).ToListAsync(cancellationToken); + } + + public async Task TryAddProjectionAsync(UserTaskRecord task, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken)) + return false; + dbContext.UserTasks.Add(task); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException) + { + if (!await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken)) + throw; + return false; + } + } + + public async Task TrySaveAsync(UserTaskRecord task, int expectedRevision, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var existing = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == task.TenantId && x.Id == task.Id, cancellationToken); + if (existing is null || existing.Revision != expectedRevision) + return false; + Copy(task, existing); + existing.Revision = expectedRevision + 1; + existing.UpdatedAt = DateTimeOffset.UtcNow; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + } + + private static IQueryable BuildQuery(UserTasksElsaDbContext dbContext, UserTaskQuery query) + { + var records = dbContext.UserTasks.AsNoTracking(); + records = records.Where(x => x.TenantId == query.TenantId); + if (query.Statuses.Count > 0) + { + var statuses = query.Statuses.ToArray(); + records = records.Where(x => statuses.Contains(x.Status)); + } + if (query.OnlyOverdue) + records = records.Where(x => x.IsOverdue); + if (query.OnlyWithoutDueDate) + records = records.Where(x => x.DueAt == null); + if (!string.IsNullOrWhiteSpace(query.TaskType)) + records = records.Where(x => x.TaskType == query.TaskType); + if (query.PriorityFrom is not null) + records = records.Where(x => x.Priority >= query.PriorityFrom); + if (query.PriorityTo is not null) + records = records.Where(x => x.Priority <= query.PriorityTo); + if (query.DueFrom is not null) + records = records.Where(x => x.DueAt >= query.DueFrom); + if (query.DueTo is not null) + records = records.Where(x => x.DueAt <= query.DueTo); + if (!string.IsNullOrWhiteSpace(query.WorkflowDefinitionId)) + records = records.Where(x => x.WorkflowDefinitionId == query.WorkflowDefinitionId); + if (!string.IsNullOrWhiteSpace(query.WorkflowInstanceId)) + records = records.Where(x => x.WorkflowInstanceId == query.WorkflowInstanceId); + if (!string.IsNullOrWhiteSpace(query.Reference)) + records = records.Where(x => x.Reference == query.Reference); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + records = records.Where(x => x.Title.Contains(search) || (x.Summary != null && x.Summary.Contains(search)) || (x.Reference != null && x.Reference.Contains(search)) || (x.TaskType != null && x.TaskType.Contains(search))); + } + if (query.Scope is { } requestedScope && + (!string.Equals(requestedScope.TenantId, query.TenantId, StringComparison.Ordinal) || + !string.Equals(requestedScope.Subject.TenantId, query.TenantId, StringComparison.Ordinal) || + requestedScope.Groups.Any(group => !string.Equals(group.TenantId, query.TenantId, StringComparison.Ordinal)))) + return records.Where(_ => false); + + if (query.Scope is not { } scope) + return records; + + var subject = scope.Subject; + var subjectType = subject.Type.ToString(); + var groupKeys = scope.Groups.Select(GetParticipantKey).Distinct(StringComparer.Ordinal).ToArray(); + var tenantId = query.TenantId; + + if (scope.ExcludeBlocking) + records = records.Where(task => task.HealthSeverity != UserTaskHealthSeverity.Blocking); + + // Manager-only scopes were already rejected by the policy for non-managers. Reaching them here means + // the caller manages the tenant, so the tenant predicate above is the whole authorization. + if (scope.RequiresManager) + { + if (!scope.IsManager) + return records.Where(_ => false); + if (scope.Kind == UserTaskQueryScopeKind.NeedsAttention) + { + records = records.Where(task => + task.HealthSeverity == UserTaskHealthSeverity.Blocking + || task.IsOverdue + || (task.AssigneeId == null && task.Status != UserTaskStatus.Completed && task.Status != UserTaskStatus.TimedOut && task.Status != UserTaskStatus.Cancelled) + || task.Status == UserTaskStatus.Completing + || task.Status == UserTaskStatus.TimingOut + || task.Status == UserTaskStatus.Cancelling); + } + return records; + } + + // Correlated predicates are applied in the query path so tenant, eligibility, and exclusion are + // evaluated before totals, cursors, and page limits — an unauthorized row can never reach the page. + return scope.Kind switch + { + UserTaskQueryScopeKind.Assigned => records.Where(task => + task.AssigneeProvider == subject.Provider && task.AssigneeType == subjectType && task.AssigneeId == subject.Id), + + UserTaskQueryScopeKind.Available => records.Where(task => + task.AssigneeId == null + && task.Status != UserTaskStatus.Completed && task.Status != UserTaskStatus.TimedOut && task.Status != UserTaskStatus.Cancelled + && !dbContext.UserTaskExclusions.Any(exclusion => + exclusion.TenantId == tenantId && exclusion.TaskId == task.Id && + exclusion.ParticipantType == UserTaskParticipantType.User && exclusion.Provider == subject.Provider && exclusion.ParticipantId == subject.Id) + && (dbContext.UserTaskCandidates.Any(candidate => + candidate.TenantId == tenantId && candidate.TaskId == task.Id && + candidate.ParticipantType == UserTaskParticipantType.User && candidate.Provider == subject.Provider && candidate.ParticipantId == subject.Id) + || (groupKeys.Length > 0 && dbContext.UserTaskCandidates.Any(candidate => + candidate.TenantId == tenantId && candidate.TaskId == task.Id && + candidate.ParticipantType == UserTaskParticipantType.Group && groupKeys.Contains(candidate.ParticipantKey))) + || dbContext.UserTaskSnapshotMembers.Any(member => + member.TenantId == tenantId && member.TaskId == task.Id && + ((member.ParticipantType == UserTaskParticipantType.User && member.Provider == subject.Provider && member.ParticipantId == subject.Id) + || (member.ParticipantType == UserTaskParticipantType.Group && groupKeys.Contains(member.ParticipantKey)))))), + + UserTaskQueryScopeKind.History => records.Where(task => + (task.Status == UserTaskStatus.Completed || task.Status == UserTaskStatus.TimedOut || task.Status == UserTaskStatus.Cancelled) + && dbContext.UserTaskEvents.Any(@event => + @event.TenantId == tenantId && @event.TaskId == task.Id && + @event.ActorProvider == subject.Provider && @event.ActorType == subjectType && @event.ActorId == subject.Id)), + + _ => records.Where(_ => false) + }; + } + + private static IQueryable ApplyOrdering(IQueryable records, UserTaskQuery query) + { + return query.Sort.ToLowerInvariant() switch + { + "due" when query.Descending => records.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id), + "due" => records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id), + "priority" when query.Descending => records.OrderByDescending(x => x.Priority).ThenBy(x => x.Id), + "priority" => records.OrderBy(x => x.Priority).ThenBy(x => x.Id), + "title" when query.Descending => records.OrderByDescending(x => x.Title).ThenBy(x => x.Id), + "title" => records.OrderBy(x => x.Title).ThenBy(x => x.Id), + _ when query.Descending => records.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id), + _ => records.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) + }; + } + + private static IQueryable ApplyCursor(IQueryable records, UserTaskQuery query) + { + if (string.IsNullOrWhiteSpace(query.Cursor) || !TryReadCursor(query.Cursor, out var cursorValue, out var cursorId)) + return records; + + return query.Sort.ToLowerInvariant() switch + { + "priority" when int.TryParse(cursorValue, out var priority) => query.Descending + ? records.Where(x => x.Priority < priority || (x.Priority == priority && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => x.Priority > priority || (x.Priority == priority && string.Compare(x.Id, cursorId) > 0)), + "title" => query.Descending + ? records.Where(x => string.Compare(x.Title, cursorValue) < 0 || (x.Title == cursorValue && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => string.Compare(x.Title, cursorValue) > 0 || (x.Title == cursorValue && string.Compare(x.Id, cursorId) > 0)), + "due" when cursorValue == "~null" => records.Where(x => x.DueAt == null && string.Compare(x.Id, cursorId) > 0), + "due" when DateTimeOffset.TryParse(cursorValue, out var dueAt) => query.Descending + ? records.Where(x => x.DueAt == null || x.DueAt < dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => x.DueAt == null || x.DueAt > dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cursorId) > 0)), + _ when DateTimeOffset.TryParse(cursorValue, out var createdAt) => query.Descending + ? records.Where(x => x.CreatedAt < createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => x.CreatedAt > createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursorId) > 0)), + _ => records + }; + } + + private static string CreateCursor(UserTaskRecord record, string sort) + { + var value = sort.ToLowerInvariant() switch + { + "priority" => record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), + "title" => record.Title, + "due" => record.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null", + _ => record.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture) + }; + return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static bool TryReadCursor(string cursor, out string value, out string id) + { + value = id = ""; + try + { + var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4); + var parts = JsonSerializer.Deserialize(Convert.FromBase64String(padded), JsonOptions); + if (parts is not [var cursorValue, var cursorId] || string.IsNullOrWhiteSpace(cursorId)) + return false; + value = cursorValue; + id = cursorId; + return true; + } + catch (FormatException) + { + return false; + } + catch (JsonException) + { + return false; + } + } + + private static async Task LoadAggregateAsync(UserTasksElsaDbContext dbContext, UserTaskRecord record, CancellationToken cancellationToken) + { + return (await LoadAggregatesAsync(dbContext, [record], cancellationToken))[0]; + } + + private static async Task> LoadAggregatesAsync(UserTasksElsaDbContext dbContext, IReadOnlyCollection records, CancellationToken cancellationToken) + { + var tasks = records.Select(ToModel).ToList(); + if (tasks.Count == 0) + return tasks; + + var tenantId = records.First().TenantId; + var taskIds = tasks.Select(x => x.Id).ToArray(); + var taskById = tasks.ToDictionary(x => x.Id, StringComparer.Ordinal); + var candidates = await dbContext.UserTaskCandidates.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(candidates, x => x.TaskId, taskById)) + { + task.CandidateUsers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticipant).ToList(); + task.CandidateGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToParticipant).ToList(); + } + + var snapshots = await dbContext.UserTaskSnapshotMembers.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(snapshots, x => x.TaskId, taskById)) + { + task.SnapshotMembers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticipant).ToList(); + task.SnapshotGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToParticipant).ToList(); + } + + var exclusions = await dbContext.UserTaskExclusions.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(exclusions, x => x.TaskId, taskById)) + task.ExcludedUsers = group.Select(ToParticipant).ToList(); + + var events = await dbContext.UserTaskEvents.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).OrderBy(x => x.Revision).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(events, x => x.TaskId, taskById)) + task.Events = group.Select(ToEvent).ToList(); + + var operations = await dbContext.UserTaskOperations.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).OrderBy(x => x.CreatedAt).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(operations, x => x.TaskId, taskById)) + task.Operations = group.Select(ToOperation).ToList(); + + var invitations = await dbContext.UserTaskInvitations.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains(x.TaskId)).OrderBy(x => x.IssuedAt).ToListAsync(cancellationToken); + foreach (var (task, group) in GroupByLoadedTask(invitations, x => x.TaskId, taskById)) + task.Invitations = group.Select(ToInvitation).ToList(); + + return tasks; + } + + /// + /// Pairs each group of child rows with the task it belongs to, dropping groups whose task is not on the + /// current page. The filter is explicit and each group still costs a single dictionary probe. + /// + private static IEnumerable<(UserTask Task, IGrouping Rows)> GroupByLoadedTask( + IEnumerable rows, Func taskIdSelector, Dictionary taskById) => + rows.GroupBy(taskIdSelector) + .Select(group => (Task: taskById.GetValueOrDefault(group.Key), Rows: group)) + .Where(pair => pair.Task is not null) + .Select(pair => (pair.Task!, pair.Rows)); + + private static UserTask ToModel(UserTaskRecord record) => new() + { + Id = record.Id, + TenantId = record.TenantId, + WorkflowDefinitionId = record.WorkflowDefinitionId, + WorkflowDefinitionName = record.WorkflowDefinitionName, + WorkflowDefinitionVersion = record.WorkflowDefinitionVersion, + WorkflowInstanceId = record.WorkflowInstanceId, + WorkflowInstanceReference = record.WorkflowInstanceReference, + ActivityInstanceId = record.ActivityInstanceId, + BookmarkId = record.BookmarkId, + MaterializationKey = record.MaterializationKey, + Title = record.Title, + Summary = record.Summary, + Reference = record.Reference, + Tags = Deserialize>(record.TagsJson) ?? new(StringComparer.OrdinalIgnoreCase), + TaskType = record.TaskType, + Requester = ToParticipant(record.RequesterProvider, record.RequesterType, record.RequesterId, record.RequesterDisplayName, record.TenantId), + Assignee = ToParticipant(record.AssigneeProvider, record.AssigneeType, record.AssigneeId, record.AssigneeDisplayName, record.TenantId), + MembershipResolutionMode = record.MembershipResolutionMode ?? UserTaskMembershipResolutionMode.Live, + AllowManagerExclusionOverride = record.AllowManagerExclusionOverride, + Priority = record.Priority, + DueAt = record.DueAt, + IsOverdue = record.IsOverdue, + Instructions = Deserialize(record.InstructionsJson), + TaskData = DeserializeJson(record.TaskDataJson), + RequestedForm = Deserialize(record.FormReferenceJson), + PinnedForm = Deserialize(record.PinnedFormJson), + Actions = Deserialize>(record.ActionsJson) ?? [new UserTaskAction("Complete", "Complete")], + InvitationDefinitions = Deserialize>(record.InvitationDefinitionsJson) ?? [], + EnableTimeoutOutcome = record.TimeoutEnabled, + EnableCancellationOutcome = record.CancellationEnabled, + Status = record.Status, + HealthSeverity = record.HealthSeverity, + HealthCode = record.HealthCode, + HealthMessage = record.HealthMessage, + CompletionActionKey = record.CompletionActionKey, + CompletionData = DeserializeJson(record.CompletionDataJson), + CompletedBy = Deserialize(record.CompletionActorJson), + CreatedAt = record.CreatedAt, + UpdatedAt = record.UpdatedAt, + AssignedAt = record.AssignedAt, + CompletedAt = record.CompletedAt, + Revision = record.Revision + }; + + private static UserTaskRecord ToRecord(UserTask task) + { + var record = new UserTaskRecord { Id = string.IsNullOrWhiteSpace(task.Id) ? Guid.NewGuid().ToString("N") : task.Id }; + Copy(task, record); + return record; + } + + private static void Copy(UserTask source, UserTaskRecord target) + { + target.Id = source.Id; + target.TenantId = source.TenantId; + target.WorkflowDefinitionId = source.WorkflowDefinitionId; + target.WorkflowDefinitionName = source.WorkflowDefinitionName; + target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion; + target.WorkflowInstanceId = source.WorkflowInstanceId; + target.WorkflowInstanceReference = source.WorkflowInstanceReference; + target.ActivityInstanceId = source.ActivityInstanceId; + target.BookmarkId = source.BookmarkId; + target.MaterializationKey = source.MaterializationKey; + target.Title = source.Title; + target.Summary = source.Summary; + target.Reference = source.Reference; + target.TaskType = source.TaskType; + target.TagsJson = Serialize(source.Tags); + target.RequesterProvider = source.Requester?.Provider; + target.RequesterType = source.Requester?.Type.ToString(); + target.RequesterId = source.Requester?.Id; + target.RequesterDisplayName = source.Requester?.DisplayName; + target.Priority = source.Priority; + target.DueAt = source.DueAt; + target.IsOverdue = source.IsOverdue; + target.Status = source.Status; + target.TimeoutEnabled = source.EnableTimeoutOutcome; + target.CancellationEnabled = source.EnableCancellationOutcome; + target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride; + target.MembershipResolutionMode = source.MembershipResolutionMode; + target.AssigneeProvider = source.Assignee?.Provider; + target.AssigneeType = source.Assignee?.Type.ToString(); + target.AssigneeId = source.Assignee?.Id; + target.AssigneeDisplayName = source.Assignee?.DisplayName; + target.InstructionsJson = Serialize(source.Instructions); + target.TaskDataJson = Serialize(source.TaskData); + target.FormReferenceJson = Serialize(source.RequestedForm); + target.PinnedFormJson = Serialize(source.PinnedForm); + target.ActionsJson = Serialize(source.Actions); + target.InvitationDefinitionsJson = Serialize(source.InvitationDefinitions); + target.HealthIssuesJson = Serialize(new { source.HealthSeverity, source.HealthCode, source.HealthMessage }); + target.HealthSeverity = source.HealthSeverity; + target.HealthCode = source.HealthCode; + target.HealthMessage = source.HealthMessage; + target.CompletionActionKey = source.CompletionActionKey; + target.CompletionDataJson = Serialize(source.CompletionData); + target.CompletionActorJson = Serialize(source.CompletedBy); + target.CreatedAt = source.CreatedAt; + target.UpdatedAt = source.UpdatedAt; + target.AssignedAt = source.AssignedAt; + target.CompletedAt = source.CompletedAt; + target.Revision = source.Revision; + } + + private static void Copy(UserTaskRecord source, UserTaskRecord target) + { + target.TenantId = source.TenantId; + target.WorkflowDefinitionId = source.WorkflowDefinitionId; + target.WorkflowDefinitionName = source.WorkflowDefinitionName; + target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion; + target.WorkflowInstanceId = source.WorkflowInstanceId; + target.WorkflowInstanceReference = source.WorkflowInstanceReference; + target.ActivityInstanceId = source.ActivityInstanceId; + target.BookmarkId = source.BookmarkId; + target.MaterializationKey = source.MaterializationKey; + target.Title = source.Title; + target.Summary = source.Summary; + target.Reference = source.Reference; + target.TaskType = source.TaskType; + target.TagsJson = source.TagsJson; + target.RequesterProvider = source.RequesterProvider; + target.RequesterType = source.RequesterType; + target.RequesterId = source.RequesterId; + target.RequesterDisplayName = source.RequesterDisplayName; + target.Priority = source.Priority; + target.DueAt = source.DueAt; + target.IsOverdue = source.IsOverdue; + target.Status = source.Status; + target.TimeoutEnabled = source.TimeoutEnabled; + target.CancellationEnabled = source.CancellationEnabled; + target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride; + target.MembershipResolutionMode = source.MembershipResolutionMode; + target.AssigneeProvider = source.AssigneeProvider; + target.AssigneeType = source.AssigneeType; + target.AssigneeId = source.AssigneeId; + target.AssigneeDisplayName = source.AssigneeDisplayName; + target.InstructionsJson = source.InstructionsJson; + target.TaskDataJson = source.TaskDataJson; + target.FormReferenceJson = source.FormReferenceJson; + target.PinnedFormJson = source.PinnedFormJson; + target.ActionsJson = source.ActionsJson; + target.InvitationDefinitionsJson = source.InvitationDefinitionsJson; + target.HealthIssuesJson = source.HealthIssuesJson; + target.HealthSeverity = source.HealthSeverity; + target.HealthCode = source.HealthCode; + target.HealthMessage = source.HealthMessage; + target.CompletionActionKey = source.CompletionActionKey; + target.CompletionDataJson = source.CompletionDataJson; + target.CompletionActorJson = source.CompletionActorJson; + target.CreatedAt = source.CreatedAt; + target.UpdatedAt = source.UpdatedAt; + target.AssignedAt = source.AssignedAt; + target.CompletedAt = source.CompletedAt; + target.CreatedFromBookmarkRevision = source.CreatedFromBookmarkRevision; + } + + private static async Task ReplaceChildrenAsync(UserTasksElsaDbContext dbContext, UserTask task, CancellationToken cancellationToken) + { + dbContext.UserTaskCandidates.RemoveRange(await dbContext.UserTaskCandidates.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken)); + dbContext.UserTaskSnapshotMembers.RemoveRange(await dbContext.UserTaskSnapshotMembers.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken)); + dbContext.UserTaskExclusions.RemoveRange(await dbContext.UserTaskExclusions.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken)); + var existingEvents = await dbContext.UserTaskEvents.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken); + var existingEventIds = existingEvents.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + var existingEventRevisions = existingEvents.Select(x => x.Revision).ToHashSet(); + dbContext.UserTaskEvents.AddRange(task.Events.Where(x => !existingEventIds.Contains(x.Id) && !existingEventRevisions.Contains(x.Revision)).Select(ToEventRecord)); + + var existingOperations = await dbContext.UserTaskOperations.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken); + var existingOperationIds = existingOperations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + foreach (var operation in task.Operations) + { + var entity = existingOperations.FirstOrDefault(x => x.Id == operation.Id || x.OperationId == operation.OperationId); + if (entity is null) + { + if (!existingOperationIds.Contains(operation.Id)) + dbContext.UserTaskOperations.Add(ToOperationRecord(operation)); + continue; + } + + entity.Kind = operation.Kind.ToString(); + entity.ExpectedRevision = operation.ExpectedRevision; + entity.RequestHash = operation.RequestHash; + entity.Status = operation.Status switch + { + UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed, + UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed, + _ => UserTaskPersistenceOperationStatus.Enqueued + }; + entity.UpdatedAt = operation.UpdatedAt; + entity.ActionKey = operation.ActionKey; + entity.ProtectedPayloadJson = Serialize(operation.Data); + entity.ErrorCode = operation.ErrorCode; + } + + var existingInvitations = await dbContext.UserTaskInvitations.Where(x => x.TenantId == task.TenantId && x.TaskId == task.Id).ToListAsync(cancellationToken); + var existingInvitationIds = existingInvitations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + foreach (var invitation in task.Invitations) + { + var entity = existingInvitations.FirstOrDefault(x => x.Id == invitation.Id); + if (entity is null) + { + if (!existingInvitationIds.Contains(invitation.Id)) + dbContext.UserTaskInvitations.Add(ToInvitationRecord(invitation)); + continue; + } + + entity.Status = invitation.Status; + entity.VerifiedAt = invitation.VerifiedAt; + entity.ConsumedAt = invitation.ConsumedAt; + entity.RevokedAt = invitation.RevokedAt; + } + dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCandidateSource.DirectUser))); + dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceCandidateSource.DirectGroup))); + dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x))); + dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x))); + dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x))); + } + + private static UserTaskCandidateRecord ToCandidate(UserTask task, ParticipantReference participant, UserTaskPersistenceCandidateSource source) => new() + { + TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(participant), ParticipantType = participant.Type, + ParticipantId = participant.Id, DisplayName = participant.DisplayName, Source = source + }; + + private static UserTaskSnapshotMemberRecord ToSnapshot(UserTask task, ParticipantReference participant) => new() + { + TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(participant), ParticipantType = participant.Type, + ParticipantId = participant.Id, CreatedAt = task.CreatedAt + }; + + private static UserTaskExclusionRecord ToExclusion(UserTask task, ParticipantReference participant) => new() + { + TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(participant), ParticipantType = participant.Type, + ParticipantId = participant.Id, CreatedAt = task.CreatedAt + }; + + private static ParticipantReference ToParticipant(UserTaskCandidateRecord row) => new(row.TenantId, row.Provider, row.ParticipantType, row.ParticipantId, row.DisplayName); + private static ParticipantReference ToParticipant(UserTaskSnapshotMemberRecord row) => new(row.TenantId, row.Provider, row.ParticipantType, row.ParticipantId); + private static ParticipantReference ToParticipant(UserTaskExclusionRecord row) => new(row.TenantId, row.Provider, row.ParticipantType, row.ParticipantId); + + private static ParticipantReference? ToParticipant(string? provider, string? type, string? id, string? displayName, string tenantId) + { + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(id) || !Enum.TryParse(type, true, out var participantType)) + return null; + return new ParticipantReference(tenantId, provider, participantType, id, displayName); + } + + private static string GetParticipantKey(ParticipantReference participant) => $"{participant.Provider}|{participant.Type}|{participant.Id}"; + + private static UserTaskEvent ToEvent(UserTaskEventRecord row) => new(row.Id, row.TenantId, row.TaskId, row.Revision, row.EventType, row.OccurredAt, Deserialize(row.ActorJson) ?? ToParticipant(row.ActorProvider, row.ActorType, row.ActorId, null, row.TenantId), row.OperationId, row.Reason, Deserialize>(row.MetadataJson)); + + private static UserTaskEventRecord ToEventRecord(UserTaskEvent value) => new() + { + Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, Revision = value.Revision, EventType = value.EventType, + OccurredAt = value.OccurredAt, ActorProvider = value.Actor?.Provider, ActorType = value.Actor?.Type.ToString(), ActorId = value.Actor?.Id, + ActorJson = Serialize(value.Actor), OperationId = value.OperationId, Reason = value.Reason, + MetadataJson = Serialize(value.Metadata ?? new Dictionary()) + }; + + private static UserTaskOperation ToOperation(UserTaskOperationRecord row) => new( + row.Id, row.TenantId, row.TaskId, row.OperationId, + Enum.TryParse(row.Kind, true, out var kind) ? kind : UserTaskOperationKind.Claim, + row.ExpectedRevision, row.RequestHash, + row.Status switch + { + UserTaskPersistenceOperationStatus.Completed => UserTaskOperationStatus.Completed, + UserTaskPersistenceOperationStatus.Failed => UserTaskOperationStatus.Failed, + _ => UserTaskOperationStatus.Accepted + }, + row.CreatedAt, row.UpdatedAt, row.ActionKey, DeserializeJson(row.ProtectedPayloadJson), row.ErrorCode); + + private static UserTaskOperationRecord ToOperationRecord(UserTaskOperation value) => new() + { + Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, OperationId = value.OperationId, Kind = value.Kind.ToString(), + ExpectedRevision = value.ExpectedRevision, RequestHash = value.RequestHash, + Status = value.Status switch + { + UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed, + UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed, + _ => UserTaskPersistenceOperationStatus.Enqueued + }, + CreatedAt = value.CreatedAt, UpdatedAt = value.UpdatedAt, ActionKey = value.ActionKey, ProtectedPayloadJson = Serialize(value.Data), ErrorCode = value.ErrorCode + }; + + private static UserTaskInvitation ToInvitation(UserTaskInvitationRecord row) => new( + row.Id, row.TenantId, row.TaskId, Deserialize(row.RecipientJson), row.TokenHash, row.Status, + row.IssuedAt, row.ExpiresAt, row.VerifierProvider, row.VerifiedAt, row.ConsumedAt, row.RevokedAt, row.SiblingGroupId) + { + AllowedActions = Deserialize>(row.AllowedActionsJson) ?? [] + }; + + private static UserTaskInvitationRecord ToInvitationRecord(UserTaskInvitation value) => new() + { + Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, RecipientJson = Serialize(value.Recipient), TokenHash = value.TokenHash, + VerifierProvider = value.VerifierName ?? "default", Status = value.Status, IssuedAt = value.IssuedAt, ExpiresAt = value.ExpiresAt, + VerifiedAt = value.VerifiedAt, ConsumedAt = value.ConsumedAt, RevokedAt = value.RevokedAt, SiblingGroupId = value.SiblingGroupId, + AllowedActionsJson = Serialize(value.AllowedActions) + }; + + private static async Task ExistsByMaterializationKeyAsync(UserTasksElsaDbContext dbContext, string tenantId, string key, CancellationToken cancellationToken) => await dbContext.UserTasks.AsNoTracking().AnyAsync(x => x.TenantId == tenantId && x.MaterializationKey == key, cancellationToken); + + private static void EnsureExpectedRevision(UserTaskRecord? existing, string taskId, int expectedRevision) + { + if (existing is null) + throw new KeyNotFoundException($"User task '{taskId}' was not found."); + if (existing.Revision != expectedRevision) + throw new UserTaskRevisionConflictException(taskId, expectedRevision); + } + + private static string Serialize(T value) => JsonSerializer.Serialize(value, JsonOptions); + private static string Serialize(JsonElement? value) => !value.HasValue || value.Value.ValueKind == JsonValueKind.Undefined ? "null" : value.Value.GetRawText(); + private static T? Deserialize(string? value) => string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) ? default : JsonSerializer.Deserialize(value, JsonOptions); + private static JsonElement? DeserializeJson(string? value) => Deserialize(value); +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/ShellFeatures/EFCoreUserTasksPersistenceShellFeatureBase.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/ShellFeatures/EFCoreUserTasksPersistenceShellFeatureBase.cs new file mode 100644 index 000000000..62ff04554 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/ShellFeatures/EFCoreUserTasksPersistenceShellFeatureBase.cs @@ -0,0 +1,27 @@ +using Elsa.Persistence.EFCore; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Persistence.EFCore.Contracts; +using Elsa.UserTasks.Persistence.EFCore.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Persistence.EFCore.ShellFeatures; + +/// +/// Shared provider shell base for User Tasks persistence. +/// +public abstract class EFCoreUserTasksPersistenceShellFeatureBase : PersistenceShellFeatureBase +{ + protected override void OnConfiguring(IServiceCollection services) + { + AddStore(services); + AddStore(services); + AddStore(services); + services.AddScoped(); + services.AddScoped(); + + // Guest sessions and undelivered invitation secrets must survive a restart and a failover, so the + // provider replaces the Core in-memory defaults. + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs new file mode 100644 index 000000000..abd44c349 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs @@ -0,0 +1,189 @@ +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore; + +internal static class UserTaskEntityConfiguration +{ + public static void Configure(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("UserTasks"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.WorkflowDefinitionId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.WorkflowDefinitionName).HasMaxLength(500); + entity.Property(x => x.WorkflowInstanceId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.WorkflowInstanceReference).HasMaxLength(500); + entity.Property(x => x.ActivityInstanceId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.BookmarkId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.MaterializationKey).HasMaxLength(900).IsRequired(); + entity.Property(x => x.Title).HasMaxLength(500).IsRequired(); + entity.Property(x => x.Summary).HasMaxLength(4000); + entity.Property(x => x.Reference).HasMaxLength(500); + entity.Property(x => x.TaskType).HasMaxLength(200); + entity.Property(x => x.TagsJson).IsRequired(); + entity.Property(x => x.RequesterProvider).HasMaxLength(200); + entity.Property(x => x.RequesterType).HasMaxLength(64); + entity.Property(x => x.RequesterId).HasMaxLength(450); + entity.Property(x => x.RequesterDisplayName).HasMaxLength(500); + entity.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.MembershipResolutionMode).HasConversion().HasMaxLength(32); + entity.Property(x => x.AssigneeProvider).HasMaxLength(200); + entity.Property(x => x.AssigneeType).HasMaxLength(64); + entity.Property(x => x.AssigneeId).HasMaxLength(450); + entity.Property(x => x.AssigneeDisplayName).HasMaxLength(500); + entity.Property(x => x.InstructionsJson).IsRequired(); + entity.Property(x => x.TaskDataJson).IsRequired(); + entity.Property(x => x.InvitationDefinitionsJson).IsRequired(); + entity.Property(x => x.HealthIssuesJson).IsRequired(); + entity.Property(x => x.HealthSeverity).HasConversion().HasMaxLength(32); + entity.Property(x => x.HealthCode).HasMaxLength(200); + entity.Property(x => x.HealthMessage).HasMaxLength(4000); + entity.Property(x => x.Revision).IsConcurrencyToken().IsRequired(); + entity.HasIndex(x => new { x.TenantId, x.MaterializationKey }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.BookmarkId }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.Status }); + entity.HasIndex(x => new { x.TenantId, x.AssigneeProvider, x.AssigneeType, x.AssigneeId }); + entity.HasIndex(x => new { x.TenantId, x.Priority }); + entity.HasIndex(x => new { x.TenantId, x.DueAt }); + entity.HasIndex(x => new { x.TenantId, x.WorkflowDefinitionId }); + entity.HasIndex(x => new { x.TenantId, x.WorkflowInstanceId }); + entity.HasIndex(x => new { x.TenantId, x.ActivityInstanceId }); + entity.HasIndex(x => new { x.TenantId, x.CreatedAt }); + entity.HasIndex(x => new { x.TenantId, x.CompletedAt }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskCandidates"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.Provider).HasMaxLength(200).IsRequired(); + entity.Property(x => x.ParticipantKey).HasMaxLength(800).IsRequired(); + entity.Property(x => x.ParticipantType).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.ParticipantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.DisplayName).HasMaxLength(500); + entity.Property(x => x.Source).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.SourceGroupProvider).HasMaxLength(200); + entity.Property(x => x.SourceGroupId).HasMaxLength(450); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.Provider, x.ParticipantType, x.ParticipantId, x.Source }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.Provider, x.ParticipantType, x.ParticipantId }); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.ParticipantKey }); + entity.HasIndex(x => new { x.TenantId, x.TaskId }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskSnapshotMembers"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.Provider).HasMaxLength(200).IsRequired(); + entity.Property(x => x.ParticipantKey).HasMaxLength(800).IsRequired(); + entity.Property(x => x.ParticipantType).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.ParticipantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.SourceGroupProvider).HasMaxLength(200); + entity.Property(x => x.SourceGroupId).HasMaxLength(450); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.Provider, x.ParticipantType, x.ParticipantId }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.Provider, x.ParticipantType, x.ParticipantId }); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.ParticipantKey }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskExclusions"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.Provider).HasMaxLength(200).IsRequired(); + entity.Property(x => x.ParticipantKey).HasMaxLength(800).IsRequired(); + entity.Property(x => x.ParticipantType).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.ParticipantId).HasMaxLength(450).IsRequired(); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.Provider, x.ParticipantType, x.ParticipantId }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.Provider, x.ParticipantType, x.ParticipantId }); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.ParticipantKey }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskEvents"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.EventType).HasMaxLength(64).IsRequired(); + entity.Property(x => x.OperationId).HasMaxLength(450); + entity.Property(x => x.ActorProvider).HasMaxLength(200); + entity.Property(x => x.ActorType).HasMaxLength(64); + entity.Property(x => x.ActorId).HasMaxLength(450); + entity.Property(x => x.Reason).HasMaxLength(4000); + entity.Property(x => x.MetadataJson).IsRequired(); + // Audit is append-only and several entries may share a revision (an audited read does not + // consume the concurrency token), so this is a lookup index rather than a uniqueness constraint. + entity.HasIndex(x => new { x.TaskId, x.Revision }); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.OccurredAt }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskOperations"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.OperationId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.Kind).HasMaxLength(64).IsRequired(); + entity.Property(x => x.RequestHash).HasMaxLength(128).IsRequired(); + entity.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.ActionKey).HasMaxLength(200); + entity.Property(x => x.ErrorCode).HasMaxLength(200); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.OperationId }).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.Status, x.UpdatedAt }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskInvitations"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.SiblingGroupId).HasMaxLength(450); + entity.Property(x => x.TokenHash).HasMaxLength(256).IsRequired(); + entity.Property(x => x.VerifierProvider).HasMaxLength(200).IsRequired(); + entity.Property(x => x.AllowedActionsJson).IsRequired(); + entity.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.Status }); + entity.HasIndex(x => new { x.TenantId, x.Status, x.ExpiresAt }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskInvitationDeliveries"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.InvitationId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.DispatcherProvider).HasMaxLength(200).IsRequired(); + entity.Property(x => x.EncryptedToken).IsRequired(); + entity.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(x => x.LastErrorCode).HasMaxLength(128); + entity.HasIndex(x => new { x.TenantId, x.Status, x.AvailableAt }); + entity.HasIndex(x => new { x.TenantId, x.InvitationId }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserTaskGuestSessions"); + entity.HasKey(x => x.Id); + entity.Property(x => x.TenantId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.TaskId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.InvitationId).HasMaxLength(450).IsRequired(); + entity.Property(x => x.SessionTokenHash).HasMaxLength(256).IsRequired(); + entity.Property(x => x.GuestParticipantJson).IsRequired(); + entity.Property(x => x.CapabilitiesJson).IsRequired(); + entity.HasIndex(x => x.SessionTokenHash).IsUnique(); + entity.HasIndex(x => new { x.TenantId, x.TaskId, x.ExpiresAt }); + }); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTasksElsaDbContext.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTasksElsaDbContext.cs new file mode 100644 index 000000000..c38545ef1 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTasksElsaDbContext.cs @@ -0,0 +1,32 @@ +using Elsa.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.UserTasks.Persistence.EFCore; + +/// +/// Persistence-side User Tasks context. Aggregate-safe JSON stays alongside normalized relationships so +/// every relational provider can share the same tenant-scoped schema. +/// +public class UserTasksElsaDbContext : ElsaDbContextBase +{ + public UserTasksElsaDbContext(DbContextOptions options, IServiceProvider serviceProvider) + : base(options, serviceProvider) + { + } + + public DbSet UserTasks => Set(); + public DbSet UserTaskCandidates => Set(); + public DbSet UserTaskSnapshotMembers => Set(); + public DbSet UserTaskExclusions => Set(); + public DbSet UserTaskEvents => Set(); + public DbSet UserTaskOperations => Set(); + public DbSet UserTaskInvitations => Set(); + public DbSet UserTaskInvitationDeliveries => Set(); + public DbSet UserTaskGuestSessions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UserTaskEntityConfiguration.Configure(modelBuilder); + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/Elsa.UserTasks.Persistence.VNext.csproj b/src/modules/Elsa.UserTasks.Persistence.VNext/Elsa.UserTasks.Persistence.VNext.csproj new file mode 100644 index 000000000..c52eef481 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/Elsa.UserTasks.Persistence.VNext.csproj @@ -0,0 +1,8 @@ + + Provides provider-neutral experimental persistence for Elsa user tasks.elsa module user tasks persistence vnext experimental + + + + + + diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ModuleExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..0387879a8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ModuleExtensions.cs @@ -0,0 +1,12 @@ +using Elsa.Features.Services; + +namespace Elsa.UserTasks.Persistence.VNext.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseUserTasksPersistenceVNext(this IModule module) + { + module.Services.AddUserTasksPersistenceVNext(); + return module; + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..56720344d --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Elsa.Persistence.VNext.Contracts; +using Elsa.UserTasks.Persistence.VNext.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Elsa.UserTasks.Persistence.VNext.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUserTasksPersistenceVNext(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Scoped()); + return services; + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/FodyWeavers.xml b/src/modules/Elsa.UserTasks.Persistence.VNext/FodyWeavers.xml new file mode 100644 index 000000000..8304193f8 --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/FodyWeavers.xml @@ -0,0 +1,2 @@ + + diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs new file mode 100644 index 000000000..8487acb4e --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Persistence.VNext.Document; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; + +namespace Elsa.UserTasks.Persistence.VNext.Repositories; + +/// +/// Provider-neutral document implementation. The aggregate is stored as one document while the schema +/// provider advertises the same logical units and indexes as the relational providers. +/// +public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUserTaskRepository +{ + public const string StorageUnitName = "UserTasks"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + public async Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) + { + var document = await documentStore.LoadAsync(StorageUnitName, DocumentId(tenantId, taskId), cancellationToken); + return document is null ? null : Deserialize(document); + } + + public async Task QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default) + { + var tasks = new List(); + foreach (var status in Enum.GetValues()) + { + var documents = await documentStore.QueryAsync( + new DocumentQuery(StorageUnitName, new Dictionary + { + ["TenantId"] = query.TenantId, + ["Status"] = status.ToString() + }), cancellationToken); + tasks.AddRange(documents.Select(Deserialize)); + } + + var filtered = tasks.Where(x => Matches(x, query)).Where(x => query.Scope is null || IsVisible(x, query.Scope)).ToList(); + int? totalCount = query.IncludeTotalCount ? filtered.Count : null; + filtered = ApplyOrdering(filtered, query).ToList(); + filtered = ApplyCursor(filtered, query).ToList(); + var limit = Math.Clamp(query.Limit <= 0 ? 50 : query.Limit, 1, 200); + var hasMore = filtered.Count > limit; + var page = filtered.Take(limit).ToList(); + return new UserTaskQueryResult(page, hasMore ? CreateCursor(page[^1], query.Sort) : null, totalCount); + } + + public Task FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default) => FindByIndexAsync(tenantId, x => x.MaterializationKey == key, cancellationToken); + + public Task FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default) => FindByIndexAsync(tenantId, x => x.BookmarkId == bookmarkId, cancellationToken); + + public async Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + { + // The document store has no cross-tenant secondary index for invitation hashes, so this scans the + // storage unit. Verification is rate limited and rare; correctness matters more than the scan here. + foreach (var task in await LoadAllAsync(cancellationToken)) + { + var invitation = task.Invitations.FirstOrDefault(x => string.Equals(x.TokenHash, tokenHash, StringComparison.Ordinal)); + if (invitation != null) + return (task, invitation); + } + return null; + } + + public async Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default) + { + var existing = await LoadDocumentAsync(task.TenantId, task.Id, cancellationToken); + if (existing is null) + throw new KeyNotFoundException($"User task '{task.Id}' was not found."); + var loaded = existing.Value; + if (loaded.Task.Revision != expectedRevision) + throw new UserTaskRevisionConflictException(task.Id, expectedRevision); + + task.Revision = expectedRevision + 1; + task.UpdatedAt = DateTimeOffset.UtcNow; + try + { + await documentStore.SaveAsync(CreateRequest(task, loaded.Document.Version), cancellationToken); + } + catch (DocumentStoreConcurrencyException exception) + { + // A writer won the race between the read above and this save. Same contract as the other + // providers so the caller sees one exception type regardless of the installed store. + throw new UserTaskRevisionConflictException(task.Id, expectedRevision, exception); + } + } + + public async Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default) + { + if (await FindByMaterializationKeyAsync(task.TenantId, task.MaterializationKey, cancellationToken) is not null) + return; + try + { + await documentStore.SaveAsync(CreateRequest(task, expectedVersion: 0), cancellationToken); + } + catch (DocumentStoreConcurrencyException) + { + // Projection is idempotent when the same aggregate ID was committed concurrently. + } + } + + public async Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationToken = default) + { + var existing = await LoadDocumentAsync(tenantId, taskId, cancellationToken); + if (existing is null) + return; + + // The document store has no separate audit stream, so the entry is written back with the aggregate. + // The revision is deliberately left as-is: an audited read must not consume the concurrency token. + var loaded = existing.Value; + loaded.Task.Events.Add(@event); + try + { + await documentStore.SaveAsync(CreateRequest(loaded.Task, loaded.Document.Version), cancellationToken); + } + catch (DocumentStoreConcurrencyException) + { + // A concurrent writer won. The audit entry is advisory, so losing this race is not an error. + } + } + + public async Task TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func mutation, CancellationToken cancellationToken = default) + { + var existing = await LoadDocumentAsync(tenantId, taskId, cancellationToken); + if (existing is null) + return false; + var loaded = existing.Value; + if (loaded.Task.Revision != expectedRevision || !mutation(loaded.Task)) + return false; + loaded.Task.Revision = expectedRevision + 1; + loaded.Task.UpdatedAt = DateTimeOffset.UtcNow; + try + { + await documentStore.SaveAsync(CreateRequest(loaded.Task, loaded.Document.Version), cancellationToken); + return true; + } + catch (DocumentStoreConcurrencyException) + { + return false; + } + } + + /// + /// Loads every stored task across tenants. Only the invitation-hash lookup uses this: an anonymous + /// holder presents a secret and no tenant, so the scan cannot be narrowed by an index. + /// + private async Task> LoadAllAsync(CancellationToken cancellationToken) + { + var tasks = new List(); + foreach (var status in Enum.GetValues()) + { + var documents = await documentStore.QueryAsync(new DocumentQuery(StorageUnitName, new Dictionary + { + ["Status"] = status.ToString() + }), cancellationToken); + tasks.AddRange(documents.Select(Deserialize)); + } + return tasks; + } + + private async Task FindByIndexAsync(string tenantId, Func predicate, CancellationToken cancellationToken) + { + foreach (var status in Enum.GetValues()) + { + var documents = await documentStore.QueryAsync(new DocumentQuery(StorageUnitName, new Dictionary + { + ["TenantId"] = tenantId, + ["Status"] = status.ToString() + }), cancellationToken); + var task = documents.Select(Deserialize).FirstOrDefault(predicate); + if (task is not null) + return task; + } + return null; + } + + private async Task<(StoredDocument Document, UserTask Task)?> LoadDocumentAsync(string tenantId, string taskId, CancellationToken cancellationToken) + { + var document = await documentStore.LoadAsync(StorageUnitName, DocumentId(tenantId, taskId), cancellationToken); + return document is null ? null : (document, Deserialize(document)); + } + + private static SaveDocumentRequest CreateRequest(UserTask task, long expectedVersion) => new( + StorageUnitName, + DocumentId(task.TenantId, task.Id), + JsonSerializer.Serialize(task, JsonOptions), + new Dictionary + { + ["TenantId"] = task.TenantId, + ["Status"] = task.Status.ToString(), + ["MaterializationKey"] = task.MaterializationKey, + ["BookmarkId"] = task.BookmarkId, + ["TaskType"] = task.TaskType, + ["AssigneeProvider"] = task.Assignee?.Provider, + ["AssigneeType"] = task.Assignee?.Type.ToString(), + ["AssigneeId"] = task.Assignee?.Id, + ["HealthSeverity"] = task.HealthSeverity?.ToString(), + ["Priority"] = task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["DueAt"] = task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) + }, expectedVersion); + + private static UserTask Deserialize(StoredDocument document) => JsonSerializer.Deserialize(document.Content, JsonOptions) + ?? throw new DocumentStoreValidationException($"Stored User Task document '{document.Id}' could not be deserialized."); + + private static string DocumentId(string tenantId, string taskId) => $"{tenantId}:{taskId}"; + + private static bool Matches(UserTask task, UserTaskQuery query) + { + var search = query.Search?.Trim(); + return task.TenantId == query.TenantId && (query.Statuses.Count == 0 || query.Statuses.Contains(task.Status)) && + (!query.OnlyOverdue || task.IsOverdue) && (!query.OnlyWithoutDueDate || task.DueAt is null) && + (string.IsNullOrWhiteSpace(query.TaskType) || task.TaskType == query.TaskType) && + (!query.PriorityFrom.HasValue || task.Priority >= query.PriorityFrom) && (!query.PriorityTo.HasValue || task.Priority <= query.PriorityTo) && + (!query.DueFrom.HasValue || task.DueAt >= query.DueFrom) && (!query.DueTo.HasValue || task.DueAt <= query.DueTo) && + (string.IsNullOrWhiteSpace(query.WorkflowDefinitionId) || task.WorkflowDefinitionId == query.WorkflowDefinitionId) && + (string.IsNullOrWhiteSpace(query.WorkflowInstanceId) || task.WorkflowInstanceId == query.WorkflowInstanceId) && + (string.IsNullOrWhiteSpace(query.Reference) || task.Reference == query.Reference) && + (string.IsNullOrWhiteSpace(search) || task.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || task.Summary?.Contains(search, StringComparison.OrdinalIgnoreCase) == true || task.Reference?.Contains(search, StringComparison.OrdinalIgnoreCase) == true || task.TaskType?.Contains(search, StringComparison.OrdinalIgnoreCase) == true); + } + + private static bool IsVisible(UserTask task, UserTaskQueryScope scope) + { + if (!string.Equals(scope.TenantId, task.TenantId, StringComparison.Ordinal) || + !string.Equals(scope.Subject.TenantId, task.TenantId, StringComparison.Ordinal) || + scope.Groups.Any(group => !string.Equals(group.TenantId, task.TenantId, StringComparison.Ordinal))) + return false; + if (scope.ExcludeBlocking && task.HealthSeverity == UserTaskHealthSeverity.Blocking) + return false; + // Manager-only scopes were already rejected by the policy for non-managers. + if (scope.RequiresManager) + return scope.IsManager && (scope.Kind != UserTaskQueryScopeKind.NeedsAttention || NeedsAttention(task)); + + var subject = scope.Subject; + var groups = scope.Groups; + return scope.Kind switch + { + UserTaskQueryScopeKind.Assigned => task.Assignee?.Matches(subject) == true, + UserTaskQueryScopeKind.Available => task.IsOpen && task.Assignee is null && IsEligible(task, subject, groups), + UserTaskQueryScopeKind.History => task.IsTerminal && + (task.CompletedBy?.Matches(subject) == true || task.Events.Any(x => x.Actor?.Matches(subject) == true)), + _ => false + }; + } + + private static bool IsEligible(UserTask task, ParticipantReference subject, IReadOnlyCollection groups) + { + if (task.ExcludedUsers.Any(x => x.Matches(subject))) + return false; + if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) + return task.SnapshotMembers.Any(x => x.Matches(subject)) || task.SnapshotGroups.Any(x => groups.Any(x.Matches)); + return task.CandidateUsers.Any(x => x.Matches(subject)) || task.CandidateGroups.Any(x => groups.Any(x.Matches)); + } + + private static bool NeedsAttention(UserTask task) => + task.HealthSeverity == UserTaskHealthSeverity.Blocking + || task.IsOverdue + || (task.IsOpen && task.Assignee is null) + || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling; + + private static IEnumerable ApplyOrdering(IEnumerable tasks, UserTaskQuery query) => query.Sort.ToLowerInvariant() switch + { + "priority" => query.Descending ? tasks.OrderByDescending(x => x.Priority).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Priority).ThenBy(x => x.Id), + "title" => query.Descending ? tasks.OrderByDescending(x => x.Title).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Title).ThenBy(x => x.Id), + "due" => query.Descending ? tasks.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id), + _ => query.Descending ? tasks.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) + }; + + private static IEnumerable ApplyCursor(IEnumerable tasks, UserTaskQuery query) + { + if (!TryReadCursor(query.Cursor, out var value, out var id)) + return tasks; + return query.Sort.ToLowerInvariant() switch + { + "priority" when int.TryParse(value, out var priority) => tasks.Where(x => query.Descending ? x.Priority < priority || x.Priority == priority && string.Compare(x.Id, id) > 0 : x.Priority > priority || x.Priority == priority && string.Compare(x.Id, id) > 0), + "title" => tasks.Where(x => query.Descending ? string.Compare(x.Title, value) < 0 || x.Title == value && string.Compare(x.Id, id) > 0 : string.Compare(x.Title, value) > 0 || x.Title == value && string.Compare(x.Id, id) > 0), + "due" when value == "~null" => tasks.Where(x => x.DueAt == null && string.Compare(x.Id, id) > 0), + "due" when DateTimeOffset.TryParse(value, out var due) => tasks.Where(x => x.DueAt == null || query.Descending && x.DueAt < due || !query.Descending && x.DueAt > due || x.DueAt == due && string.Compare(x.Id, id) > 0), + _ when DateTimeOffset.TryParse(value, out var created) => tasks.Where(x => query.Descending ? x.CreatedAt < created || x.CreatedAt == created && string.Compare(x.Id, id) > 0 : x.CreatedAt > created || x.CreatedAt == created && string.Compare(x.Id, id) > 0), + _ => tasks + }; + } + + private static string CreateCursor(UserTask task, string sort) + { + var value = sort.ToLowerInvariant() switch + { + "priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), + "title" => task.Title, + "due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null", + _ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture) + }; + return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static bool TryReadCursor(string? cursor, out string value, out string id) + { + value = id = ""; + if (string.IsNullOrWhiteSpace(cursor)) + return false; + try + { + var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4); + var values = JsonSerializer.Deserialize(Convert.FromBase64String(padded), JsonOptions); + if (values is not [var parsedValue, var parsedId]) + return false; + value = parsedValue; + id = parsedId; + return true; + } + catch (FormatException) { return false; } + catch (JsonException) { return false; } + } +} diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs new file mode 100644 index 000000000..ea67f725c --- /dev/null +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs @@ -0,0 +1,93 @@ +using Elsa.Persistence.VNext; +using Elsa.Persistence.VNext.Builders; +using Elsa.Persistence.VNext.Contracts; + +namespace Elsa.UserTasks.Persistence.VNext; + +public sealed class UserTaskPersistenceSchemaProvider : IPersistenceSchemaProvider +{ + public PersistenceSchema DescribeSchema() + { + var schema = new PersistenceSchemaBuilder("Elsa.UserTasks").Version(1); + schema.StorageUnit("UserTasks", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450) + .RequiredField("WorkflowDefinitionId", PersistenceColumnType.String, 450).RequiredField("WorkflowInstanceId", PersistenceColumnType.String, 450) + .RequiredField("ActivityInstanceId", PersistenceColumnType.String, 450).RequiredField("BookmarkId", PersistenceColumnType.String, 450) + .RequiredField("MaterializationKey", PersistenceColumnType.String, 900).RequiredField("Title", PersistenceColumnType.String, 500) + .Field("Summary", PersistenceColumnType.Text).Field("Reference", PersistenceColumnType.String, length: 500).Field("TaskType", PersistenceColumnType.String, length: 200) + .RequiredField("TagsJson", PersistenceColumnType.Json).Field("Requester", PersistenceColumnType.Json).Field("Assignee", PersistenceColumnType.Json) + .Field("RequesterProvider", PersistenceColumnType.String, length: 200).Field("RequesterType", PersistenceColumnType.String, length: 64) + .Field("RequesterId", PersistenceColumnType.String, length: 450).Field("RequesterDisplayName", PersistenceColumnType.String, length: 500) + .Field("AssigneeProvider", PersistenceColumnType.String, length: 200).Field("AssigneeType", PersistenceColumnType.String, length: 32).Field("AssigneeId", PersistenceColumnType.String, length: 450) + .Field("HealthSeverity", PersistenceColumnType.String, length: 32).Field("HealthCode", PersistenceColumnType.String, length: 200).Field("HealthMessage", PersistenceColumnType.Text) + .RequiredField("Status", PersistenceColumnType.String, 32).RequiredField("Priority", PersistenceColumnType.Int32).Field("DueAt", PersistenceColumnType.DateTimeOffset) + .RequiredField("IsOverdue", PersistenceColumnType.Boolean).RequiredField("TimeoutEnabled", PersistenceColumnType.Boolean) + .RequiredField("CancellationEnabled", PersistenceColumnType.Boolean).RequiredField("AllowManagerExclusionOverride", PersistenceColumnType.Boolean) + .Field("MembershipResolutionMode", PersistenceColumnType.String, length: 32).RequiredField("Revision", PersistenceColumnType.Int32) + .RequiredField("InstructionsJson", PersistenceColumnType.Json).RequiredField("TaskDataJson", PersistenceColumnType.Json) + .Field("FormReferenceJson", PersistenceColumnType.Json).Field("PinnedFormJson", PersistenceColumnType.Json).Field("ActionsJson", PersistenceColumnType.Json) + .RequiredField("InvitationDefinitionsJson", PersistenceColumnType.Json).RequiredField("CompletionDataJson", PersistenceColumnType.Json) + .RequiredField("HealthIssuesJson", PersistenceColumnType.Json).Field("CompletionActionKey", PersistenceColumnType.String, length: 200).Field("CompletionActorJson", PersistenceColumnType.Json) + .RequiredField("CreatedAt", PersistenceColumnType.DateTimeOffset).RequiredField("UpdatedAt", PersistenceColumnType.DateTimeOffset) + .Field("AssignedAt", PersistenceColumnType.DateTimeOffset).Field("CompletedAt", PersistenceColumnType.DateTimeOffset).Field("CreatedFromBookmarkRevision", PersistenceColumnType.Int64) + .Key("PK_UserTasks", "Id").Index("IX_UserTasks_Tenant_MaterializationKey", ["TenantId", "MaterializationKey"], unique: true) + .Index("IX_UserTasks_Tenant_BookmarkId", ["TenantId", "BookmarkId"], unique: true).Index("IX_UserTasks_Tenant_Status", ["TenantId", "Status"]) + .Index("IX_UserTasks_Tenant_Assignee", ["TenantId", "AssigneeProvider", "AssigneeType", "AssigneeId"]).Index("IX_UserTasks_Tenant_Priority", ["TenantId", "Priority"]) + .Index("IX_UserTasks_Tenant_DueAt", ["TenantId", "DueAt"]).Index("IX_UserTasks_Tenant_WorkflowDefinition", ["TenantId", "WorkflowDefinitionId"]) + .Index("IX_UserTasks_Tenant_WorkflowInstance", ["TenantId", "WorkflowInstanceId"]).Index("IX_UserTasks_Tenant_ActivityInstance", ["TenantId", "ActivityInstanceId"]) + .Index("IX_UserTasks_Tenant_CreatedAt", ["TenantId", "CreatedAt"]).Index("IX_UserTasks_Tenant_CompletedAt", ["TenantId", "CompletedAt"]), @namespace: "Elsa.UserTasks"); + AddParticipantUnit(schema, "UserTaskCandidates", includeSource: true); + AddParticipantUnit(schema, "UserTaskSnapshotMembers", includeSource: false); + AddParticipantUnit(schema, "UserTaskExclusions", includeSource: false); + schema.StorageUnit("UserTaskEvents", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .RequiredField("Revision", PersistenceColumnType.Int32).RequiredField("EventType", PersistenceColumnType.String, 64).RequiredField("OccurredAt", PersistenceColumnType.DateTimeOffset) + .Field("OperationId", PersistenceColumnType.String, length: 450).Field("ActorProvider", PersistenceColumnType.String, length: 200).Field("ActorType", PersistenceColumnType.String, length: 64) + .Field("ActorId", PersistenceColumnType.String, length: 450).Field("Actor", PersistenceColumnType.Json).Field("Reason", PersistenceColumnType.Text).RequiredField("MetadataJson", PersistenceColumnType.Json) + .Key("PK_UserTaskEvents", "Id").Index("IX_UserTaskEvents_Task_Revision", ["TaskId", "Revision"], unique: true).Index("IX_UserTaskEvents_Tenant_Task_OccurredAt", ["TenantId", "TaskId", "OccurredAt"]), @namespace: "Elsa.UserTasks"); + schema.StorageUnit("UserTaskOperations", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .RequiredField("OperationId", PersistenceColumnType.String, 450).RequiredField("Kind", PersistenceColumnType.String, 64).RequiredField("ExpectedRevision", PersistenceColumnType.Int32) + .RequiredField("RequestHash", PersistenceColumnType.String, 128).RequiredField("Status", PersistenceColumnType.String, 32).Field("ActionKey", PersistenceColumnType.String, length: 200) + .Field("ErrorCode", PersistenceColumnType.String, length: 200).RequiredField("Attempts", PersistenceColumnType.Int32).Field("SafeResultJson", PersistenceColumnType.Json) + .Field("ProtectedPayloadJson", PersistenceColumnType.Json).RequiredField("CreatedAt", PersistenceColumnType.DateTimeOffset).RequiredField("UpdatedAt", PersistenceColumnType.DateTimeOffset).Field("CompletedAt", PersistenceColumnType.DateTimeOffset) + .Key("PK_UserTaskOperations", "Id").Index("IX_UserTaskOperations_Tenant_Task_Operation", ["TenantId", "TaskId", "OperationId"], unique: true), @namespace: "Elsa.UserTasks"); + schema.StorageUnit("UserTaskInvitations", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .Field("SiblingGroupId", PersistenceColumnType.String, length: 450).Field("RecipientJson", PersistenceColumnType.Json).RequiredField("TokenHash", PersistenceColumnType.String, 256).RequiredField("VerifierProvider", PersistenceColumnType.String, 200) + .Field("ChallengeJson", PersistenceColumnType.Json) + .RequiredField("Status", PersistenceColumnType.String, 32).RequiredField("IssuedAt", PersistenceColumnType.DateTimeOffset).RequiredField("ExpiresAt", PersistenceColumnType.DateTimeOffset) + .Field("VerifiedAt", PersistenceColumnType.DateTimeOffset).Field("ConsumedAt", PersistenceColumnType.DateTimeOffset).Field("RevokedAt", PersistenceColumnType.DateTimeOffset) + .Key("PK_UserTaskInvitations", "Id").Index("IX_UserTaskInvitations_TokenHash", "TokenHash", unique: true).Index("IX_UserTaskInvitations_Tenant_Task_Status", ["TenantId", "TaskId", "Status"]), @namespace: "Elsa.UserTasks"); + schema.StorageUnit("UserTaskInvitationDeliveries", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .RequiredField("InvitationId", PersistenceColumnType.String, 450).RequiredField("DispatcherProvider", PersistenceColumnType.String, 200).RequiredField("EncryptedToken", PersistenceColumnType.Text) + .Field("DeliveryMetadataJson", PersistenceColumnType.Json).RequiredField("Status", PersistenceColumnType.String, 32).RequiredField("Attempts", PersistenceColumnType.Int32) + .RequiredField("AvailableAt", PersistenceColumnType.DateTimeOffset).RequiredField("ExpiresAt", PersistenceColumnType.DateTimeOffset).Field("LastErrorCode", PersistenceColumnType.String, length: 128) + .RequiredField("CreatedAt", PersistenceColumnType.DateTimeOffset).Field("DeliveredAt", PersistenceColumnType.DateTimeOffset) + .Key("PK_UserTaskInvitationDeliveries", "Id").Index("IX_UserTaskInvitationDeliveries_Tenant_Status_AvailableAt", ["TenantId", "Status", "AvailableAt"]) + .Index("IX_UserTaskInvitationDeliveries_Tenant_Invitation", ["TenantId", "InvitationId"], unique: true), @namespace: "Elsa.UserTasks"); + schema.StorageUnit("UserTaskGuestSessions", storage => storage + .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .RequiredField("InvitationId", PersistenceColumnType.String, 450).RequiredField("SessionTokenHash", PersistenceColumnType.String, 256).RequiredField("GuestParticipantJson", PersistenceColumnType.Json) + .RequiredField("CapabilitiesJson", PersistenceColumnType.Json).RequiredField("IssuedAt", PersistenceColumnType.DateTimeOffset).RequiredField("ExpiresAt", PersistenceColumnType.DateTimeOffset).Field("RevokedAt", PersistenceColumnType.DateTimeOffset) + .Key("PK_UserTaskGuestSessions", "Id").Index("IX_UserTaskGuestSessions_SessionTokenHash", "SessionTokenHash", unique: true).Index("IX_UserTaskGuestSessions_Tenant_Task_ExpiresAt", ["TenantId", "TaskId", "ExpiresAt"]), @namespace: "Elsa.UserTasks"); + return schema.Build(); + } + + private static void AddParticipantUnit(PersistenceSchemaBuilder schema, string name, bool includeSource) => schema.StorageUnit(name, storage => + { + storage.RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) + .RequiredField("Provider", PersistenceColumnType.String, 200).RequiredField("ParticipantKey", PersistenceColumnType.String, 800).RequiredField("ParticipantType", PersistenceColumnType.String, 32) + .RequiredField("ParticipantId", PersistenceColumnType.String, 450).Field("DisplayName", PersistenceColumnType.String, length: 500); + if (includeSource) + storage.RequiredField("Source", PersistenceColumnType.String, 32).Field("SourceGroupProvider", PersistenceColumnType.String, length: 200).Field("SourceGroupId", PersistenceColumnType.String, length: 450); + storage.RequiredField("CreatedAt", PersistenceColumnType.DateTimeOffset).Key($"PK_{name}", "Id"); + if (includeSource) + storage.Index($"IX_{name}_Tenant_Task_Participant", ["TenantId", "TaskId", "Provider", "ParticipantType", "ParticipantId", "Source"], unique: true); + else + storage.Index($"IX_{name}_Tenant_Task_Participant", ["TenantId", "TaskId", "Provider", "ParticipantType", "ParticipantId"], unique: true); + storage.Index($"IX_{name}_Tenant_Participant", ["TenantId", "Provider", "ParticipantType", "ParticipantId"]) + .Index($"IX_{name}_Tenant_Task_ParticipantKey", ["TenantId", "TaskId", "ParticipantKey"]); + }, @namespace: "Elsa.UserTasks"); +} diff --git a/src/modules/Elsa.UserTasks/Activities/UserTask.cs b/src/modules/Elsa.UserTasks/Activities/UserTask.cs new file mode 100644 index 000000000..130e96d85 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Activities/UserTask.cs @@ -0,0 +1,144 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Activities; + +/// +/// Creates a workflow-bound human task and suspends the workflow until a governed outcome resumes it. +/// +[Activity("Elsa", "User Tasks", "Creates a user task and waits for a human outcome.", Kind = ActivityKind.Task)] +public sealed class UserTask : Activity +{ + public const string InputKey = "UserTaskInput"; + + [Input(Description = "The task title.")] + public Input Title { get; set; } = null!; + [Input] public Input Summary { get; set; } = null!; + [Input] public Input Reference { get; set; } = null!; + [Input] public Input?> Tags { get; set; } = null!; + [Input] public Input TaskType { get; set; } = null!; + [Input] public Input Requester { get; set; } = null!; + [Input] public Input Assignee { get; set; } = null!; + [Input] public Input?> CandidateUsers { get; set; } = null!; + [Input] public Input?> CandidateGroups { get; set; } = null!; + [Input] public Input?> ExcludedUsers { get; set; } = null!; + [Input] public Input MembershipResolutionMode { get; set; } = new(new Literal(UserTaskMembershipResolutionMode.Live)); + [Input] public Input AllowManagerExclusionOverride { get; set; } = null!; + [Input] public Input Priority { get; set; } = new(new Literal(50)); + [Input] public Input DueAt { get; set; } = null!; + [Input] public Input Instructions { get; set; } = null!; + [Input] public Input TaskData { get; set; } = null!; + [Input] public Input Form { get; set; } = null!; + [Input(Description = "Completion actions. Keys are literal; labels may use expressions.", DefaultSyntax = "Literal", SupportedSyntaxes = ["Literal"])] + public Input?> ActionDefinitions { get; set; } = null!; + [Input] public Input?> Invitations { get; set; } = null!; + [Input] public Input EnableTimeoutOutcome { get; set; } = null!; + [Input] public Input EnableCancellationOutcome { get; set; } = null!; + [Output] public Output TaskId { get; set; } = null!; + + public UserTask() + { + } + + public UserTask(string title, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Title = new Input(new Literal(title)); + } + + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var identityGenerator = context.GetRequiredService(); + var taskId = identityGenerator.GenerateId(); + var bookmarkId = identityGenerator.GenerateId(); + var options = context.GetService>()?.Value ?? new UserTasksOptions(); + var definition = new UserTaskDefinitionSnapshot + { + Title = Title.Get(context), + Summary = Summary.GetOrDefault(context), + Reference = Reference.GetOrDefault(context), + Tags = Tags.GetOrDefault(context) ?? [], + TaskType = TaskType.GetOrDefault(context), + Requester = Requester.GetOrDefault(context), + Assignee = Assignee.GetOrDefault(context), + CandidateUsers = CandidateUsers.GetOrDefault(context) ?? [], + CandidateGroups = CandidateGroups.GetOrDefault(context) ?? [], + ExcludedUsers = ExcludedUsers.GetOrDefault(context) ?? [], + MembershipResolutionMode = MembershipResolutionMode.GetOrDefault(context), + AllowManagerExclusionOverride = AllowManagerExclusionOverride.GetOrDefault(context), + Priority = Priority.GetOrDefault(context), + DueAt = DueAt.GetOrDefault(context), + Instructions = Instructions.GetOrDefault(context), + TaskData = TaskData.GetOrDefault(context), + FormReference = Form.GetOrDefault(context), + Actions = (ActionDefinitions.GetOrDefault(context) ?? []).Select(x => x.Materialize(context)).ToArray(), + Invitations = Invitations.GetOrDefault(context) ?? [], + EnableTimeoutOutcome = EnableTimeoutOutcome.GetOrDefault(context), + EnableCancellationOutcome = EnableCancellationOutcome.GetOrDefault(context) + }; + var workflow = context.WorkflowExecutionContext.Workflow; + var materialization = new UserTaskMaterialization( + // The workflow's own tenant wins; the configured default is only a fallback for single-tenant hosts. + workflow.Identity.TenantId ?? options.DefaultTenantId, + workflow.Identity.DefinitionId, + context.WorkflowExecutionContext.Id, + context.Id, + bookmarkId, + definition, + [], + [], + context.WorkflowExecutionContext.SystemClock.UtcNow, + taskId, + workflow.WorkflowMetadata.Name, + workflow.Identity.Version, + context.WorkflowExecutionContext.CorrelationId); + context.CreateBookmark(new CreateBookmarkArgs + { + BookmarkId = bookmarkId, + BookmarkName = nameof(UserTask), + Stimulus = materialization, + Callback = ResumeAsync, + IncludeActivityInstanceId = false + }); + TaskId.Set(context, taskId); + } + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var stimulus = context.GetWorkflowInput(InputKey); + Result?.Set(context, new UserTaskResult(stimulus.ActionKey, stimulus.CompletionData, stimulus.CompletedBy, stimulus.CompletedAt ?? context.WorkflowExecutionContext.SystemClock.UtcNow)); + await context.CompleteActivityAsync(); + } +} + +/// +/// Design-time action definition. Key is deliberately a plain literal so workflow expressions cannot +/// change the contract of an already activated task; only the label is expression-capable. +/// +public sealed class UserTaskActionDefinition +{ + public string Key { get; set; } = ""; + public Input Label { get; set; } = null!; + public IReadOnlyDictionary? Metadata { get; set; } + + public UserTaskActionDefinition() + { + } + + public UserTaskActionDefinition(string key, string label) + { + Key = key; + Label = new Input(new Literal(label)); + } + + internal UserTaskAction Materialize(ActivityExecutionContext context) => new(Key, Label.Get(context), Metadata); +} diff --git a/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs b/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs new file mode 100644 index 000000000..6b298a307 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs @@ -0,0 +1,184 @@ +using System.Security.Claims; +using System.Text.Json; +using Elsa.UserTasks.Models; + +namespace Elsa.UserTasks.Contracts; + +public interface IUserTaskIdentityResolver +{ + ValueTask ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default); +} + +public interface IUserTaskAccessPolicy +{ + /// + /// Builds the authorization predicate for a list request, or returns null when the actor may not + /// list in the requested scope at all. Callers must treat null as a denial, never as "no filter". + /// + Task CreateScopeAsync(UserTaskActor actor, UserTaskQueryScopeKind kind, CancellationToken cancellationToken = default); + Task AuthorizeAsync(UserTask task, UserTaskActor actor, UserTaskAccessOperation operation, CancellationToken cancellationToken = default); +} + +public interface IUserTaskParticipantDirectory +{ + Task SearchAsync(UserTaskParticipantQuery query, CancellationToken cancellationToken = default); + Task> ResolveDisplayNamesAsync(IReadOnlyCollection participants, CancellationToken cancellationToken = default); + Task> EnumerateGroupMembersAsync(ParticipantReference group, CancellationToken cancellationToken = default); +} + +public interface IUserTaskFormProvider +{ + string Name { get; } + Task ResolveAsync(UserTaskFormReference reference, CancellationToken cancellationToken = default); + Task ValidateAndNormalizeAsync(ResolvedUserTaskForm form, string actionKey, JsonElement data, CancellationToken cancellationToken = default); +} + +/// +/// Signals that a task changed between being read and being saved. Every repository translates its own +/// store-specific concurrency failure into this type, so callers never have to know which provider is +/// installed to tell a lost optimistic-concurrency race from a genuine fault. +/// +public sealed class UserTaskRevisionConflictException(string taskId, int expectedRevision, Exception? innerException = null) + : Exception($"User task '{taskId}' changed before revision {expectedRevision} could be saved.", innerException) +{ + public string TaskId { get; } = taskId; + public int ExpectedRevision { get; } = expectedRevision; +} + +public interface IUserTaskRepository +{ + Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default); + Task QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default); + Task FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default); + Task FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default); + + /// + /// Resolves the task that owns an invitation by the invitation's stored token hash. Lookup is + /// deliberately hash-keyed and tenant-agnostic: an anonymous caller presents only a secret, and the + /// provider must never be asked to trust a caller-supplied tenant or task identifier. + /// + Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default); + /// Throws when the stored revision has moved on. + Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default); + Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default); + + /// + /// Appends one audit entry without touching the aggregate's revision. Audit is append-only and must not + /// consume the optimistic-concurrency token: recording a read would otherwise invalidate the expected + /// revision a client is already holding and turn its next command into a spurious conflict. + /// + Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationToken = default); + Task TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func mutation, CancellationToken cancellationToken = default); +} + +public interface IUserTaskManager +{ + Task ProjectAsync(UserTaskMaterialization materialization, CancellationToken cancellationToken = default); + /// Returns null when the actor may not list in the requested scope. + Task QueryAsync(UserTaskQuery query, UserTaskQueryScopeKind scope, UserTaskActor actor, CancellationToken cancellationToken = default); + Task GetAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default); + Task GetCapabilitiesAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default); + Task GetEventsAsync(string tenantId, string taskId, string? cursor, int limit, UserTaskActor actor, CancellationToken cancellationToken = default); + + /// + /// Discloses one masked form field after an explicit, audited request. Returns null when the task + /// is invisible, the field is unknown, or the field was not marked revealable by its form provider. + /// + Task RevealFieldAsync(string tenantId, string taskId, string fieldKey, UserTaskActor actor, CancellationToken cancellationToken = default); + Task ClaimAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task ReleaseAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task AssignAsync(string tenantId, string taskId, UserTaskAssignRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task UpdateSchedulingAsync(string tenantId, string taskId, UserTaskSchedulingUpdate request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task CompleteAsync(string tenantId, string taskId, UserTaskCompletionRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task TimeoutAsync(string tenantId, string taskId, int expectedRevision, DateTimeOffset now, CancellationToken cancellationToken = default); + Task CancelAsync(string tenantId, string taskId, UserTaskCancelRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task RetryResolutionAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); +} + +public interface IUserTaskProjectionService +{ + Task ProjectCommittedBookmarksAsync(IReadOnlyCollection materializations, CancellationToken cancellationToken = default); + Task FinalizeBookmarkRemovalAsync(UserTaskBookmarkRemoval removal, CancellationToken cancellationToken = default); +} + +public interface IUserTaskReconciler +{ + Task ReconcileAsync(UserTaskReconciliationRequest request, CancellationToken cancellationToken = default); +} + +public interface IUserTaskDueService +{ + Task MarkOverdueAsync(string tenantId, DateTimeOffset? now = null, CancellationToken cancellationToken = default); +} + +public interface IUserTaskWorkflowResumer +{ + Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default); +} + +public interface IUserTaskInvitationDispatcher +{ + Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default); +} + +public interface IUserTaskInvitationVerifier +{ + Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default); +} + +/// +/// Issues and resolves revocable, task-scoped guest sessions. Implementations must store only a hash of the +/// session credential and must invalidate a session as soon as its task closes. +/// +public interface IUserTaskGuestSessionIssuer +{ + Task IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default); + + /// Resolves a presented credential, or returns null for unknown, expired, or revoked sessions. + Task ResolveAsync(string credential, CancellationToken cancellationToken = default); + + /// Revokes every session issued for a task. Called when the task reaches a terminal state. + Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default); +} + +/// +/// Rate limiter for the anonymous invitation surface. The default implementation is a per-process sliding +/// window; hosts running multiple replicas should replace it with a shared-store implementation. +/// +public interface IUserTaskInvitationRateLimiter +{ + /// Returns false when the caller has exhausted its budget and must receive a 429. + ValueTask TryAcquireAsync(string partitionKey, CancellationToken cancellationToken = default); +} + +/// +/// Holds invitation secrets between issuance and successful delivery. Entries are encrypted at rest through +/// ASP.NET Core Data Protection and removed once delivery succeeds or the invitation expires. +/// +public interface IUserTaskInvitationOutbox +{ + Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default); + Task> DequeueDueAsync(int maxCount, CancellationToken cancellationToken = default); + Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default); + Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken = default); +} + +public interface IUserTaskInvitationService +{ + Task IssueAsync(string tenantId, string taskId, UserTaskInvitationIssueRequest request, UserTaskActor actor, CancellationToken cancellationToken = default); + Task?> ListAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default); + Task RevokeAsync(string tenantId, string taskId, string invitationId, int expectedRevision, UserTaskActor actor, CancellationToken cancellationToken = default); + + /// + /// Describes the challenge an anonymous holder must answer. The descriptor is deliberately generic so a + /// caller cannot distinguish a missing, expired, consumed, or revoked invitation from a valid one. + /// + Task DescribeAsync(string token, CancellationToken cancellationToken = default); + + Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default); +} + +public interface IUserTaskNotificationSink +{ + Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.UserTasks/Elsa.UserTasks.csproj b/src/modules/Elsa.UserTasks/Elsa.UserTasks.csproj new file mode 100644 index 000000000..a73a1cf42 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Elsa.UserTasks.csproj @@ -0,0 +1,18 @@ + + + Provides identity-neutral, workflow-bound human user tasks. + elsa workflow user tasks human tasks + false + + + + + + + + + + + + + diff --git a/src/modules/Elsa.UserTasks/Endpoints/UserTaskApiModels.cs b/src/modules/Elsa.UserTasks/Endpoints/UserTaskApiModels.cs new file mode 100644 index 000000000..5ba8ec37b --- /dev/null +++ b/src/modules/Elsa.UserTasks/Endpoints/UserTaskApiModels.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Elsa.UserTasks.Models; +using Microsoft.AspNetCore.Http; + +namespace Elsa.UserTasks.Endpoints; + +public sealed class ListUserTasksRequest +{ + /// One of assigned, available, history, all, or needs-attention. + public string? Scope { get; set; } + public string? Cursor { get; set; } + public int Limit { get; set; } = 50; + public string? Sort { get; set; } + public string? Direction { get; set; } + /// Repeatable. Unknown values are ignored rather than rejected. + public string[]? Status { get; set; } + public int? PriorityFrom { get; set; } + public int? PriorityTo { get; set; } + /// A derived due filter: overdue, today, thisWeek, or noDueDate. + public string? Due { get; set; } + public DateTimeOffset? From { get; set; } + public DateTimeOffset? To { get; set; } + public string? WorkflowDefinitionId { get; set; } + public string? WorkflowInstanceId { get; set; } + public string? Reference { get; set; } + public string? TaskType { get; set; } + public string? Search { get; set; } + public bool IncludeTotalCount { get; set; } +} + +public sealed record UserTaskListResponse(IReadOnlyCollection Items, string? NextCursor, int? TotalCount); + +public class UserTaskMutationApiRequest +{ + public int ExpectedRevision { get; set; } + /// Reused verbatim across retries of one user action so a retry is idempotent, never a second command. + public string? OperationId { get; set; } + public string? Reason { get; set; } +} + +public sealed class AssignUserTaskApiRequest : UserTaskMutationApiRequest +{ + public UserTaskParticipantSummary Assignee { get; set; } = null!; +} + +public sealed class ScheduleUserTaskApiRequest : UserTaskMutationApiRequest +{ + public int? Priority { get; set; } + public DateTimeOffset? DueAt { get; set; } +} + +public sealed class CompleteUserTaskApiRequest +{ + public int ExpectedRevision { get; set; } + public string OperationId { get; set; } = ""; + public string ActionKey { get; set; } = ""; + public JsonElement? Data { get; set; } +} + +public sealed class CancelUserTaskApiRequest +{ + public int ExpectedRevision { get; set; } + public string OperationId { get; set; } = ""; + public string Reason { get; set; } = ""; +} + +public sealed class RevealUserTaskFieldApiRequest +{ + public string FieldKey { get; set; } = ""; +} + +public sealed record RevealUserTaskFieldResponse(string FieldKey, JsonElement? Value); + +/// +/// The uniform command envelope. status is completed for a synchronous state change and +/// accepted for a terminal command that resumes the workflow asynchronously. +/// +public sealed record UserTaskOperationResponse(string OperationId, string Status, int Revision, UserTaskSummary? Task); + +/// A safe error body. Codes are stable; messages never carry exception or payload detail. +public sealed record UserTaskErrorResponse(string Code, string Message); + +public sealed class IssueUserTaskInvitationApiRequest +{ + public int ExpectedRevision { get; set; } + public string VerifierName { get; set; } = ""; + public IReadOnlyCollection AllowedActions { get; set; } = []; + public string? Recipient { get; set; } + public TimeSpan? Lifetime { get; set; } + public string? OperationId { get; set; } +} + +public sealed record UserTaskInvitationListResponse(IReadOnlyCollection Items); + +public sealed class VerifyUserTaskInvitationApiRequest +{ + public string? Code { get; set; } + public string? State { get; set; } +} + +public sealed record UserTaskGuestSessionResponse(string? SessionCredential, string? TaskId, DateTimeOffset? ExpiresAt); + +public sealed class UserTaskParticipantLookupApiRequest +{ + public string? Search { get; set; } + public string? Type { get; set; } + public string? Cursor { get; set; } + public int Limit { get; set; } = 50; +} + +public sealed record UserTaskParticipantSearchResponse(IReadOnlyCollection Items, string? NextCursor); + +/// +/// Maps the domain's conflict codes onto HTTP semantics and safe copy. Keeping this in one place stops an +/// individual endpoint from inventing a status that leaks whether a task exists. +/// +internal static class UserTaskErrors +{ + public static int StatusCodeFor(string code) => code switch + { + // A denied command answers exactly like a missing one. The transport layer already rejected a + // caller without the module permission, so reaching a domain denial means the relationship check + // failed — and answering 403 there would tell an ID-guessing caller that the task exists. + "not-found" or "forbidden" => StatusCodes.Status404NotFound, + "revision-conflict" or "idempotency-conflict" or "terminal" or "transition-in-progress" or "not-claimable" => StatusCodes.Status409Conflict, + "invalid-action" or "reserved-action" or "form-required" or "form-invalid" or "form-resolution-failed" + or "form-provider-missing" or "payload-too-large" or "cancellation-disabled" or "reason-required" + or "invalid-priority" or "excluded-assignee" or "cross-tenant-assignee" or "timeout-not-due" => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status409Conflict + }; + + public static UserTaskErrorResponse Describe(string code) => new(code, code switch + { + // Same copy for both, so the message cannot re-introduce the distinction the status code hides. + "not-found" or "forbidden" => "This task is no longer available.", + "revision-conflict" => "The task changed since it was loaded. Reload it and try again.", + "idempotency-conflict" => "That operation ID was already used with a different request.", + "terminal" => "This task has already reached a final state.", + "transition-in-progress" => "This task is already finishing. Reload it to see the result.", + "not-claimable" => "This task is no longer available to claim.", + "invalid-action" => "That action is not configured for this task.", + "reserved-action" => "That action is reserved and cannot be selected.", + "form-required" => "This task does not accept a response payload.", + "form-invalid" => "The response did not pass validation.", + "form-resolution-failed" or "form-provider-missing" => "This task's form is unavailable. Ask an operator to retry resolution.", + "payload-too-large" => "The response is too large.", + "cancellation-disabled" => "Cancellation is not enabled for this task.", + "reason-required" => "A reason is required.", + "invalid-priority" => "Priority must be between 0 and 100.", + "excluded-assignee" => "That participant is excluded from this task.", + "cross-tenant-assignee" => "That participant belongs to a different tenant.", + "timeout-not-due" => "This task is not due for timeout.", + _ => "The request could not be completed." + }); +} diff --git a/src/modules/Elsa.UserTasks/Endpoints/UserTasksEndpoints.cs b/src/modules/Elsa.UserTasks/Endpoints/UserTasksEndpoints.cs new file mode 100644 index 000000000..2bb30f9ff --- /dev/null +++ b/src/modules/Elsa.UserTasks/Endpoints/UserTasksEndpoints.cs @@ -0,0 +1,684 @@ +using System.Text.Json; +using Elsa.Abstractions; +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Permissions; +using Elsa.UserTasks.Services; +using FastEndpoints; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Endpoints; + +/// +/// Shared behavior for the authenticated task endpoints: resolve the actor once, and translate a domain +/// conflict code into the single canonical HTTP shape. +/// +internal static class UserTaskEndpointHelpers +{ + public static UserTaskQueryScopeKind ParseScope(string? value) => value?.Trim().ToLowerInvariant() switch + { + "available" => UserTaskQueryScopeKind.Available, + "history" => UserTaskQueryScopeKind.History, + "all" => UserTaskQueryScopeKind.All, + "needs-attention" or "needsattention" => UserTaskQueryScopeKind.NeedsAttention, + _ => UserTaskQueryScopeKind.Assigned + }; + + /// Unknown status values are dropped rather than rejected, so a stale bookmark still loads. + public static IReadOnlyCollection ParseStatuses(IEnumerable? values) => values == null + ? [] + : values.Select(value => Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : (UserTaskStatus?)null) + .Where(x => x != null) + .Select(x => x!.Value) + .Distinct() + .ToArray(); + + public static UserTaskQuery ApplyDueFilter(UserTaskQuery query, string? due, DateTimeOffset now) => due?.Trim().ToLowerInvariant() switch + { + "overdue" => query with { OnlyOverdue = true }, + "nodueDate" or "nodue" or "nodate" => query with { OnlyWithoutDueDate = true }, + "today" => query with { DueFrom = now.Date, DueTo = now.Date.AddDays(1).AddTicks(-1) }, + "thisweek" => query with { DueFrom = now.Date, DueTo = now.Date.AddDays(7).AddTicks(-1) }, + _ => query + }; + + public static UserTaskParticipantType? ParseParticipantType(string? value) => value?.Trim().ToLowerInvariant() switch + { + "group" => UserTaskParticipantType.Group, + "user" => UserTaskParticipantType.User, + _ => null + }; +} + +internal abstract class UserTaskEndpointBase : ElsaEndpoint + where TRequest : notnull, new() + where TResponse : notnull +{ + protected async Task SendConflictAsync(string code, CancellationToken cancellationToken) => + await HttpContext.Response.SendAsync(UserTaskErrors.Describe(code), UserTaskErrors.StatusCodeFor(code), cancellation: cancellationToken); + + /// + /// Sends the canonical command envelope. Terminal commands answer 202 because the workflow resumes + /// out of band; clients observe the final state through requery or invalidation. + /// + protected async Task SendOperationAsync(UserTaskOperationResult result, UserTaskActor actor, IUserTaskAccessPolicy policy, bool accepted, CancellationToken cancellationToken) + { + if (!result.Accepted) + { + await SendConflictAsync(result.ConflictCode ?? "conflict", cancellationToken); + return; + } + + var summary = await UserTaskModelMapper.ToSummaryAsync(result.Task, actor, policy, cancellationToken); + var response = new UserTaskOperationResponse(result.Operation.OperationId, accepted ? "accepted" : "completed", result.Task.Revision, summary); + await HttpContext.Response.SendAsync(response, accepted ? StatusCodes.Status202Accepted : StatusCodes.Status200OK, cancellation: cancellationToken); + } +} + +internal sealed class FeatureCapabilitiesEndpoint(IUserTaskIdentityResolver identityResolver, IOptions options, IUserTaskParticipantDirectory directory) + : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-tasks/capabilities"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + + var settings = options.Value; + var isManager = actor.IsManager && actor.HasPermission(UserTasksPermissions.Manage); + // The descriptor is advisory: it decides what the client renders, never what the server allows. + await Send.OkAsync(new UserTaskFeatureCapabilities + { + Enabled = true, + CanList = actor.HasPermission(UserTasksPermissions.Read), + CanRead = actor.HasPermission(UserTasksPermissions.Read), + CanReadAll = isManager, + CanClaim = actor.HasPermission(UserTasksPermissions.Claim), + CanRelease = actor.HasPermission(UserTasksPermissions.Claim), + CanComplete = actor.HasPermission(UserTasksPermissions.Complete), + CanAssign = actor.HasPermission(UserTasksPermissions.Assign), + CanUpdate = actor.HasPermission(UserTasksPermissions.Update), + CanCancel = actor.HasPermission(UserTasksPermissions.Cancel), + CanCreateGuestLinks = actor.HasPermission(UserTasksPermissions.Invite), + CanViewProtected = actor.HasPermission(UserTasksPermissions.Read), + ParticipantPicker = actor.HasPermission(UserTasksPermissions.LookupParticipants) && directory is not EmptyUserTaskParticipantDirectory, + Realtime = settings.RealtimeEnabled, + PollingIntervalSeconds = settings.PollingIntervalSeconds + }, cancellationToken); + } +} + +internal sealed class ListEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, ISystemClock clock) + : ElsaEndpoint +{ + public override void Configure() + { + Get("/user-tasks"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(ListUserTasksRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + + var query = new UserTaskQuery + { + TenantId = actor.Subject.TenantId, + Cursor = request.Cursor, + Limit = request.Limit, + Sort = request.Sort ?? "created", + Descending = string.Equals(request.Direction, "desc", StringComparison.OrdinalIgnoreCase), + Statuses = UserTaskEndpointHelpers.ParseStatuses(request.Status), + PriorityFrom = request.PriorityFrom, + PriorityTo = request.PriorityTo, + DueFrom = request.From, + DueTo = request.To, + WorkflowDefinitionId = request.WorkflowDefinitionId, + WorkflowInstanceId = request.WorkflowInstanceId, + Reference = request.Reference, + TaskType = request.TaskType, + Search = request.Search, + IncludeTotalCount = request.IncludeTotalCount + }; + query = UserTaskEndpointHelpers.ApplyDueFilter(query, request.Due, clock.UtcNow); + + var scope = UserTaskEndpointHelpers.ParseScope(request.Scope); + var result = await manager.QueryAsync(query, scope, actor, cancellationToken); + if (result == null) + { + // The requested scope is not available to this actor (for example a non-manager asking for + // `all`). This is an authorization outcome, not an empty page. + await Send.ForbiddenAsync(cancellationToken); + return; + } + + await Send.OkAsync(new UserTaskListResponse(result.Items, result.NextCursor, result.TotalCount), cancellationToken); + } +} + +internal sealed class GetEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-tasks/{taskId}"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + var task = actor == null ? null : await manager.GetAsync(actor.Subject.TenantId, Route("taskId")!, actor, cancellationToken); + if (task == null) + { + // Concealment is deliberate: an unauthorized caller must not be able to tell an existing task + // from a missing one. + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(task, cancellationToken); + } +} + +internal sealed class CapabilitiesEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-tasks/{taskId}/capabilities"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + var result = actor == null ? null : await manager.GetCapabilitiesAsync(actor.Subject.TenantId, Route("taskId")!, actor, cancellationToken); + if (result == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(result, cancellationToken); + } +} + +internal sealed class ListEventsEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-tasks/{taskId}/events"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + var result = actor == null + ? null + : await manager.GetEventsAsync(actor.Subject.TenantId, Route("taskId")!, Query("cursor", isRequired: false), + Query("limit", isRequired: false) ?? 50, actor, cancellationToken); + if (result == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(result, cancellationToken); + } +} + +internal sealed class ClaimEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/claim"); + ConfigurePermissions(UserTasksPermissions.Claim); + } + + public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var result = await manager.ClaimAsync(actor.Subject.TenantId, Route("taskId")!, new(request.ExpectedRevision, request.OperationId), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken); + } +} + +internal sealed class ReleaseEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/release"); + ConfigurePermissions(UserTasksPermissions.Claim); + } + + public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var result = await manager.ReleaseAsync(actor.Subject.TenantId, Route("taskId")!, new(request.ExpectedRevision, request.OperationId), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken); + } +} + +internal sealed class AssignEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/assign"); + ConfigurePermissions(UserTasksPermissions.Assign); + } + + public override async Task HandleAsync(AssignUserTaskApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + if (request.Assignee is null || string.IsNullOrWhiteSpace(request.Assignee.Id)) + { + await SendConflictAsync("invalid-action", cancellationToken); + return; + } + + // The tenant is taken from the caller's own scope and never from the body, so a cross-tenant + // reference cannot be constructed over the wire at all. + var assignee = new ParticipantReference( + actor.Subject.TenantId, + string.IsNullOrWhiteSpace(request.Assignee.Provider) ? actor.Subject.Provider : request.Assignee.Provider!, + string.Equals(request.Assignee.Kind, UserTaskParticipantSummary.GroupKind, StringComparison.OrdinalIgnoreCase) ? UserTaskParticipantType.Group : UserTaskParticipantType.User, + request.Assignee.Id, + request.Assignee.DisplayName); + var result = await manager.AssignAsync(actor.Subject.TenantId, Route("taskId")!, + new(request.ExpectedRevision, assignee, request.Reason, request.OperationId), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken); + } +} + +internal sealed class ScheduleEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Patch("/user-tasks/{taskId}"); + ConfigurePermissions(UserTasksPermissions.Update); + } + + public override async Task HandleAsync(ScheduleUserTaskApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var result = await manager.UpdateSchedulingAsync(actor.Subject.TenantId, Route("taskId")!, + new(request.ExpectedRevision, request.Priority, request.DueAt, request.OperationId), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken); + } +} + +internal sealed class CompleteEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/complete"); + ConfigurePermissions(UserTasksPermissions.Complete); + } + + public override async Task HandleAsync(CompleteUserTaskApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + if (string.IsNullOrWhiteSpace(request.OperationId)) + { + await SendConflictAsync("invalid-action", cancellationToken); + return; + } + var result = await manager.CompleteAsync(actor.Subject.TenantId, Route("taskId")!, + new(request.ExpectedRevision, request.OperationId, request.ActionKey, request.Data), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken); + } +} + +internal sealed class CancelEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/cancel"); + ConfigurePermissions(UserTasksPermissions.Cancel); + } + + public override async Task HandleAsync(CancelUserTaskApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var result = await manager.CancelAsync(actor.Subject.TenantId, Route("taskId")!, + new(request.ExpectedRevision, request.OperationId, request.Reason), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken); + } +} + +internal sealed class RetryResolutionEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAccessPolicy policy) + : UserTaskEndpointBase +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/retry-resolution"); + ConfigurePermissions(UserTasksPermissions.Manage); + } + + public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var result = await manager.RetryResolutionAsync(actor.Subject.TenantId, Route("taskId")!, new(request.ExpectedRevision, request.OperationId), actor, cancellationToken); + await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken); + } +} + +internal sealed class RevealFieldEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) + : ElsaEndpoint +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/reveal"); + ConfigurePermissions(UserTasksPermissions.Read); + } + + public override async Task HandleAsync(RevealUserTaskFieldApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + var value = actor == null || string.IsNullOrWhiteSpace(request.FieldKey) + ? null + : await manager.RevealFieldAsync(actor.Subject.TenantId, Route("taskId")!, request.FieldKey, actor, cancellationToken); + if (value == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(new RevealUserTaskFieldResponse(request.FieldKey, value), cancellationToken); + } +} + +internal sealed class IssueInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identityResolver) + : ElsaEndpoint +{ + public override void Configure() + { + Post("/user-tasks/{taskId}/invitations"); + ConfigurePermissions(UserTasksPermissions.Invite); + } + + public override async Task HandleAsync(IssueUserTaskInvitationApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + + var result = await invitations.IssueAsync(actor.Subject.TenantId, Route("taskId")!, + new(request.ExpectedRevision, request.VerifierName, request.AllowedActions, + request.Recipient, request.Lifetime, request.OperationId), actor, cancellationToken); + if (result == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + // The response carries metadata only. The secret leaves through the dispatcher, never the API. + await HttpContext.Response.SendAsync(result, StatusCodes.Status201Created, cancellation: cancellationToken); + } +} + +internal sealed class ListInvitationsEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identityResolver) + : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-tasks/{taskId}/invitations"); + ConfigurePermissions(UserTasksPermissions.Invite); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + var result = actor == null ? null : await invitations.ListAsync(actor.Subject.TenantId, Route("taskId")!, actor, cancellationToken); + if (result == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(new UserTaskInvitationListResponse(result), cancellationToken); + } +} + +internal sealed class RevokeInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identityResolver) + : ElsaEndpoint +{ + public override void Configure() + { + Delete("/user-tasks/{taskId}/invitations/{invitationId}"); + ConfigurePermissions(UserTasksPermissions.Invite); + } + + public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + var revoked = await invitations.RevokeAsync(actor.Subject.TenantId, Route("taskId")!, Route("invitationId")!, request.ExpectedRevision, actor, cancellationToken); + if (!revoked) + await Send.NotFoundAsync(cancellationToken); + else + await Send.NoContentAsync(cancellationToken); + } +} + +internal sealed class ListParticipantsEndpoint(IUserTaskParticipantDirectory directory, IUserTaskIdentityResolver identityResolver) + : ElsaEndpoint +{ + public override void Configure() + { + Get("/user-task-participants"); + ConfigurePermissions(UserTasksPermissions.LookupParticipants); + } + + public override async Task HandleAsync(UserTaskParticipantLookupApiRequest request, CancellationToken cancellationToken) + { + var actor = await identityResolver.ResolveAsync(User, cancellationToken); + if (actor == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + + // A host without a directory answers with an empty page rather than an identity-module error, so the + // picker degrades to a plain reference editor instead of breaking the page. + var result = await directory.SearchAsync(new(actor.Subject.TenantId, request.Search, + UserTaskEndpointHelpers.ParseParticipantType(request.Type), request.Cursor, + Math.Clamp(request.Limit <= 0 ? 50 : request.Limit, 1, 200)), cancellationToken); + var items = result.Items + .Where(x => string.Equals(x.TenantId, actor.Subject.TenantId, StringComparison.Ordinal)) + .Select(x => UserTaskParticipantSummary.From(x)!) + .ToArray(); + await Send.OkAsync(new UserTaskParticipantSearchResponse(items, result.NextCursor), cancellationToken); + } +} + +/// +/// Anonymous invitation surface. Both endpoints are rate limited per caller and answer with the same generic +/// shape for valid and invalid tokens. +/// +internal abstract class AnonymousInvitationEndpointBase : Endpoint + where TRequest : notnull, new() + where TResponse : notnull +{ + protected async Task TryAcquireAsync(IUserTaskInvitationRateLimiter limiter, CancellationToken cancellationToken) + { + // Partition by remote address, not by token: probing many tokens from one host drains one budget. + var partition = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (await limiter.TryAcquireAsync(partition, cancellationToken)) + return true; + + await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invitation-unavailable"), StatusCodes.Status429TooManyRequests, cancellation: cancellationToken); + return false; + } +} + +internal sealed class DescribeInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskInvitationRateLimiter limiter) + : AnonymousInvitationEndpointBase +{ + public override void Configure() + { + Get("/user-task-invitations/{token}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(EmptyRequest request, CancellationToken cancellationToken) + { + if (!await TryAcquireAsync(limiter, cancellationToken)) + return; + await Send.OkAsync(await invitations.DescribeAsync(Route("token")!, cancellationToken), cancellationToken); + } +} + +internal sealed class VerifyInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskInvitationRateLimiter limiter) + : AnonymousInvitationEndpointBase +{ + public override void Configure() + { + Post("/user-task-invitations/{token}/verify"); + AllowAnonymous(); + } + + public override async Task HandleAsync(VerifyUserTaskInvitationApiRequest request, CancellationToken cancellationToken) + { + if (!await TryAcquireAsync(limiter, cancellationToken)) + return; + + var result = await invitations.VerifyAsync(new(Route("token")!, request.Code, request.State), cancellationToken); + if (!result.Succeeded) + { + // One shape for missing, expired, consumed, revoked, and wrong-code. No existence oracle. + await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invitation-unavailable"), StatusCodes.Status400BadRequest, cancellation: cancellationToken); + return; + } + await Send.OkAsync(new UserTaskGuestSessionResponse(result.SessionToken, result.TaskId, result.ExpiresAt), cancellationToken); + } +} + +/// +/// Guest task surface. The presented session credential identifies the task, so no task ID appears in the +/// route and a guest can never address a task other than the one their invitation was issued for. +/// +internal sealed class GuestTaskEndpoint(IUserTaskManager manager, UserTaskGuestActorResolver guestResolver) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/user-task-sessions/current"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var actor = await guestResolver.ResolveAsync(UserTaskGuestActorResolver.ReadCredential(HttpContext.Request.Headers.Authorization), cancellationToken); + if (actor?.GuestTaskId == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + + var detail = await manager.GetAsync(actor.Subject.TenantId, actor.GuestTaskId, actor, cancellationToken); + if (detail == null) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + await Send.OkAsync(detail, cancellationToken); + } +} + +internal sealed class GuestCompleteEndpoint(IUserTaskManager manager, UserTaskGuestActorResolver guestResolver, IUserTaskAccessPolicy policy) + : Endpoint +{ + public override void Configure() + { + Post("/user-task-sessions/current/complete"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CompleteUserTaskApiRequest request, CancellationToken cancellationToken) + { + var actor = await guestResolver.ResolveAsync(UserTaskGuestActorResolver.ReadCredential(HttpContext.Request.Headers.Authorization), cancellationToken); + if (actor?.GuestTaskId == null) + { + await Send.UnauthorizedAsync(cancellationToken); + return; + } + if (string.IsNullOrWhiteSpace(request.OperationId)) + { + await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invalid-action"), StatusCodes.Status422UnprocessableEntity, cancellation: cancellationToken); + return; + } + + var result = await manager.CompleteAsync(actor.Subject.TenantId, actor.GuestTaskId, + new(request.ExpectedRevision, request.OperationId, request.ActionKey, request.Data), actor, cancellationToken); + if (!result.Accepted) + { + var code = result.ConflictCode ?? "conflict"; + await HttpContext.Response.SendAsync(UserTaskErrors.Describe(code), UserTaskErrors.StatusCodeFor(code), cancellation: cancellationToken); + return; + } + + var summary = await UserTaskModelMapper.ToSummaryAsync(result.Task, actor, policy, cancellationToken); + await HttpContext.Response.SendAsync(new UserTaskOperationResponse(result.Operation.OperationId, "accepted", result.Task.Revision, summary), + StatusCodes.Status202Accepted, cancellation: cancellationToken); + } +} diff --git a/src/modules/Elsa.UserTasks/Extensions/ModuleExtensions.cs b/src/modules/Elsa.UserTasks/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..561aabea4 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Extensions/ModuleExtensions.cs @@ -0,0 +1,10 @@ +using Elsa.Features.Services; +using Elsa.UserTasks.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class UserTasksModuleExtensions +{ + public static IModule UseUserTasks(this IModule module, Action? configure = null) => module.Use(configure); +} diff --git a/src/modules/Elsa.UserTasks/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.UserTasks/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..f94d633f2 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Repositories; +using Elsa.UserTasks.Services; +using Elsa.Mediator.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Elsa.UserTasks.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUserTasksServices(this IServiceCollection services, Action? configure = null) + { + if (configure != null) + services.Configure(configure); + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + // The in-memory defaults below hold process state, so they stay singletons. A persistence provider + // replaces them with scoped, store-backed implementations. + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Everything that consumes a repository is scoped: a provider-backed repository resolves a DbContext + // from the ambient scope, and a singleton consumer would capture it. + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.AddNotificationHandler(); + + // Hosted workers own the periodic side of the module: due/timeout sweeps, projection reconciliation, + // and draining the encrypted invitation outbox. + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + return services; + } +} diff --git a/src/modules/Elsa.UserTasks/Features/UserTasksFeature.cs b/src/modules/Elsa.UserTasks/Features/UserTasksFeature.cs new file mode 100644 index 000000000..ede78e70a --- /dev/null +++ b/src/modules/Elsa.UserTasks/Features/UserTasksFeature.cs @@ -0,0 +1,23 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Elsa.UserTasks.Extensions; + +namespace Elsa.UserTasks.Features; + +public sealed class UserTasksFeature(IModule module) : FeatureBase(module) +{ + public Action? ConfigureOptions { get; set; } + + public override void Configure() + { + Module.AddFastEndpointsAssembly(); + } + + public override void Apply() + { + Services.AddUserTasksServices(ConfigureOptions); + Module.AddFastEndpointsFromModule(); + } +} diff --git a/src/modules/Elsa.UserTasks/FodyWeavers.xml b/src/modules/Elsa.UserTasks/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.UserTasks/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.UserTasks/Handlers/UserTaskBookmarkPersistedHandler.cs b/src/modules/Elsa.UserTasks/Handlers/UserTaskBookmarkPersistedHandler.cs new file mode 100644 index 000000000..75c40b54b --- /dev/null +++ b/src/modules/Elsa.UserTasks/Handlers/UserTaskBookmarkPersistedHandler.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Elsa.Common; +using Elsa.Mediator.Contracts; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Notifications; + +namespace Elsa.UserTasks.Handlers; + +/// +/// Bridges the workflow bookmark commit notification to the User Tasks projection. This keeps workflow +/// and task persistence decoupled: task projection is never written from inside activity execution. +/// +public sealed class UserTaskBookmarkPersistedHandler(IUserTaskProjectionService projectionService, ISystemClock clock) : INotificationHandler +{ + public async Task HandleAsync(WorkflowBookmarksPersisted notification, CancellationToken cancellationToken) + { + var materializations = notification.Diff.Added + .Select(Deserialize) + .Where(x => x != null) + .Select(x => x!) + .ToArray(); + if (materializations.Length > 0) + await projectionService.ProjectCommittedBookmarksAsync(materializations, cancellationToken); + + foreach (var bookmark in notification.Diff.Removed) + { + var materialization = Deserialize(bookmark); + if (materialization?.TaskId == null) + continue; + await projectionService.FinalizeBookmarkRemovalAsync(new UserTaskBookmarkRemoval( + materialization.TenantId, + materialization.TaskId, + bookmark.Id, + clock.UtcNow), cancellationToken); + } + } + + private static UserTaskMaterialization? Deserialize(Bookmark bookmark) + { + try + { + return bookmark.Payload switch + { + UserTaskMaterialization materialization => materialization, + JsonElement element => element.Deserialize(), + null => null, + _ => JsonSerializer.Deserialize(JsonSerializer.Serialize(bookmark.Payload)) + }; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/modules/Elsa.UserTasks/HostedServices/UserTaskWorkers.cs b/src/modules/Elsa.UserTasks/HostedServices/UserTaskWorkers.cs new file mode 100644 index 000000000..38d14abb2 --- /dev/null +++ b/src/modules/Elsa.UserTasks/HostedServices/UserTaskWorkers.cs @@ -0,0 +1,124 @@ +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.HostedServices; + +/// +/// Shared plumbing for the User Tasks background workers: a periodic loop that never lets one failed pass +/// tear down the host, and a bounded scope per pass so scoped providers can be resolved safely. +/// +public abstract class UserTaskPeriodicWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) : BackgroundService +{ + protected UserTasksOptions Options { get; } = options.Value; + + protected abstract TimeSpan Interval { get; } + + protected abstract Task ExecutePassAsync(IServiceProvider services, CancellationToken cancellationToken); + + /// The tenants a pass sweeps. Defaults to the configured default tenant when no catalog is set. + protected IReadOnlyCollection TenantIds => Options.WorkerTenantIds.Count > 0 + ? Options.WorkerTenantIds.ToArray() + : [Options.DefaultTenantId]; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + await ExecutePassAsync(scope.ServiceProvider, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogWarning(exception, "A User Tasks background pass failed and will be retried"); + } + + try + { + if (!await timer.WaitForNextTickAsync(stoppingToken)) + return; + } + catch (OperationCanceledException) + { + return; + } + } + } +} + +/// Marks tasks overdue and applies the reserved timeout outcome once a due date elapses. +public sealed class UserTaskDueWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + : UserTaskPeriodicWorker(scopeFactory, options, logger) +{ + protected override TimeSpan Interval => Options.DueSweepInterval; + + protected override async Task ExecutePassAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var dueService = services.GetRequiredService(); + foreach (var tenantId in TenantIds) + await dueService.MarkOverdueAsync(tenantId, cancellationToken: cancellationToken); + } +} + +/// Repairs projections that diverged from committed bookmarks after an interrupted write. +public sealed class UserTaskReconciliationWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + : UserTaskPeriodicWorker(scopeFactory, options, logger) +{ + protected override TimeSpan Interval => Options.ReconciliationInterval; + + protected override async Task ExecutePassAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var reconciler = services.GetRequiredService(); + foreach (var tenantId in TenantIds) + await reconciler.ReconcileAsync(new UserTaskReconciliationRequest { TenantId = tenantId }, cancellationToken); + } +} + +/// +/// Drains the encrypted invitation outbox. A failed dispatch is rescheduled with the configured back-off and +/// is abandoned once the schedule is exhausted, so an undeliverable secret always expires rather than +/// lingering indefinitely. +/// +public sealed class UserTaskInvitationDeliveryWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + : UserTaskPeriodicWorker(scopeFactory, options, logger) +{ + private readonly ILogger _logger = logger; + + protected override TimeSpan Interval => TimeSpan.FromSeconds(5); + + protected override async Task ExecutePassAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var outbox = services.GetRequiredService(); + var dispatcher = services.GetRequiredService(); + var clock = services.GetRequiredService(); + + foreach (var delivery in await outbox.DequeueDueAsync(50, cancellationToken)) + { + try + { + await dispatcher.DispatchAsync(delivery, cancellationToken); + await outbox.CompleteAsync(delivery.Id, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + var delays = Options.InvitationDeliveryRetryDelays; + var delay = delivery.Attempt < delays.Count ? delays[delivery.Attempt] : delays.Count > 0 ? delays[^1] : TimeSpan.FromMinutes(5); + // The exception may carry recipient details, so only the invitation ID is logged. + _logger.LogWarning(exception, "Invitation delivery {InvitationId} failed on attempt {Attempt}", delivery.InvitationId, delivery.Attempt + 1); + await outbox.RescheduleAsync(delivery.Id, clock.UtcNow.Add(delay), cancellationToken); + } + } + } +} diff --git a/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs b/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs new file mode 100644 index 000000000..4fcf0127d --- /dev/null +++ b/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs @@ -0,0 +1,672 @@ +using System.Security.Claims; +using System.Text.Json; +using Elsa.Mediator.Contracts; + +namespace Elsa.UserTasks.Models; + +public enum UserTaskParticipantType +{ + User, + Group +} + +public enum UserTaskMembershipResolutionMode +{ + Live, + Snapshot +} + +public enum UserTaskStatus +{ + Unassigned, + Available, + Assigned, + Completing, + TimingOut, + Cancelling, + Completed, + TimedOut, + Cancelled +} + +public enum UserTaskHealthSeverity +{ + Advisory, + Blocking +} + +public enum UserTaskAccessOperation +{ + ReadSummary, + ReadProtected, + Claim, + Release, + Assign, + UpdateScheduling, + Complete, + Cancel, + Manage, + IssueInvitation, + RetryResolution +} + +public enum UserTaskOperationKind +{ + Claim, + Release, + Assign, + ScheduleUpdate, + Complete, + Timeout, + Cancel, + RetryResolution, + InvitationVerification +} + +public enum UserTaskOperationStatus +{ + Accepted, + Completed, + Failed +} + +public enum UserTaskInvitationStatus +{ + Pending, + Dispatched, + Verified, + Consumed, + Revoked, + Expired +} + +public sealed record ParticipantReference( + string TenantId, + string Provider, + UserTaskParticipantType Type, + string Id, + string? DisplayName = null) +{ + public bool Matches(ParticipantReference? other) => other != null + && string.Equals(TenantId, other.TenantId, StringComparison.Ordinal) + && string.Equals(Provider, other.Provider, StringComparison.Ordinal) + && Type == other.Type + && string.Equals(Id, other.Id, StringComparison.Ordinal); +} + +public sealed record UserTaskActor( + ParticipantReference Subject, + IReadOnlyCollection Groups, + string? DisplayName = null) +{ + /// Indicates that the host has granted tenant-scoped manager access. + public bool IsManager { get; init; } + public IReadOnlySet Permissions { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Set when the caller authenticated with a guest invitation session. A guest is scoped to exactly + /// one task; every policy decision for any other task must deny. + /// + public string? GuestTaskId { get; init; } + + /// The completion action keys a guest session was issued for. Empty for ordinary actors. + public IReadOnlySet GuestAllowedActions { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + public bool IsGuest => GuestTaskId != null; + + public bool HasPermission(string permission) => Permissions.Contains(permission) || Permissions.Contains("*"); +} + +public sealed record UserTaskAction( + string Key, + string Label, + IReadOnlyDictionary? Metadata = null); + +/// +/// Describes a guest invitation that is materialized with a task. The secret itself is created only +/// by Core at runtime and is never part of this definition. +/// +public sealed record UserTaskInvitationDefinition( + string VerifierName, + IReadOnlyCollection AllowedActions, + TimeSpan? Lifetime = null, + bool BearerOnly = false, + string? Recipient = null, + IReadOnlyDictionary? Configuration = null); + +public sealed record UserTaskFormReference( + string ProviderName, + string Key, + string? Binding = null, + string? Version = null); + +public sealed record ResolvedUserTaskForm( + UserTaskFormReference Requested, + string PinnedVersion, + IReadOnlyDictionary Metadata) +{ + /// + /// Provider-neutral field descriptors used to render the response surface. Descriptors carry shape and + /// disclosure flags only; values are read from the task payload under the protected-access decision. + /// + public IReadOnlyCollection Fields { get; init; } = []; +} + +public sealed record UserTaskFormFieldDescriptor( + string Key, + string Label, + string Type = "text", + bool Required = false, + bool Masked = false, + bool CanReveal = false); + +public sealed record UserTaskDefinitionSnapshot +{ + public string Title { get; init; } = ""; + public string? Summary { get; init; } + public string? Reference { get; init; } + public IReadOnlyCollection Tags { get; init; } = []; + public string? TaskType { get; init; } + public ParticipantReference? Requester { get; init; } + public ParticipantReference? Assignee { get; init; } + public IReadOnlyCollection CandidateUsers { get; init; } = []; + public IReadOnlyCollection CandidateGroups { get; init; } = []; + public IReadOnlyCollection ExcludedUsers { get; init; } = []; + public UserTaskMembershipResolutionMode MembershipResolutionMode { get; init; } = UserTaskMembershipResolutionMode.Live; + public bool AllowManagerExclusionOverride { get; init; } + public int Priority { get; init; } = 50; + public DateTimeOffset? DueAt { get; init; } + public string? Instructions { get; init; } + public JsonElement? TaskData { get; init; } + public UserTaskFormReference? FormReference { get; init; } + public IReadOnlyCollection Actions { get; init; } = []; + public IReadOnlyCollection Invitations { get; init; } = []; + public bool EnableTimeoutOutcome { get; init; } + public bool EnableCancellationOutcome { get; init; } + + public UserTaskDefinitionSnapshot Normalize() + { + var actions = Actions.Count == 0 + ? [new UserTaskAction("Complete", "Complete")] + : Actions; + + if (string.IsNullOrWhiteSpace(Title)) + throw new ArgumentException("A User Task title is required.", nameof(Title)); + if (actions.Any(x => string.IsNullOrWhiteSpace(x.Key) || string.IsNullOrWhiteSpace(x.Label))) + throw new ArgumentException("User Task action keys and labels are required.", nameof(Actions)); + if (actions.Any(x => string.Equals(x.Key, "Timeout", StringComparison.OrdinalIgnoreCase) || string.Equals(x.Key, "Cancelled", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Timeout and Cancelled are reserved User Task action keys."); + if (actions.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != actions.Count) + throw new ArgumentException("User Task action keys must be unique."); + if (Priority is < 0 or > 100) + throw new ArgumentOutOfRangeException(nameof(Priority), "Priority must be between 0 and 100."); + if (Invitations.Any(x => string.IsNullOrWhiteSpace(x.VerifierName) || x.AllowedActions.Count == 0 || x.AllowedActions.Any(string.IsNullOrWhiteSpace))) + throw new ArgumentException("Invitation verifier names and allowed actions are required.", nameof(Invitations)); + if (Invitations.Any(invitation => invitation.AllowedActions.Any(allowed => !actions.Any(action => string.Equals(action.Key, allowed, StringComparison.OrdinalIgnoreCase))))) + throw new ArgumentException("Invitation actions must be configured User Task actions.", nameof(Invitations)); + + return this with { Actions = actions }; + } +} + +public sealed class UserTask +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TenantId { get; set; } = ""; + public string WorkflowDefinitionId { get; set; } = ""; + public string? WorkflowDefinitionName { get; set; } + public int? WorkflowDefinitionVersion { get; set; } + public string WorkflowInstanceId { get; set; } = ""; + /// A safe, host-authored instance reference (correlation ID or instance name). Never a bookmark or token. + public string? WorkflowInstanceReference { get; set; } + public string ActivityInstanceId { get; set; } = ""; + public string BookmarkId { get; set; } = ""; + public string MaterializationKey { get; set; } = ""; + public string Title { get; set; } = "User task"; + public string? Summary { get; set; } + public string? Reference { get; set; } + public HashSet Tags { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public string? TaskType { get; set; } + public ParticipantReference? Requester { get; set; } + public ParticipantReference? Assignee { get; set; } + public List CandidateUsers { get; set; } = []; + public List CandidateGroups { get; set; } = []; + public List SnapshotMembers { get; set; } = []; + public List SnapshotGroups { get; set; } = []; + public List ExcludedUsers { get; set; } = []; + public UserTaskMembershipResolutionMode MembershipResolutionMode { get; set; } = UserTaskMembershipResolutionMode.Live; + public bool AllowManagerExclusionOverride { get; set; } + public int Priority { get; set; } = 50; + public DateTimeOffset? DueAt { get; set; } + public bool IsOverdue { get; set; } + public string? Instructions { get; set; } + public JsonElement? TaskData { get; set; } + public UserTaskFormReference? RequestedForm { get; set; } + public ResolvedUserTaskForm? PinnedForm { get; set; } + public List Actions { get; set; } = [new("Complete", "Complete")]; + public List InvitationDefinitions { get; set; } = []; + public bool EnableTimeoutOutcome { get; set; } + public bool EnableCancellationOutcome { get; set; } + public UserTaskStatus Status { get; set; } = UserTaskStatus.Available; + public UserTaskHealthSeverity? HealthSeverity { get; set; } + public string? HealthCode { get; set; } + public string? HealthMessage { get; set; } + public string? CompletionActionKey { get; set; } + public JsonElement? CompletionData { get; set; } + public ParticipantReference? CompletedBy { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? AssignedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public int Revision { get; set; } = 1; + public List Events { get; set; } = []; + public List Operations { get; set; } = []; + public List Invitations { get; set; } = []; + + public bool IsTerminal => Status is UserTaskStatus.Completed or UserTaskStatus.TimedOut or UserTaskStatus.Cancelled; + + public bool IsOpen => !IsTerminal; +} + +public sealed record UserTaskEvent( + string Id, + string TenantId, + string TaskId, + int Revision, + string EventType, + DateTimeOffset OccurredAt, + ParticipantReference? Actor = null, + string? OperationId = null, + string? Reason = null, + IReadOnlyDictionary? Metadata = null); + +public sealed record UserTaskOperation( + string Id, + string TenantId, + string TaskId, + string OperationId, + UserTaskOperationKind Kind, + int ExpectedRevision, + string RequestHash, + UserTaskOperationStatus Status, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + string? ActionKey = null, + JsonElement? Data = null, + string? ErrorCode = null); + +public sealed record UserTaskInvitation( + string Id, + string TenantId, + string TaskId, + string? Recipient, + string TokenHash, + UserTaskInvitationStatus Status, + DateTimeOffset IssuedAt, + DateTimeOffset ExpiresAt, + string? VerifierName = null, + DateTimeOffset? VerifiedAt = null, + DateTimeOffset? ConsumedAt = null, + DateTimeOffset? RevokedAt = null, + string? SiblingGroupId = null) +{ + /// + /// The completion actions this invitation was issued for. It is copied from the activity's invitation + /// definition at issuance so a later definition change cannot widen an outstanding guest link. + /// + public IReadOnlyCollection AllowedActions { get; init; } = []; +} + +public sealed record UserTaskInvitationDelivery( + string Id, + string TenantId, + string TaskId, + string InvitationId, + string DispatcherName, + string Token, + DateTimeOffset ExpiresAt) +{ + public string? Recipient { get; init; } + public int Attempt { get; init; } + public DateTimeOffset? NotBefore { get; init; } +} + +public sealed record UserTaskInvitationChallenge(string Token, string? Code = null, string? State = null); + +/// +/// A deliberately uniform challenge descriptor. The same shape is returned for valid and invalid tokens so +/// the anonymous surface never becomes an invitation-existence oracle. +/// +public sealed record UserTaskInvitationChallengeDescriptor(string ChallengeType, string Prompt, bool RequiresCode); + +public sealed record UserTaskInvitationVerificationResult(bool Succeeded, string? FailureCode = null, string? Subject = null); + +public sealed record GuestSessionResult(bool Succeeded, string? Token = null, DateTimeOffset? ExpiresAt = null, string? FailureCode = null, string? TaskId = null); + +/// A resolved guest session. It authorizes exactly one task and one set of completion actions. +public sealed record UserTaskGuestSession( + string TenantId, + string TaskId, + ParticipantReference Subject, + IReadOnlyCollection AllowedActions, + DateTimeOffset ExpiresAt); + +public sealed record UserTaskResult( + string ActionKey, + JsonElement? Data, + ParticipantReference? CompletedBy, + DateTimeOffset CompletedAt); + +public sealed record UserTaskInvitationSummary( + string Id, + string TaskId, + string? Recipient, + UserTaskInvitationStatus Status, + DateTimeOffset IssuedAt, + DateTimeOffset ExpiresAt, + string? VerifierName); + +public sealed record UserTaskInvitationIssueRequest( + int ExpectedRevision, + string VerifierName, + IReadOnlyCollection AllowedActions, + string? Recipient = null, + TimeSpan? Lifetime = null, + string? OperationId = null); + +public sealed record UserTaskInvitationIssueResult( + UserTaskInvitationSummary Invitation, + string? OperationId = null); + +public sealed record UserTaskInvitationVerificationResultWithSession( + bool Succeeded, + string? TaskId = null, + string? SessionToken = null, + DateTimeOffset? ExpiresAt = null, + string? FailureCode = null); + +public sealed record UserTaskMaterialization( + string TenantId, + string WorkflowDefinitionId, + string WorkflowInstanceId, + string ActivityInstanceId, + string BookmarkId, + UserTaskDefinitionSnapshot Definition, + IReadOnlyCollection SnapshotMembers, + IReadOnlyCollection SnapshotGroups, + DateTimeOffset CreatedAt, + string? TaskId = null, + string? WorkflowDefinitionName = null, + int? WorkflowDefinitionVersion = null, + string? WorkflowInstanceReference = null); + +public sealed record UserTaskStimulus( + string TenantId, + string TaskId, + string OperationId, + string ActionKey, + JsonElement? CompletionData, + ParticipantReference? CompletedBy = null, + DateTimeOffset? CompletedAt = null, + string? BookmarkId = null); + +public sealed record UserTaskBookmarkRemoval( + string TenantId, + string TaskId, + string BookmarkId, + DateTimeOffset RemovedAt); + +public sealed record UserTaskMutationRequest(int ExpectedRevision, string? OperationId = null); + +public sealed record UserTaskAssignRequest( + int ExpectedRevision, + ParticipantReference Assignee, + string? Reason = null, + string? OperationId = null); + +public sealed record UserTaskSchedulingUpdate( + int ExpectedRevision, + int? Priority = null, + DateTimeOffset? DueAt = null, + string? OperationId = null); + +public sealed record UserTaskCompletionRequest( + int ExpectedRevision, + string OperationId, + string ActionKey, + JsonElement? Data = null); + +public sealed record UserTaskCancelRequest(int ExpectedRevision, string OperationId, string Reason); + +/// +/// The caller-selected list scope. It is part of the authorization predicate, not a display filter: +/// and require tenant-scoped manager access. +/// +public enum UserTaskQueryScopeKind +{ + Assigned, + Available, + History, + All, + NeedsAttention +} + +public sealed record UserTaskQueryScope( + string TenantId, + ParticipantReference Subject, + IReadOnlyCollection Groups, + bool IsManager = false, + UserTaskQueryScopeKind Kind = UserTaskQueryScopeKind.Assigned, + bool ExcludeBlocking = true) +{ + public bool IncludeAssigned => Kind is UserTaskQueryScopeKind.Assigned; + public bool IncludeCandidates => Kind is UserTaskQueryScopeKind.Available; + public bool IncludeHistory => Kind is UserTaskQueryScopeKind.History; + public bool RequiresManager => Kind is UserTaskQueryScopeKind.All or UserTaskQueryScopeKind.NeedsAttention; +} + +public sealed record UserTaskQuery +{ + public string TenantId { get; init; } = ""; + public UserTaskQueryScope? Scope { get; init; } + public string? Cursor { get; init; } + public int Limit { get; init; } = 50; + public string? Search { get; init; } + public IReadOnlyCollection Statuses { get; init; } = []; + /// Restricts the page to tasks whose due date has already elapsed. + public bool OnlyOverdue { get; init; } + /// Restricts the page to tasks that carry no due date at all. + public bool OnlyWithoutDueDate { get; init; } + public string? TaskType { get; init; } + public int? PriorityFrom { get; init; } + public int? PriorityTo { get; init; } + public DateTimeOffset? DueFrom { get; init; } + public DateTimeOffset? DueTo { get; init; } + public string? WorkflowDefinitionId { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? Reference { get; init; } + public string Sort { get; init; } = "created"; + public bool Descending { get; init; } + public bool IncludeTotalCount { get; init; } +} + +public sealed record UserTaskQueryResult( + IReadOnlyCollection Items, + string? NextCursor, + int? TotalCount); + +public sealed record UserTaskProjectionResult(UserTask Task, bool Created); + +public sealed record UserTaskReconciliationRequest(int PageSize = 100, DateTimeOffset? OlderThan = null) +{ + /// Runs a tenant-scoped pass. Hosts with a tenant catalog can invoke one pass per tenant. + public string TenantId { get; init; } = ""; +} + +public sealed record UserTaskReconciliationResult(int Recreated, int Requeued, int Finalized, int Ambiguous); + +public sealed record UserTaskOperationResult( + UserTask Task, + UserTaskOperation Operation, + bool Accepted, + string? ConflictCode = null); + +public sealed record UserTaskQueryResultDto( + IReadOnlyCollection Items, + string? NextCursor, + int? TotalCount); + +/// +/// Wire projection of a participant. The tenant is implicit in the caller's own scope and is deliberately +/// never emitted, so a response can never be used to enumerate foreign tenants. +/// +public sealed record UserTaskParticipantSummary(string Kind, string? Provider, string Id, string? DisplayName) +{ + public const string UserKind = "user"; + public const string GroupKind = "group"; + + public static UserTaskParticipantSummary? From(ParticipantReference? reference) => reference == null + ? null + : new(reference.Type == UserTaskParticipantType.Group ? GroupKind : UserKind, reference.Provider, reference.Id, reference.DisplayName); +} + +/// The safe summary every authorized caller receives. Protected fields live on . +public record UserTaskSummary +{ + public string Id { get; init; } = ""; + public string Title { get; init; } = ""; + public string? Summary { get; init; } + public string? Reference { get; init; } + public IReadOnlyCollection Tags { get; init; } = []; + public string? TaskType { get; init; } + public string Status { get; init; } = nameof(UserTaskStatus.Available); + public int Priority { get; init; } = 50; + public UserTaskParticipantSummary? Assignee { get; init; } + /// A count-only candidate description. Candidate identities are never disclosed to peers. + public string? CandidateSummary { get; init; } + public DateTimeOffset? DueAt { get; init; } + public bool IsOverdue { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? AssignedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public string? WorkflowDefinitionId { get; init; } + public string? WorkflowDefinitionName { get; init; } + public int? WorkflowDefinitionVersion { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? WorkflowInstanceReference { get; init; } + public string? HealthSeverity { get; init; } + public string? HealthCode { get; init; } + public IReadOnlyCollection AllowedActions { get; init; } = []; + public int Revision { get; init; } +} + +public sealed record UserTaskDetail : UserTaskSummary +{ + public string? Instructions { get; init; } + /// The protected task payload. Present only when is true. + public JsonElement? Data { get; init; } + public UserTaskDisclosure Disclosure { get; init; } = new(); + public UserTaskWorkflowContext? Workflow { get; init; } + public UserTaskFormProjection? Form { get; init; } + public IReadOnlyCollection Actions { get; init; } = []; + /// The recorded completion action key, when the task has reached a terminal or transitional outcome. + public string? Outcome { get; init; } + public JsonElement? Response { get; init; } + public UserTaskParticipantSummary? CompletedBy { get; init; } +} + +public sealed record UserTaskDisclosure +{ + public bool CanViewProtected { get; init; } + public bool CanViewWorkflow { get; init; } + public bool CanViewHistory { get; init; } + /// True when the projection was produced for a guest session and is therefore already narrowed. + public bool GuestVisible { get; init; } +} + +public sealed record UserTaskWorkflowContext +{ + public string? DefinitionId { get; init; } + public string? DefinitionName { get; init; } + public int? DefinitionVersion { get; init; } + public string? InstanceId { get; init; } + public string? InstanceReference { get; init; } +} + +public sealed record UserTaskFormProjection +{ + public string Provider { get; init; } = ""; + public string Key { get; init; } = ""; + public string? Version { get; init; } + public IReadOnlyCollection Fields { get; init; } = []; + public IReadOnlyCollection Actions { get; init; } = []; +} + +public sealed record UserTaskFormField +{ + public string Key { get; init; } = ""; + public string Label { get; init; } = ""; + public string Type { get; init; } = "text"; + public bool Required { get; init; } + public bool Masked { get; init; } + public bool CanReveal { get; init; } + /// Always null for masked fields; those are read through the explicit reveal command. + public JsonElement? Value { get; init; } +} + +public sealed record UserTaskFormAction(string Key, string Label); + +/// Safe audit projection. Actor identifiers, reasons containing input, and metadata are not disclosed. +public sealed record UserTaskEventSummary(string Id, string Kind, string? Summary, DateTimeOffset OccurredAt, string? ActorDisplayName); + +public sealed record UserTaskEventsResult(IReadOnlyCollection Items, string? NextCursor); + +/// Per-task capability and concurrency projection. +public sealed record UserTaskCapabilities( + string TaskId, + int Revision, + IReadOnlyCollection AllowedActions, + bool CanReadProtected, + bool CanManage); + +/// +/// Tenant- and actor-scoped feature descriptor. Studio reads this before rendering navigation; it is +/// advisory only and never a substitute for per-request authorization. +/// +public sealed record UserTaskFeatureCapabilities +{ + public bool Enabled { get; init; } + public bool CanList { get; init; } + public bool CanRead { get; init; } + public bool CanReadAll { get; init; } + public bool CanClaim { get; init; } + public bool CanComplete { get; init; } + public bool CanRelease { get; init; } + public bool CanAssign { get; init; } + public bool CanUpdate { get; init; } + public bool CanCancel { get; init; } + public bool CanCreateGuestLinks { get; init; } + public bool CanViewProtected { get; init; } + public bool ParticipantPicker { get; init; } + public bool Realtime { get; init; } + public int PollingIntervalSeconds { get; init; } = 30; +} + +public sealed record UserTaskParticipantQuery(string TenantId, string? Search = null, UserTaskParticipantType? Type = null, string? Cursor = null, int Limit = 50); + +public sealed record ParticipantSearchResult(IReadOnlyCollection Items, string? NextCursor, int? TotalCount); + +public sealed record UserTaskFormValidationResult(bool Succeeded, JsonElement? NormalizedData = null, IReadOnlyCollection? Errors = null); + +public abstract record UserTaskLifecycleNotification(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : INotification; + +public sealed record UserTaskCreated(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision, IReadOnlyCollection ChangedFields) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskCompletionAccepted(string TenantId, string TaskId, UserTaskStatus Status, int Revision, string OperationId) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskCompleted(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskTimedOut(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskCancelled(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskOverdue(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskInvitationChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); +public sealed record UserTaskHealthChanged(string TenantId, string TaskId, UserTaskStatus Status, int Revision) : UserTaskLifecycleNotification(TenantId, TaskId, Status, Revision); diff --git a/src/modules/Elsa.UserTasks/Options/UserTasksOptions.cs b/src/modules/Elsa.UserTasks/Options/UserTasksOptions.cs new file mode 100644 index 000000000..f39445304 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Options/UserTasksOptions.cs @@ -0,0 +1,41 @@ +namespace Elsa.UserTasks.Options; + +public sealed class UserTasksOptions +{ + public string DefaultTenantId { get; set; } = ""; + public string DefaultProvider { get; set; } = "default"; + public string SubjectClaimType { get; set; } = "sub"; + public string TenantClaimType { get; set; } = "tenant"; + public string ProviderClaimType { get; set; } = "elsa:identity-provider"; + public string DisplayNameClaimType { get; set; } = "name"; + public ICollection GroupClaimTypes { get; set; } = ["groups", "group"]; + public ICollection PermissionClaimTypes { get; set; } = ["permission", "permissions"]; + public int MaximumPayloadBytes { get; set; } = 256 * 1024; + public TimeSpan DefaultInvitationLifetime { get; set; } = TimeSpan.FromDays(7); + + /// Upper bound on a guest session, applied on top of the invitation's own expiry. + public TimeSpan GuestSessionLifetime { get; set; } = TimeSpan.FromHours(4); + + /// Attempts allowed per caller partition within . + public int AnonymousRateLimit { get; set; } = 10; + + public TimeSpan AnonymousRateLimitWindow { get; set; } = TimeSpan.FromMinutes(5); + + /// Back-off schedule for invitation delivery retries. Delivery is abandoned once the list is exhausted. + public IReadOnlyList InvitationDeliveryRetryDelays { get; set; } = + [TimeSpan.FromSeconds(15), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(30)]; + + /// Advertised to clients through the feature capability descriptor. + public bool RealtimeEnabled { get; set; } = true; + + public int PollingIntervalSeconds { get; set; } = 30; + + /// How often the hosted worker marks overdue tasks and applies the timeout outcome. + public TimeSpan DueSweepInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// How often the hosted worker reconciles committed bookmarks against projected tasks. + public TimeSpan ReconciliationInterval { get; set; } = TimeSpan.FromMinutes(15); + + /// Tenants swept by the hosted workers. Empty means the default tenant only. + public ICollection WorkerTenantIds { get; set; } = []; +} diff --git a/src/modules/Elsa.UserTasks/Permissions/UserTasksPermissions.cs b/src/modules/Elsa.UserTasks/Permissions/UserTasksPermissions.cs new file mode 100644 index 000000000..bd1365197 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Permissions/UserTasksPermissions.cs @@ -0,0 +1,14 @@ +namespace Elsa.UserTasks.Permissions; + +public static class UserTasksPermissions +{ + public const string Read = "read:user-tasks"; + public const string Claim = "claim:user-tasks"; + public const string Complete = "complete:user-tasks"; + public const string Assign = "assign:user-tasks"; + public const string Update = "update:user-tasks"; + public const string Cancel = "cancel:user-tasks"; + public const string Invite = "invite:user-tasks"; + public const string Manage = "manage:user-tasks"; + public const string LookupParticipants = "lookup:user-task-participants"; +} diff --git a/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs b/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs new file mode 100644 index 000000000..5e0e8c5a8 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs @@ -0,0 +1,367 @@ +using System.Text; +using System.Text.Json; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; + +namespace Elsa.UserTasks.Repositories; + +/// +/// A deterministic repository for the Core module and development hosts. It uses a single lock to +/// provide the same compare-and-swap and projection idempotency guarantees expected from durable stores. +/// +public sealed class InMemoryUserTaskRepository : IUserTaskRepository +{ + private readonly object _sync = new(); + private readonly Dictionary _tasks = new(StringComparer.Ordinal); + + public Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) + { + lock (_sync) + return Task.FromResult(_tasks.TryGetValue(Key(tenantId, taskId), out var task) ? Clone(task) : null); + } + + public Task QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default) + { + lock (_sync) + { + var scope = query.Scope; + var items = _tasks.Values + .Where(x => string.Equals(x.TenantId, query.TenantId, StringComparison.Ordinal)) + // Visibility is evaluated before counting, cursoring, and paging so an unauthorized row can + // never influence a total or push an authorized row off the page. + .Where(x => scope == null || IsVisible(x, scope)) + .Where(x => query.Statuses.Count == 0 || query.Statuses.Contains(x.Status)) + .Where(x => !query.OnlyOverdue || x.IsOverdue) + .Where(x => !query.OnlyWithoutDueDate || x.DueAt == null) + .Where(x => query.PriorityFrom == null || x.Priority >= query.PriorityFrom) + .Where(x => query.PriorityTo == null || x.Priority <= query.PriorityTo) + .Where(x => query.DueFrom == null || x.DueAt >= query.DueFrom) + .Where(x => query.DueTo == null || x.DueAt <= query.DueTo) + .Where(x => query.WorkflowDefinitionId == null || x.WorkflowDefinitionId == query.WorkflowDefinitionId) + .Where(x => query.WorkflowInstanceId == null || x.WorkflowInstanceId == query.WorkflowInstanceId) + .Where(x => query.Reference == null || string.Equals(x.Reference, query.Reference, StringComparison.OrdinalIgnoreCase)) + .Where(x => query.TaskType == null || string.Equals(x.TaskType, query.TaskType, StringComparison.OrdinalIgnoreCase)) + .Where(x => MatchesSearch(x, query.Search)); + + var filteredCount = query.IncludeTotalCount ? items.Count() : 0; + var materialized = Sort(items, query.Sort, query.Descending).ToList(); + if (!string.IsNullOrWhiteSpace(query.Cursor) && DecodeCursor(query.Cursor!) is { } cursor) + materialized = materialized.Where(x => IsAfterCursor(x, cursor, query.Descending, query.Sort)).ToList(); + + int? total = query.IncludeTotalCount ? filteredCount : null; + var limit = Math.Clamp(query.Limit, 1, 200); + var page = materialized.Take(limit).Select(Clone).ToArray(); + var next = materialized.Count > limit ? EncodeCursor(page[^1], query.Sort) : null; + return Task.FromResult(new UserTaskQueryResult(page, next, total)); + } + } + + public Task FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default) + { + lock (_sync) + return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.MaterializationKey == key) is { } task ? Clone(task) : null); + } + + public Task FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default) + { + lock (_sync) + return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.BookmarkId == bookmarkId) is { } task ? Clone(task) : null); + } + + public Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + { + lock (_sync) + { + foreach (var task in _tasks.Values) + { + // Ordinal comparison over a fixed-length hex hash; the secret itself is never stored. + var invitation = task.Invitations.FirstOrDefault(x => string.Equals(x.TokenHash, tokenHash, StringComparison.Ordinal)); + if (invitation != null) + return Task.FromResult<(UserTask, UserTaskInvitation)?>((Clone(task), invitation)); + } + return Task.FromResult<(UserTask, UserTaskInvitation)?>(null); + } + } + + public Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default) + { + lock (_sync) + { + var key = Key(task.TenantId, task.Id); + if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision) + throw new UserTaskRevisionConflictException(task.Id, expectedRevision); + + var copy = Clone(task); + copy.Revision = expectedRevision + 1; + copy.UpdatedAt = DateTimeOffset.UtcNow; + _tasks[key] = copy; + return Task.CompletedTask; + } + } + + public Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default) + { + lock (_sync) + { + if (_tasks.Values.Any(x => x.TenantId == task.TenantId && + ((!string.IsNullOrEmpty(task.MaterializationKey) && x.MaterializationKey == task.MaterializationKey) || + (!string.IsNullOrEmpty(task.BookmarkId) && x.BookmarkId == task.BookmarkId)))) + return Task.CompletedTask; + + _tasks[Key(task.TenantId, task.Id)] = Clone(task); + return Task.CompletedTask; + } + } + + public Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationToken = default) + { + lock (_sync) + { + if (_tasks.TryGetValue(Key(tenantId, taskId), out var current)) + current.Events.Add(@event); + return Task.CompletedTask; + } + } + + public Task TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func mutation, CancellationToken cancellationToken = default) + { + lock (_sync) + { + var key = Key(tenantId, taskId); + if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision) + return Task.FromResult(false); + + var copy = Clone(current); + if (!mutation(copy)) + return Task.FromResult(false); + + copy.Revision = expectedRevision + 1; + copy.UpdatedAt = DateTimeOffset.UtcNow; + _tasks[key] = copy; + return Task.FromResult(true); + } + } + + private static bool IsVisible(UserTask task, UserTaskQueryScope scope) + { + if (!string.Equals(task.TenantId, scope.TenantId, StringComparison.Ordinal)) + return false; + if (scope.ExcludeBlocking && task.HealthSeverity == UserTaskHealthSeverity.Blocking) + return false; + // Manager-only scopes were already rejected by the policy for non-managers, so reaching them here + // means the caller manages the tenant. + if (scope.RequiresManager) + return scope.IsManager && (scope.Kind != UserTaskQueryScopeKind.NeedsAttention || NeedsAttention(task)); + + return scope.Kind switch + { + UserTaskQueryScopeKind.Assigned => task.Assignee?.Matches(scope.Subject) == true, + UserTaskQueryScopeKind.Available => task.IsOpen && task.Assignee == null && IsEligible(task, scope), + UserTaskQueryScopeKind.History => task.IsTerminal && + (task.CompletedBy?.Matches(scope.Subject) == true || task.Events.Any(x => x.Actor?.Matches(scope.Subject) == true)), + _ => false + }; + } + + private static bool IsEligible(UserTask task, UserTaskQueryScope scope) + { + if (task.ExcludedUsers.Any(x => x.Matches(scope.Subject))) + return false; + if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) + return task.SnapshotMembers.Any(x => x.Matches(scope.Subject)); + return task.CandidateUsers.Any(x => x.Matches(scope.Subject)) + || task.CandidateGroups.Any(candidate => scope.Groups.Any(candidate.Matches)); + } + + private static bool NeedsAttention(UserTask task) => + task.HealthSeverity == UserTaskHealthSeverity.Blocking + || task.IsOverdue + || (task.IsOpen && task.Assignee == null) + || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling; + + private static bool MatchesSearch(UserTask task, string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return true; + var value = search.Trim(); + return (task.Title?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) + || (task.Summary?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) + || (task.Reference?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) + || (task.TaskType?.Contains(value, StringComparison.OrdinalIgnoreCase) == true) + || task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase)); + } + + private static IEnumerable Sort(IEnumerable items, string sort, bool descending) + { + var normalized = NormalizeSort(sort); + return normalized switch + { + "due" => descending + ? items.OrderByDescending(x => x.DueAt.HasValue).ThenByDescending(x => x.DueAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.DueAt.HasValue ? 0 : 1).ThenBy(x => x.DueAt).ThenBy(x => x.Id, StringComparer.Ordinal), + "priority" => descending + ? items.OrderByDescending(x => x.Priority).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal), + "updated" => descending + ? items.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id, StringComparer.Ordinal), + "completed" => descending + ? items.OrderByDescending(x => x.CompletedAt.HasValue).ThenByDescending(x => x.CompletedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.CompletedAt.HasValue ? 0 : 1).ThenBy(x => x.CompletedAt).ThenBy(x => x.Id, StringComparer.Ordinal), + "title" => descending + ? items.OrderByDescending(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Id, StringComparer.Ordinal), + _ => descending + ? items.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) + : items.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id, StringComparer.Ordinal) + }; + } + + private static bool IsAfterCursor(UserTask task, (string Kind, string Value, string Id) cursor, bool descending, string sort) + { + var normalized = NormalizeSort(sort); + // Due/completed ordering keeps null values at the end in both directions. A generic numeric + // comparison would incorrectly drop nulls after a descending non-null page (or reintroduce + // non-null values after a descending null page). + if (descending && (normalized is "due" or "completed")) + { + var valueIsNull = SortValue(task, normalized) == null; + var cursorIsNull = cursor.Value == "~"; + if (valueIsNull != cursorIsNull) + return valueIsNull; + } + + var comparison = CompareSortValue(SortKind(sort), SortValue(task, sort), cursor.Kind, cursor.Value); + if (comparison == 0) + comparison = StringComparer.Ordinal.Compare(task.Id, cursor.Id); + return descending ? comparison < 0 : comparison > 0; + } + + private static string EncodeCursor(UserTask task, string sort) + { + var kind = SortKind(sort); + var value = EncodeSortValue(SortValue(task, sort), kind); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(kind + "|" + value + "|" + task.Id)); + } + + private static (string Kind, string Value, string Id)? DecodeCursor(string cursor) + { + try + { + var value = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + var first = value.IndexOf('|'); + var last = value.LastIndexOf('|'); + return first <= 0 || last <= first ? null : (value[..first], value[(first + 1)..last], value[(last + 1)..]); + } + catch (FormatException) + { + return null; + } + } + + private static string NormalizeSort(string sort) => sort.ToLowerInvariant() switch + { + "dueat" => "due", + "completedat" => "completed", + _ => sort.ToLowerInvariant() is "due" or "priority" or "updated" or "completed" or "title" ? sort.ToLowerInvariant() : "created" + }; + + private static string SortKind(string sort) => NormalizeSort(sort) switch + { + "priority" => "i", + "title" => "s", + "due" or "completed" => "n", + _ => "n" + }; + + private static object? SortValue(UserTask task, string sort) => NormalizeSort(sort) switch + { + "due" => task.DueAt, + "priority" => task.Priority, + "updated" => task.UpdatedAt, + "completed" => task.CompletedAt, + "title" => task.Title, + _ => task.CreatedAt + }; + + private static string EncodeSortValue(object? value, string kind) => value switch + { + null => "~", + DateTimeOffset date => date.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), + int number => number.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString() ?? "")) + }; + + private static int CompareSortValue(string kind, object? value, string cursorKind, string cursorValue) + { + if (!string.Equals(kind, cursorKind, StringComparison.Ordinal)) + return 0; + if (value == null || cursorValue == "~") + return value == null && cursorValue == "~" ? 0 : value == null ? 1 : -1; + if (kind == "i") + return int.Parse(value.ToString()!, System.Globalization.CultureInfo.InvariantCulture).CompareTo(int.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture)); + if (kind == "n") + return long.Parse(value switch { DateTimeOffset d => d.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), _ => value.ToString()! }, System.Globalization.CultureInfo.InvariantCulture) + .CompareTo(long.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture)); + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursorValue)); + return StringComparer.OrdinalIgnoreCase.Compare(value.ToString(), decoded); + } + + private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId; + + private static UserTask Clone(UserTask task) => new() + { + Id = task.Id, + TenantId = task.TenantId, + WorkflowDefinitionId = task.WorkflowDefinitionId, + WorkflowDefinitionName = task.WorkflowDefinitionName, + WorkflowDefinitionVersion = task.WorkflowDefinitionVersion, + WorkflowInstanceId = task.WorkflowInstanceId, + WorkflowInstanceReference = task.WorkflowInstanceReference, + ActivityInstanceId = task.ActivityInstanceId, + BookmarkId = task.BookmarkId, + MaterializationKey = task.MaterializationKey, + Title = task.Title, + Summary = task.Summary, + Reference = task.Reference, + Tags = new HashSet(task.Tags, StringComparer.OrdinalIgnoreCase), + TaskType = task.TaskType, + Requester = task.Requester, + Assignee = task.Assignee, + CandidateUsers = [..task.CandidateUsers], + CandidateGroups = [..task.CandidateGroups], + SnapshotMembers = [..task.SnapshotMembers], + SnapshotGroups = [..task.SnapshotGroups], + ExcludedUsers = [..task.ExcludedUsers], + MembershipResolutionMode = task.MembershipResolutionMode, + AllowManagerExclusionOverride = task.AllowManagerExclusionOverride, + Priority = task.Priority, + DueAt = task.DueAt, + IsOverdue = task.IsOverdue, + Instructions = task.Instructions, + TaskData = Clone(task.TaskData), + RequestedForm = task.RequestedForm, + PinnedForm = task.PinnedForm, + Actions = [..task.Actions], + InvitationDefinitions = [..task.InvitationDefinitions], + EnableTimeoutOutcome = task.EnableTimeoutOutcome, + EnableCancellationOutcome = task.EnableCancellationOutcome, + Status = task.Status, + HealthSeverity = task.HealthSeverity, + HealthCode = task.HealthCode, + HealthMessage = task.HealthMessage, + CompletionActionKey = task.CompletionActionKey, + CompletionData = Clone(task.CompletionData), + CompletedBy = task.CompletedBy, + CreatedAt = task.CreatedAt, + UpdatedAt = task.UpdatedAt, + AssignedAt = task.AssignedAt, + CompletedAt = task.CompletedAt, + Revision = task.Revision, + Events = [..task.Events], + Operations = [..task.Operations], + Invitations = [..task.Invitations] + }; + + private static JsonElement? Clone(JsonElement? value) => value is { } element ? element.Clone() : null; +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultClaimsIdentityResolver.cs b/src/modules/Elsa.UserTasks/Services/DefaultClaimsIdentityResolver.cs new file mode 100644 index 000000000..db9d9e566 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultClaimsIdentityResolver.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultClaimsIdentityResolver(IOptions options) : IUserTaskIdentityResolver +{ + private readonly UserTasksOptions _options = options.Value; + + public ValueTask ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) + { + var subjectId = principal.FindFirstValue(_options.SubjectClaimType) + ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrWhiteSpace(subjectId)) + return ValueTask.FromResult(null); + + var tenantId = principal.FindFirstValue(_options.TenantClaimType) ?? _options.DefaultTenantId; + var provider = principal.FindFirstValue(_options.ProviderClaimType) ?? _options.DefaultProvider; + var subject = new ParticipantReference(tenantId, provider, UserTaskParticipantType.User, subjectId, + principal.FindFirstValue(_options.DisplayNameClaimType) ?? principal.Identity?.Name); + var groups = _options.GroupClaimTypes + .SelectMany(type => principal.FindAll(type)) + .Select(x => x.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .Select(id => new ParticipantReference(tenantId, provider, UserTaskParticipantType.Group, id)) + .ToArray(); + var permissions = _options.PermissionClaimTypes + .SelectMany(type => principal.FindAll(type)) + .Select(x => x.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return ValueTask.FromResult(new UserTaskActor(subject, groups, subject.DisplayName) + { + IsManager = principal.IsInRole("user-tasks:manager") + || permissions.Contains("manage:user-tasks"), + Permissions = permissions + }); + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskAccessPolicy.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskAccessPolicy.cs new file mode 100644 index 000000000..f1ce024ca --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskAccessPolicy.cs @@ -0,0 +1,143 @@ +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Permissions; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskAccessPolicy : IUserTaskAccessPolicy +{ + /// + /// Guests authenticate through a task-scoped invitation session. They may read and complete exactly one + /// task and can never claim, release, reassign, schedule, invite, cancel, or manage. + /// + private static readonly UserTaskAccessOperation[] GuestOperations = + [ + UserTaskAccessOperation.ReadSummary, + UserTaskAccessOperation.ReadProtected, + UserTaskAccessOperation.Complete + ]; + + public Task CreateScopeAsync(UserTaskActor actor, UserTaskQueryScopeKind kind, CancellationToken cancellationToken = default) + { + // A guest session is issued for one task and carries no list capability at all. + if (actor.IsGuest || !actor.HasPermission(UserTasksPermissions.Read)) + return Task.FromResult(null); + + var isManager = IsManager(actor); + if (!isManager && kind is UserTaskQueryScopeKind.All or UserTaskQueryScopeKind.NeedsAttention) + return Task.FromResult(null); + + return Task.FromResult(new UserTaskQueryScope( + actor.Subject.TenantId, + actor.Subject, + actor.Groups, + isManager, + kind, + ExcludeBlocking: !isManager)); + } + + public Task AuthorizeAsync(UserTask task, UserTaskActor actor, UserTaskAccessOperation operation, CancellationToken cancellationToken = default) + { + if (!string.Equals(task.TenantId, actor.Subject.TenantId, StringComparison.Ordinal)) + return Task.FromResult(false); + if (!actor.HasPermission(RequiredPermission(operation))) + return Task.FromResult(false); + + if (actor.IsGuest) + return Task.FromResult(AuthorizeGuest(task, actor, operation)); + + var isManager = IsManager(actor); + if (task.HealthSeverity == UserTaskHealthSeverity.Blocking && !isManager) + return Task.FromResult(false); + + var isAssignee = task.Assignee?.Matches(actor.Subject) == true; + var isCandidate = IsCandidate(task, actor); + var isCompleter = task.CompletedBy?.Matches(actor.Subject) == true; + var acted = task.Events.Any(x => x.Actor?.Matches(actor.Subject) == true); + + var result = operation switch + { + UserTaskAccessOperation.ReadSummary => isManager || isAssignee || isCandidate || isCompleter || acted, + UserTaskAccessOperation.ReadProtected => isManager || (task.IsTerminal ? isCompleter : isAssignee), + UserTaskAccessOperation.Claim => !task.IsTerminal && task.Status is (UserTaskStatus.Available or UserTaskStatus.Unassigned) && isCandidate && !IsExcluded(task, actor), + UserTaskAccessOperation.Release => !task.IsTerminal && isAssignee, + UserTaskAccessOperation.Assign => isManager, + UserTaskAccessOperation.UpdateScheduling => isManager, + // The completer may replay the same terminal operation for idempotency; new terminal + // commands are rejected by the manager after operation lookup. + UserTaskAccessOperation.Complete => task.IsTerminal ? isCompleter : isAssignee, + UserTaskAccessOperation.Cancel => isManager, + UserTaskAccessOperation.Manage => isManager, + UserTaskAccessOperation.IssueInvitation => isManager, + UserTaskAccessOperation.RetryResolution => isManager, + _ => false + }; + + return Task.FromResult(result); + } + + private static bool AuthorizeGuest(UserTask task, UserTaskActor actor, UserTaskAccessOperation operation) + { + if (!string.Equals(actor.GuestTaskId, task.Id, StringComparison.Ordinal)) + return false; + if (!GuestOperations.Contains(operation)) + return false; + // The invitation claimed the task for the guest participant; losing that assignment (through a + // manager reassignment or reissue) revokes the guest's access on the next request. + if (task.Assignee?.Matches(actor.Subject) != true) + return false; + return operation != UserTaskAccessOperation.Complete || !task.IsTerminal; + } + + /// + /// A tenant-scoped manager must hold manage:user-tasks (or the wildcard grant). The actor flag on + /// its own is host-supplied metadata and is never sufficient. + /// + private static bool IsManager(UserTaskActor actor) => + !actor.IsGuest && actor.IsManager && actor.HasPermission(UserTasksPermissions.Manage); + + private static bool IsCandidate(UserTask task, UserTaskActor actor) + { + if (task.ExcludedUsers.Any(x => x.Matches(actor.Subject))) + return false; + + if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) + return task.SnapshotMembers.Any(x => x.Matches(actor.Subject)); + + return task.CandidateUsers.Any(x => x.Matches(actor.Subject)) + || task.CandidateGroups.Any(candidate => actor.Groups.Any(candidate.Matches)); + } + + private static bool IsExcluded(UserTask task, UserTaskActor actor) => + task.ExcludedUsers.Any(x => x.Matches(actor.Subject)) && !(IsManager(actor) && task.AllowManagerExclusionOverride); + + private static string RequiredPermission(UserTaskAccessOperation operation) => operation switch + { + UserTaskAccessOperation.ReadSummary or UserTaskAccessOperation.ReadProtected => UserTasksPermissions.Read, + UserTaskAccessOperation.Claim or UserTaskAccessOperation.Release => UserTasksPermissions.Claim, + UserTaskAccessOperation.Complete => UserTasksPermissions.Complete, + UserTaskAccessOperation.Assign => UserTasksPermissions.Assign, + UserTaskAccessOperation.UpdateScheduling => UserTasksPermissions.Update, + UserTaskAccessOperation.Cancel => UserTasksPermissions.Cancel, + UserTaskAccessOperation.Manage => UserTasksPermissions.Manage, + UserTaskAccessOperation.IssueInvitation => UserTasksPermissions.Invite, + UserTaskAccessOperation.RetryResolution => UserTasksPermissions.Manage, + _ => UserTasksPermissions.Read + }; +} + +/// +/// A safe default for hosts that do not expose a participant directory. It keeps lookup optional and +/// avoids coupling User Tasks to Elsa.Identity. +/// +public sealed class EmptyUserTaskParticipantDirectory : IUserTaskParticipantDirectory +{ + public Task SearchAsync(UserTaskParticipantQuery query, CancellationToken cancellationToken = default) => + Task.FromResult(new ParticipantSearchResult([], null, 0)); + + public Task> ResolveDisplayNamesAsync(IReadOnlyCollection participants, CancellationToken cancellationToken = default) => + Task.FromResult(participants); + + public Task> EnumerateGroupMembersAsync(ParticipantReference group, CancellationToken cancellationToken = default) => + Task.FromResult>([]); +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskDueService.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskDueService.cs new file mode 100644 index 000000000..3a41c8044 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskDueService.cs @@ -0,0 +1,74 @@ +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.Workflows; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskDueService( + IUserTaskRepository repository, + IUserTaskManager manager, + IUserTaskNotificationSink notifications, + IIdentityGenerator identityGenerator, + ISystemClock clock) : IUserTaskDueService +{ + public async Task MarkOverdueAsync(string tenantId, DateTimeOffset? now = null, CancellationToken cancellationToken = default) + { + var scanNow = now ?? clock.UtcNow; + var cursor = (string?)null; + var marked = 0; + + do + { + var due = await repository.QueryAsync(new UserTaskQuery + { + TenantId = tenantId, + DueTo = scanNow, + Cursor = cursor, + Sort = "due", + Limit = 200 + }, cancellationToken); + + foreach (var task in due.Items.Where(x => !x.IsTerminal && x.DueAt <= scanNow)) + { + if (task.EnableTimeoutOutcome) + { + try + { + var result = await manager.TimeoutAsync(tenantId, task.Id, task.Revision, scanNow, cancellationToken); + if (result.Accepted) + marked++; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // The accepted transition remains durable and is repaired by reconciliation if + // bookmark delivery failed. Continue scanning other tasks. + } + + continue; + } + + if (task.IsOverdue || !await repository.TryMutateAsync(tenantId, task.Id, task.Revision, current => + { + if (current.IsOverdue || current.IsTerminal) + return false; + current.IsOverdue = true; + current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, current.Id, current.Revision + 1, + "OverdueNotified", clock.UtcNow)); + return true; + }, cancellationToken)) + continue; + + marked++; + var committed = await repository.GetAsync(tenantId, task.Id, cancellationToken); + if (committed != null) + await notifications.PublishAsync(new UserTaskOverdue(tenantId, committed.Id, committed.Status, committed.Revision), cancellationToken); + } + + cursor = due.NextCursor; + } + while (cursor != null); + + return marked; + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs new file mode 100644 index 000000000..636d69270 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs @@ -0,0 +1,244 @@ +using System.Security.Cryptography; +using System.Text; +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.Workflows; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Services; + +/// +/// Implements the invitation protocol. Secrets never leave this service in plaintext except across the +/// dispatcher boundary: only a SHA-256 hash is persisted on the task, and verification resolves the owning +/// task by that hash so the flow works across restarts and behind any persistence provider. +/// +public sealed class DefaultUserTaskInvitationService( + IUserTaskRepository repository, + IUserTaskAccessPolicy accessPolicy, + IUserTaskInvitationOutbox outbox, + IUserTaskInvitationVerifier verifier, + IUserTaskGuestSessionIssuer sessionIssuer, + IUserTaskNotificationSink notifications, + IIdentityGenerator identityGenerator, + ISystemClock clock, + IOptions options) : IUserTaskInvitationService +{ + /// The single public failure code. Callers must not be able to tell these cases apart. + private const string GenericFailure = "invitation-unavailable"; + + public async Task IssueAsync(string tenantId, string taskId, UserTaskInvitationIssueRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, cancellationToken)) + return null; + if (task.IsTerminal || request.ExpectedRevision != task.Revision || string.IsNullOrWhiteSpace(request.VerifierName)) + return null; + + var actions = request.AllowedActions.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + // Match the requested action set exactly against one of the activity's invitation definitions. + // A manager cannot broaden a guest link beyond what the workflow designer materialized. + var definition = task.InvitationDefinitions.FirstOrDefault(x => + string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase) + && x.AllowedActions.Count == actions.Length + && x.AllowedActions.All(allowed => actions.Contains(allowed, StringComparer.OrdinalIgnoreCase))); + if (definition == null || actions.Length == 0) + return null; + + var now = clock.UtcNow; + var lifetime = request.Lifetime ?? definition.Lifetime ?? options.Value.DefaultInvitationLifetime; + if (lifetime <= TimeSpan.Zero) + return null; + var expiresAt = now.Add(lifetime); + if (task.DueAt is { } dueAt && dueAt < expiresAt) + expiresAt = dueAt; + if (expiresAt <= now) + return null; + + var token = Base64Url(RandomNumberGenerator.GetBytes(32)); + var tokenHash = HashToken(token); + var siblingGroupId = task.Invitations.FirstOrDefault(x => + string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase) + && x.Status is (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched))?.SiblingGroupId + ?? identityGenerator.GenerateId(); + var invitation = new UserTaskInvitation( + identityGenerator.GenerateId(), tenantId, taskId, request.Recipient, tokenHash, + UserTaskInvitationStatus.Pending, now, expiresAt, request.VerifierName, + SiblingGroupId: siblingGroupId) + { + // Pinned at issuance: a later workflow definition change cannot widen an outstanding link. + AllowedActions = actions + }; + + task.Invitations.Add(invitation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, + "InvitationIssued", now, actor.Subject, request.OperationId)); + try + { + await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return null; + } + + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed.Revision), cancellationToken); + + // The raw token crosses only the dispatcher boundary. It is parked in the encrypted outbox first so + // a dispatcher failure can be retried without ever re-deriving the secret or returning it to the API. + await outbox.EnqueueAsync(new UserTaskInvitationDelivery( + identityGenerator.GenerateId(), tenantId, taskId, invitation.Id, definition.VerifierName, token, expiresAt) + { + Recipient = request.Recipient + }, cancellationToken); + + return new UserTaskInvitationIssueResult(ToSummary(invitation), request.OperationId); + } + + public async Task?> ListAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, cancellationToken)) + return null; + return task.Invitations.Select(ToSummary).ToArray(); + } + + public async Task RevokeAsync(string tenantId, string taskId, string invitationId, int expectedRevision, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, cancellationToken)) + return false; + if (!await repository.TryMutateAsync(tenantId, taskId, expectedRevision, current => + { + var invitation = current.Invitations.FirstOrDefault(x => x.Id == invitationId); + if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationStatus.Consumed or UserTaskInvitationStatus.Expired) + return false; + var index = current.Invitations.IndexOf(invitation); + current.Invitations[index] = invitation with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = clock.UtcNow }; + current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, current.Revision + 1, + "InvitationRevoked", clock.UtcNow, actor.Subject)); + return true; + }, cancellationToken)) + return false; + + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (committed != null) + await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed.Revision), cancellationToken); + return true; + } + + public async Task DescribeAsync(string token, CancellationToken cancellationToken = default) + { + // Copy is identical for every token. Only the challenge shape differs, and only for a token the + // caller already holds, so this cannot be used to probe whether an unknown token exists. + var resolved = await ResolveOpenInvitationAsync(token, cancellationToken); + var bearerOnly = resolved is { } match && FindDefinition(match.Task, match.Invitation)?.BearerOnly == true; + return bearerOnly + ? new UserTaskInvitationChallengeDescriptor("bearer", "Open this task to continue.", RequiresCode: false) + : new UserTaskInvitationChallengeDescriptor("code", "Enter the verification code you were sent to continue.", RequiresCode: true); + } + + public async Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default) + { + if (await ResolveOpenInvitationAsync(challenge.Token, cancellationToken) is not { } resolved) + return Failed(); + + var (task, invitation) = resolved; + var definition = FindDefinition(task, invitation); + var challengeResult = definition?.BearerOnly == true + ? new UserTaskInvitationVerificationResult(true, Subject: null) + : await verifier.VerifyAsync(challenge, cancellationToken); + if (!challengeResult.Succeeded) + return Failed(); + + var subject = new ParticipantReference(task.TenantId, "guest", UserTaskParticipantType.User, + challengeResult.Subject ?? $"invitation:{invitation.Id}"); + var verifiedAt = clock.UtcNow; + + // Claiming, consuming, and sibling revocation happen inside one compare-and-swap. A second holder + // racing the same sibling group therefore loses and receives the same generic failure. + var claimed = await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current => + { + var currentInvitation = current.Invitations.FirstOrDefault(x => x.Id == invitation.Id); + if (currentInvitation == null || currentInvitation.ExpiresAt <= verifiedAt || currentInvitation.Status is not (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched)) + return false; + if (current.IsTerminal || current.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) + return false; + + current.Assignee = subject; + current.AssignedAt = verifiedAt; + current.Status = UserTaskStatus.Assigned; + var index = current.Invitations.IndexOf(currentInvitation); + current.Invitations[index] = currentInvitation with + { + Status = UserTaskInvitationStatus.Consumed, + VerifiedAt = verifiedAt, + ConsumedAt = verifiedAt + }; + var revokedSiblings = 0; + for (var i = 0; i < current.Invitations.Count; i++) + { + var sibling = current.Invitations[i]; + if (sibling.Id != currentInvitation.Id && sibling.SiblingGroupId == currentInvitation.SiblingGroupId && sibling.Status is (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched)) + { + current.Invitations[i] = sibling with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = verifiedAt }; + revokedSiblings++; + } + } + // The challenge response and the secret itself are never written to the audit trail. + current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), current.TenantId, current.Id, current.Revision + 1, + "InvitationVerified", verifiedAt, subject, Metadata: new Dictionary { ["revokedSiblingCount"] = revokedSiblings })); + return true; + }, cancellationToken); + if (!claimed) + return Failed(); + + var consumed = invitation with { Status = UserTaskInvitationStatus.Consumed, VerifiedAt = verifiedAt, ConsumedAt = verifiedAt }; + var session = await sessionIssuer.IssueAsync(consumed, subject, cancellationToken); + return session.Succeeded + ? new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt) + : Failed(); + } + + private async Task<(UserTask Task, UserTaskInvitation Invitation)?> ResolveOpenInvitationAsync(string? token, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(token)) + return null; + var match = await repository.FindByInvitationTokenHashAsync(HashToken(token), cancellationToken); + if (match is not { } found) + return null; + var invitation = found.Invitation; + return invitation.ExpiresAt <= clock.UtcNow || invitation.Status is not (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched) + ? null + : found; + } + + private static UserTaskInvitationDefinition? FindDefinition(UserTask task, UserTaskInvitation invitation) => + task.InvitationDefinitions.FirstOrDefault(x => string.Equals(x.VerifierName, invitation.VerifierName, StringComparison.OrdinalIgnoreCase)); + + private static UserTaskInvitationVerificationResultWithSession Failed() => new(false, FailureCode: GenericFailure); + + private static UserTaskInvitationSummary ToSummary(UserTaskInvitation invitation) => new( + invitation.Id, invitation.TaskId, invitation.Recipient, invitation.Status, invitation.IssuedAt, invitation.ExpiresAt, invitation.VerifierName); + + internal static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + internal static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} + +/// A dispatcher that drops deliveries. Hosts replace it with an email, SMS, or webhook dispatcher. +public sealed class NullUserTaskInvitationDispatcher : IUserTaskInvitationDispatcher +{ + public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +/// +/// The default verifier refuses every challenge. A host that enables guest invitations must register a real +/// verifier; failing closed is preferable to accepting any bearer who holds a link. +/// +public sealed class DefaultUserTaskInvitationVerifier : IUserTaskInvitationVerifier +{ + public Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default) => + Task.FromResult(new UserTaskInvitationVerificationResult(false, "challenge-required")); +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs new file mode 100644 index 000000000..58848ba24 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs @@ -0,0 +1,601 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.Common; +using Elsa.Workflows; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskManager( + IUserTaskRepository repository, + IUserTaskAccessPolicy accessPolicy, + IEnumerable formProviders, + IUserTaskWorkflowResumer workflowResumer, + IUserTaskNotificationSink notificationSink, + IIdentityGenerator identityGenerator, + ISystemClock clock, + Microsoft.Extensions.Options.IOptions options, + IUserTaskParticipantDirectory? participantDirectory = null) : IUserTaskManager +{ + private readonly IReadOnlyDictionary _formProviders = formProviders + .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + private readonly UserTasksOptions _options = options.Value; + + public async Task ProjectAsync(UserTaskMaterialization materialization, CancellationToken cancellationToken = default) + { + var definition = materialization.Definition.Normalize(); + if (PayloadBytes(definition.TaskData) > _options.MaximumPayloadBytes || Encoding.UTF8.GetByteCount(definition.Instructions ?? "") > _options.MaximumPayloadBytes) + throw new ArgumentException("User Task protected payload exceeds the configured limit."); + var existing = await repository.FindByMaterializationKeyAsync(materialization.TenantId, MaterializationKey(materialization), cancellationToken); + if (existing != null) + return new UserTaskProjectionResult(existing, false); + + ResolvedUserTaskForm? pinnedForm = null; + var healthSeverity = (UserTaskHealthSeverity?)null; + string? healthCode = null; + string? healthMessage = null; + var snapshotMembers = materialization.SnapshotMembers.ToList(); + var snapshotGroups = materialization.SnapshotGroups.ToList(); + if (definition.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) + { + snapshotMembers.AddRange(definition.CandidateUsers); + snapshotGroups.AddRange(definition.CandidateGroups); + if (participantDirectory != null) + { + foreach (var group in definition.CandidateGroups) + { + try + { + snapshotMembers.AddRange(await participantDirectory.EnumerateGroupMembersAsync(group, cancellationToken)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "snapshot-resolution-failed"; + healthMessage = "A candidate group could not be resolved for this task."; + } + } + } + else if (definition.CandidateGroups.Count > 0) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "snapshot-directory-missing"; + healthMessage = "A participant directory is required to snapshot candidate groups."; + } + } + if (definition.FormReference != null) + { + if (!_formProviders.TryGetValue(definition.FormReference.ProviderName, out var provider)) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "form-provider-missing"; + healthMessage = "The configured form provider is not available."; + } + else + { + pinnedForm = await provider.ResolveAsync(definition.FormReference, cancellationToken); + if (pinnedForm == null) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "form-resolution-failed"; + healthMessage = "The configured form could not be resolved."; + } + } + } + + var task = new UserTask + { + Id = materialization.TaskId ?? identityGenerator.GenerateId(), + TenantId = materialization.TenantId, + WorkflowDefinitionId = materialization.WorkflowDefinitionId, + WorkflowDefinitionName = materialization.WorkflowDefinitionName, + WorkflowDefinitionVersion = materialization.WorkflowDefinitionVersion, + WorkflowInstanceId = materialization.WorkflowInstanceId, + WorkflowInstanceReference = materialization.WorkflowInstanceReference, + ActivityInstanceId = materialization.ActivityInstanceId, + BookmarkId = materialization.BookmarkId, + MaterializationKey = MaterializationKey(materialization), + Title = definition.Title, + Summary = definition.Summary, + Reference = definition.Reference, + Tags = new HashSet(definition.Tags, StringComparer.OrdinalIgnoreCase), + TaskType = definition.TaskType, + Requester = definition.Requester, + Assignee = definition.Assignee, + CandidateUsers = [..definition.CandidateUsers], + CandidateGroups = [..definition.CandidateGroups], + SnapshotMembers = [..snapshotMembers.Distinct()], + SnapshotGroups = [..snapshotGroups.Distinct()], + ExcludedUsers = [..definition.ExcludedUsers], + MembershipResolutionMode = definition.MembershipResolutionMode, + AllowManagerExclusionOverride = definition.AllowManagerExclusionOverride, + Priority = definition.Priority, + DueAt = definition.DueAt, + Instructions = definition.Instructions, + TaskData = definition.TaskData, + RequestedForm = definition.FormReference, + PinnedForm = pinnedForm, + Actions = [..definition.Actions], + InvitationDefinitions = [..definition.Invitations], + EnableTimeoutOutcome = definition.EnableTimeoutOutcome, + EnableCancellationOutcome = definition.EnableCancellationOutcome, + Status = definition.Assignee != null ? UserTaskStatus.Assigned : HasCandidates(definition, materialization) ? UserTaskStatus.Available : UserTaskStatus.Unassigned, + HealthSeverity = healthSeverity, + HealthCode = healthCode, + HealthMessage = healthMessage, + CreatedAt = materialization.CreatedAt, + UpdatedAt = materialization.CreatedAt + }; + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), task.TenantId, task.Id, 1, "Created", task.CreatedAt)); + await repository.AddProjectionAsync(task, cancellationToken); + var projected = await repository.FindByMaterializationKeyAsync(task.TenantId, task.MaterializationKey, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskCreated(projected.TenantId, projected.Id, projected.Status, projected.Revision), cancellationToken); + return new UserTaskProjectionResult(projected, true); + } + + public async Task QueryAsync(UserTaskQuery query, UserTaskQueryScopeKind scopeKind, UserTaskActor actor, CancellationToken cancellationToken = default) + { + // A null scope is a denial, not an absent filter. Falling through with the caller's raw query would + // hand an unauthorized actor a tenant-wide page. + var scope = await accessPolicy.CreateScopeAsync(actor, scopeKind, cancellationToken); + if (scope == null) + return null; + + var scopedQuery = query with { TenantId = actor.Subject.TenantId, Scope = scope }; + var result = await repository.QueryAsync(scopedQuery, cancellationToken); + var summaries = new List(); + foreach (var item in result.Items) + summaries.Add(await UserTaskModelMapper.ToSummaryAsync(item, actor, accessPolicy, cancellationToken)); + return new UserTaskQueryResultDto(summaries, result.NextCursor, result.TotalCount); + } + + public async Task GetAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, cancellationToken)) + return null; + return await UserTaskModelMapper.ToDetailAsync(task, actor, accessPolicy, cancellationToken); + } + + public async Task GetCapabilitiesAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + return task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, cancellationToken) + ? null + : await UserTaskModelMapper.ToCapabilitiesAsync(task, actor, accessPolicy, cancellationToken); + } + + public async Task GetEventsAsync(string tenantId, string taskId, string? cursor, int limit, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, cancellationToken)) + return null; + + // Audit history is protected disclosure: a candidate who has never acted on the task sees the safe + // summary but not who claimed, released, or completed it. Guests never see history at all. + var canReadProtected = await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken); + if (actor.IsGuest || !canReadProtected) + return new UserTaskEventsResult([], null); + + var ordered = task.Events.OrderBy(x => x.OccurredAt).ThenBy(x => x.Id, StringComparer.Ordinal).ToList(); + var startIndex = 0; + if (!string.IsNullOrWhiteSpace(cursor)) + { + var index = ordered.FindIndex(x => string.Equals(x.Id, DecodeEventCursor(cursor), StringComparison.Ordinal)); + startIndex = index < 0 ? ordered.Count : index + 1; + } + + var pageSize = Math.Clamp(limit <= 0 ? 50 : limit, 1, 200); + var page = ordered.Skip(startIndex).Take(pageSize).ToArray(); + var next = startIndex + page.Length < ordered.Count && page.Length > 0 ? EncodeEventCursor(page[^1].Id) : null; + return new UserTaskEventsResult(page.Select(UserTaskModelMapper.ToEventSummary).ToArray(), next); + } + + public async Task RevealFieldAsync(string tenantId, string taskId, string fieldKey, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken)) + return null; + + // Only a field the form provider explicitly marked revealable can be disclosed, and only one at a + // time. Unmarked or unknown keys are indistinguishable from an unauthorized task. + var descriptor = task.PinnedForm?.Fields.FirstOrDefault(x => string.Equals(x.Key, fieldKey, StringComparison.Ordinal)); + if (descriptor is not { Masked: true, CanReveal: true }) + return null; + + var value = UserTaskModelMapper.ReadFieldValue(task.TaskData, fieldKey); + if (value == null) + return null; + + // Appended without bumping the revision: a reveal is a read, and consuming the concurrency token + // here would make the caller's next command conflict for no reason. + await repository.AppendEventAsync(tenantId, taskId, new(identityGenerator.GenerateId(), tenantId, taskId, task.Revision, + "FieldRevealed", clock.UtcNow, actor.Subject, Metadata: new Dictionary { ["fieldKey"] = fieldKey }), cancellationToken); + return value; + } + + private static string EncodeEventCursor(string eventId) => Convert.ToBase64String(Encoding.UTF8.GetBytes(eventId)); + + private static string? DecodeEventCursor(string cursor) + { + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + } + catch (FormatException) + { + return null; + } + } + + public Task ClaimAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) => + MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Claim, actor, UserTaskAccessOperation.Claim, + task => + { + if (task.Status is not (UserTaskStatus.Available or UserTaskStatus.Unassigned)) + return "not-claimable"; + task.Assignee = actor.Subject; + task.AssignedAt = clock.UtcNow; + task.Status = UserTaskStatus.Assigned; + return null; + }, cancellationToken); + + public Task ReleaseAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) => + MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Release, actor, UserTaskAccessOperation.Release, + task => + { + task.Assignee = null; + task.AssignedAt = null; + task.Status = HasCandidates(task) ? UserTaskStatus.Available : UserTaskStatus.Unassigned; + return null; + }, cancellationToken); + + public Task AssignAsync(string tenantId, string taskId, UserTaskAssignRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) => + MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Assign, actor, UserTaskAccessOperation.Assign, + task => + { + if (!string.Equals(request.Assignee.TenantId, tenantId, StringComparison.Ordinal)) + return "cross-tenant-assignee"; + var excluded = task.ExcludedUsers.Any(x => x.Matches(request.Assignee)); + if (excluded && (!task.AllowManagerExclusionOverride || string.IsNullOrWhiteSpace(request.Reason))) + return "excluded-assignee"; + if (task.IsTerminal) + return "terminal"; + task.Assignee = request.Assignee; + task.AssignedAt = clock.UtcNow; + task.Status = UserTaskStatus.Assigned; + return null; + }, cancellationToken, request.Reason, request.Assignee); + + public Task UpdateSchedulingAsync(string tenantId, string taskId, UserTaskSchedulingUpdate request, UserTaskActor actor, CancellationToken cancellationToken = default) => + MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.ScheduleUpdate, actor, UserTaskAccessOperation.UpdateScheduling, + task => + { + if (task.IsTerminal) + return "terminal"; + if (request.Priority is < 0 or > 100) + return "invalid-priority"; + if (request.Priority.HasValue) + task.Priority = request.Priority.Value; + if (request.DueAt.HasValue) + task.DueAt = request.DueAt.Value; + return null; + }, cancellationToken, requestValue: new { request.Priority, request.DueAt }); + + public async Task CompleteAsync(string tenantId, string taskId, UserTaskCompletionRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null) + return Failure(taskId, tenantId, request.OperationId, UserTaskOperationKind.Complete, "not-found"); + if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Complete, cancellationToken)) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "forbidden"); + // A guest link is issued for a specific outcome set. Selecting any other configured action — even a + // valid one — is an authorization failure, not a validation failure. + if (actor.IsGuest && !actor.GuestAllowedActions.Contains(request.ActionKey)) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "forbidden"); + + if (PayloadBytes(request.Data) > _options.MaximumPayloadBytes) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "payload-too-large"); + var action = task.Actions.FirstOrDefault(x => string.Equals(x.Key, request.ActionKey, StringComparison.Ordinal)); + var isTimeout = string.Equals(request.ActionKey, "Timeout", StringComparison.Ordinal); + var isCancelled = string.Equals(request.ActionKey, "Cancelled", StringComparison.Ordinal); + var existingOperation = FindOperation(task, request.OperationId); + if (existingOperation != null && (isTimeout || isCancelled || action == null)) + { + var rawHash = Hash(UserTaskOperationKind.Complete, new { request.ExpectedRevision, request.ActionKey, request.Data }); + return existingOperation.RequestHash == rawHash + ? new UserTaskOperationResult(task, existingOperation, existingOperation.Status != UserTaskOperationStatus.Failed) + : Failure(task, request.OperationId, UserTaskOperationKind.Complete, "idempotency-conflict"); + } + if (isTimeout || isCancelled) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "reserved-action"); + if (action == null) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "invalid-action"); + if (task.RequestedForm == null && request.Data is { } data && data.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-required"); + if (task.RequestedForm != null && task.PinnedForm == null) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-resolution-failed"); + if (task.PinnedForm != null) + { + if (!_formProviders.TryGetValue(task.PinnedForm.Requested.ProviderName, out var provider)) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-provider-missing"); + var normalized = await provider.ValidateAndNormalizeAsync(task.PinnedForm, request.ActionKey, request.Data ?? JsonDocument.Parse("{}").RootElement, cancellationToken); + if (!normalized.Succeeded) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-invalid"); + request = request with { Data = normalized.NormalizedData }; + if (PayloadBytes(request.Data) > _options.MaximumPayloadBytes) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "payload-too-large"); + } + + // Hash the canonical form data that will actually be persisted. This makes retries compare the + // same request after provider normalization instead of comparing a raw pre-normalization shape. + var requestHash = Hash(UserTaskOperationKind.Complete, new { request.ExpectedRevision, request.ActionKey, request.Data }); + var existing = FindOperation(task, request.OperationId); + if (existing != null) + return existing.RequestHash == requestHash ? new UserTaskOperationResult(task, existing, existing.Status != UserTaskOperationStatus.Failed) : Failure(task, request.OperationId, UserTaskOperationKind.Complete, "idempotency-conflict"); + if (task.Revision != request.ExpectedRevision) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "revision-conflict"); + if (task.IsTerminal) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "terminal"); + if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "transition-in-progress"); + + var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, request.OperationId, UserTaskOperationKind.Complete, + request.ExpectedRevision, requestHash, UserTaskOperationStatus.Accepted, + clock.UtcNow, clock.UtcNow, request.ActionKey, request.Data); + task.Status = UserTaskStatus.Completing; + task.CompletionActionKey = request.ActionKey; + task.CompletionData = request.Data; + task.CompletedBy = actor.Subject; + task.Operations.Add(operation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "CompletionRequested", clock.UtcNow, actor.Subject, request.OperationId)); + try + { + await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "revision-conflict"); + } + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskCompletionAccepted(tenantId, taskId, committed.Status, committed.Revision, request.OperationId), cancellationToken); + await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, ["completionRequested"]), cancellationToken); + await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, request.OperationId, request.ActionKey, request.Data, actor.Subject, clock.UtcNow, committed.BookmarkId), cancellationToken); + return new UserTaskOperationResult(committed, operation, true); + } + + /// + /// Reserves the timeout outcome for the due-date worker. This deliberately does not flow through + /// : reserved outcomes are system transitions and must not be callable + /// as worker-selected actions. + /// + public async Task TimeoutAsync(string tenantId, string taskId, int expectedRevision, DateTimeOffset now, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null) + return Failure(taskId, tenantId, $"timeout:{taskId}:{expectedRevision}", UserTaskOperationKind.Timeout, "not-found"); + + var operationId = $"timeout:{task.Id}:{expectedRevision}"; + var hash = Hash(UserTaskOperationKind.Timeout, new { expectedRevision, dueAt = task.DueAt }); + var existing = FindOperation(task, operationId); + if (existing != null) + { + if (existing.RequestHash != hash) + return Failure(task, operationId, UserTaskOperationKind.Timeout, "idempotency-conflict"); + if (existing.Status != UserTaskOperationStatus.Accepted) + return new UserTaskOperationResult(task, existing, existing.Status == UserTaskOperationStatus.Completed); + + // A previous delivery attempt may have failed after the accepted operation was committed. + // Retrying the same bookmark stimulus is safe and lets reconciliation repair that gap. + await workflowResumer.ResumeAsync(task, new UserTaskStimulus(tenantId, taskId, operationId, "Timeout", null, null, existing.CreatedAt, task.BookmarkId), cancellationToken); + return new UserTaskOperationResult(task, existing, true); + } + + if (task.IsTerminal) + return Failure(task, operationId, UserTaskOperationKind.Timeout, "terminal"); + if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) + return Failure(task, operationId, UserTaskOperationKind.Timeout, "transition-in-progress"); + if (!task.EnableTimeoutOutcome || task.DueAt is not { } dueAt || dueAt > now) + return Failure(task, operationId, UserTaskOperationKind.Timeout, "timeout-not-due"); + if (task.Revision != expectedRevision) + return Failure(task, operationId, UserTaskOperationKind.Timeout, "revision-conflict"); + + var operation = new UserTaskOperation( + identityGenerator.GenerateId(), tenantId, taskId, operationId, UserTaskOperationKind.Timeout, + expectedRevision, hash, UserTaskOperationStatus.Accepted, clock.UtcNow, clock.UtcNow, + ActionKey: "Timeout"); + task.IsOverdue = true; + task.Status = UserTaskStatus.TimingOut; + task.CompletionActionKey = "Timeout"; + task.Operations.Add(operation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, + "TimeoutRequested", clock.UtcNow, OperationId: operationId)); + try + { + await repository.SaveAsync(task, expectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return Failure(task, operationId, UserTaskOperationKind.Timeout, "revision-conflict"); + } + + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, ["timeoutRequested"]), cancellationToken); + await notificationSink.PublishAsync(new UserTaskOverdue(tenantId, taskId, committed.Status, committed.Revision), cancellationToken); + await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, operationId, "Timeout", null, null, now, committed.BookmarkId), cancellationToken); + var committedOperation = FindOperation(committed, operationId) ?? operation; + return new UserTaskOperationResult(committed, committedOperation, true); + } + + public async Task CancelAsync(string tenantId, string taskId, UserTaskCancelRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null) + return Failure(taskId, tenantId, request.OperationId, UserTaskOperationKind.Cancel, "not-found"); + if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Cancel, cancellationToken)) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "forbidden"); + if (string.IsNullOrWhiteSpace(request.Reason)) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "reason-required"); + if (!task.EnableCancellationOutcome) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "cancellation-disabled"); + var hash = Hash(UserTaskOperationKind.Cancel, new { request.ExpectedRevision, request.Reason }); + var existing = FindOperation(task, request.OperationId); + if (existing != null) + return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status != UserTaskOperationStatus.Failed) : Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "idempotency-conflict"); + if (task.IsTerminal) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "terminal"); + if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "transition-in-progress"); + if (task.Revision != request.ExpectedRevision) + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "revision-conflict"); + var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, request.OperationId, UserTaskOperationKind.Cancel, + request.ExpectedRevision, hash, UserTaskOperationStatus.Accepted, clock.UtcNow, clock.UtcNow, ActionKey: "Cancelled", Data: null, ErrorCode: null); + task.Status = UserTaskStatus.Cancelling; + task.CompletionActionKey = "Cancelled"; + task.CompletedBy = actor.Subject; + task.Operations.Add(operation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "CancellationRequested", clock.UtcNow, actor.Subject, request.OperationId, request.Reason)); + try + { + await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "revision-conflict"); + } + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, ["cancellationRequested"]), cancellationToken); + await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, request.OperationId, "Cancelled", null, actor.Subject, clock.UtcNow, committed.BookmarkId), cancellationToken); + return new UserTaskOperationResult(committed, operation, true); + } + + public async Task RetryResolutionAsync(string tenantId, string taskId, UserTaskMutationRequest request, UserTaskActor actor, CancellationToken cancellationToken = default) + { + var operationId = request.OperationId ?? identityGenerator.GenerateId(); + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null) + return Failure(taskId, tenantId, operationId, UserTaskOperationKind.RetryResolution, "not-found"); + if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.RetryResolution, cancellationToken)) + return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "forbidden"); + var hash = Hash(UserTaskOperationKind.RetryResolution, new { request.ExpectedRevision }); + var existing = FindOperation(task, operationId); + if (existing != null) + return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status != UserTaskOperationStatus.Failed) : Failure(task, operationId, UserTaskOperationKind.RetryResolution, "idempotency-conflict"); + if (task.Revision != request.ExpectedRevision) + return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "revision-conflict"); + + if (task.RequestedForm != null) + { + task.PinnedForm = _formProviders.TryGetValue(task.RequestedForm.ProviderName, out var provider) + ? await provider.ResolveAsync(task.RequestedForm, cancellationToken) + : null; + } + UserTaskHealthSeverity? healthSeverity = task.RequestedForm != null && task.PinnedForm == null ? UserTaskHealthSeverity.Blocking : null; + var healthCode = healthSeverity == UserTaskHealthSeverity.Blocking ? "form-resolution-failed" : null; + var healthMessage = healthSeverity == UserTaskHealthSeverity.Blocking ? "The configured form could not be resolved." : null; + if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot && task.CandidateGroups.Count > 0) + { + var snapshotMembers = task.CandidateUsers.ToList(); + var snapshotGroups = task.CandidateGroups.ToList(); + if (participantDirectory == null) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "snapshot-directory-missing"; + healthMessage = "A participant directory is required to snapshot candidate groups."; + } + else + { + foreach (var group in task.CandidateGroups) + { + try + { + snapshotMembers.AddRange(await participantDirectory.EnumerateGroupMembersAsync(group, cancellationToken)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + healthSeverity = UserTaskHealthSeverity.Blocking; + healthCode = "snapshot-resolution-failed"; + healthMessage = "A candidate group could not be resolved for this task."; + } + } + } + task.SnapshotMembers = [..snapshotMembers.Distinct()]; + task.SnapshotGroups = [..snapshotGroups.Distinct()]; + } + task.HealthSeverity = healthSeverity; + task.HealthCode = healthCode; + task.HealthMessage = healthMessage; + var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, operationId, UserTaskOperationKind.RetryResolution, + request.ExpectedRevision, hash, UserTaskOperationStatus.Completed, clock.UtcNow, clock.UtcNow); + task.Operations.Add(operation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "ResolutionRetried", clock.UtcNow, actor.Subject, operationId)); + try + { + await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "revision-conflict"); + } + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskHealthChanged(tenantId, taskId, committed.Status, committed.Revision), cancellationToken); + return new UserTaskOperationResult(committed, operation, true); + } + + private async Task MutateAsync(string tenantId, string taskId, int expectedRevision, string? requestedOperationId, + UserTaskOperationKind kind, UserTaskActor actor, UserTaskAccessOperation access, Func mutation, CancellationToken cancellationToken, string? reason = null, object? requestValue = null) + { + var operationId = requestedOperationId ?? identityGenerator.GenerateId(); + var task = await repository.GetAsync(tenantId, taskId, cancellationToken); + if (task == null) + return Failure(taskId, tenantId, operationId, kind, "not-found"); + if (!await accessPolicy.AuthorizeAsync(task, actor, access, cancellationToken)) + return Failure(task, operationId, kind, "forbidden"); + var hash = Hash(kind, new { expectedRevision, reason, requestValue, actor.Subject }); + var existing = FindOperation(task, operationId); + if (existing != null) + return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status == UserTaskOperationStatus.Completed) : Failure(task, operationId, kind, "idempotency-conflict"); + if (task.Revision != expectedRevision) + return Failure(task, operationId, kind, "revision-conflict"); + if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) + return Failure(task, operationId, kind, "transition-in-progress"); + var reasonCode = mutation(task); + if (reasonCode != null) + return Failure(task, operationId, kind, reasonCode); + var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, operationId, kind, expectedRevision, + hash, UserTaskOperationStatus.Completed, clock.UtcNow, clock.UtcNow); + task.Operations.Add(operation); + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, expectedRevision + 1, kind.ToString(), clock.UtcNow, actor.Subject, operationId, reason)); + try + { + await repository.SaveAsync(task, expectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return Failure(task, operationId, kind, "revision-conflict"); + } + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; + await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, [kind.ToString()]), cancellationToken); + return new UserTaskOperationResult(committed, operation, true); + } + + private static UserTaskOperation? FindOperation(UserTask task, string operationId) => task.Operations.FirstOrDefault(x => x.OperationId == operationId); + private static bool HasCandidates(UserTaskDefinitionSnapshot definition, UserTaskMaterialization materialization) => definition.Assignee != null || definition.CandidateUsers.Count > 0 || definition.CandidateGroups.Count > 0 || materialization.SnapshotMembers.Count > 0 || definition.Invitations.Count > 0; + private static bool HasCandidates(UserTask task) => task.CandidateUsers.Count > 0 || task.CandidateGroups.Count > 0 || task.SnapshotMembers.Count > 0 || task.InvitationDefinitions.Count > 0; + private static string MaterializationKey(UserTaskMaterialization materialization) => string.Join("/", materialization.TenantId, materialization.WorkflowInstanceId, materialization.ActivityInstanceId); + private static string Hash(UserTaskOperationKind kind, object value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(kind + ":" + JsonSerializer.Serialize(value)))); + private static int PayloadBytes(JsonElement? value) => value is { } element ? JsonSerializer.SerializeToUtf8Bytes(element).Length : 0; + + private UserTaskOperationResult Failure(string taskId, string tenantId, string operationId, UserTaskOperationKind kind, string code) => + Failure(new UserTask { Id = taskId, TenantId = tenantId }, operationId, kind, code); + + private static UserTaskOperationResult Failure(UserTask task, string operationId, UserTaskOperationKind kind, string code) + { + var operation = new UserTaskOperation(Guid.NewGuid().ToString("N"), task.TenantId, task.Id, operationId, kind, task.Revision, + "", UserTaskOperationStatus.Failed, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ErrorCode: code); + return new UserTaskOperationResult(task, operation, false, code); + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskNotificationSink.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskNotificationSink.cs new file mode 100644 index 000000000..0529d8ce0 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskNotificationSink.cs @@ -0,0 +1,14 @@ +using Elsa.Mediator.Contracts; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskNotificationSink(IServiceProvider services) : IUserTaskNotificationSink +{ + public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default) + { + return services.GetService()?.SendAsync(notification, cancellationToken) ?? Task.CompletedTask; + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskProjectionService.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskProjectionService.cs new file mode 100644 index 000000000..a66be18b0 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskProjectionService.cs @@ -0,0 +1,79 @@ +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.Workflows; +using Elsa.Common; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskProjectionService( + IUserTaskManager manager, + IUserTaskRepository repository, + IUserTaskNotificationSink notifications, + IUserTaskGuestSessionIssuer guestSessions, + IIdentityGenerator identityGenerator, + ISystemClock clock) : IUserTaskProjectionService +{ + public async Task ProjectCommittedBookmarksAsync(IReadOnlyCollection materializations, CancellationToken cancellationToken = default) + { + foreach (var materialization in materializations) + await manager.ProjectAsync(materialization, cancellationToken); + } + + public async Task FinalizeBookmarkRemovalAsync(UserTaskBookmarkRemoval removal, CancellationToken cancellationToken = default) + { + var task = await repository.GetAsync(removal.TenantId, removal.TaskId, cancellationToken); + if (task == null || task.BookmarkId != removal.BookmarkId) + return; + var expectedRevision = task.Revision; + var terminalStatus = task.Status switch + { + UserTaskStatus.Completing => UserTaskStatus.Completed, + UserTaskStatus.TimingOut => UserTaskStatus.TimedOut, + UserTaskStatus.Cancelling => UserTaskStatus.Cancelled, + _ when !task.IsTerminal => UserTaskStatus.Cancelled, + _ => (UserTaskStatus?)null + }; + if (terminalStatus == null) + return; + var expectedOperationKind = task.Status switch + { + UserTaskStatus.Completing => UserTaskOperationKind.Complete, + UserTaskStatus.TimingOut => UserTaskOperationKind.Timeout, + UserTaskStatus.Cancelling => UserTaskOperationKind.Cancel, + _ => (UserTaskOperationKind?)null + }; + var operation = expectedOperationKind is { } operationKind + ? task.Operations.LastOrDefault(x => x.Status == UserTaskOperationStatus.Accepted && x.Kind == operationKind) + : null; + if (task.Status is (UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling) && operation == null) + return; + if (operation != null) + { + var index = task.Operations.IndexOf(operation); + task.Operations[index] = operation with { Status = UserTaskOperationStatus.Completed, UpdatedAt = clock.UtcNow }; + } + task.Status = terminalStatus.Value; + task.CompletedAt ??= clock.UtcNow; + task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), task.TenantId, task.Id, expectedRevision + 1, + terminalStatus.Value.ToString(), clock.UtcNow, operation == null ? null : task.CompletedBy, operation?.OperationId, + Metadata: operation?.ActionKey == null ? null : new Dictionary { ["actionKey"] = operation.ActionKey })); + try + { + await repository.SaveAsync(task, expectedRevision, cancellationToken); + } + catch (UserTaskRevisionConflictException) + { + return; + } + // The task is closed, so any outstanding guest session for it must stop working immediately rather + // than lingering until its own TTL elapses. + await guestSessions.RevokeForTaskAsync(task.TenantId, task.Id, cancellationToken); + var committed = await repository.GetAsync(task.TenantId, task.Id, cancellationToken) ?? task; + await notifications.PublishAsync(terminalStatus.Value switch + { + UserTaskStatus.Completed => new UserTaskCompleted(committed.TenantId, committed.Id, committed.Status, committed.Revision), + UserTaskStatus.TimedOut => new UserTaskTimedOut(committed.TenantId, committed.Id, committed.Status, committed.Revision), + _ => new UserTaskCancelled(committed.TenantId, committed.Id, committed.Status, committed.Revision) + }, cancellationToken); + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskReconciler.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskReconciler.cs new file mode 100644 index 000000000..55d927bc7 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskReconciler.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using Elsa.Common; +using Elsa.Common.Models; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; + +namespace Elsa.UserTasks.Services; + +/// +/// Performs a bounded, tenant-scoped repair pass. The workflow bookmark is the source of truth: when a +/// bookmark exists, accepted transitions are retried; when it is gone, the projection is finalized after +/// commit. The bookmark store is optional so the service remains usable with the Core in-memory stack. +/// +public sealed class DefaultUserTaskReconciler( + IUserTaskRepository repository, + IUserTaskManager manager, + IUserTaskProjectionService projectionService, + IUserTaskWorkflowResumer workflowResumer, + ISystemClock clock, + IBookmarkStore? bookmarkStore = null) : IUserTaskReconciler +{ + public async Task ReconcileAsync(UserTaskReconciliationRequest request, CancellationToken cancellationToken = default) + { + var olderThan = request.OlderThan ?? clock.UtcNow.Subtract(TimeSpan.FromMinutes(5)); + var pageSize = Math.Clamp(request.PageSize, 1, 200); + var recreated = bookmarkStore == null ? 0 : await RecreateMissingProjectionsAsync(request.TenantId, pageSize, cancellationToken); + var cursor = (string?)null; + var requeued = 0; + var finalized = 0; + var ambiguous = 0; + + do + { + var result = await repository.QueryAsync(new UserTaskQuery + { + TenantId = request.TenantId, + Cursor = cursor, + Sort = "created", + Limit = pageSize + }, cancellationToken); + foreach (var task in result.Items.Where(x => x.UpdatedAt <= olderThan)) + { + var bookmark = bookmarkStore == null + ? null + : await bookmarkStore.FindAsync(new BookmarkFilter + { + BookmarkId = task.BookmarkId, + WorkflowInstanceId = task.WorkflowInstanceId + }, cancellationToken); + + if (bookmarkStore != null && bookmark == null && task.IsOpen) + { + var before = task.Status; + await projectionService.FinalizeBookmarkRemovalAsync(new UserTaskBookmarkRemoval(task.TenantId, task.Id, task.BookmarkId, clock.UtcNow), cancellationToken); + var after = await repository.GetAsync(task.TenantId, task.Id, cancellationToken); + if (after?.Status != before) + finalized++; + continue; + } + + if (task.Status is not (UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling)) + continue; + + var operation = task.Operations.LastOrDefault(x => x.Status == UserTaskOperationStatus.Accepted && x.Kind is (UserTaskOperationKind.Complete or UserTaskOperationKind.Timeout or UserTaskOperationKind.Cancel)); + if (operation == null) + { + ambiguous++; + continue; + } + + if (bookmarkStore != null && bookmark == null) + { + var before = task.Status; + await projectionService.FinalizeBookmarkRemovalAsync(new UserTaskBookmarkRemoval(task.TenantId, task.Id, task.BookmarkId, clock.UtcNow), cancellationToken); + var after = await repository.GetAsync(task.TenantId, task.Id, cancellationToken); + if (after?.Status != before) + finalized++; + continue; + } + + try + { + var action = task.Status == UserTaskStatus.TimingOut ? "Timeout" : task.Status == UserTaskStatus.Cancelling ? "Cancelled" : task.CompletionActionKey ?? "Complete"; + await workflowResumer.ResumeAsync(task, new UserTaskStimulus(task.TenantId, task.Id, operation.OperationId, + action, task.CompletionData, task.CompletedBy, task.CompletedAt ?? operation.CreatedAt, task.BookmarkId), cancellationToken); + requeued++; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Keep the accepted operation durable for the next bounded pass. + await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current => + { + current.HealthSeverity = UserTaskHealthSeverity.Advisory; + current.HealthCode = "stale-transition"; + current.HealthMessage = "A workflow outcome requires reconciliation."; + return true; + }, cancellationToken); + } + } + + cursor = result.NextCursor; + } + while (cursor != null); + + return new UserTaskReconciliationResult(recreated, requeued, finalized, ambiguous); + } + + private async Task RecreateMissingProjectionsAsync(string tenantId, int pageSize, CancellationToken cancellationToken) + { + var recreated = 0; + var page = 0; + while (true) + { + var bookmarks = (await bookmarkStore!.FindManyAsync(new BookmarkFilter { Name = nameof(Elsa.UserTasks.Activities.UserTask) }, + PageArgs.FromPage(page, pageSize), cancellationToken)).Items.ToArray(); + if (bookmarks.Length == 0) + break; + + // OfType drops undeserializable bookmarks and narrows the sequence in one step, so the tenant + // filter reads explicitly and no null-forgiving operator is needed downstream. + foreach (var materialization in bookmarks + .Select(Deserialize) + .OfType() + .Where(x => string.Equals(x.TenantId, tenantId, StringComparison.Ordinal))) + { + // Stays a guard rather than a filter: it awaits the repository, which LINQ cannot express here. + if (await repository.FindByMaterializationKeyAsync(tenantId, MaterializationKey(materialization), cancellationToken) != null) + continue; + await manager.ProjectAsync(materialization, cancellationToken); + recreated++; + } + + if (bookmarks.Length < pageSize) + break; + page++; + } + return recreated; + } + + private static string MaterializationKey(UserTaskMaterialization materialization) => string.Join("/", materialization.TenantId, materialization.WorkflowInstanceId, materialization.ActivityInstanceId); + + private static UserTaskMaterialization? Deserialize(StoredBookmark bookmark) + { + try + { + return bookmark.Payload switch + { + UserTaskMaterialization materialization => materialization, + JsonElement element => element.Deserialize(), + null => null, + _ => JsonSerializer.Deserialize(JsonSerializer.Serialize(bookmark.Payload)) + }; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskWorkflowResumer.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskWorkflowResumer.cs new file mode 100644 index 000000000..5a7cf68a3 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskWorkflowResumer.cs @@ -0,0 +1,21 @@ +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.Services; + +public sealed class DefaultUserTaskWorkflowResumer(IServiceProvider services) : IUserTaskWorkflowResumer +{ + public async Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default) + { + var resumer = services.GetService(); + if (resumer == null) + throw new InvalidOperationException("A workflow resumer is required to complete a User Task."); + + await resumer.ResumeAsync(task.BookmarkId, new Dictionary + { + [Elsa.UserTasks.Activities.UserTask.InputKey] = stimulus + }, cancellationToken); + } +} diff --git a/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs b/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs new file mode 100644 index 000000000..3117a3bea --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs @@ -0,0 +1,230 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.Services; + +/// +/// In-process guest session store. Only the SHA-256 hash of a credential is retained, so a memory dump or a +/// log of this structure cannot be replayed against the API. Hosts running more than one replica should +/// register a shared-store implementation instead. +/// +public sealed class InMemoryUserTaskGuestSessionIssuer(ISystemClock clock, IOptions options) : IUserTaskGuestSessionIssuer +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + public Task IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default) + { + var now = clock.UtcNow; + // The session never outlives the invitation it came from, and never exceeds the host's own ceiling. + var expiresAt = Min(invitation.ExpiresAt, now.Add(options.Value.GuestSessionLifetime)); + if (expiresAt <= now) + return Task.FromResult(new GuestSessionResult(false, FailureCode: "session-unavailable")); + + var token = DefaultUserTaskInvitationService.Base64Url(RandomNumberGenerator.GetBytes(32)); + _sessions[DefaultUserTaskInvitationService.HashToken(token)] = new UserTaskGuestSession( + invitation.TenantId, invitation.TaskId, subject, invitation.AllowedActions.ToArray(), expiresAt); + return Task.FromResult(new GuestSessionResult(true, token, expiresAt, TaskId: invitation.TaskId)); + } + + public Task ResolveAsync(string credential, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(credential)) + return Task.FromResult(null); + + var hash = DefaultUserTaskInvitationService.HashToken(credential); + if (!_sessions.TryGetValue(hash, out var session)) + return Task.FromResult(null); + if (session.ExpiresAt > clock.UtcNow) + return Task.FromResult(session); + + _sessions.TryRemove(hash, out _); + return Task.FromResult(null); + } + + public Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) + { + foreach (var entry in _sessions.Where(x => x.Value.TenantId == tenantId && x.Value.TaskId == taskId).ToArray()) + _sessions.TryRemove(entry.Key, out _); + return Task.CompletedTask; + } + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; +} + +/// +/// Fixed-window limiter for the anonymous invitation surface. Counters are keyed by a caller partition +/// (normally the remote address) and never by the token, so probing many tokens from one host still +/// consumes one budget. +/// +public sealed class SlidingWindowUserTaskInvitationRateLimiter(ISystemClock clock, IOptions options) : IUserTaskInvitationRateLimiter +{ + private readonly ConcurrentDictionary _windows = new(StringComparer.Ordinal); + + public ValueTask TryAcquireAsync(string partitionKey, CancellationToken cancellationToken = default) + { + var settings = options.Value; + if (settings.AnonymousRateLimit <= 0) + return ValueTask.FromResult(true); + + var now = clock.UtcNow; + var allowed = true; + _windows.AddOrUpdate(partitionKey, + _ => new Window(now, 1), + (_, existing) => + { + if (now - existing.StartedAt >= settings.AnonymousRateLimitWindow) + return new Window(now, 1); + allowed = existing.Count < settings.AnonymousRateLimit; + return existing with { Count = existing.Count + 1 }; + }); + + // Opportunistic eviction keeps the dictionary bounded without a dedicated timer. + if (_windows.Count > 10_000) + { + foreach (var stale in _windows.Where(x => now - x.Value.StartedAt >= settings.AnonymousRateLimitWindow).Take(1_000).ToArray()) + _windows.TryRemove(stale.Key, out _); + } + + return ValueTask.FromResult(allowed); + } + + private sealed record Window(DateTimeOffset StartedAt, int Count); +} + +/// +/// Transient outbox for invitation secrets awaiting delivery. Entries are encrypted with ASP.NET Core Data +/// Protection so the plaintext token exists only inside a dispatch attempt, and are dropped once delivery +/// succeeds, the retry schedule is exhausted, or the invitation expires. +/// +public sealed class InMemoryUserTaskInvitationOutbox( + IDataProtectionProvider dataProtectionProvider, + ISystemClock clock, + IOptions options) : IUserTaskInvitationOutbox +{ + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelivery.v1"); + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + + public Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default) + { + _entries[delivery.Id] = new Entry( + delivery.Id, delivery.TenantId, delivery.TaskId, delivery.InvitationId, delivery.DispatcherName, + delivery.Recipient, _protector.Protect(JsonSerializer.Serialize(delivery.Token)), delivery.ExpiresAt, + delivery.Attempt, delivery.NotBefore ?? clock.UtcNow); + return Task.CompletedTask; + } + + public Task> DequeueDueAsync(int maxCount, CancellationToken cancellationToken = default) + { + var now = clock.UtcNow; + foreach (var expired in _entries.Where(x => x.Value.ExpiresAt <= now).ToArray()) + _entries.TryRemove(expired.Key, out _); + + var due = _entries.Values + .Where(x => x.NotBefore <= now) + .OrderBy(x => x.NotBefore) + .Take(Math.Max(1, maxCount)) + .Select(Unprotect) + .Where(x => x != null) + .Select(x => x!) + .ToArray(); + return Task.FromResult>(due); + } + + public Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default) + { + _entries.TryRemove(deliveryId, out _); + return Task.CompletedTask; + } + + public Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken = default) + { + if (!_entries.TryGetValue(deliveryId, out var entry)) + return Task.CompletedTask; + + var attempt = entry.Attempt + 1; + // Abandon rather than retry forever: an undeliverable secret should expire, and a manager can reissue. + if (attempt > options.Value.InvitationDeliveryRetryDelays.Count) + _entries.TryRemove(deliveryId, out _); + else + _entries[deliveryId] = entry with { Attempt = attempt, NotBefore = notBefore }; + return Task.CompletedTask; + } + + private UserTaskInvitationDelivery? Unprotect(Entry entry) + { + try + { + var token = JsonSerializer.Deserialize(_protector.Unprotect(entry.ProtectedToken)); + return token == null + ? null + : new UserTaskInvitationDelivery(entry.Id, entry.TenantId, entry.TaskId, entry.InvitationId, entry.DispatcherName, token, entry.ExpiresAt) + { + Recipient = entry.Recipient, + Attempt = entry.Attempt, + NotBefore = entry.NotBefore + }; + } + catch (CryptographicException) + { + // A rotated or unavailable key makes the secret unrecoverable. Drop it instead of surfacing it. + _entries.TryRemove(entry.Id, out _); + return null; + } + } + + private sealed record Entry( + string Id, + string TenantId, + string TaskId, + string InvitationId, + string DispatcherName, + string? Recipient, + string ProtectedToken, + DateTimeOffset ExpiresAt, + int Attempt, + DateTimeOffset NotBefore); +} + +/// +/// Resolves a presented guest credential into a task-scoped actor. The actor carries only the permissions a +/// guest needs, plus the task and action allowlist the policy layer enforces. +/// +public sealed class UserTaskGuestActorResolver(IUserTaskGuestSessionIssuer sessions) +{ + public const string CredentialScheme = "UserTaskSession"; + + public async Task ResolveAsync(string? credential, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(credential)) + return null; + if (await sessions.ResolveAsync(credential, cancellationToken) is not { } session) + return null; + + return new UserTaskActor(session.Subject, [], session.Subject.DisplayName) + { + IsManager = false, + Permissions = new HashSet([Permissions.UserTasksPermissions.Read, Permissions.UserTasksPermissions.Complete], StringComparer.OrdinalIgnoreCase), + GuestTaskId = session.TaskId, + GuestAllowedActions = new HashSet(session.AllowedActions, StringComparer.OrdinalIgnoreCase) + }; + } + + /// Extracts the credential from an Authorization: UserTaskSession <token> header value. + public static string? ReadCredential(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader)) + return null; + var value = authorizationHeader.Trim(); + return value.StartsWith(CredentialScheme + " ", StringComparison.OrdinalIgnoreCase) + ? value[(CredentialScheme.Length + 1)..].Trim() + : null; + } +} diff --git a/src/modules/Elsa.UserTasks/Services/UserTaskModelMapper.cs b/src/modules/Elsa.UserTasks/Services/UserTaskModelMapper.cs new file mode 100644 index 000000000..e20274717 --- /dev/null +++ b/src/modules/Elsa.UserTasks/Services/UserTaskModelMapper.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; + +namespace Elsa.UserTasks.Services; + +/// +/// Projects the internal aggregate onto the wire contract. Every protected value passes through an explicit +/// policy decision here, so an endpoint can never widen disclosure by choosing a different response type. +/// +public static class UserTaskModelMapper +{ + private static readonly UserTaskAccessOperation[] ActionOperations = + [ + UserTaskAccessOperation.Claim, + UserTaskAccessOperation.Release, + UserTaskAccessOperation.Assign, + UserTaskAccessOperation.UpdateScheduling, + UserTaskAccessOperation.Complete, + UserTaskAccessOperation.Cancel, + UserTaskAccessOperation.IssueInvitation, + UserTaskAccessOperation.RetryResolution + ]; + + public static string ActionName(UserTaskAccessOperation operation) => operation switch + { + UserTaskAccessOperation.Claim => "claim", + UserTaskAccessOperation.Release => "release", + UserTaskAccessOperation.Assign => "assign", + UserTaskAccessOperation.UpdateScheduling => "update-scheduling", + UserTaskAccessOperation.Complete => "complete", + UserTaskAccessOperation.Cancel => "cancel", + UserTaskAccessOperation.IssueInvitation => "invite", + UserTaskAccessOperation.RetryResolution => "retry-resolution", + _ => operation.ToString().ToLowerInvariant() + }; + + public static async Task ToSummaryAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy policy, CancellationToken cancellationToken = default) + { + var allowed = new List(); + foreach (var operation in ActionOperations) + { + if (await policy.AuthorizeAsync(task, actor, operation, cancellationToken)) + allowed.Add(ActionName(operation)); + } + + // Blocking health is an operator signal. Surfacing it to an ordinary participant would leak that a + // directory or form provider failed, so it is folded away unless the caller manages the tenant. + var healthVisible = actor.IsManager && !actor.IsGuest; + var workflowVisible = !actor.IsGuest; + return new UserTaskSummary + { + Id = task.Id, + Title = task.Title, + Summary = task.Summary, + Reference = task.Reference, + Tags = task.Tags.ToArray(), + TaskType = task.TaskType, + Status = task.Status.ToString(), + Priority = task.Priority, + Assignee = actor.IsGuest ? null : UserTaskParticipantSummary.From(task.Assignee), + CandidateSummary = actor.IsGuest ? null : DescribeCandidates(task), + DueAt = task.DueAt, + IsOverdue = task.IsOverdue, + CreatedAt = task.CreatedAt, + UpdatedAt = task.UpdatedAt, + AssignedAt = actor.IsGuest ? null : task.AssignedAt, + CompletedAt = task.CompletedAt, + WorkflowDefinitionId = workflowVisible ? NullIfEmpty(task.WorkflowDefinitionId) : null, + WorkflowDefinitionName = workflowVisible ? task.WorkflowDefinitionName : null, + WorkflowDefinitionVersion = workflowVisible ? task.WorkflowDefinitionVersion : null, + WorkflowInstanceId = workflowVisible ? NullIfEmpty(task.WorkflowInstanceId) : null, + WorkflowInstanceReference = workflowVisible ? task.WorkflowInstanceReference : null, + HealthSeverity = healthVisible ? task.HealthSeverity?.ToString() : null, + HealthCode = healthVisible ? task.HealthCode : null, + AllowedActions = allowed, + Revision = task.Revision + }; + } + + public static async Task ToDetailAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy policy, CancellationToken cancellationToken = default) + { + var summary = await ToSummaryAsync(task, actor, policy, cancellationToken); + var canReadProtected = await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken); + var canViewHistory = !actor.IsGuest && (actor.IsManager || canReadProtected); + var disclosure = new UserTaskDisclosure + { + CanViewProtected = canReadProtected, + CanViewWorkflow = !actor.IsGuest, + CanViewHistory = canViewHistory, + GuestVisible = actor.IsGuest + }; + + // Guests may only complete the action keys their invitation was issued for, so the action list they + // receive is intersected with that allowlist rather than showing the workflow's full action set. + var actions = task.Actions + .Where(action => !actor.IsGuest || actor.GuestAllowedActions.Contains(action.Key)) + .Select(action => new UserTaskFormAction(action.Key, action.Label)) + .ToArray(); + + return new UserTaskDetail + { + Id = summary.Id, + Title = summary.Title, + Summary = summary.Summary, + Reference = summary.Reference, + Tags = summary.Tags, + TaskType = summary.TaskType, + Status = summary.Status, + Priority = summary.Priority, + Assignee = summary.Assignee, + CandidateSummary = summary.CandidateSummary, + DueAt = summary.DueAt, + IsOverdue = summary.IsOverdue, + CreatedAt = summary.CreatedAt, + UpdatedAt = summary.UpdatedAt, + AssignedAt = summary.AssignedAt, + CompletedAt = summary.CompletedAt, + WorkflowDefinitionId = summary.WorkflowDefinitionId, + WorkflowDefinitionName = summary.WorkflowDefinitionName, + WorkflowDefinitionVersion = summary.WorkflowDefinitionVersion, + WorkflowInstanceId = summary.WorkflowInstanceId, + WorkflowInstanceReference = summary.WorkflowInstanceReference, + HealthSeverity = summary.HealthSeverity, + HealthCode = summary.HealthCode, + AllowedActions = summary.AllowedActions, + Revision = summary.Revision, + Instructions = canReadProtected ? task.Instructions : null, + Data = canReadProtected ? task.TaskData : null, + Disclosure = disclosure, + Workflow = disclosure.CanViewWorkflow ? ToWorkflowContext(task) : null, + Form = ToFormProjection(task, actions, canReadProtected), + Actions = actions, + Outcome = canReadProtected ? task.CompletionActionKey : null, + Response = canReadProtected ? task.CompletionData : null, + CompletedBy = canReadProtected && !actor.IsGuest ? UserTaskParticipantSummary.From(task.CompletedBy) : null + }; + } + + public static async Task ToCapabilitiesAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy policy, CancellationToken cancellationToken = default) + { + var summary = await ToSummaryAsync(task, actor, policy, cancellationToken); + return new UserTaskCapabilities(task.Id, task.Revision, summary.AllowedActions, + await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken), + actor.IsManager && !actor.IsGuest); + } + + public static UserTaskEventSummary ToEventSummary(UserTaskEvent @event) => + new(@event.Id, @event.EventType, @event.Reason, @event.OccurredAt, @event.Actor?.DisplayName); + + private static UserTaskWorkflowContext ToWorkflowContext(UserTask task) => new() + { + DefinitionId = NullIfEmpty(task.WorkflowDefinitionId), + DefinitionName = task.WorkflowDefinitionName, + DefinitionVersion = task.WorkflowDefinitionVersion, + InstanceId = NullIfEmpty(task.WorkflowInstanceId), + InstanceReference = task.WorkflowInstanceReference + }; + + private static UserTaskFormProjection? ToFormProjection(UserTask task, IReadOnlyCollection actions, bool canReadProtected) + { + if (task.PinnedForm is not { } form) + return null; + + var fields = form.Fields.Select(descriptor => new UserTaskFormField + { + Key = descriptor.Key, + Label = descriptor.Label, + Type = descriptor.Type, + Required = descriptor.Required, + Masked = descriptor.Masked, + CanReveal = descriptor.Masked && descriptor.CanReveal && canReadProtected, + // A masked value never rides along with the form. It is disclosed only through the explicit, + // audited reveal command, so an accidental log or screenshot of the detail response is inert. + Value = canReadProtected && !descriptor.Masked ? ReadFieldValue(task.TaskData, descriptor.Key) : null + }).ToArray(); + + return new UserTaskFormProjection + { + Provider = form.Requested.ProviderName, + Key = form.Requested.Key, + Version = form.PinnedVersion, + Fields = fields, + Actions = actions + }; + } + + internal static JsonElement? ReadFieldValue(JsonElement? data, string key) => + data is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(key, out var value) ? value.Clone() : null; + + private static string? DescribeCandidates(UserTask task) + { + var users = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot + ? task.SnapshotMembers.Count(x => x.Type == UserTaskParticipantType.User) + : task.CandidateUsers.Count; + var groups = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot + ? task.SnapshotGroups.Count + : task.CandidateGroups.Count; + if (users == 0 && groups == 0) + return null; + + // Counts only: disclosing which peers are eligible would let any candidate enumerate the others. + var parts = new List(2); + if (users > 0) + parts.Add(users == 1 ? "1 user" : $"{users} users"); + if (groups > 0) + parts.Add(groups == 1 ? "1 group" : $"{groups} groups"); + return string.Join(", ", parts); + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; +} diff --git a/src/modules/Elsa.UserTasks/ShellFeatures/UserTasksFeature.cs b/src/modules/Elsa.UserTasks/ShellFeatures/UserTasksFeature.cs new file mode 100644 index 000000000..5e1c69709 --- /dev/null +++ b/src/modules/Elsa.UserTasks/ShellFeatures/UserTasksFeature.cs @@ -0,0 +1,28 @@ +using CShells.FastEndpoints.Features; +using CShells.Features; +using Elsa.ShellFeatures; +using Elsa.UserTasks.Extensions; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.UserTasks.ShellFeatures; + +[ShellFeature( + DisplayName = "User Tasks", + Description = "Provides identity-neutral, workflow-bound human tasks.", + DependsOn = [typeof(ElsaFastEndpointsFeature)])] +[UsedImplicitly] +public sealed class UserTasksFeature : IFastEndpointsShellFeature +{ + public string DefaultTenantId { get; set; } = ""; + public string DefaultProvider { get; set; } = "default"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddUserTasksServices(options => + { + options.DefaultTenantId = DefaultTenantId; + options.DefaultProvider = DefaultProvider; + }); + } +} diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index 30afc6d7a..ff9b3c394 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -153,12 +153,17 @@ public class WorkflowDefinitionPublisher( public async Task RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default) { - var filter = new WorkflowDefinitionFilter + var latestVersionFilter = new WorkflowDefinitionFilter { DefinitionId = definitionId, VersionOptions = VersionOptions.Latest }; - var latestVersion = await workflowDefinitionStore.FindAsync(filter, cancellationToken); + var latestVersion = await workflowDefinitionStore.FindAsync(latestVersionFilter, cancellationToken); + var lastVersionFilter = new WorkflowDefinitionFilter + { + DefinitionId = definitionId + }; + var lastVersion = await workflowDefinitionStore.FindLastVersionAsync(lastVersionFilter, cancellationToken); if (latestVersion != null) { @@ -168,7 +173,7 @@ public class WorkflowDefinitionPublisher( var draft = await GetDraftAsync(definitionId, VersionOptions.SpecificVersion(version), cancellationToken); draft!.Id = identityGenerator.GenerateId(); - draft.Version = (latestVersion?.Version ?? 0) + 1; + draft.Version = (lastVersion?.Version ?? 0) + 1; draft.IsLatest = true; await workflowDefinitionStore.SaveAsync(draft, cancellationToken); @@ -251,4 +256,4 @@ public class WorkflowDefinitionPublisher( return definition; } -} \ No newline at end of file +} diff --git a/test/component/Directory.Build.props b/test/component/Directory.Build.props index fb63b368e..1a1add0ec 100644 --- a/test/component/Directory.Build.props +++ b/test/component/Directory.Build.props @@ -7,9 +7,10 @@ + - \ No newline at end of file + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionVersioning/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionVersioning/Tests.cs new file mode 100644 index 000000000..75663612b --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionVersioning/Tests.cs @@ -0,0 +1,58 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionVersioning; + +public class Tests +{ + private readonly IServiceProvider _services; + + public Tests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).Build(); + } + + [Fact] + public async Task RevertVersionAsync_ShouldAllocateVersionAfterHighestStoredVersion() + { + const string definitionId = "test-definition"; + var store = _services.GetRequiredService(); + var publisher = _services.GetRequiredService(); + var definitions = new WorkflowDefinition[] + { + new() + { + Id = "v1", + DefinitionId = definitionId, + Version = 1, + IsPublished = true, + IsLatest = false + }, + new() + { + Id = "v2", + DefinitionId = definitionId, + Version = 2, + IsPublished = false, + IsLatest = true + }, + new() + { + Id = "v3", + DefinitionId = definitionId, + Version = 3, + IsPublished = false, + IsLatest = false + } + }; + + await store.SaveManyAsync(definitions); + + var revertedDefinition = await publisher.RevertVersionAsync(definitionId, 1); + + Assert.Equal(4, revertedDefinition.Version); + } +} diff --git a/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/EFCoreUserTaskRepositoryTests.cs b/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/EFCoreUserTaskRepositoryTests.cs new file mode 100644 index 000000000..3e9765d83 --- /dev/null +++ b/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/EFCoreUserTaskRepositoryTests.cs @@ -0,0 +1,242 @@ +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Services; +using Elsa.Workflows; +using Microsoft.Extensions.Options; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Persistence.EFCore; +using Elsa.UserTasks.Persistence.EFCore.Repositories; +using Elsa.UserTasks.Persistence.EFCore.Sqlite.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Elsa.UserTasks.Persistence.EFCore.UnitTests; + +public sealed class EFCoreUserTaskRepositoryTests : IAsyncLifetime +{ + private readonly string databasePath = Path.Join(Path.GetTempPath(), $"elsa-user-tasks-{Guid.NewGuid():N}.db"); + private readonly ServiceProvider serviceProvider; + + public EFCoreUserTaskRepositoryTests() + { + var services = new ServiceCollection(); + services.AddSqliteEntityModelCreatingHandlers(); + services.AddDbContextFactory(builder => + builder.UseElsaSqlite(typeof(SqliteUserTasksPersistenceFeatureExtensions).Assembly, $"Data Source={databasePath}")); + services.AddScoped>(); + services.AddScoped(); + serviceProvider = services.BuildServiceProvider(); + } + + public async Task InitializeAsync() + { + await using var scope = serviceProvider.CreateAsyncScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var dbContext = await factory.CreateDbContextAsync(); + await dbContext.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await serviceProvider.DisposeAsync(); + if (File.Exists(databasePath)) + File.Delete(databasePath); + } + + [Fact] + public async Task RoundTrip_PreservesProtectedAggregateAndAppendOnlyHistory() + { + await using var scope = serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var createdAt = DateTimeOffset.UtcNow; + var actor = new ParticipantReference("tenant-1", "directory", UserTaskParticipantType.User, "u1", "Alice"); + var task = CreateTask(createdAt, actor); + task.Events.Add(new UserTaskEvent("event-1", task.TenantId, task.Id, 1, "Created", createdAt, actor)); + task.Operations.Add(new UserTaskOperation("operation-1", task.TenantId, task.Id, "op-1", UserTaskOperationKind.Claim, 1, "hash", UserTaskOperationStatus.Accepted, createdAt, createdAt, "Complete")); + + await repository.AddProjectionAsync(task); + var reloaded = await repository.GetAsync(task.TenantId, task.Id); + Assert.NotNull(reloaded); + var loaded = reloaded!; + Assert.Single(loaded.CandidateUsers); + Assert.Equal("alice", loaded.CandidateUsers[0].DisplayName?.ToLowerInvariant()); + Assert.Single(loaded.InvitationDefinitions); + Assert.Equal("Complete", loaded.Operations.Single().ActionKey); + + loaded.Events.Add(new UserTaskEvent("event-2", task.TenantId, task.Id, 2, "Claimed", createdAt.AddMinutes(1), actor)); + loaded.Operations[0] = loaded.Operations[0] with { Status = UserTaskOperationStatus.Completed, ErrorCode = "none", UpdatedAt = createdAt.AddMinutes(1) }; + await repository.SaveAsync(loaded, 1); + + var saved = await repository.GetAsync(task.TenantId, task.Id); + Assert.NotNull(saved); + var persisted = saved!; + Assert.Equal(2, persisted.Events.Count); + Assert.Equal(2, persisted.Revision); + Assert.Equal(UserTaskOperationStatus.Completed, persisted.Operations.Single().Status); + Assert.Equal("Complete", persisted.Operations.Single().ActionKey); + Assert.Equal("none", persisted.Operations.Single().ErrorCode); + } + + [Fact] + public async Task QueryScopeAndConcurrency_AreAppliedBeforePaging() + { + await using var scope = serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var actor = new ParticipantReference("tenant-1", "directory", UserTaskParticipantType.User, "u1"); + var task = CreateTask(DateTimeOffset.UtcNow, actor); + await repository.AddProjectionAsync(task); + var pagingSecondTask = CreateTask(DateTimeOffset.UtcNow.AddSeconds(1), actor); + await repository.AddProjectionAsync(pagingSecondTask); + + var query = await repository.QueryAsync(Query(task.TenantId, actor, limit: 1, includeTotalCount: true)); + Assert.Single(query.Items); + Assert.Single(query.Items.Single().CandidateUsers); + Assert.Equal(2, query.TotalCount); + Assert.NotNull(query.NextCursor); + + var finalPage = await repository.QueryAsync(Query(task.TenantId, actor, limit: 1) with { Cursor = query.NextCursor }); + Assert.Single(finalPage.Items); + Assert.Null(finalPage.NextCursor); + + // The subject holds no assignment yet, so the assigned scope must be empty even though the + // available scope returns both rows. + var assignedScope = await repository.QueryAsync(Query(task.TenantId, actor, UserTaskQueryScopeKind.Assigned, includeTotalCount: true)); + Assert.Empty(assignedScope.Items); + Assert.Equal(0, assignedScope.TotalCount); + + var crossTenantScope = await repository.QueryAsync(new UserTaskQuery + { + TenantId = task.TenantId, + IncludeTotalCount = true, + Scope = new UserTaskQueryScope("other-tenant", actor with { TenantId = "other-tenant" }, [], Kind: UserTaskQueryScopeKind.Available) + }); + Assert.Empty(crossTenantScope.Items); + Assert.Equal(0, crossTenantScope.TotalCount); + + var first = await repository.GetAsync(task.TenantId, task.Id); + var second = await repository.GetAsync(task.TenantId, task.Id); + Assert.NotNull(first); + Assert.NotNull(second); + var firstTask = first!; + var secondTask = second!; + firstTask.Status = UserTaskStatus.Assigned; + await repository.SaveAsync(firstTask, 1); + secondTask.Status = UserTaskStatus.Completed; + await Assert.ThrowsAsync(() => repository.SaveAsync(secondTask, 1)); + + var excludedTask = CreateTask(DateTimeOffset.UtcNow.AddSeconds(2), actor); + excludedTask.ExcludedUsers = [actor]; + await repository.AddProjectionAsync(excludedTask); + var excludedQuery = await repository.QueryAsync(Query(task.TenantId, actor, includeTotalCount: true)); + Assert.DoesNotContain(excludedQuery.Items, x => x.Id == excludedTask.Id); + // Three tasks exist; the excluded one is filtered in the query path, so it is absent from the + // total as well rather than merely being hidden on the page. + Assert.Equal(2, excludedQuery.TotalCount); + } + + [Fact] + public async Task InvitationLookup_ResolvesTheOwningTaskFromATokenHashWithoutATenantHint() + { + await using var scope = serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var actor = new ParticipantReference("tenant-1", "directory", UserTaskParticipantType.User, "u1"); + var task = CreateTask(DateTimeOffset.UtcNow, actor); + task.Invitations.Add(new("invitation-1", task.TenantId, task.Id, "guest@example.com", "HASH-1", + UserTaskInvitationStatus.Pending, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1), "email-code") + { + AllowedActions = ["Complete"] + }); + await repository.AddProjectionAsync(task); + + var match = await repository.FindByInvitationTokenHashAsync("HASH-1"); + + Assert.NotNull(match); + var resolved = match!.Value; + Assert.Equal(task.Id, resolved.Task.Id); + Assert.Equal("Complete", Assert.Single(resolved.Invitation.AllowedActions)); + Assert.Null(await repository.FindByInvitationTokenHashAsync("HASH-UNKNOWN")); + } + + [Fact] + public async Task Manager_ReturnsARevisionConflictWhenAConcurrentEditWinsAgainstTheRealStore() + { + await using var scope = serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var clock = new FixedClock(); + var manager = new DefaultUserTaskManager(repository, new DefaultUserTaskAccessPolicy(), [], + new NoOpResumer(), new NoOpSink(), new SequentialIdentityGenerator(), clock, + Microsoft.Extensions.Options.Options.Create(new UserTasksOptions())); + + var subject = new ParticipantReference("tenant-1", "directory", UserTaskParticipantType.User, "u1"); + var actor = new UserTaskActor(subject, []) + { + Permissions = new HashSet(["read:user-tasks", "claim:user-tasks", "complete:user-tasks"], StringComparer.OrdinalIgnoreCase) + }; + var task = CreateTask(DateTimeOffset.UtcNow, subject); + await repository.AddProjectionAsync(task); + + // Someone else moves the task on, so the revision the caller holds is now stale. + var concurrent = (await repository.GetAsync(task.TenantId, task.Id))!; + concurrent.Priority = 90; + await repository.SaveAsync(concurrent, 1); + + // The store raises its own concurrency exception here. It must surface as the documented + // conflict result rather than escaping to the unhandled-error middleware. + var result = await manager.ClaimAsync(task.TenantId, task.Id, new(1, "claim-1"), actor); + + Assert.False(result.Accepted); + Assert.Equal("revision-conflict", result.ConflictCode); + } + + private sealed class FixedClock : ISystemClock + { + public DateTimeOffset UtcNow { get; } = DateTimeOffset.UtcNow; + } + + private sealed class SequentialIdentityGenerator : IIdentityGenerator + { + private int counter; + public string GenerateId() => $"id-{Interlocked.Increment(ref counter)}"; + } + + private sealed class NoOpResumer : IUserTaskWorkflowResumer + { + public Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class NoOpSink : IUserTaskNotificationSink + { + public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private static UserTaskQuery Query(string tenantId, ParticipantReference subject, + UserTaskQueryScopeKind kind = UserTaskQueryScopeKind.Available, int limit = 50, bool includeTotalCount = false) => new() + { + TenantId = tenantId, + Limit = limit, + IncludeTotalCount = includeTotalCount, + Scope = new(tenantId, subject, [], Kind: kind) + }; + + private static UserTask CreateTask(DateTimeOffset createdAt, ParticipantReference actor) => new() + { + Id = Guid.NewGuid().ToString("N"), + TenantId = actor.TenantId, + WorkflowDefinitionId = "definition-1", + WorkflowInstanceId = "instance-1", + ActivityInstanceId = "activity-1", + BookmarkId = Guid.NewGuid().ToString("N"), + MaterializationKey = Guid.NewGuid().ToString("N"), + Title = "Approve request", + Summary = "Review the request", + Tags = ["finance"], + CandidateUsers = [actor], + InvitationDefinitions = [new UserTaskInvitationDefinition("email-code", ["Complete"])], + CreatedAt = createdAt, + UpdatedAt = createdAt + }; +} diff --git a/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj b/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj new file mode 100644 index 000000000..911932fed --- /dev/null +++ b/test/unit/Elsa.UserTasks.Persistence.EFCore.UnitTests/Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj @@ -0,0 +1,9 @@ + + + [Elsa.UserTasks.Persistence.EFCore]* + 0 + + + + + diff --git a/test/unit/Elsa.UserTasks.UnitTests/Elsa.UserTasks.UnitTests.csproj b/test/unit/Elsa.UserTasks.UnitTests/Elsa.UserTasks.UnitTests.csproj new file mode 100644 index 000000000..74d0da364 --- /dev/null +++ b/test/unit/Elsa.UserTasks.UnitTests/Elsa.UserTasks.UnitTests.csproj @@ -0,0 +1,10 @@ + + + [Elsa.UserTasks]* + 0 + + + + + + diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs new file mode 100644 index 000000000..0dcdd9f80 --- /dev/null +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Services; +using Xunit; + +namespace Elsa.UserTasks.UnitTests; + +/// +/// Covers the invitation and guest-session boundary: one-time secrets, generic anonymous responses, rate +/// limiting, task-scoped guest authorization, and revocation. +/// +public class UserTaskInvitationTests +{ + private const string Tenant = UserTaskTestFixture.TenantId; + + private readonly UserTaskTestFixture _fixture = new(); + + private static Func WithBearerInvitation(params string[] actions) => + definition => definition with { Invitations = [new("bearer", actions.Length > 0 ? actions : ["Approve"], BearerOnly: true)] }; + + [Fact] + public async Task Issue_KeepsTheSecretOutOfTheApiResultAndOffTheAuditTrail() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + + var issued = await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), manager); + Assert.NotNull(issued); + + await _fixture.DrainOutboxAsync(); + var token = _fixture.Dispatcher.Token; + Assert.NotNull(token); + Assert.DoesNotContain(token, JsonSerializer.Serialize(issued)); + + var stored = await _fixture.Repository.GetAsync(Tenant, task.Id); + Assert.DoesNotContain(token, JsonSerializer.Serialize(stored!.Events)); + Assert.DoesNotContain(token, JsonSerializer.Serialize(stored.Invitations)); + } + + [Fact] + public async Task Issue_RefusesToBroadenAnInvitationBeyondTheActivityDefinition() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation("Approve")); + + // "Reject" is a configured task action but was not part of the materialized invitation definition. + Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve", "Reject"]), manager)); + Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Reject"]), manager)); + } + + [Fact] + public async Task Issue_RequiresTheInvitePermissionAndManagerRelationship() + { + var participant = _fixture.Actor("user-1", "read:user-tasks", "invite:user-tasks"); + var task = await _fixture.ProjectAsync(participant.Subject, WithBearerInvitation()); + + Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), participant)); + } + + [Fact] + public async Task Verify_ConsumesTheWinningInvitationAndRevokesItsSiblings() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + + await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(1, "bearer", ["Approve"]), manager); + await _fixture.DrainOutboxAsync(); + var first = _fixture.Dispatcher.Token!; + await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(2, "bearer", ["Approve"]), manager); + await _fixture.DrainOutboxAsync(); + var second = _fixture.Dispatcher.Token!; + + var verified = await _fixture.Invitations.VerifyAsync(new(first)); + Assert.True(verified.Succeeded); + Assert.Equal(task.Id, verified.TaskId); + Assert.NotNull(verified.SessionToken); + + // The sibling and a replay of the winner are both rejected, with the same opaque code. + var sibling = await _fixture.Invitations.VerifyAsync(new(second)); + var replay = await _fixture.Invitations.VerifyAsync(new(first)); + Assert.False(sibling.Succeeded); + Assert.False(replay.Succeeded); + Assert.Equal("invitation-unavailable", sibling.FailureCode); + Assert.Equal(sibling.FailureCode, replay.FailureCode); + + var claimed = await _fixture.Repository.GetAsync(Tenant, task.Id); + Assert.Equal(UserTaskStatus.Assigned, claimed!.Status); + Assert.Equal("guest", claimed.Assignee!.Provider); + } + + [Fact] + public async Task Verify_ReturnsTheSameFailureForUnknownExpiredAndWrongCodeInvitations() + { + var fixture = new UserTaskTestFixture(verifier: new UserTaskTestFixture.AcceptingVerifier()); + var manager = fixture.ManagerActor(); + var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject, + definition => definition with { Invitations = [new("code", ["Approve"])] }); + + await fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "code", ["Approve"]), manager); + await fixture.DrainOutboxAsync(); + var token = fixture.Dispatcher.Token!; + + var unknown = await fixture.Invitations.VerifyAsync(new("not-a-real-token", "correct")); + var wrongCode = await fixture.Invitations.VerifyAsync(new(token, "wrong")); + Assert.False(unknown.Succeeded); + Assert.False(wrongCode.Succeeded); + Assert.Equal(unknown.FailureCode, wrongCode.FailureCode); + Assert.Null(unknown.TaskId); + Assert.Null(wrongCode.TaskId); + + fixture.Clock.UtcNow = fixture.Clock.UtcNow.AddDays(30); + var expired = await fixture.Invitations.VerifyAsync(new(token, "correct")); + Assert.False(expired.Succeeded); + Assert.Equal(unknown.FailureCode, expired.FailureCode); + } + + [Fact] + public async Task Describe_ReturnsTheSameShapeForAnUnknownToken() + { + var known = await _fixture.Invitations.DescribeAsync("unknown-token-a"); + var other = await _fixture.Invitations.DescribeAsync("unknown-token-b"); + + Assert.Equal(known, other); + Assert.True(known.RequiresCode); + } + + [Fact] + public async Task RateLimiter_StopsAcceptingOnceTheBudgetForACallerIsSpent() + { + var options = Microsoft.Extensions.Options.Options.Create(new UserTasksOptions { AnonymousRateLimit = 3, AnonymousRateLimitWindow = TimeSpan.FromMinutes(5) }); + var limiter = new SlidingWindowUserTaskInvitationRateLimiter(_fixture.Clock, options); + + Assert.True(await limiter.TryAcquireAsync("10.0.0.1")); + Assert.True(await limiter.TryAcquireAsync("10.0.0.1")); + Assert.True(await limiter.TryAcquireAsync("10.0.0.1")); + Assert.False(await limiter.TryAcquireAsync("10.0.0.1")); + + // A different caller has its own budget, and the window resets on its own. + Assert.True(await limiter.TryAcquireAsync("10.0.0.2")); + _fixture.Clock.UtcNow = _fixture.Clock.UtcNow.AddMinutes(6); + Assert.True(await limiter.TryAcquireAsync("10.0.0.1")); + } + + [Fact] + public async Task GuestSession_IsScopedToItsOwnTaskAndCannotReachAnother() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager); + + var ownDetail = await _fixture.Manager.GetAsync(Tenant, task.Id, guest); + Assert.NotNull(ownDetail); + Assert.True(ownDetail.Disclosure.GuestVisible); + + var other = await _fixture.Manager.ProjectAsync(new(Tenant, "definition-2", "instance-2", "activity-2", "bookmark-2", + new() { Title = "Other", Actions = [new("Approve", "Approve")] }, [], [], _fixture.Clock.UtcNow, "task-2")); + Assert.Null(await _fixture.Manager.GetAsync(Tenant, other.Task.Id, guest)); + + var cross = await _fixture.Manager.CompleteAsync(Tenant, other.Task.Id, new(other.Task.Revision, "op-1", "Approve"), guest); + Assert.False(cross.Accepted); + Assert.Equal("forbidden", cross.ConflictCode); + } + + [Fact] + public async Task GuestProjection_OmitsWorkflowContextParticipantsAndHistory() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager); + + var detail = await _fixture.Manager.GetAsync(Tenant, task.Id, guest); + Assert.NotNull(detail); + Assert.Null(detail.Workflow); + Assert.Null(detail.WorkflowInstanceId); + Assert.Null(detail.Assignee); + Assert.Null(detail.CandidateSummary); + Assert.False(detail.Disclosure.CanViewHistory); + + var events = await _fixture.Manager.GetEventsAsync(Tenant, task.Id, null, 50, guest); + Assert.NotNull(events); + Assert.Empty(events.Items); + } + + [Fact] + public async Task GuestCompletion_IsLimitedToTheActionsItsInvitationWasIssuedFor() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation("Approve")); + var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager); + var current = await _fixture.Repository.GetAsync(Tenant, task.Id); + + // "Reject" is a valid task action but was never granted to this guest link. + var rejected = await _fixture.Manager.CompleteAsync(Tenant, task.Id, new(current!.Revision, "op-reject", "Reject"), guest); + Assert.False(rejected.Accepted); + Assert.Equal("forbidden", rejected.ConflictCode); + + var approved = await _fixture.Manager.CompleteAsync(Tenant, task.Id, new(current.Revision, "op-approve", "Approve"), guest); + Assert.True(approved.Accepted); + } + + [Theory] + [InlineData(UserTaskAccessOperation.Claim)] + [InlineData(UserTaskAccessOperation.Release)] + [InlineData(UserTaskAccessOperation.Assign)] + [InlineData(UserTaskAccessOperation.UpdateScheduling)] + [InlineData(UserTaskAccessOperation.Cancel)] + [InlineData(UserTaskAccessOperation.Manage)] + [InlineData(UserTaskAccessOperation.IssueInvitation)] + [InlineData(UserTaskAccessOperation.RetryResolution)] + public async Task GuestSession_IsDeniedEveryManagementOperation(UserTaskAccessOperation operation) + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager); + var current = await _fixture.Repository.GetAsync(Tenant, task.Id); + + Assert.False(await _fixture.Policy.AuthorizeAsync(current!, guest, operation)); + Assert.Null(await _fixture.Policy.CreateScopeAsync(guest, UserTaskQueryScopeKind.Assigned)); + } + + [Fact] + public async Task GuestSession_StopsResolvingOnceTheTaskCloses() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); + Assert.NotNull(await _fixture.GuestActors.ResolveAsync(credential)); + + await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(Tenant, task.Id, task.BookmarkId, _fixture.Clock.UtcNow)); + + Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); + } + + [Fact] + public async Task GuestSession_ExpiresAtTheHostCeilingEvenWhenTheInvitationLivesLonger() + { + var options = new UserTasksOptions { GuestSessionLifetime = TimeSpan.FromMinutes(30), DefaultInvitationLifetime = TimeSpan.FromDays(7) }; + var fixture = new UserTaskTestFixture(options); + var manager = fixture.ManagerActor(); + var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (_, credential) = await fixture.IssueGuestSessionAsync(task, manager); + + fixture.Clock.UtcNow = fixture.Clock.UtcNow.AddMinutes(31); + + Assert.Null(await fixture.GuestActors.ResolveAsync(credential)); + } + + [Fact] + public async Task Outbox_RetriesADispatchFailureAndAbandonsItOnceTheScheduleIsExhausted() + { + var options = new UserTasksOptions { InvitationDeliveryRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)] }; + var fixture = new UserTaskTestFixture(options); + var manager = fixture.ManagerActor(); + var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject, WithBearerInvitation()); + await fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), manager); + + for (var attempt = 0; attempt < 2; attempt++) + { + var due = Assert.Single(await fixture.Outbox.DequeueDueAsync(10)); + await fixture.Outbox.RescheduleAsync(due.Id, fixture.Clock.UtcNow); + } + + // The schedule is exhausted, so the encrypted secret is dropped rather than retried forever. + var third = Assert.Single(await fixture.Outbox.DequeueDueAsync(10)); + await fixture.Outbox.RescheduleAsync(third.Id, fixture.Clock.UtcNow); + Assert.Empty(await fixture.Outbox.DequeueDueAsync(10)); + } + + [Fact] + public async Task GuestActorResolver_ReadsOnlyItsOwnAuthorizationScheme() + { + var manager = _fixture.ManagerActor(); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); + var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); + + Assert.Equal(credential, UserTaskGuestActorResolver.ReadCredential($"UserTaskSession {credential}")); + Assert.Null(UserTaskGuestActorResolver.ReadCredential($"Bearer {credential}")); + Assert.Null(UserTaskGuestActorResolver.ReadCredential(null)); + Assert.Null(await _fixture.GuestActors.ResolveAsync("not-a-session")); + } +} diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs new file mode 100644 index 000000000..40b17aece --- /dev/null +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs @@ -0,0 +1,191 @@ +using Elsa.Common; +using Elsa.UserTasks.Contracts; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Repositories; +using Elsa.UserTasks.Services; +using Elsa.Workflows; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; + +namespace Elsa.UserTasks.UnitTests; + +/// +/// Wires the Core User Tasks slice against in-memory doubles. Every test shares this arrangement so a +/// behavior change surfaces in one place instead of being restated in each test body. +/// +public sealed class UserTaskTestFixture +{ + public const string TenantId = "tenant"; + + public InMemoryUserTaskRepository Repository { get; } = new(); + public TestClock Clock { get; } = new(); + public TestIdentityGenerator Identity { get; } = new(); + public TestResumer Resumer { get; } = new(); + public TestSink Sink { get; } = new(); + public CapturingDispatcher Dispatcher { get; } = new(); + public DefaultUserTaskAccessPolicy Policy { get; } = new(); + public IOptions Options { get; } + public InMemoryUserTaskGuestSessionIssuer GuestSessions { get; } + public InMemoryUserTaskInvitationOutbox Outbox { get; } + public DefaultUserTaskInvitationService Invitations { get; } + public UserTaskGuestActorResolver GuestActors { get; } + public DefaultUserTaskManager Manager { get; } + public DefaultUserTaskProjectionService Projection { get; } + + public UserTaskTestFixture(UserTasksOptions? options = null, IUserTaskInvitationVerifier? verifier = null, params IUserTaskFormProvider[] formProviders) + { + Options = Microsoft.Extensions.Options.Options.Create(options ?? new UserTasksOptions()); + GuestSessions = new(Clock, Options); + Outbox = new(new PassthroughDataProtectionProvider(), Clock, Options); + Manager = new(Repository, Policy, formProviders, Resumer, Sink, Identity, Clock, Options); + Projection = new(Manager, Repository, Sink, GuestSessions, Identity, Clock); + Invitations = new(Repository, Policy, Outbox, verifier ?? new DefaultUserTaskInvitationVerifier(), GuestSessions, Sink, Identity, Clock, Options); + GuestActors = new(GuestSessions); + } + + public UserTaskActor Actor(string id, params string[] permissions) => + new(new(TenantId, "oidc", UserTaskParticipantType.User, id), []) + { + Permissions = new HashSet(permissions.Length > 0 ? permissions : ["read:user-tasks", "claim:user-tasks", "complete:user-tasks"], StringComparer.OrdinalIgnoreCase) + }; + + public UserTaskActor ManagerActor(string id = "manager-1") => Actor(id) with + { + IsManager = true, + Permissions = new HashSet([ + "read:user-tasks", "claim:user-tasks", "complete:user-tasks", "assign:user-tasks", + "update:user-tasks", "cancel:user-tasks", "invite:user-tasks", "manage:user-tasks" + ], StringComparer.OrdinalIgnoreCase) + }; + + public UserTaskMaterialization Materialization(ParticipantReference candidate, Func? configure = null) + { + var definition = new UserTaskDefinitionSnapshot + { + Title = "Approval", + CandidateUsers = [candidate], + Instructions = "private instructions", + Actions = [new("Approve", "Approve"), new("Reject", "Reject")] + }; + return new(TenantId, "definition", "instance", "activity", "bookmark", + configure?.Invoke(definition) ?? definition, [], [], Clock.UtcNow, "task-1", + "Approval workflow", 3, "correlation-1"); + } + + /// Projects a task and returns it, so tests can start from a committed projection in one line. + public async Task ProjectAsync(ParticipantReference candidate, Func? configure = null) => + (await Manager.ProjectAsync(Materialization(candidate, configure))).Task; + + /// Drives the guest flow end to end and returns the resolved guest actor. + public async Task<(UserTaskActor Guest, string Credential)> IssueGuestSessionAsync(UserTask task, UserTaskActor manager, string verifierName = "bearer", params string[] actions) + { + var issued = await Invitations.IssueAsync(TenantId, task.Id, new(task.Revision, verifierName, actions.Length > 0 ? actions : ["Approve"]), manager); + if (issued == null) + throw new InvalidOperationException("The invitation could not be issued."); + + await DrainOutboxAsync(); + var verified = await Invitations.VerifyAsync(new(Dispatcher.Token!)); + if (!verified.Succeeded) + throw new InvalidOperationException("The invitation could not be verified."); + + var guest = await GuestActors.ResolveAsync(verified.SessionToken!) + ?? throw new InvalidOperationException("The guest session did not resolve."); + return (guest, verified.SessionToken!); + } + + /// Runs the delivery step the hosted worker would normally perform. + public async Task DrainOutboxAsync() + { + foreach (var delivery in await Outbox.DequeueDueAsync(50)) + { + await Dispatcher.DispatchAsync(delivery); + await Outbox.CompleteAsync(delivery.Id); + } + } + + public sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; + } + + public sealed class TestIdentityGenerator : IIdentityGenerator + { + private int _counter; + public string GenerateId() => $"id-{Interlocked.Increment(ref _counter)}"; + } + + public sealed class TestResumer : IUserTaskWorkflowResumer + { + public UserTaskStimulus? LastStimulus { get; private set; } + + public Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default) + { + LastStimulus = stimulus; + return Task.CompletedTask; + } + } + + public sealed class TestSink : IUserTaskNotificationSink + { + public List Published { get; } = []; + + public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default) + { + Published.Add(notification); + return Task.CompletedTask; + } + } + + public sealed class CapturingDispatcher : IUserTaskInvitationDispatcher + { + public string? Token { get; private set; } + public List Tokens { get; } = []; + + public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default) + { + Token = delivery.Token; + Tokens.Add(delivery.Token); + return Task.CompletedTask; + } + } + + /// Accepts any challenge. Used to exercise the non-bearer verification path. + public sealed class AcceptingVerifier(string? subject = null) : IUserTaskInvitationVerifier + { + public Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default) => + Task.FromResult(new UserTaskInvitationVerificationResult(challenge.Code == "correct", Subject: subject)); + } + + /// + /// Data Protection stand-in. The outbox's contract is only that the ciphertext round-trips, so the test + /// double marks the payload rather than pulling the full key-management stack into a unit test. + /// + private sealed class PassthroughDataProtectionProvider : IDataProtectionProvider, IDataProtector + { + private const string Marker = "protected:"; + + public IDataProtector CreateProtector(string purpose) => this; + public byte[] Protect(byte[] plaintext) => System.Text.Encoding.UTF8.GetBytes(Marker + Convert.ToBase64String(plaintext)); + + public byte[] Unprotect(byte[] protectedData) + { + var value = System.Text.Encoding.UTF8.GetString(protectedData); + if (!value.StartsWith(Marker, StringComparison.Ordinal)) + throw new System.Security.Cryptography.CryptographicException("The payload was not protected by this provider."); + return Convert.FromBase64String(value[Marker.Length..]); + } + } +} + +/// A form provider that returns a fixed descriptor set, including one masked, revealable field. +public sealed class TestFormProvider(params UserTaskFormFieldDescriptor[] fields) : IUserTaskFormProvider +{ + public string Name => "test"; + + public Task ResolveAsync(UserTaskFormReference reference, CancellationToken cancellationToken = default) => + Task.FromResult(new(reference, "v1", new Dictionary()) { Fields = fields }); + + public Task ValidateAndNormalizeAsync(ResolvedUserTaskForm form, string actionKey, System.Text.Json.JsonElement data, CancellationToken cancellationToken = default) => + Task.FromResult(new UserTaskFormValidationResult(true, data)); +} diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs new file mode 100644 index 000000000..8b5bcaf1b --- /dev/null +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs @@ -0,0 +1,326 @@ +using System.Security.Claims; +using System.Text.Json; +using Elsa.UserTasks.Models; +using Elsa.UserTasks.Options; +using Elsa.UserTasks.Repositories; +using Elsa.UserTasks.Services; +using Xunit; + +namespace Elsa.UserTasks.UnitTests; + +public class UserTaskTests +{ + private readonly UserTaskTestFixture _fixture = new(); + + [Fact] + public async Task ClaimsResolver_PreservesExternalGroupClaimValues() + { + var resolver = new DefaultClaimsIdentityResolver(Microsoft.Extensions.Options.Options.Create(new UserTasksOptions { DefaultTenantId = "tenant", DefaultProvider = "oidc" })); + var principal = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", "user-1"), + new Claim("groups", "group,with;delimiters"), + new Claim("groups", "finance"), + new Claim("permission", "read:user-tasks") + ], "test")); + + var actor = await resolver.ResolveAsync(principal); + + Assert.NotNull(actor); + Assert.Contains(actor.Groups, x => x.Id == "group,with;delimiters"); + Assert.Contains(actor.Groups, x => x.Id == "finance"); + Assert.Equal("tenant", actor.Subject.TenantId); + Assert.Equal("oidc", actor.Subject.Provider); + } + + [Fact] + public async Task Repository_CursorIsStableAndTotalCountIgnoresCursor() + { + var repository = new InMemoryUserTaskRepository(); + for (var i = 0; i < 3; i++) + await repository.AddProjectionAsync(new() { Id = $"task-{i}", TenantId = "tenant", Title = $"Task {i}", CreatedAt = DateTimeOffset.UtcNow.AddMinutes(i) }); + + var first = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, IncludeTotalCount = true }); + var second = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Cursor = first.NextCursor, IncludeTotalCount = true }); + + Assert.Equal(3, first.TotalCount); + Assert.Equal(3, second.TotalCount); + Assert.Single(second.Items); + Assert.DoesNotContain(first.Items.Select(x => x.Id), x => second.Items.Any(y => y.Id == x)); + } + + [Theory] + [InlineData("created", false)] + [InlineData("created", true)] + [InlineData("due", false)] + [InlineData("due", true)] + [InlineData("priority", false)] + [InlineData("priority", true)] + [InlineData("title", false)] + [InlineData("title", true)] + public async Task Repository_CursorCoversSupportedSortsAndDirections(string sort, bool descending) + { + var repository = new InMemoryUserTaskRepository(); + var now = DateTimeOffset.UtcNow; + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Alpha", Priority = 10, DueAt = now.AddHours(1), CreatedAt = now.AddMinutes(1) }); + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Beta", Priority = 50, DueAt = null, CreatedAt = now.AddMinutes(2) }); + await repository.AddProjectionAsync(new() { Id = "task-c", TenantId = "tenant", Title = "Gamma", Priority = 90, DueAt = now.AddHours(2), CreatedAt = now.AddMinutes(3) }); + + var first = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, IncludeTotalCount = true }); + var second = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, Cursor = first.NextCursor, IncludeTotalCount = true }); + var ids = first.Items.Concat(second.Items).Select(x => x.Id).ToArray(); + + Assert.Equal(3, first.TotalCount); + Assert.Equal(3, second.TotalCount); + Assert.Equal(3, ids.Distinct().Count()); + } + + [Fact] + public async Task Manager_HidesProtectedFieldsUntilClaimAndCompletesAfterBookmarkFinalization() + { + var actor = _fixture.Actor("user-1"); + var task = await _fixture.ProjectAsync(actor.Subject); + + var candidateDetail = await _fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor); + Assert.NotNull(candidateDetail); + Assert.Null(candidateDetail.Instructions); + Assert.False(candidateDetail.Disclosure.CanViewProtected); + + var claimed = await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), actor); + Assert.True(claimed.Accepted); + var assignedDetail = await _fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor); + Assert.Equal("private instructions", assignedDetail!.Instructions); + Assert.True(assignedDetail.Disclosure.CanViewProtected); + Assert.NotEmpty(assignedDetail.Actions); + + var completing = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor); + Assert.True(completing.Accepted); + Assert.Equal(UserTaskStatus.Completing, completing.Task.Status); + Assert.Equal(actor.Subject, _fixture.Resumer.LastStimulus!.CompletedBy); + + var retry = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor); + Assert.True(retry.Accepted); + Assert.Equal(completing.Operation.OperationId, retry.Operation.OperationId); + + var divergent = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Reject"), actor); + Assert.False(divergent.Accepted); + Assert.Equal("idempotency-conflict", divergent.ConflictCode); + + await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(UserTaskTestFixture.TenantId, task.Id, task.BookmarkId, _fixture.Clock.UtcNow)); + var completed = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id); + Assert.Equal(UserTaskStatus.Completed, completed!.Status); + + var terminalRetry = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor); + Assert.True(terminalRetry.Accepted); + Assert.Equal(completing.Operation.OperationId, terminalRetry.Operation.OperationId); + } + + [Fact] + public async Task Policy_RequiresOperationPermissionInAdditionToCandidateRelationship() + { + var actor = _fixture.Actor("user-1", "read:user-tasks"); + var task = await _fixture.ProjectAsync(actor.Subject); + + var claim = await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-no-permission"), actor); + + Assert.False(claim.Accepted); + Assert.Equal("forbidden", claim.ConflictCode); + } + + [Fact] + public async Task Policy_ManagerFlagAloneDoesNotGrantManagementWithoutThePermission() + { + var actor = _fixture.Actor("user-1", "read:user-tasks", "assign:user-tasks") with { IsManager = true }; + var candidate = _fixture.Actor("user-2"); + var task = await _fixture.ProjectAsync(candidate.Subject); + + // The host set IsManager but never granted manage:user-tasks, so management must still be refused. + Assert.False(await _fixture.Policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Assign)); + Assert.Null(await _fixture.Policy.CreateScopeAsync(actor, UserTaskQueryScopeKind.All)); + } + + [Fact] + public async Task Policy_WildcardPermissionGrantSatisfiesManagementChecks() + { + var root = _fixture.Actor("root", "*") with { IsManager = true }; + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject); + + Assert.True(await _fixture.Policy.AuthorizeAsync(task, root, UserTaskAccessOperation.Manage)); + var scope = await _fixture.Policy.CreateScopeAsync(root, UserTaskQueryScopeKind.All); + Assert.NotNull(scope); + Assert.True(scope.IsManager); + } + + [Theory] + [InlineData(UserTaskQueryScopeKind.All)] + [InlineData(UserTaskQueryScopeKind.NeedsAttention)] + public async Task Query_ManagerOnlyScopesAreDeniedRatherThanSilentlyNarrowed(UserTaskQueryScopeKind kind) + { + var actor = _fixture.Actor("user-1"); + await _fixture.ProjectAsync(actor.Subject); + + // A denial must be distinguishable from an empty page, or the endpoint would answer 200 with no rows. + Assert.Null(await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, kind, actor)); + } + + [Fact] + public async Task Query_AvailableScopeExcludesClaimedTasksAndExcludedCandidates() + { + var candidate = _fixture.Actor("user-1"); + var excluded = _fixture.Actor("user-2"); + var open = await _fixture.ProjectAsync(candidate.Subject, definition => definition with + { + CandidateUsers = [candidate.Subject, excluded.Subject], + ExcludedUsers = [excluded.Subject] + }); + + var availableToCandidate = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, candidate); + Assert.NotNull(availableToCandidate); + Assert.Single(availableToCandidate.Items); + + var availableToExcluded = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, excluded); + Assert.NotNull(availableToExcluded); + Assert.Empty(availableToExcluded.Items); + + await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, open.Id, new(1, "claim-1"), candidate); + + var afterClaim = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, candidate); + Assert.NotNull(afterClaim); + Assert.Empty(afterClaim.Items); + + var assigned = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Assigned, candidate); + Assert.NotNull(assigned); + Assert.Single(assigned.Items); + } + + [Fact] + public async Task Summary_DoesNotDiscloseCandidateIdentitiesOrBlockingHealthToParticipants() + { + var candidate = _fixture.Actor("user-1"); + var peer = _fixture.Actor("user-2"); + var task = await _fixture.ProjectAsync(candidate.Subject, definition => definition with { CandidateUsers = [candidate.Subject, peer.Subject] }); + task.HealthSeverity = UserTaskHealthSeverity.Advisory; + task.HealthCode = "advisory-code"; + + var summary = await UserTaskModelMapper.ToSummaryAsync(task, candidate, _fixture.Policy); + + Assert.Equal("2 users", summary.CandidateSummary); + Assert.DoesNotContain(peer.Subject.Id, JsonSerializer.Serialize(summary)); + Assert.Null(summary.HealthSeverity); + Assert.Null(summary.HealthCode); + } + + [Fact] + public async Task Summary_CarriesWorkflowContextForAuthorizedParticipants() + { + var actor = _fixture.Actor("user-1"); + var task = await _fixture.ProjectAsync(actor.Subject); + + var summary = await UserTaskModelMapper.ToSummaryAsync(task, actor, _fixture.Policy); + + Assert.Equal("Approval workflow", summary.WorkflowDefinitionName); + Assert.Equal(3, summary.WorkflowDefinitionVersion); + Assert.Equal("correlation-1", summary.WorkflowInstanceReference); + } + + [Fact] + public async Task Events_AreWithheldFromParticipantsWithoutProtectedAccess() + { + var candidate = _fixture.Actor("user-1"); + var task = await _fixture.ProjectAsync(candidate.Subject); + + var beforeClaim = await _fixture.Manager.GetEventsAsync(UserTaskTestFixture.TenantId, task.Id, null, 50, candidate); + Assert.NotNull(beforeClaim); + Assert.Empty(beforeClaim.Items); + + await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), candidate); + + var afterClaim = await _fixture.Manager.GetEventsAsync(UserTaskTestFixture.TenantId, task.Id, null, 50, candidate); + Assert.NotNull(afterClaim); + Assert.NotEmpty(afterClaim.Items); + // Actor identifiers never reach the audit projection; only a display name may. + Assert.DoesNotContain(candidate.Subject.Id, JsonSerializer.Serialize(afterClaim)); + } + + [Fact] + public async Task Detail_WithholdsMaskedFieldValuesUntilAnExplicitRevealAndDoesNotConsumeTheRevision() + { + var form = new TestFormProvider( + new("note", "Note"), + new("iban", "IBAN", Masked: true, CanReveal: true), + new("pin", "PIN", Masked: true)); + var fixture = new UserTaskTestFixture(formProviders: form); + var actor = fixture.Actor("user-1"); + var payload = JsonDocument.Parse("""{"note":"visible","iban":"NL00BANK","pin":"1234"}""").RootElement; + var task = await fixture.ProjectAsync(actor.Subject, definition => definition with + { + FormReference = new("test", "invoice"), + TaskData = payload + }); + await fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), actor); + + var detail = await fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor); + var fields = detail!.Form!.Fields.ToDictionary(x => x.Key); + Assert.Equal("visible", fields["note"].Value?.GetString()); + Assert.Null(fields["iban"].Value); + Assert.Null(fields["pin"].Value); + Assert.True(fields["iban"].CanReveal); + Assert.False(fields["pin"].CanReveal); + Assert.DoesNotContain("NL00BANK", JsonSerializer.Serialize(detail.Form)); + + var revisionBeforeReveal = detail.Revision; + var revealed = await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", actor); + Assert.Equal("NL00BANK", revealed?.GetString()); + + // A field the provider did not mark revealable is indistinguishable from an unknown one. + Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "pin", actor)); + Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "nope", actor)); + + var afterReveal = await fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id); + // The reveal is audited but must not consume the concurrency token, or the caller's next command + // would conflict for no reason. + Assert.Equal(revisionBeforeReveal, afterReveal!.Revision); + Assert.Contains(afterReveal.Events, x => x.EventType == "FieldRevealed"); + Assert.DoesNotContain("NL00BANK", JsonSerializer.Serialize(afterReveal.Events)); + + var completion = await fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, + new(revisionBeforeReveal, "complete-1", "Approve", payload), actor); + Assert.True(completion.Accepted); + } + + [Fact] + public async Task RevealField_IsRefusedForCallersWithoutProtectedAccess() + { + var form = new TestFormProvider(new UserTaskFormFieldDescriptor("iban", "IBAN", Masked: true, CanReveal: true)); + var fixture = new UserTaskTestFixture(formProviders: form); + var candidate = fixture.Actor("user-1"); + var task = await fixture.ProjectAsync(candidate.Subject, definition => definition with + { + FormReference = new("test", "invoice"), + TaskData = JsonDocument.Parse("""{"iban":"NL00BANK"}""").RootElement + }); + + // The candidate can see the task but has not claimed it, so protected access is not granted yet. + Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", candidate)); + + await fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), candidate); + Assert.NotNull(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", candidate)); + } + + [Fact] + public async Task DueService_ReservesTimeoutAndResumesWorkflow() + { + var dueAt = _fixture.Clock.UtcNow.AddMinutes(-1); + var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, + definition => definition with { DueAt = dueAt, EnableTimeoutOutcome = true }); + var due = new DefaultUserTaskDueService(_fixture.Repository, _fixture.Manager, _fixture.Sink, _fixture.Identity, _fixture.Clock); + + Assert.Equal(1, await due.MarkOverdueAsync(UserTaskTestFixture.TenantId, _fixture.Clock.UtcNow)); + var timingOut = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id); + Assert.Equal(UserTaskStatus.TimingOut, timingOut!.Status); + Assert.Equal("Timeout", _fixture.Resumer.LastStimulus!.ActionKey); + + await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(UserTaskTestFixture.TenantId, task.Id, task.BookmarkId, _fixture.Clock.UtcNow)); + var timedOut = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id); + Assert.Equal(UserTaskStatus.TimedOut, timedOut!.Status); + } +}