Add architecture and practices documentation
Introduce comprehensive documentation covering architecture (ARCHITECTURE.md), concerns (CONCERNS.md), coding conventions (CONVENTIONS.md), integrations (INTEGRATIONS.md), technology stack (STACK.md), codebase structure (STRUCTURE.md), and testing patterns (TESTING.md). Enhance testing with additional test cases for external sign-in flows, ensuring accurate timestamp recording for identity links.
This commit is contained in:
parent
ff10a68100
commit
5429008d98
|
|
@ -3,11 +3,83 @@
|
|||
.DS_Store
|
||||
.agents
|
||||
.agents/skills
|
||||
.agents/skills/elsa-release
|
||||
.agents/skills/elsa-release/SKILL.md
|
||||
.agents/skills/elsa-release/agents
|
||||
.agents/skills/elsa-release/scripts
|
||||
.agents/skills/elsa-release-announcements
|
||||
.agents/skills/elsa-release-announcements/SKILL.md
|
||||
.agents/skills/elsa-release-announcements/agents
|
||||
.agents/skills/elsa-release-announcements/scripts
|
||||
.agents/skills/elsa-roadmap-refresh
|
||||
.agents/skills/elsa-roadmap-refresh/SKILL.md
|
||||
.agents/skills/elsa-roadmap-refresh/agents
|
||||
.agents/skills/greploop
|
||||
.agents/skills/greploop/LICENSE
|
||||
.agents/skills/greploop/SKILL.md
|
||||
.agents/skills/greploop/references
|
||||
.agents/skills/speckit-analyze
|
||||
.agents/skills/speckit-analyze/SKILL.md
|
||||
.agents/skills/speckit-checklist
|
||||
.agents/skills/speckit-checklist/SKILL.md
|
||||
.agents/skills/speckit-clarify
|
||||
.agents/skills/speckit-clarify/SKILL.md
|
||||
.agents/skills/speckit-constitution
|
||||
.agents/skills/speckit-constitution/SKILL.md
|
||||
.agents/skills/speckit-git-commit
|
||||
.agents/skills/speckit-git-commit/SKILL.md
|
||||
.agents/skills/speckit-git-feature
|
||||
.agents/skills/speckit-git-feature/SKILL.md
|
||||
.agents/skills/speckit-git-initialize
|
||||
.agents/skills/speckit-git-initialize/SKILL.md
|
||||
.agents/skills/speckit-git-remote
|
||||
.agents/skills/speckit-git-remote/SKILL.md
|
||||
.agents/skills/speckit-git-validate
|
||||
.agents/skills/speckit-git-validate/SKILL.md
|
||||
.agents/skills/speckit-implement
|
||||
.agents/skills/speckit-implement/SKILL.md
|
||||
.agents/skills/speckit-plan
|
||||
.agents/skills/speckit-plan/SKILL.md
|
||||
.agents/skills/speckit-specify
|
||||
.agents/skills/speckit-specify/SKILL.md
|
||||
.agents/skills/speckit-tasks
|
||||
.agents/skills/speckit-tasks/SKILL.md
|
||||
.agents/skills/speckit-taskstoissues
|
||||
.agents/skills/speckit-taskstoissues/SKILL.md
|
||||
.claude
|
||||
.claude/skills
|
||||
.claude/skills/greploop
|
||||
.claude/skills/greploop/LICENSE
|
||||
.claude/skills/greploop/SKILL.md
|
||||
.claude/skills/greploop/references
|
||||
.claude/skills/speckit-analyze
|
||||
.claude/skills/speckit-analyze/SKILL.md
|
||||
.claude/skills/speckit-checklist
|
||||
.claude/skills/speckit-checklist/SKILL.md
|
||||
.claude/skills/speckit-clarify
|
||||
.claude/skills/speckit-clarify/SKILL.md
|
||||
.claude/skills/speckit-constitution
|
||||
.claude/skills/speckit-constitution/SKILL.md
|
||||
.claude/skills/speckit-git-commit
|
||||
.claude/skills/speckit-git-commit/SKILL.md
|
||||
.claude/skills/speckit-git-feature
|
||||
.claude/skills/speckit-git-feature/SKILL.md
|
||||
.claude/skills/speckit-git-initialize
|
||||
.claude/skills/speckit-git-initialize/SKILL.md
|
||||
.claude/skills/speckit-git-remote
|
||||
.claude/skills/speckit-git-remote/SKILL.md
|
||||
.claude/skills/speckit-git-validate
|
||||
.claude/skills/speckit-git-validate/SKILL.md
|
||||
.claude/skills/speckit-implement
|
||||
.claude/skills/speckit-implement/SKILL.md
|
||||
.claude/skills/speckit-plan
|
||||
.claude/skills/speckit-plan/SKILL.md
|
||||
.claude/skills/speckit-specify
|
||||
.claude/skills/speckit-specify/SKILL.md
|
||||
.claude/skills/speckit-tasks
|
||||
.claude/skills/speckit-tasks/SKILL.md
|
||||
.claude/skills/speckit-taskstoissues
|
||||
.claude/skills/speckit-taskstoissues/SKILL.md
|
||||
.claude/worktrees
|
||||
.config
|
||||
.config/dotnet-tools.json
|
||||
|
|
@ -27,6 +99,11 @@
|
|||
.github/agents/speckit.checklist.agent.md
|
||||
.github/agents/speckit.clarify.agent.md
|
||||
.github/agents/speckit.constitution.agent.md
|
||||
.github/agents/speckit.git.commit.agent.md
|
||||
.github/agents/speckit.git.feature.agent.md
|
||||
.github/agents/speckit.git.initialize.agent.md
|
||||
.github/agents/speckit.git.remote.agent.md
|
||||
.github/agents/speckit.git.validate.agent.md
|
||||
.github/agents/speckit.implement.agent.md
|
||||
.github/agents/speckit.plan.agent.md
|
||||
.github/agents/speckit.specify.agent.md
|
||||
|
|
@ -40,6 +117,11 @@
|
|||
.github/prompts/speckit.checklist.prompt.md
|
||||
.github/prompts/speckit.clarify.prompt.md
|
||||
.github/prompts/speckit.constitution.prompt.md
|
||||
.github/prompts/speckit.git.commit.prompt.md
|
||||
.github/prompts/speckit.git.feature.prompt.md
|
||||
.github/prompts/speckit.git.initialize.prompt.md
|
||||
.github/prompts/speckit.git.remote.prompt.md
|
||||
.github/prompts/speckit.git.validate.prompt.md
|
||||
.github/prompts/speckit.implement.prompt.md
|
||||
.github/prompts/speckit.plan.prompt.md
|
||||
.github/prompts/speckit.specify.prompt.md
|
||||
|
|
@ -52,9 +134,11 @@
|
|||
.github/workflows/claude.yml
|
||||
.github/workflows/copilot-setup-steps.yml
|
||||
.github/workflows/docker-ca.yml
|
||||
.github/workflows/greploop.yml
|
||||
.github/workflows/packages.yml
|
||||
.github/workflows/pr.yml
|
||||
.github/workflows/stale.yml
|
||||
.github/workflows/update-wiki.yml
|
||||
.gitignore
|
||||
.idea
|
||||
.idea/.idea.Elsa
|
||||
|
|
@ -68,13 +152,36 @@
|
|||
.idea/.idea.Elsa/.idea/db-forest-config.xml
|
||||
.idea/.idea.Elsa/.idea/encodings.xml
|
||||
.idea/.idea.Elsa/.idea/indexLayout.xml
|
||||
.idea/.idea.Elsa/.idea/jsonSchemas.xml
|
||||
.idea/.idea.Elsa/.idea/projectSettingsUpdater.xml
|
||||
.idea/.idea.Elsa/.idea/vcs.xml
|
||||
.idea/.idea.Elsa/.idea/workspace.xml
|
||||
.nuke
|
||||
.nuke/build.schema.json
|
||||
.nuke/parameters.json
|
||||
.nuke/temp
|
||||
.nuke/temp/build.2026-07-14_22-59-48.log
|
||||
.nuke/temp/build.log
|
||||
.specify
|
||||
.specify/extensions
|
||||
.specify/extensions/.registry
|
||||
.specify/extensions/git
|
||||
.specify/extensions/git/README.md
|
||||
.specify/extensions/git/commands
|
||||
.specify/extensions/git/config-template.yml
|
||||
.specify/extensions/git/extension.yml
|
||||
.specify/extensions/git/git-config.yml
|
||||
.specify/extensions/git/scripts
|
||||
.specify/extensions.yml
|
||||
.specify/feature.json
|
||||
.specify/init-options.json
|
||||
.specify/integration.json
|
||||
.specify/integrations
|
||||
.specify/integrations/claude
|
||||
.specify/integrations/claude/scripts
|
||||
.specify/integrations/claude.manifest.json
|
||||
.specify/integrations/codex.manifest.json
|
||||
.specify/integrations/speckit.manifest.json
|
||||
.specify/memory
|
||||
.specify/memory/constitution.md
|
||||
.specify/scripts
|
||||
|
|
@ -83,6 +190,7 @@
|
|||
.specify/scripts/bash/common.sh
|
||||
.specify/scripts/bash/create-new-feature.sh
|
||||
.specify/scripts/bash/setup-plan.sh
|
||||
.specify/scripts/bash/setup-tasks.sh
|
||||
.specify/scripts/bash/update-agent-context.sh
|
||||
.specify/templates
|
||||
.specify/templates/agent-file-template.md
|
||||
|
|
@ -91,115 +199,7 @@
|
|||
.specify/templates/plan-template.md
|
||||
.specify/templates/spec-template.md
|
||||
.specify/templates/tasks-template.md
|
||||
.vscode
|
||||
.vscode/settings.json
|
||||
CONTRIBUTING.md
|
||||
Directory.Build.props
|
||||
Directory.Build.targets
|
||||
Directory.Packages.props
|
||||
Elsa.sln
|
||||
Elsa.sln.DotSettings
|
||||
Elsa.sln.DotSettings.user
|
||||
LICENSE
|
||||
NuGet.Config
|
||||
README.md
|
||||
announcements
|
||||
announcements/discord-3.6.3.md
|
||||
announcements/discord-3.7.1.md
|
||||
announcements/elsa-3.6.3-announcement-pack.md
|
||||
announcements/elsa-3.7.1-announcement-pack.md
|
||||
artifacts
|
||||
artifacts/.DS_Store
|
||||
artifacts/package-manifest-check
|
||||
artifacts/package-manifest-check/Elsa.Caching.1.0.0.nupkg
|
||||
artifacts/package-manifest-check/Elsa.Caching.1.0.0.snupkg
|
||||
artifacts/tls-smoke
|
||||
artifacts/tls-smoke/TlsSmoke
|
||||
artifacts/tls-smoke/TlsSmoke.deps.json
|
||||
artifacts/tls-smoke/TlsSmoke.dll
|
||||
artifacts/tls-smoke/TlsSmoke.pdb
|
||||
artifacts/tls-smoke/TlsSmoke.runtimeconfig.json
|
||||
artifacts/tls-smoke/TlsSmoke.xml
|
||||
build.cmd
|
||||
build.ps1
|
||||
build.sh
|
||||
design
|
||||
design/.DS_Store
|
||||
design/artwork
|
||||
design/artwork/android-elsa-portrait.png
|
||||
design/artwork/creative-elsa-animation.gif
|
||||
design/artwork/elsa-3.1-art.png
|
||||
design/artwork/elsa-logo-art.png
|
||||
design/artwork/elsa-logo-art.psd
|
||||
design/artwork/elsa-v3-avatar.png
|
||||
design/artwork/elsa-waves.mp4
|
||||
design/artwork/glass.ai
|
||||
design/custom
|
||||
design/custom/1x
|
||||
design/custom/1x/Artboard 1.png
|
||||
design/custom/2x
|
||||
design/custom/2x/Artboard 1@2x.png
|
||||
design/custom/2x/elsa-logo.png
|
||||
design/custom/icon.ai
|
||||
design/custom/icon.png
|
||||
design/custom/icon.psd
|
||||
design/elsa-workflows-workflow-engine-for-dotnet.png
|
||||
design/github-social-preview-banner-for-elsa.psd
|
||||
design/icon
|
||||
design/icon/dark
|
||||
design/icon/dark/android-icon-144x144.png
|
||||
design/icon/dark/android-icon-192x192.png
|
||||
design/icon/dark/android-icon-36x36.png
|
||||
design/icon/dark/android-icon-48x48.png
|
||||
design/icon/dark/android-icon-72x72.png
|
||||
design/icon/dark/android-icon-96x96.png
|
||||
design/icon/dark/apple-icon-114x114.png
|
||||
design/icon/dark/apple-icon-120x120.png
|
||||
design/icon/dark/apple-icon-144x144.png
|
||||
design/icon/dark/apple-icon-152x152.png
|
||||
design/icon/dark/apple-icon-180x180.png
|
||||
design/icon/dark/apple-icon-57x57.png
|
||||
design/icon/dark/apple-icon-60x60.png
|
||||
design/icon/dark/apple-icon-72x72.png
|
||||
design/icon/dark/apple-icon-76x76.png
|
||||
design/icon/dark/apple-icon-precomposed.png
|
||||
design/icon/dark/apple-icon.png
|
||||
design/icon/dark/browserconfig.xml
|
||||
design/icon/dark/favicon-16x16.png
|
||||
design/icon/dark/favicon-32x32.png
|
||||
design/icon/dark/favicon-96x96.png
|
||||
design/icon/dark/favicon.ico
|
||||
design/icon/dark/manifest.json
|
||||
design/icon/dark/ms-icon-144x144.png
|
||||
design/icon/dark/ms-icon-150x150.png
|
||||
design/icon/dark/ms-icon-310x310.png
|
||||
design/icon/dark/ms-icon-70x70.png
|
||||
design/icon/light
|
||||
design/icon/light/android-icon-144x144.png
|
||||
design/icon/light/android-icon-192x192.png
|
||||
design/icon/light/android-icon-36x36.png
|
||||
design/icon/light/android-icon-48x48.png
|
||||
design/icon/light/android-icon-72x72.png
|
||||
design/icon/light/android-icon-96x96.png
|
||||
design/icon/light/apple-icon-114x114.png
|
||||
design/icon/light/apple-icon-120x120.png
|
||||
design/icon/light/apple-icon-144x144.png
|
||||
design/icon/light/apple-icon-152x152.png
|
||||
design/icon/light/apple-icon-180x180.png
|
||||
design/icon/light/apple-icon-57x57.png
|
||||
design/icon/light/apple-icon-60x60.png
|
||||
design/icon/light/apple-icon-72x72.png
|
||||
design/icon/light/apple-icon-76x76.png
|
||||
design/icon/light/apple-icon-precomposed.png
|
||||
design/icon/light/apple-icon.png
|
||||
design/icon/light/browserconfig.xml
|
||||
design/icon/light/favicon-16x16.png
|
||||
design/icon/light/favicon-32x32.png
|
||||
design/icon/light/favicon-96x96.png
|
||||
design/icon/light/favicon.ico
|
||||
design/icon/light/manifest.json
|
||||
design/icon/light/ms-icon-144x144.png
|
||||
design/icon/light/ms-icon-150x150.png
|
||||
.specify/workflows
|
||||
|
||||
=== STACK DETECTION (manifest files) ===
|
||||
|
||||
|
|
@ -212,6 +212,8 @@ MinimumVisualStudioVersion = 10.0.40219.1
|
|||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{61017E64-6D00-49CB-9E81-5002DC8F7D5F}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\Directory.Build.props = src\Directory.Build.props
|
||||
src\PackageManifestHints.cs = src\PackageManifestHints.cs
|
||||
src\PackageManifest.props = src\PackageManifest.props
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{C6658DE0-2B2F-47F0-BB61-2CA66D435C09}"
|
||||
|
|
@ -227,8 +229,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D
|
|||
README.md = README.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{90031D64-CA0F-46D0-9AF4-8DC023A5FFCD}"
|
||||
|
|
@ -285,7 +285,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "expressions", "expressions"
|
|||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "security", "security", "{5948B0A5-7873-4DBB-BA03-EB283D6EA91B}"
|
||||
|
||||
[TRUNCATED] Showing first 80 of 1336 lines.
|
||||
[TRUNCATED] Showing first 80 of 2359 lines.
|
||||
|
||||
=== ENTRY POINTS ===
|
||||
No common entry points found. Check 'main' or 'scripts.start' in manifest files above.
|
||||
|
|
@ -298,87 +298,90 @@ No .env.example or .env.template found. Identify required environment variables
|
|||
|
||||
=== TODO / FIXME / HACK (production code only, test dirs excluded) ===
|
||||
src/common/Elsa.Mediator/Middleware/Notification/Components/NotificationLoggingMiddleware.cs:20: // TODO: Log notification.
|
||||
src/modules/Elsa.Http.Webhooks/ShellFeatures/WebhooksFeature.cs:28: // TODO: Configure HTTP client with configurable parameters and resilience.
|
||||
src/modules/Elsa.Alterations/Services/DefaultAlterationRunner.cs:91: // TODO: Importing back into the workflow runtime makes sense, but this also causes another SAVE ction of the workflow instance in the DB, which also happens in the previous step during the commit action.
|
||||
src/modules/Elsa.Expressions.Liquid/Services/LiquidTemplateManager.cs:61: // TODO: add signal based cache invalidation.
|
||||
src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs:8: // TODO: found a library to use a Html Content Parser and use a complexe object Type, until this, this class allow to accept request send using text/html content-type
|
||||
src/modules/Elsa.Http/DownloadableContentHandlers/UrlDownloadableContentHandler.cs:35: // TODO: Uncomment the next two lines if we implement file caching for this handler.
|
||||
src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs:153: // TODO: Delete the cached file after the workflow completes.
|
||||
src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs:704: // TODO: Check if we should not use the target subStatus here instead.
|
||||
src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs:150: // TODO: Disable filtering for now until we redesign log sanitization.
|
||||
src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs:709: // TODO: Check if we should not use the target subStatus here instead.
|
||||
src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs:156: // TODO: Disable filtering for now until we redesign log sanitization.
|
||||
src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs:73: // TODO: This should be configurable, because this won't work for e.g. file streams and other non-serializable types.
|
||||
src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs:79: // TODO: This is a temporary solution. We need to find a better way to handle this.
|
||||
src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs:96: // TODO: Remove this in the future when users have migrated workflows to use the new UsableAsActivity options property.
|
||||
src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs:105: // TODO: The following code handles backward compatibility with variable definitions referencing older .NET type namespaces.
|
||||
src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs:108: // TODO: The following code handles backward compatibility with variable definitions referencing older .NET type namespaces.
|
||||
|
||||
=== GIT RECENT COMMITS (last 20) ===
|
||||
c7a97c641 Shorten configured application instance names
|
||||
0028604ca Merge pull request #7742 from elsa-workflows/sfmskywalker-port-asb-stable-instance-371
|
||||
1aef08381 fix: address greptile stable instance feedback
|
||||
fc48af062 fix: add stable application instance name option
|
||||
d8d7eae20 Merge branch 'release/3.6.2' into release/3.7.0
|
||||
a76e7a666 chore(packages): add Snappier
|
||||
b88af1e02 fix: handle arrays of generic types in GetFriendlyTypeName (issue - #7369) (#7400)
|
||||
8ee0b43b3 Add Claude Code GitHub Workflow (#7411)
|
||||
ad90e81af Enhance logging configuration, update HttpFeature dependencies, and add new identity persistence options
|
||||
e7eaa4b1f Integrate `Elsa.Workflows.Runtime.Distributed` into `Elsa.ModularServer.Web` and configure shell features for enhanced distributed runtime support.
|
||||
3d8d3b7de Merge remote-tracking branch 'origin/release/3.6.1'
|
||||
9f8423932 Handle null activities in `RunWorkflowResultAssertions`
|
||||
632d1a383 Refactor shells setup and clean up NuGet package source mapping.
|
||||
29d8a64a9 Update package versions: CShells to 0.0.14 and Nuplane to 0.0.1 in props file.
|
||||
6ff391745 Remove unused `Elsa.Workflows.Api.Contracts` import from `WorkflowsApiFeature`.
|
||||
3decb1268 feat: extend shells integration and modular server support (#7399)
|
||||
8d7d1a986 Fix BulkDispatchWorkflows sharing input dictionary across dispatches (#7284)
|
||||
a459904e1 refactor | Added a new property to expose the ability to disable the sorting (#7360)
|
||||
793ae15bf Fix tenant scope for interrupted workflow restarts (#7389)
|
||||
3a3766861 fix: correct workflow summary pagination on release 3.6.1 (#7392)
|
||||
ff10a6810 refactor: update package reference configuration
|
||||
e59a0f172 Merge remote-tracking branch 'origin/release/3.8.0' into release/3.8.0
|
||||
103028452 feat: introduce HTTP webhooks module
|
||||
46b29d6ef Merge pull request #7901 from DenDeline/bugfix/7900-tenant-request-services
|
||||
c557c455a Merge release/3.8.0 into bugfix/7900-tenant-request-services
|
||||
dacad1464 Merge pull request #7905 from elsa-workflows/codex/pr-7903-review-fixes
|
||||
286a0d83d Harden external identity race compensation
|
||||
d10dbdd48 Merge branch 'codex/pr-7903-review-fixes' into release/3.8.0
|
||||
f4d749d3f Merge branch 'codex/auth-refinements' into release/3.8.0
|
||||
fe125ac33 Fix external authentication review findings
|
||||
ed50e1cc9 Merge pull request #7904 from elsa-workflows/codex/fix-7306-tenant-agnostic-registry-population
|
||||
1b74bb94c fix: validate external login methods before discovery
|
||||
542058971 Avoid repeated tenant-agnostic registry population
|
||||
a24a6fe26 Add test to validate complete configuration requirements for connections
|
||||
a60b5a36b Refine test for archived connections in shadow relationships; introduce `IsAvailableForAuthentication` helper for improved connection filtering logic.
|
||||
572cd325b Merge compact external authentication secret IDs
|
||||
119f49f7a fix: simplify external authentication secret IDs
|
||||
9f09aca3f Refine test to ensure archived connections do not participate as active shadows; update shadow relationship management to exclude archived entries.
|
||||
14f373528 Add test for handling archived database overrides in connection shadows
|
||||
6e3ed5c4e Remove migration for external authentication in EFCore.Sqlite module
|
||||
|
||||
=== HIGH-CHURN FILES (last 90 days, top 20) ===
|
||||
4 Directory.Packages.props
|
||||
3 src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs
|
||||
3 test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs
|
||||
3 src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj
|
||||
3 src/apps/Elsa.ModularServer.Web/Program.cs
|
||||
2 src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs
|
||||
2 Elsa.sln
|
||||
2 src/modules/Elsa.Common/Elsa.Common.csproj
|
||||
2 src/apps/Elsa.ModularServer.Web/appsettings.Development.json
|
||||
2 src/apps/Elsa.ModularServer.Web/appsettings.json
|
||||
2 NuGet.Config
|
||||
2 src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs
|
||||
2 README.md
|
||||
1 src/modules/Elsa.Hosting.Management/AssemblyInfo.cs
|
||||
1 src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs
|
||||
1 test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj
|
||||
1 src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs
|
||||
1 test/unit/Elsa.Expressions.UnitTests/Extensions/TypeExtensionsTests.cs
|
||||
1 .github/workflows/claude-code-review.yml
|
||||
1 .github/workflows/claude.yml
|
||||
35 Elsa.sln
|
||||
28 Directory.Packages.props
|
||||
18 src/apps/Elsa.ModularServer.Web/appsettings.json
|
||||
15 src/apps/Elsa.Server.Web/Program.cs
|
||||
14 src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj
|
||||
12 AGENTS.md
|
||||
12 src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs
|
||||
11 src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs
|
||||
11 src/modules/Elsa.Identity/Services/DefaultSecretHasher.cs
|
||||
10 src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs
|
||||
10 src/apps/Elsa.ModularServer.Web/Program.cs
|
||||
10 src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs
|
||||
10 test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs
|
||||
10 src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaDistributedLockHealthCheck.cs
|
||||
10 test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaDistributedLockHealthCheckTests.cs
|
||||
9 test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs
|
||||
9 test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs
|
||||
9 src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs
|
||||
9 test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs
|
||||
9 src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs
|
||||
|
||||
=== MONOREPO SIGNALS ===
|
||||
No monorepo signals detected.
|
||||
|
||||
=== CODE METRICS ===
|
||||
Total files scanned: 3908
|
||||
Total lines of code: 217582
|
||||
Total files scanned: 5427
|
||||
Total lines of code: 319973
|
||||
|
||||
Files by language:
|
||||
C#: 3265
|
||||
Other: 631
|
||||
C#: 4365
|
||||
Other: 1044
|
||||
Java: 6
|
||||
Python: 4
|
||||
JavaScript: 4
|
||||
Python: 2
|
||||
TypeScript: 2
|
||||
TypeScript/React: 2
|
||||
|
||||
Top 10 largest files:
|
||||
design/artwork/elsa-logo-art.psd: 13979.6KB
|
||||
src/apps/Elsa.ModularServer.Web/elsa_logs.db: 9796.0KB
|
||||
design/video/exports/elsa-workflows-readme.gif: 10791.9KB
|
||||
design/artwork/elsa-v3-avatar.png: 5827.9KB
|
||||
design/video/exports/elsa-workflows-readme.mp4: 3582.4KB
|
||||
design/artwork/creative-elsa-animation.gif: 3461.2KB
|
||||
src/apps/Elsa.ModularServer.Web/elsa_workflows.db-wal: 3218.8KB
|
||||
design/artwork/elsa-waves.mp4: 3178.4KB
|
||||
design/logo/Esla_Source.ai: 2043.9KB
|
||||
src/apps/Elsa.ModularServer.Web/console-log-streaming-vanilla-sample.db: 1532.0KB
|
||||
src/apps/Elsa.ModularServer.Web/elsa_workflows.db: 1416.0KB
|
||||
src/apps/Elsa.ModularServer.Web/elsa_logs.db: 1516.0KB
|
||||
design/logo/Elsa.ai: 914.8KB
|
||||
design/logo/Esla-13.png: 429.9KB
|
||||
|
||||
=== CI/CD PIPELINES ===
|
||||
CI/CD: GitHub Actions
|
||||
|
|
|
|||
50
docs/codebase/ARCHITECTURE.md
Normal file
50
docs/codebase/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Architecture
|
||||
|
||||
## Architectural Style
|
||||
|
||||
- Primary style: modular, feature-oriented ASP.NET Core application with dependency-injected ports and adapters.
|
||||
- Classification evidence: External Authentication defines protocol-neutral contracts and broker orchestration, while OIDC and EF Core are separate adapter modules.
|
||||
- Primary constraints: tenant isolation, non-reversible subject storage, safe public errors, and replaceable in-memory/durable stores.
|
||||
|
||||
## System Flow
|
||||
|
||||
```text
|
||||
provider callback -> protocol adapter -> external identity resolver -> session/grant stores -> identity-link activity write -> safe redirect/API
|
||||
```
|
||||
|
||||
1. The callback endpoint delegates to `ExternalAuthenticationBroker.CompleteCallbackAsync`.
|
||||
2. The configured adapter validates the provider callback and returns a normalized `ExternalIdentity`.
|
||||
3. `DefaultExternalIdentityResolver` finds or atomically provisions the tenant-scoped link and Elsa user.
|
||||
4. The broker resolves grants and persists the external session and one-time completion grant.
|
||||
5. The provisioner records the successful sign-in on the same tenant/connection/issuer/subject/user tuple.
|
||||
6. Identity-link list endpoints map safe fields, including `LastSignedInAt`, without returning subject hashes.
|
||||
|
||||
## Layer/Module Responsibilities
|
||||
|
||||
| Module | Owns | Must not own | Evidence |
|
||||
|---|---|---|---|
|
||||
| Broker | Successful authentication orchestration and completion boundary | Provider token validation details | `ExternalAuthenticationBroker.cs` |
|
||||
| Resolver/provisioner | Link lookup, JIT creation, tuple isolation, activity persistence | Browser redirects | `DefaultExternalIdentityResolver.cs`, `IExternalIdentityProvisioner` |
|
||||
| EF Core adapter | Durable entity mapping and atomic compare-and-set updates | API response shaping | `EFCoreExternalIdentityProvisioner.cs` |
|
||||
| Identity-links endpoint | Tenant-scoped filters and safe response mapping | Raw external subjects | `IdentityLinkEndpoints.cs` |
|
||||
|
||||
## Reused Patterns
|
||||
|
||||
| Pattern | Where | Purpose |
|
||||
|---|---|---|
|
||||
| Port/adapter | `IExternalAuthenticationAdapter`, OIDC implementation | Isolate protocols |
|
||||
| Repository/provisioner | In-memory and EF external identity provisioners | Replace persistence without changing broker behavior |
|
||||
| DI replacement | EF Core service collection extension | Replace single-node defaults with durable stores |
|
||||
| Safe DTO mapping | Identity-link endpoint document | Prevent subject/hash disclosure |
|
||||
|
||||
## Known Architectural Risks
|
||||
|
||||
- Session, completion grant, and link activity are separate store writes; there is no cross-store transaction.
|
||||
- Custom resolver/provisioner pairs can opt into `IExternalIdentitySignInTracker`; the built-in pair implements it.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalIdentityResolver.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs`
|
||||
46
docs/codebase/CONCERNS.md
Normal file
46
docs/codebase/CONCERNS.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Codebase Concerns
|
||||
|
||||
## Top Risks
|
||||
|
||||
| Severity | Concern | Evidence | Impact | Suggested action |
|
||||
|---|---|---|---|---|
|
||||
| Medium | External sign-in completion spans session, grant, and link stores | `ExternalAuthenticationBroker.cs` | Partial writes are possible if a later store fails | Keep writes ordered and make completion operations idempotent |
|
||||
| Medium | Custom sign-in trackers must preserve tenant/connection/subject/user scoping | `IExternalIdentitySignInTracker` | Incorrect implementations could update the wrong link | Require conformance tests for tracker implementations |
|
||||
| Low | In-memory stores are single-node only | `InMemoryExternalIdentityProvisioner.cs` | State diverges across replicas | Use EF Core stores for multi-node deployments |
|
||||
|
||||
## Technical Debt
|
||||
|
||||
| Debt item | Why | Where | Risk | Suggested fix |
|
||||
|---|---|---|---|---|
|
||||
| Broker constructor has many dependencies | Broker owns several local and external flows | `ExternalAuthenticationBroker.cs` | Manual tests/benchmarks are costly to construct | Consider focused orchestration objects only when another change justifies it |
|
||||
| `[TODO]` Repository-wide debt inventory | Investigation intentionally focused on external identity tracking | `docs/codebase/.codebase-scan.txt` | Other modules are not assessed here | Run an extended acquisition audit separately |
|
||||
|
||||
## Security Concerns
|
||||
|
||||
| Risk | OWASP | Evidence | Current mitigation | Gap |
|
||||
|---|---|---|---|---|
|
||||
| Cross-tenant link access | A01 | Identity-link endpoint/store filters | Tenant accessor plus tenant-scoped queries | Custom stores need equivalent tests |
|
||||
| Subject/token disclosure | A02 | Safe endpoint DTO and redaction tests | HMAC subject storage and redaction | None found in this flow |
|
||||
|
||||
## Performance and Scaling Concerns
|
||||
|
||||
| Concern | Evidence | Symptom | Scaling risk | Suggested improvement |
|
||||
|---|---|---|---|---|
|
||||
| Compare-and-set tracking may retry under contention | `EFCoreExternalIdentityProvisioner.RecordSuccessfulSignInAsync` | Extra query/update round trip on races | Hot shared identities could retry | Retain unique tuple index and monitor contention |
|
||||
|
||||
## Fragile/High-Churn Areas
|
||||
|
||||
| Area | Why fragile | Churn signal | Safe change strategy |
|
||||
|---|---|---|---|
|
||||
| External authentication broker/persistence | Security-sensitive multi-store flow | Active feature code and current defect | Use callback-level, EF, and endpoint regression tests together |
|
||||
|
||||
## `[ASK USER]` Questions
|
||||
|
||||
None for this focused investigation.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `docs/codebase/.codebase-scan.txt`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs`
|
||||
- `test/integration/Elsa.ExternalAuthentication.IntegrationTests/`
|
||||
42
docs/codebase/CONVENTIONS.md
Normal file
42
docs/codebase/CONVENTIONS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Coding Conventions
|
||||
|
||||
## Naming Rules
|
||||
|
||||
| Item | Rule | Example | Evidence |
|
||||
|---|---|---|---|
|
||||
| Files | PascalCase for C# types | `ExternalAuthenticationBroker.cs` | `src/modules/Elsa.ExternalAuthentication/Services/` |
|
||||
| Methods | PascalCase; async methods end in `Async` | `CompleteCallbackAsync` | `ExternalAuthenticationBroker.cs` |
|
||||
| Interfaces | `I` prefix | `IExternalIdentityProvisioner` | `ExternalAuthenticationContracts.cs` |
|
||||
| Locals/parameters | camelCase | `signedInAt` | `EFCoreExternalIdentityProvisioner.cs` |
|
||||
|
||||
## Formatting and Linting
|
||||
|
||||
- Formatter/rules: Roslyn `.editorconfig` settings.
|
||||
- Relevant rules: 4-space indentation, system usings first, braces required, file-scoped namespaces, `var` preferred.
|
||||
- Run commands: `dotnet format Elsa.sln` when formatting is explicitly required; normal validation uses `dotnet build` and `dotnet test`.
|
||||
|
||||
## Import and Module Conventions
|
||||
|
||||
- Usings are outside namespaces and system directives sort first.
|
||||
- Project namespaces follow the feature folder layout.
|
||||
- Public contracts are kept in contract/model namespaces rather than persistence projects.
|
||||
|
||||
## Error and Logging Conventions
|
||||
|
||||
- Services throw internal exceptions; broker endpoints translate failures to bounded public categories.
|
||||
- Logging uses `Microsoft.Extensions.Logging` and structured message templates.
|
||||
- External authentication response DTOs omit subject hashes and sensitive tokens; redaction is covered by dedicated tests.
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- Tests live under `test/{unit,integration,component,performance}` and use xUnit.
|
||||
- Prefer real in-memory implementations; NSubstitute is used at external or expensive boundaries.
|
||||
- Integration test coverage threshold defaults to 10% per project.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `.editorconfig`
|
||||
- `Directory.Build.props`
|
||||
- `test/Directory.Build.props`
|
||||
- `test/integration/Elsa.ExternalAuthentication.IntegrationTests/`
|
||||
|
||||
42
docs/codebase/INTEGRATIONS.md
Normal file
42
docs/codebase/INTEGRATIONS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# External Integrations
|
||||
|
||||
## Integration Inventory
|
||||
|
||||
| System | Type | Purpose | Auth model | Criticality | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| External identity providers | OIDC adapter | Authenticate upstream users | Authorization code/OIDC callback | High | `Elsa.ExternalAuthentication.OpenIdConnect` |
|
||||
| Relational databases | EF Core | Durable external-auth state and links | Deployment connection string | High | `Elsa.ExternalAuthentication.Persistence.EFCore.*` |
|
||||
| Elsa Studio/client | REST | Discover login methods and manage links | Elsa permissions/tenant context | High | `Endpoints/IdentityLinks`, `Elsa.Api.Client` |
|
||||
|
||||
## Data Stores
|
||||
|
||||
| Store | Role | Access layer | Key risk | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| In-memory dictionaries | Single-node development/test state | In-memory provisioners/stores | Not shared across nodes | `Stores/InMemory`, `InMemoryExternalIdentityProvisioner.cs` |
|
||||
| EF Core relational store | Durable links, sessions, grants, transactions | EF Core store implementations | Cross-store completion is not one transaction | `Elsa.ExternalAuthentication.Persistence.EFCore` |
|
||||
|
||||
## Secrets and Credentials Handling
|
||||
|
||||
- Connection secrets use secret bindings and resolver abstractions.
|
||||
- External subjects are HMAC-hashed before persistence.
|
||||
- Safe identity-link API documents omit both raw subjects and subject hashes.
|
||||
|
||||
## Reliability and Failure Behavior
|
||||
|
||||
- Callback state is one-time and bounded by expiry.
|
||||
- Failed/cancelled adapter callbacks do not reach the successful-sign-in tracking boundary.
|
||||
- Durable link activity uses compare-and-set semantics to retain the newest concurrent timestamp.
|
||||
|
||||
## Observability for Integrations
|
||||
|
||||
- Broker outcomes publish bounded security notifications.
|
||||
- Connection tests record health observations without mutating sign-in activity.
|
||||
- `[TODO]` Repository-wide tracing/metrics inventory was outside this focused investigation.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `src/modules/Elsa.ExternalAuthentication.OpenIdConnect/Services/OpenIdConnectExternalAuthenticationAdapter.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Services/HmacExternalAuthenticationHandleHasher.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Services/ConnectionTestService.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Notifications/ExternalAuthenticationSecurityNotifications.cs`
|
||||
|
||||
49
docs/codebase/STACK.md
Normal file
49
docs/codebase/STACK.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Technology Stack
|
||||
|
||||
## Runtime Summary
|
||||
|
||||
| Area | Value | Evidence |
|
||||
|---|---|---|
|
||||
| Primary language | C# (`LangVersion` latest) | `Directory.Build.props` |
|
||||
| Runtime | .NET 8, 9, and 10 for source projects | `src/Directory.Build.props` |
|
||||
| Package manager | NuGet with central package management | `Directory.Packages.props` |
|
||||
| Build system | MSBuild solution with NUKE wrappers | `Elsa.sln`, `build/Build.cs`, `build.sh` |
|
||||
|
||||
## Production Frameworks and Dependencies
|
||||
|
||||
| Dependency | Version | Role | Evidence |
|
||||
|---|---:|---|---|
|
||||
| FastEndpoints | target-dependent 7.1.1/7.2.0 | HTTP endpoint framework | `Directory.Packages.props` |
|
||||
| Entity Framework Core | target-dependent 9.0.17/10.0.9 | Durable relational persistence | `Directory.Packages.props` |
|
||||
| Microsoft.Extensions.Logging | target-dependent 9.0.17/10.0.9 | Application logging | `Directory.Packages.props` |
|
||||
|
||||
## Development Toolchain
|
||||
|
||||
| Tool | Purpose | Evidence |
|
||||
|---|---|---|
|
||||
| xUnit 2.9.3 | Test framework | `Directory.Packages.props`, `test/Directory.Build.props` |
|
||||
| NSubstitute 5.3.0 | Test doubles | `Directory.Packages.props` |
|
||||
| coverlet | Coverage with a 10% project threshold | `test/Directory.Build.props` |
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
dotnet build Elsa.sln
|
||||
dotnet test Elsa.sln
|
||||
dotnet test test/integration/Elsa.ExternalAuthentication.IntegrationTests/Elsa.ExternalAuthentication.IntegrationTests.csproj
|
||||
./build.sh Test
|
||||
```
|
||||
|
||||
## Environment and Config
|
||||
|
||||
- ASP.NET Core applications use `appsettings*.json` and environment-variable overrides.
|
||||
- Production source enables nullable reference types and implicit usings.
|
||||
- External Authentication supports in-memory defaults and optional EF Core providers.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `Directory.Build.props`
|
||||
- `src/Directory.Build.props`
|
||||
- `test/Directory.Build.props`
|
||||
- `Directory.Packages.props`
|
||||
|
||||
42
docs/codebase/STRUCTURE.md
Normal file
42
docs/codebase/STRUCTURE.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Codebase Structure
|
||||
|
||||
## Top-Level Map
|
||||
|
||||
| Path | Purpose | Evidence |
|
||||
|---|---|---|
|
||||
| `src/apps/` | Runnable application hosts | `Elsa.sln` |
|
||||
| `src/common/` | Shared infrastructure | `Elsa.sln` |
|
||||
| `src/modules/` | Feature and domain modules | `Elsa.sln` |
|
||||
| `src/clients/` | API client contracts | `src/clients/Elsa.Api.Client/` |
|
||||
| `test/` | Unit, integration, component, and performance tests | `test/Directory.Build.props` |
|
||||
| `build/` | NUKE build automation | `build/Build.cs` |
|
||||
| `specs/` | Feature specifications and plans | `specs/012-weaver-grounding-tools/plan.md` |
|
||||
|
||||
## Entry Points
|
||||
|
||||
- Main runtime entry points are the application projects under `src/apps/`.
|
||||
- External Authentication is composed through `AddExternalAuthenticationServices` and optional EF Core replacement registration.
|
||||
- Endpoint discovery is module-based; identity-link endpoints live under `Elsa.ExternalAuthentication/Endpoints/IdentityLinks`.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
| Boundary | Belongs here | Must not be here |
|
||||
|---|---|---|
|
||||
| `Elsa.ExternalAuthentication` | Broker orchestration, contracts, in-memory stores, safe HTTP DTOs | Provider-specific protocol behavior |
|
||||
| `Elsa.ExternalAuthentication.OpenIdConnect` | OIDC callback and provider adapter behavior | Elsa user authorization policy |
|
||||
| `Elsa.ExternalAuthentication.Persistence.EFCore` | Durable entities and store implementations | UI presentation |
|
||||
| `Elsa.Api.Client` | Backward-compatible client DTOs and HTTP contracts | Persistence entities |
|
||||
|
||||
## Naming and Organization Rules
|
||||
|
||||
- C# files and public types use PascalCase.
|
||||
- Modules are organized by feature, then by contracts/services/endpoints/stores.
|
||||
- Namespaces follow folders and are file-scoped.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `Elsa.sln`
|
||||
- `src/modules/Elsa.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs`
|
||||
- `src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Extensions/ServiceCollectionExtensions.cs`
|
||||
- `.editorconfig`
|
||||
|
||||
47
docs/codebase/TESTING.md
Normal file
47
docs/codebase/TESTING.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Testing Patterns
|
||||
|
||||
## Test Stack and Commands
|
||||
|
||||
- Framework: xUnit 2.9.3.
|
||||
- Assertion/mocking: xUnit assertions, NSubstitute 5.3.0, ASP.NET Core TestServer, SQLite in-memory EF Core.
|
||||
|
||||
```bash
|
||||
dotnet test Elsa.sln
|
||||
dotnet test test/unit/Elsa.ExternalAuthentication.UnitTests/Elsa.ExternalAuthentication.UnitTests.csproj
|
||||
dotnet test test/integration/Elsa.ExternalAuthentication.IntegrationTests/Elsa.ExternalAuthentication.IntegrationTests.csproj
|
||||
```
|
||||
|
||||
## Test Layout
|
||||
|
||||
- Test projects are separated under `test/unit`, `test/integration`, `test/component`, and `test/performance`.
|
||||
- Test files use `*Tests.cs`; xUnit setup uses constructors or `IAsyncLifetime`.
|
||||
- Shared helpers live under `src/common/Elsa.Testing.Shared*`.
|
||||
|
||||
## Test Scope Matrix
|
||||
|
||||
| Scope | Covered | Typical target | Notes |
|
||||
|---|---|---|---|
|
||||
| Unit | Yes | Validators, in-memory stores, isolated services | Fast and dependency-light |
|
||||
| Integration | Yes | Broker flow, REST endpoints, SQLite persistence | Used for external-auth tracking regression |
|
||||
| Component | Yes | Workflow runtime behavior | Not needed for this identity defect |
|
||||
| E2E browser | `[TODO]` | Studio rendering | Studio repository is separate |
|
||||
|
||||
## Mocking and Isolation Strategy
|
||||
|
||||
- Prefer real in-memory provisioners and stores for state assertions.
|
||||
- Substitute adapters, role providers, and notification senders at boundaries.
|
||||
- SQLite `Data Source=:memory:` provides isolated durable-store tests.
|
||||
|
||||
## Coverage and Quality Signals
|
||||
|
||||
- Coverlet generates Cobertura, LCOV, and OpenCover output.
|
||||
- Default project threshold is 10% total line coverage.
|
||||
- Focused filtered runs may disable coverage; the full project run enforces the threshold.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `test/Directory.Build.props`
|
||||
- `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs`
|
||||
- `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs`
|
||||
- `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs`
|
||||
|
||||
|
|
@ -104,10 +104,7 @@
|
|||
}
|
||||
],
|
||||
"Secrets": {
|
||||
"StudioServerClientSecret": "t/6A8piT0E1hbxBSqkppC9Ug2Nr3B9E++GBt10fwhu4=",
|
||||
"KeycloakGeneralClientSecret": "elsa-keycloak-client-secret-dev",
|
||||
"KeycloakWorkforceClientSecret": "elsa-workforce-client-secret-dev",
|
||||
"KeycloakPartnersClientSecret": "elsa-partners-client-secret-dev"
|
||||
"StudioServerClientSecret": "t/6A8piT0E1hbxBSqkppC9Ug2Nr3B9E++GBt10fwhu4="
|
||||
}
|
||||
},
|
||||
"SecretsJavaScript": {},
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
IExternalAuthenticationHandleHasher handleHasher,
|
||||
IIdentityGenerator identityGenerator,
|
||||
ISystemClock clock,
|
||||
ILogger<EFCoreExternalIdentityProvisioner> logger) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore
|
||||
ILogger<EFCoreExternalIdentityProvisioner> logger) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore, IExternalIdentitySignInTracker
|
||||
{
|
||||
private readonly ExternalIdentityUserProvisioningService _userProvisioningService = new(userStore, userProvider, roleProvider, identityGenerator);
|
||||
|
||||
|
|
@ -101,6 +101,41 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
return new ProvisioningResult(user.Id, ToModel(link), wasCreated, true);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> RecordSuccessfulSignInAsync(
|
||||
string tenantId,
|
||||
string connectionKey,
|
||||
ExternalIdentity identity,
|
||||
string userId,
|
||||
DateTimeOffset signedInAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var subjectHash = handleHasher.Hash(identity.Subject);
|
||||
var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(connectionKey);
|
||||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var linkQuery = dbContext.ExternalIdentityLinks
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.ConnectionKey == normalizedConnectionKey &&
|
||||
x.Issuer == identity.Issuer &&
|
||||
x.SubjectHash == subjectHash &&
|
||||
x.UserId == userId);
|
||||
while (true)
|
||||
{
|
||||
var link = await linkQuery.AsNoTracking().SingleOrDefaultAsync(cancellationToken);
|
||||
if (link is null)
|
||||
return false;
|
||||
if (link.LastSignedInAt >= signedInAt)
|
||||
return true;
|
||||
|
||||
var previousSignedInAt = link.LastSignedInAt;
|
||||
var updated = await linkQuery
|
||||
.Where(x => x.LastSignedInAt == previousSignedInAt)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.LastSignedInAt, (DateTimeOffset?)signedInAt), cancellationToken);
|
||||
if (updated > 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,22 @@ public interface IExternalIdentityResolver
|
|||
ValueTask<ExternalIdentityResolution> ResolveAsync(ExternalIdentityResolutionContext context, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Optionally records completed sign-ins for an external identity resolver or provisioner.</summary>
|
||||
public interface IExternalIdentitySignInTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a completed sign-in for the tenant-scoped external identity link without changing its other metadata.
|
||||
/// Concurrent calls must preserve the latest timestamp.
|
||||
/// </summary>
|
||||
ValueTask<bool> RecordSuccessfulSignInAsync(
|
||||
string tenantId,
|
||||
string connectionKey,
|
||||
ExternalIdentity identity,
|
||||
string userId,
|
||||
DateTimeOffset signedInAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IExternalIdentityProvisioner
|
||||
{
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace Elsa.ExternalAuthentication.Services;
|
|||
public sealed class DefaultExternalIdentityResolver(
|
||||
IExternalIdentityProvisioner provisioner,
|
||||
IEnumerable<IUnlinkedIdentityPolicy> policies,
|
||||
IOptions<ExternalAuthenticationOptions> options) : IExternalIdentityResolver
|
||||
IOptions<ExternalAuthenticationOptions> options) : IExternalIdentityResolver, IExternalIdentitySignInTracker
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IUnlinkedIdentityPolicy> _policies = policies.ToDictionary(x => x.Type, StringComparer.Ordinal);
|
||||
|
||||
|
|
@ -51,6 +51,17 @@ public sealed class DefaultExternalIdentityResolver(
|
|||
return new ExternalIdentityResolution(result.UserId, result.WasCreated);
|
||||
}
|
||||
|
||||
public ValueTask<bool> RecordSuccessfulSignInAsync(
|
||||
string tenantId,
|
||||
string connectionKey,
|
||||
ExternalIdentity identity,
|
||||
string userId,
|
||||
DateTimeOffset signedInAt,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
provisioner is IExternalIdentitySignInTracker tracker
|
||||
? tracker.RecordSuccessfulSignInAsync(tenantId, connectionKey, identity, userId, signedInAt, cancellationToken)
|
||||
: ValueTask.FromResult(true);
|
||||
|
||||
private PolicySelection GetPolicySelection(EffectiveIdentityProviderConnection connection)
|
||||
{
|
||||
var configuredPolicy = connection.Connection.UnlinkedPolicy;
|
||||
|
|
|
|||
|
|
@ -236,7 +236,20 @@ public sealed class ExternalAuthenticationBroker(
|
|||
ExternalAuthenticationSecurityNotifier.Context(null, transaction.TenantId, connection.Connection.Id, resolution.UserId, SecurityEventOutcome.Succeeded, "External sign-in completed."),
|
||||
session.Id,
|
||||
connection.Connection.AdapterType), cancellationToken);
|
||||
return await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(transaction.CallbackUri, code, transaction.ClientState)), "external", "callback", transaction.TenantId, connection.Connection.Id, cancellationToken);
|
||||
var callbackResult = await CallbackOutcomeAsync(BrokerCallbackResult.Redirect(AppendCallbackParameters(transaction.CallbackUri, code, transaction.ClientState)), "external", "callback", transaction.TenantId, connection.Connection.Id, cancellationToken);
|
||||
if (identityResolver is IExternalIdentitySignInTracker signInTracker)
|
||||
{
|
||||
var signInRecorded = await signInTracker.RecordSuccessfulSignInAsync(
|
||||
transaction.TenantId,
|
||||
connection.Connection.Key,
|
||||
authentication.Identity,
|
||||
resolution.UserId,
|
||||
clock.UtcNow,
|
||||
cancellationToken);
|
||||
if (!signInRecorded)
|
||||
throw new InvalidOperationException("The external identity link changed while sign-in was completing.");
|
||||
}
|
||||
return callbackResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
IIdentityGenerator identityGenerator,
|
||||
ISystemClock clock,
|
||||
IExternalAuthenticationHandleHasher handleHasher,
|
||||
InMemoryExternalIdentityProvisionerState state) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore
|
||||
InMemoryExternalIdentityProvisionerState state) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore, IExternalIdentitySignInTracker
|
||||
{
|
||||
private readonly ExternalIdentityUserProvisioningService _userProvisioningService = new(userStore, userProvider, roleProvider, identityGenerator);
|
||||
|
||||
|
|
@ -79,6 +79,33 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> RecordSuccessfulSignInAsync(
|
||||
string tenantId,
|
||||
string connectionKey,
|
||||
ExternalIdentity identity,
|
||||
string userId,
|
||||
DateTimeOffset signedInAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var key = new ExternalIdentityKey(tenantId, ConnectionRevisionCalculator.NormalizeKey(connectionKey), identity.Issuer, handleHasher.Hash(identity.Subject));
|
||||
|
||||
await state.Mutex.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!state.Links.TryGetValue(key, out var link) || !string.Equals(link.UserId, userId, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
if (link.LastSignedInAt is null || link.LastSignedInAt < signedInAt)
|
||||
state.Links[key] = link with { LastSignedInAt = signedInAt };
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,22 @@ using System.Text;
|
|||
using System.Text.Json;
|
||||
using Elsa.Common;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Notifications;
|
||||
using Elsa.ExternalAuthentication.Options;
|
||||
using Elsa.ExternalAuthentication.Policies;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
using Elsa.ExternalAuthentication.Stores.InMemory;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
using Elsa.Identity.Options;
|
||||
using Elsa.Identity.Providers;
|
||||
using Elsa.Identity.Services;
|
||||
using Elsa.Mediator.Contracts;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
|
|
@ -208,6 +212,69 @@ public class BrokerSecurityTests
|
|||
Assert.DoesNotContain("issuer.example", result.RedirectUri?.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulExternalSignInRecordsTheTimestampForAnExistingIdentityLink()
|
||||
{
|
||||
var scenario = CreateIdentityLinkTrackingScenario();
|
||||
var existing = await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
|
||||
var signedInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||
scenario.Clock.UtcNow = signedInAt;
|
||||
|
||||
var result = await CompleteExternalSignInAsync(scenario);
|
||||
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(existing.Link.Id, link?.Id);
|
||||
Assert.Equal(signedInAt, link?.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulExternalSignInRecordsTheInitialTimestampForANewIdentityLink()
|
||||
{
|
||||
var scenario = CreateIdentityLinkTrackingScenario();
|
||||
var signedInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||
scenario.Clock.UtcNow = signedInAt;
|
||||
|
||||
var result = await CompleteExternalSignInAsync(scenario);
|
||||
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.NotNull(link);
|
||||
Assert.Equal(signedInAt, link.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RepeatSuccessfulExternalSignInReplacesTheIdentityLinkTimestamp()
|
||||
{
|
||||
var scenario = CreateIdentityLinkTrackingScenario();
|
||||
await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
|
||||
var firstSignInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||
scenario.Clock.UtcNow = firstSignInAt;
|
||||
await CompleteExternalSignInAsync(scenario);
|
||||
var secondSignInAt = firstSignInAt.AddMinutes(1);
|
||||
scenario.Clock.UtcNow = secondSignInAt;
|
||||
|
||||
var result = await CompleteExternalSignInAsync(scenario);
|
||||
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(secondSignInAt, link?.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsuccessfulExternalSignInDoesNotRecordTheIdentityLinkTimestamp()
|
||||
{
|
||||
var scenario = CreateIdentityLinkTrackingScenario(throwOnCallback: true);
|
||||
await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
|
||||
scenario.Clock.UtcNow = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||
|
||||
var result = await CompleteExternalSignInAsync(scenario);
|
||||
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
|
||||
|
||||
Assert.Equal("authentication_failed", result.Error?.Error);
|
||||
Assert.Null(link?.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderCallbackStateCannotBeReplayed()
|
||||
{
|
||||
|
|
@ -388,7 +455,8 @@ public class BrokerSecurityTests
|
|||
ExternalAuthenticationSecurityNotifier? notifier = null,
|
||||
ConnectionValidity connectionValidity = ConnectionValidity.Valid,
|
||||
ConnectionValidity? assessedValidity = null,
|
||||
bool includeLoginMethod = false)
|
||||
bool includeLoginMethod = false,
|
||||
ISystemClock? clock = null)
|
||||
{
|
||||
var connection = new IdentityProviderConnection
|
||||
{
|
||||
|
|
@ -415,15 +483,62 @@ public class BrokerSecurityTests
|
|||
Clients = clients?.ToList() ?? [new AuthenticationClient("studio", "Studio", AuthenticationClientType.Public,
|
||||
new HashSet<Uri> { new("https://studio.example/authentication/external/callback") }, new HashSet<Uri>(), new HashSet<string> { "https://studio.example" }, new HashSet<string> { "/workflows" }, null, true)]
|
||||
});
|
||||
var clock = new TestClock();
|
||||
return new ExternalAuthenticationBroker(registry, validityAssessor, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(clock), grants ?? new InMemoryAuthorizationGrantStore(clock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(clock), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), tenantAccessor ?? new DefaultTenantAccessor(), clock, options, notifier);
|
||||
var brokerClock = clock ?? new TestClock();
|
||||
return new ExternalAuthenticationBroker(registry, validityAssessor, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(brokerClock), grants ?? new InMemoryAuthorizationGrantStore(brokerClock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(brokerClock), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), tenantAccessor ?? new DefaultTenantAccessor(), brokerClock, options, notifier);
|
||||
}
|
||||
|
||||
private static BrokerAuthorizationRequest Request(string returnPath) => new("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", returnPath, "contoso");
|
||||
private static string? Query(Uri uri, string key) => System.Web.HttpUtility.ParseQueryString(uri.Query)[key];
|
||||
|
||||
private static IdentityLinkTrackingScenario CreateIdentityLinkTrackingScenario(bool throwOnCallback = false)
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>());
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
var provisioner = new InMemoryExternalIdentityProvisioner(
|
||||
users,
|
||||
new StoreBasedUserProvider(users),
|
||||
Substitute.For<IRoleProvider>(),
|
||||
new GuidIdentityGenerator(),
|
||||
clock,
|
||||
new FixedHasher(),
|
||||
new InMemoryExternalIdentityProvisionerState());
|
||||
var identityResolver = new DefaultExternalIdentityResolver(
|
||||
provisioner,
|
||||
[new CreateUserUnlinkedIdentityPolicy()],
|
||||
Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions
|
||||
{
|
||||
UnlinkedIdentityPolicy = new UnlinkedIdentityPolicyOptions { DefaultType = "create-user" }
|
||||
}));
|
||||
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
|
||||
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(new PermissionGrantResult([], [])));
|
||||
var adapter = new RecordingAdapter
|
||||
{
|
||||
ThrowOnCallback = throwOnCallback,
|
||||
AuthenticationResult = new ExternalAuthenticationResult(identity, identity.Claims, [])
|
||||
};
|
||||
var broker = CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, clock: clock);
|
||||
|
||||
return new IdentityLinkTrackingScenario(broker, provisioner, adapter, identity, clock);
|
||||
}
|
||||
|
||||
private static async Task<BrokerCallbackResult> CompleteExternalSignInAsync(IdentityLinkTrackingScenario scenario)
|
||||
{
|
||||
await scenario.Broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
|
||||
return await scenario.Broker.CompleteCallbackAsync("contoso", scenario.Adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [scenario.Adapter.CorrelationState!] });
|
||||
}
|
||||
|
||||
private sealed class TestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-01-01T00:00:00Z"); }
|
||||
private sealed class CurrentTestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }
|
||||
private sealed class MutableClock(DateTimeOffset utcNow) : ISystemClock { public DateTimeOffset UtcNow { get; set; } = utcNow; }
|
||||
|
||||
private sealed record IdentityLinkTrackingScenario(
|
||||
ExternalAuthenticationBroker Broker,
|
||||
InMemoryExternalIdentityProvisioner Provisioner,
|
||||
RecordingAdapter Adapter,
|
||||
ExternalIdentity Identity,
|
||||
MutableClock Clock);
|
||||
|
||||
internal sealed class RecordingAdapter : IExternalAuthenticationAdapter
|
||||
{
|
||||
|
|
|
|||
|
|
@ -154,6 +154,29 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
Assert.Empty(links!.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityLinksApiReturnsTheRecordedSignInForTheCurrentTenantAndConnection()
|
||||
{
|
||||
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
|
||||
var prelinked = await (await PrelinkAsync(identity.Subject)).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
Assert.Null(prelinked!.LastSignedInAt);
|
||||
var signedInAt = new DateTimeOffset(2026, 7, 26, 11, 0, 0, TimeSpan.Zero);
|
||||
await using (var scope = _app!.Services.CreateAsyncScope())
|
||||
{
|
||||
var tracker = scope.ServiceProvider.GetRequiredService<InMemoryExternalIdentityProvisioner>();
|
||||
Assert.True(await tracker.RecordSuccessfulSignInAsync("tenant-a", "contoso", identity, "user-a", signedInAt));
|
||||
}
|
||||
|
||||
var links = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links?connectionKey=contoso");
|
||||
var link = Assert.Single(links!.Items);
|
||||
Assert.Equal(prelinked.Id, link.Id);
|
||||
Assert.Equal(signedInAt, link.LastSignedInAt);
|
||||
Assert.Empty((await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links?connectionKey=fabrikam"))!.Items);
|
||||
|
||||
_tenant.TenantId.Returns("tenant-b");
|
||||
Assert.Empty((await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links?connectionKey=contoso"))!.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentPrelinksForTheSameTupleConvergeOnOneLinkAndUser()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -250,6 +250,45 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerPersistsTheLatestSuccessfulSignInTimestamp()
|
||||
{
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var provisioner = CreateProvisioner(hasher);
|
||||
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
|
||||
var request = new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external"));
|
||||
var created = await provisioner.CreateLinkOrGetExistingAsync(request);
|
||||
var firstSignInAt = new DateTimeOffset(2026, 7, 26, 10, 0, 0, TimeSpan.Zero);
|
||||
var latestSignInAt = firstSignInAt.AddMinutes(1);
|
||||
|
||||
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
|
||||
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, latestSignInAt));
|
||||
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
|
||||
|
||||
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
|
||||
Assert.Equal(latestSignInAt, persisted!.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentSignInsPreserveTheLatestTimestamp()
|
||||
{
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var provisioner = CreateProvisioner(hasher);
|
||||
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
|
||||
var created = await provisioner.CreateLinkOrGetExistingAsync(
|
||||
new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external")));
|
||||
var signInTimes = Enumerable.Range(0, 8)
|
||||
.Select(minutes => new DateTimeOffset(2026, 7, 26, 10, minutes, 0, TimeSpan.Zero))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(signInTimes.Select(async signedInAt =>
|
||||
await CreateProvisioner(hasher).RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, signedInAt)));
|
||||
|
||||
Assert.All(results, Assert.True);
|
||||
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
|
||||
Assert.Equal(signInTimes.Max(), persisted!.LastSignedInAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerRemovesTheJustInTimeUserThatLosesTheLinkRace()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue