Add secrets module (#7468)

* Add secrets module

* Address Greptile feedback for secrets module

* Handle unavailable secrets in provider adapter

* Address path combine review comments

* Address additional Greptile secrets review

* Address final Greptile secrets feedback

* Handle secrets test payload failures

* address greptile feedback on secrets rotation

* fix secret recreation concurrency

* address greptile secrets followups

* address greptile secrets reliability feedback

* align secret store capabilities
This commit is contained in:
Sipke Schoorstra 2026-05-20 11:48:01 +02:00 committed by GitHub
parent 37cf451ec0
commit e2e00ff235
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 3232 additions and 2 deletions

View file

@ -1,3 +1,3 @@
{
"feature_directory": "specs/006-diagnostics-console-logs"
"feature_directory": "specs/007-secrets-module"
}

View file

@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable:
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read `specs/006-diagnostics-console-logs/plan.md`.
shell commands, and other important information, read `specs/007-secrets-module/plan.md`.
<!-- SPECKIT END -->
## Active Technologies
@ -95,6 +95,8 @@ shell commands, and other important information, read `specs/006-diagnostics-con
- Bounded in-memory store by default; opt-in SQLite durable store through shared relational persistence. SQLite stores `Timestamp` and `ReceivedAt` as UTC ISO-8601 text and stores exception, scope, and property payloads as JSON text. (005-structured-log-persistence)
- C# latest, nullable reference types enabled, implicit usings enabled. + `Microsoft.Extensions.Options`, `Microsoft.AspNetCore.SignalR`, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, Elsa shell feature infrastructure, and existing Elsa identity/authorization patterns. (006-diagnostics-console-logs)
- Bounded in-memory recent buffer and bounded subscriber queues by default; no durable database schema. Providers receive redacted content only. (006-diagnostics-console-logs)
- C# latest, nullable reference types enabled, implicit usings enabled. + Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, existing Elsa identity/authorization patterns, Elsa workflow input metadata, `Microsoft.Extensions.Configuration`, `Microsoft.AspNetCore.DataProtection`, EF Core persistence infrastructure, mediator notifications, and optional JavaScript expression integration. (007-secrets-module)
- In-memory store for tests/development; Elsa-managed encrypted store with EF Core persistence for production; configuration-backed read-only store for deployment-managed values. No cloud vault or OS certificate store provider in v1. (007-secrets-module)
## Recent Changes
- 006-diagnostics-console-logs: Plans raw stdout/stderr console capture with redaction-before-provider boundaries, bounded in-memory recent/live buffers, REST backfill/source endpoints, and a SignalR live hub.

View file

@ -339,6 +339,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.ConsoleLog
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.ConsoleLogs.IntegrationTests", "test\integration\Elsa.Diagnostics.ConsoleLogs.IntegrationTests\Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj", "{93E9213A-694D-4AB4-870E-05E44F793133}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets", "src\modules\Elsa.Secrets\Elsa.Secrets.csproj", "{09B4B78B-FE02-44E2-8667-E182AF921C54}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.UnitTests", "test\unit\Elsa.Secrets.UnitTests\Elsa.Secrets.UnitTests.csproj", "{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -1331,6 +1335,30 @@ Global
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x64.Build.0 = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x86.ActiveCfg = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x86.Build.0 = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|x64.ActiveCfg = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|x64.Build.0 = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|x86.ActiveCfg = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Debug|x86.Build.0 = Debug|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|Any CPU.Build.0 = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|x64.ActiveCfg = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|x64.Build.0 = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|x86.ActiveCfg = Release|Any CPU
{09B4B78B-FE02-44E2-8667-E182AF921C54}.Release|x86.Build.0 = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|x64.ActiveCfg = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|x64.Build.0 = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|x86.ActiveCfg = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Debug|x86.Build.0 = Debug|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|Any CPU.Build.0 = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x64.ActiveCfg = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x64.Build.0 = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x86.ActiveCfg = Release|Any CPU
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -1450,6 +1478,8 @@ Global
{195FD304-EC3F-4350-93C8-AFE80C4E6896} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{D8739449-22DC-42D4-85A4-4BA547B0B458} = {18453B51-25EB-4317-A4B3-B10518252E92}
{93E9213A-694D-4AB4-870E-05E44F793133} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{09B4B78B-FE02-44E2-8667-E182AF921C54} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,34 @@
# Specification Quality Checklist: Secrets Module
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-19
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details dominate the product requirements
- [x] Focused on user value and business needs
- [x] Written for technical and product stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are framed as observable outcomes
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] Existing `elsa-extensions` baseline and gaps are captured
## Notes
- The PRD intentionally preserves compatibility with the existing simple provider concept while introducing the richer Orchard-style typed secret and store model.

View file

@ -0,0 +1,71 @@
# Import/Export Contract: Secrets Module
## Export Modes
### Reference-Only Export
Default mode. Exports safe metadata and technical-name references only.
```json
{
"technicalName": "smtp-password",
"type": "Text",
"scope": "Email",
"storeName": "ElsaEncrypted",
"referenceOnly": true
}
```
### Encrypted Value Export
Requires `export:secrets` and an explicit encryption target.
```json
{
"technicalName": "smtp-password",
"type": "Text",
"scope": "Email",
"storeName": "ElsaEncrypted",
"referenceOnly": false,
"encryptedPayload": {
"algorithm": "recipient-selected",
"keyId": "import-target-key",
"cipherText": "..."
}
}
```
Rules:
- Export packages never contain raw values.
- Encrypted export does not use shared Data Protection keys as the portability mechanism.
- Stores may refuse encrypted export when they cannot read or export values safely.
## Import Conflict Behavior
Same-technical-name conflicts fail by default.
Allowed explicit behaviors:
- `create-new`: create a new secret with a user-provided different technical name.
- `update-rotate`: rotate the existing secret with the imported payload.
- `skip`: leave the existing secret unchanged.
## Import Results
Import reports safe item-level results:
```json
{
"technicalName": "smtp-password",
"result": "Conflict",
"code": "TechnicalNameExists",
"message": "A secret with this technical name already exists."
}
```
Rules:
- Failed decryptions report the technical name and safe code only.
- Import does not partially rotate a conflicting secret unless `update-rotate` is explicit.
- Reference-only import can validate or create metadata without importing a value.

View file

@ -0,0 +1,130 @@
# REST API Contract: Secrets Module
All endpoints use the Elsa API route prefix. General responses are metadata-only and never include raw values, encrypted payloads, external-store lookup secrets, or provider-private metadata.
## Permissions
- `read:secrets`: list and inspect metadata.
- `write:secrets`: create metadata and rotate/replace values.
- `delete:secrets`: delete secrets.
- `test:secrets`: test configured resolution without exposing values.
- `export:secrets`: encrypted value export.
- `import:secrets`: import references or encrypted payloads.
## List Secrets
`GET /secrets?search=&type=&scope=&store=&status=&page=&pageSize=`
Response body:
```json
{
"items": [
{
"technicalName": "smtp-password",
"displayName": "SMTP Password",
"type": "Text",
"scope": "Email",
"storeName": "ElsaEncrypted",
"status": "Active",
"latestVersion": 3,
"expiresAt": null,
"createdAt": "2026-05-19T10:00:00Z",
"updatedAt": "2026-05-19T11:00:00Z"
}
],
"total": 1
}
```
## Get Secret Metadata
`GET /secrets/{technicalName}`
Returns one metadata model. Returns `404` when not found or unavailable to the caller.
## Create Secret
`POST /secrets`
```json
{
"technicalName": "smtp-password",
"displayName": "SMTP Password",
"description": "Password used by SMTP settings",
"type": "Text",
"scope": "Email",
"storeName": "ElsaEncrypted",
"value": "submitted-only-on-create-or-rotate",
"expiresAt": null
}
```
Rules:
- `technicalName` is immutable and unique after normalization.
- The request may contain a value or store-specific reference metadata.
- The response is metadata-only.
## Rotate Secret
`POST /secrets/{technicalName}/rotate`
Accepts the same value or store-specific reference payload shape as create. The response is metadata-only and reports the new version number.
## Revoke Or Delete
- `POST /secrets/{technicalName}/revoke`
- `DELETE /secrets/{technicalName}`
Both operations emit audit events and do not return payload material.
## Test Secret
`POST /secrets/{technicalName}/test`
Response:
```json
{
"success": true,
"code": "Ok",
"message": "Secret resolved successfully."
}
```
Rules:
- Test never returns the resolved value.
- Safe failure codes include `NotFound`, `Inactive`, `Expired`, `Revoked`, `StoreUnavailable`, `TypeMismatch`, and `Unauthorized`.
## Descriptors
- `GET /secrets/types`
- `GET /secrets/stores`
Return safe `SecretTypeDescriptor` and `SecretStoreDescriptor` collections for Studio and clients.
## Picker Query
`POST /secrets/picker/query`
Request:
```json
{
"allowedTypes": ["Text"],
"allowedScopes": ["Email"],
"requiredCapabilities": ["Read"],
"status": "Active",
"search": "smtp",
"page": 1,
"pageSize": 20
}
```
Response is metadata-only and contains only compatible secrets.
## No-Reveal Rule
There is no endpoint that reveals current cleartext secret values after creation.

View file

@ -0,0 +1,89 @@
# Runtime Contract: Secrets Module
## Secret Resolver
Runtime consumers resolve by immutable technical name. Implementations return latest active versions only and never expose cleartext through metadata APIs.
```csharp
public interface ISecretResolver
{
ValueTask<ResolvedSecret> ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default);
}
```
Resolution failures use safe error codes such as `NotFound`, `Inactive`, `Expired`, `Revoked`, `TypeMismatch`, `ScopeMismatch`, `StoreUnavailable`, or `Unauthorized`.
## Secret Manager
Management operations create logical secrets, rotate values, revoke/delete secrets, and return metadata-only models.
```csharp
public interface ISecretManager
{
ValueTask<SecretMetadata> CreateAsync(CreateSecretRequest request, CancellationToken cancellationToken = default);
ValueTask<SecretMetadata> RotateAsync(string technicalName, RotateSecretRequest request, CancellationToken cancellationToken = default);
ValueTask<SecretMetadata?> FindAsync(string technicalName, CancellationToken cancellationToken = default);
ValueTask<Page<SecretMetadata>> ListAsync(SecretQuery query, CancellationToken cancellationToken = default);
ValueTask RevokeAsync(string technicalName, CancellationToken cancellationToken = default);
ValueTask DeleteAsync(string technicalName, CancellationToken cancellationToken = default);
}
```
Rules:
- `technicalName` is immutable after creation.
- `RotateAsync` retires the current latest active version and creates a new latest active version.
- No method returns current cleartext values.
## Secret Store
Stores own payload persistence and declare their capabilities.
```csharp
public interface ISecretStore
{
SecretStoreDescriptor Descriptor { get; }
ValueTask<SecretPayload> WriteAsync(SecretWriteContext context, CancellationToken cancellationToken = default);
ValueTask<SecretPayload?> ReadAsync(SecretReadContext context, CancellationToken cancellationToken = default);
ValueTask DeleteAsync(SecretDeleteContext context, CancellationToken cancellationToken = default);
ValueTask<SecretTestResult> TestAsync(SecretTestContext context, CancellationToken cancellationToken = default);
}
```
Capability rules:
- Configuration store supports read/test where possible, not write/delete/rotate.
- Elsa encrypted store supports read/write/delete/rotate/encrypted export.
- Store-private payloads are not exposed through general metadata APIs.
## Secret Type Provider
Types provide validation, generation, payload shaping, and Studio editor metadata.
```csharp
public interface ISecretTypeProvider
{
SecretTypeDescriptor Descriptor { get; }
ValueTask ValidateAsync(SecretTypeValidationContext context, CancellationToken cancellationToken = default);
ValueTask<SecretPayloadInput> GenerateAsync(SecretGenerationContext context, CancellationToken cancellationToken = default);
}
```
V1 built-in types are `Text`, `RsaKey`, and `X509CertificateReference`.
## Compatibility Adapter
Existing `ISecretProvider.GetSecretAsync(name)` consumers continue through an adapter.
```csharp
public interface ISecretProvider
{
Task<string?> GetSecretAsync(string name, CancellationToken cancellationToken = default);
}
```
Adapter behavior:
- Map `name` to `SecretReference.TechnicalName`.
- Resolve latest active version.
- Return `null` only for legacy compatibility where the current provider did so; new APIs should return structured failures.

View file

@ -0,0 +1,69 @@
# Studio Contract: Secrets Module
The Core/server feature defines these contracts for a paired `elsa-studio` implementation. This feature does not implement Studio UI.
## Secrets Area
Studio should expose a security/settings area that uses the REST APIs to:
- List metadata with search and filters for type, scope, store, and status.
- Create a secret by choosing type, store, technical name, metadata, and type-specific payload.
- Rotate or replace values without showing the current value.
- Revoke or delete secrets.
- Test resolution without displaying values.
- Start encrypted export/import flows where the user has permission.
## Secret Picker
The picker is used by workflow activity editors and module settings editors.
Input context:
```json
{
"allowedTypes": ["Text"],
"allowedScopes": ["ConnectionString"],
"requiredCapabilities": ["Read"],
"consumer": "Elsa.Sql.SqlQuery.ConnectionString",
"allowInlineCreate": true
}
```
Output:
```json
{
"technicalName": "orders-db-connection",
"type": "Text",
"scope": "ConnectionString"
}
```
Rules:
- The serialized workflow/module value stores the immutable technical name.
- The picker displays safe metadata only.
- Inline creation is available only when the caller has permission and the selected store/type combination supports creation.
- Picker filters must include type, scope, status, store capability, and consumer context.
## Type Editors
Secret type descriptors provide editor metadata. Studio maps descriptor/editor keys to installed components.
Required v1 editor contracts:
- `Text`: value entry and optional generated value support.
- `RsaKey`: generate or submit key material according to server validation.
- `X509CertificateReference`: submit certificate reference metadata only.
When a type editor is unavailable, Studio should show metadata and block editing payload fields rather than falling back to an unsafe text box.
## Sensitive Workflow Inputs
Inputs marked with existing sensitive metadata are eligible for a secret-aware editor.
Rules:
- Existing literal values are not auto-converted unless a migration/import flow does so explicitly.
- Selecting a secret persists a `SecretReference` value, not the resolved value.
- Workflow execution resolves the latest active version at runtime.

View file

@ -0,0 +1,137 @@
# Data Model: Secrets Module
## Secret
Logical named secret referenced by workflows and modules.
- `TechnicalName`: immutable unique name used by references and exports.
- `DisplayName`: optional user-facing label.
- `Description`: optional safe metadata.
- `Type`: secret type identifier.
- `Scope`: optional classification used by pickers and consumers.
- `Tags`: optional safe labels.
- `StoreName`: selected store identifier.
- `Status`: active, retired, expired, revoked, or deleted.
- `TenantId`, `Owner`, `CreatedAt`, `UpdatedAt`: operational metadata.
- `LatestVersionNumber`: current active version number.
Validation:
- `TechnicalName` is required, trimmed, case-normalized for uniqueness, and immutable after creation.
- Metadata must not contain raw secret values.
- Only one latest active version may exist for a technical name.
## SecretVersion
Versioned value or external-reference payload for a secret.
- `SecretTechnicalName`: parent technical name.
- `Version`: monotonically increasing version number.
- `Status`: active, retired, expired, or revoked.
- `ProtectedPayload`: provider-private encrypted value or lookup metadata.
- `PayloadContentType`: store/type-specific payload descriptor.
- `ExpiresAt`: optional expiration time.
- `CreatedAt`, `UpdatedAt`, `RetiredAt`, `RevokedAt`: lifecycle timestamps.
State transitions:
- Create: no version -> version 1 active latest.
- Rotate/update: current active latest -> retired, new version -> active latest.
- Expire: active -> expired.
- Revoke: active or retired -> revoked.
- Delete: logical secret becomes unavailable; provider decides whether versions are hard-deleted or tombstoned.
## SecretReference
Serializable value stored in workflow definitions and module settings.
- `TechnicalName`: immutable secret technical name.
- `RequiredType`: optional expected secret type.
- `RequiredScope`: optional expected scope.
Resolution:
- Resolve by `TechnicalName`.
- Return the latest active version only.
- Fail for missing, expired, revoked, deleted, incompatible type, incompatible scope, or unavailable store.
## SecretTypeDescriptor
Registered metadata for a secret type.
- `Type`: stable type name.
- `DisplayName`: safe user-facing label.
- `Description`: safe help text.
- `SupportedStoreCapabilities`: required read/write/list/export/test capabilities.
- `EditorContract`: Studio editor descriptor and validation metadata.
- `CanGenerate`: whether the type can generate payloads server-side.
- `CanExportEncrypted`: whether encrypted export is supported.
Built-in v1 types:
- `Text`: standard string value.
- `RsaKey`: generated or provided RSA key material.
- `X509CertificateReference`: certificate reference metadata, not private certificate material.
## SecretStoreDescriptor
Registered metadata for a store implementation.
- `Name`: stable store identifier.
- `DisplayName`: safe user-facing label.
- `Description`: safe help text.
- `Capabilities`: read, write, list, delete, rotate, test, encrypted export.
- `SupportedTypes`: secret types the store can handle.
- `IsDefault`: whether new secrets use this store when no explicit store is selected.
V1 stores:
- `ElsaEncrypted`: writable Elsa-managed encrypted store.
- `Configuration`: read-only deployment configuration store.
## SecretPayload
Store-owned payload attached to a version.
- `StoreName`: store that owns the payload.
- `Type`: secret type.
- `ProtectedData`: encrypted value or provider-private lookup metadata.
- `Metadata`: safe store/type metadata for diagnostics and pickers.
Rules:
- General metadata APIs never return `ProtectedData`.
- Provider-private metadata is not exposed outside store-specific boundaries.
- Cleartext is not persisted in workflow definitions, module settings, logs, audit events, or general API responses.
## SecretImportItem
Safe import/export package representation.
- `TechnicalName`: target secret name.
- `Type`, `Scope`, `Description`: safe metadata.
- `StoreName`: requested target store.
- `ReferenceOnly`: whether no value is included.
- `EncryptedPayload`: optional payload encrypted for the import target.
- `ConflictBehavior`: create-new, update/rotate, skip, or unspecified.
Rules:
- Same-name conflicts fail when `ConflictBehavior` is unspecified.
- Encrypted payload import requires matching decryption material.
- Reference-only import creates or validates metadata without importing a value.
## SecretAuditEvent
Security event emitted for privileged operations.
- `Action`: create, update, rotate, revoke, delete, test, runtime-use, encrypted-export, import, failed-privileged-operation.
- `TechnicalName`: affected secret.
- `Actor`: user or system identity.
- `Timestamp`: event time.
- `Reason`: optional user-provided reason.
- `Result`: success or failure.
Rules:
- Audit events never include raw secret values, encrypted payloads, or provider-private metadata.

View file

@ -0,0 +1,142 @@
# Implementation Plan: Secrets Module
**Branch**: `007-secrets-module` | **Date**: 2026-05-19 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/007-secrets-module/spec.md`
## Summary
Introduce a first-class `Elsa.Secrets` Core/server module that promotes and redesigns the existing `elsa-extensions` secrets work into an Orchard-style system of immutable named secrets, latest-active resolution, pluggable stores, extensible secret types, safe management APIs, import/export contracts, and Studio picker UX. V1 includes an Elsa-managed encrypted store, a configuration-backed read-only store, and paired management/picker UI in `elsa-studio`.
## Technical Context
**Language/Version**: C# latest, nullable reference types enabled, implicit usings enabled.
**Primary Dependencies**: Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, existing Elsa identity/authorization patterns, Elsa workflow input metadata, `Microsoft.Extensions.Configuration`, `Microsoft.AspNetCore.DataProtection`, EF Core persistence infrastructure, mediator notifications, and optional JavaScript expression integration.
**Storage**: In-memory store for tests/development; Elsa-managed encrypted store with EF Core persistence for production; configuration-backed read-only store for deployment-managed values. No cloud vault or OS certificate store provider in v1.
**Testing**: xUnit unit tests for name validation, version lifecycle, store/type capability checks, resolution, no-reveal behavior, configuration store behavior, import/export conflict handling, and API model safety; integration tests for endpoints, permissions, EF Core store behavior, shell feature registration, and migration/adapters from the existing extension model.
**Target Platform**: ASP.NET Core Elsa Server on the repository's supported .NET target frameworks.
**Project Type**: Modular .NET server library with REST API endpoints, persistence providers, and Studio-facing contracts.
**Performance Goals**: Resolve latest-active in-process or database-backed secrets within 50 ms p95 for local store reads under normal server load; list/filter metadata pages within 250 ms p95 for 10,000 secrets with indexed fields; avoid unbounded memory growth in import/export and listing flows.
**Constraints**: Secret technical names are immutable; no cleartext reveal after creation; references resolve latest active version only; same-name import conflicts require explicit operator choice; v1 store scope is limited to Elsa-managed encrypted and configuration-backed read-only stores.
**Scale/Scope**: Core module, management/runtime contracts, API endpoints, two v1 store implementations, secret type registry and three built-in types, import/export contracts, adapter/migration path for `elsa-extensions`, shell feature registration, Studio management/picker UX, documentation, and targeted tests.
## Constitution Check
Evaluated against `.specify/memory/constitution.md` v1.1.0:
| Principle | Verdict | Evidence |
|-----------|---------|----------|
| I. Modular Architecture | PASS | Secrets is a focused module under `src/modules/` with optional persistence provider packages and published contracts for cross-module use. |
| II. Composition & Extensibility | PASS | Secret stores, secret types, value protection, import/export handlers, and picker contexts are explicit extension points. |
| III. Convention-Driven Design | PASS | Endpoints follow single-endpoint classes; features, shell features, stores, contracts, and tests follow repository naming patterns and American English. |
| IV. Async & Pipeline Execution | PASS | Store, resolution, API, import/export, and provider contracts are async and cancellation-aware. |
| V. Testing Discipline | PASS | Plan calls for unit and integration coverage of lifecycle, security, APIs, stores, migration, and shell registration. |
| VI. Trunk-Based Development | PASS | Server/Core and Studio work are isolated to their respective repositories. |
| VII. Simplicity, SRP, DRY & KISS | PASS | V1 limits concrete stores to two, avoids cleartext reveal, and defers cloud vault/certificate providers until contracts prove stable. |
## Project Structure
### Documentation (this feature)
```text
specs/007-secrets-module/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── runtime-contract.md
│ ├── rest-api.md
│ ├── studio-contract.md
│ └── import-export.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
src/modules/
├── Elsa.Secrets/
│ ├── Contracts/
│ ├── Endpoints/Secrets/
│ ├── Extensions/
│ ├── Features/
│ ├── Models/
│ ├── Notifications/
│ ├── Permissions/
│ ├── Providers/Configuration/
│ ├── Providers/InMemory/
│ ├── Services/
│ ├── ShellFeatures/
│ └── UIHints/
├── Elsa.Secrets.Persistence.EFCore/
│ ├── Configurations/
│ ├── Extensions/
│ ├── Features/
│ ├── Migrations/
│ ├── Services/
│ └── ShellFeatures/
├── Elsa.Secrets.Persistence.EFCore.PostgreSql/
├── Elsa.Secrets.Persistence.EFCore.Sqlite/
└── Elsa.Secrets.Persistence.EFCore.SqlServer/
test/unit/
└── Elsa.Secrets.UnitTests/
test/integration/
└── Elsa.Secrets.IntegrationTests/
```
**Structure Decision**: Add `Elsa.Secrets` as the Core/server contract, API, runtime, and in-memory/configuration-store module. Add EF Core persistence as optional provider packages so production encrypted storage follows Elsa persistence conventions without forcing database dependencies into the core module. Add the paired `Elsa.Studio.Secrets` module in the sibling Studio repository.
## Phase 0 Output
See [research.md](./research.md).
Resolved decisions:
- Promote the existing extension module by redesigning its boundaries instead of copying it as-is.
- Use immutable technical names as serialized secret references.
- Resolve references to latest active versions only.
- Disallow user-initiated cleartext reveal after creation.
- Keep v1 stores limited to Elsa-managed encrypted and configuration-backed read-only stores.
- Keep external store providers out of the first server/Core delivery; include the paired Studio management and picker UX.
## Phase 1 Output
- [data-model.md](./data-model.md)
- [contracts/runtime-contract.md](./contracts/runtime-contract.md)
- [contracts/rest-api.md](./contracts/rest-api.md)
- [contracts/studio-contract.md](./contracts/studio-contract.md)
- [contracts/import-export.md](./contracts/import-export.md)
- [quickstart.md](./quickstart.md)
## Post-Design Constitution Re-Check
| Principle | Verdict | Post-design evidence |
|-----------|---------|----------------------|
| I. Modular Architecture | PASS | Data model and contracts separate core runtime, API, persistence, and Studio-facing concerns. |
| II. Composition & Extensibility | PASS | Store/type/provider capability contracts allow later vault and certificate providers without changing consumer references. |
| III. Convention-Driven Design | PASS | REST, model, feature, permission, and test names are aligned with existing Elsa modules. |
| IV. Async & Pipeline Execution | PASS | Contracts use async APIs and cancellation tokens for all I/O and runtime resolution. |
| V. Testing Discipline | PASS | Quickstart and contracts identify targeted unit and integration verification paths. |
| VI. Trunk-Based Development | PASS | Core/server and Studio changes remain separated by repository boundary. |
| VII. Simplicity, SRP, DRY & KISS | PASS | No reveal path, no version pinning, and no cloud vault implementation in this first slice. |
## Phase 2 Handoff
Use `/speckit-tasks` to generate the implementation backlog. Suggested order:
1. Create module and test project skeletons with feature, shell feature, permission, and endpoint registration.
2. Implement models, immutable-name validation, lifecycle services, in-memory store, configuration store, and value protection boundaries.
3. Add REST API contracts/endpoints and no-reveal response models.
4. Add EF Core persistence packages and migrations for supported providers.
5. Add import/export and existing-extension adapter/migration contracts.
6. Add Studio-facing picker/type/store contracts, Studio management/picker UX, and documentation.
7. Add unit/integration tests and quickstart validation.
## Complexity Tracking
No constitution violations identified.

View file

@ -0,0 +1,86 @@
# Quickstart: Secrets Module
## Configure the module
```csharp
services.AddElsa(elsa =>
{
elsa.UseSecrets(secrets =>
{
secrets.ConfigureOptions = options => options.ConfigurationSectionName = "Elsa:Secrets";
});
});
```
The v1 Core/server feature provides:
- Elsa-managed encrypted store.
- Configuration-backed read-only store.
- Metadata-only management APIs.
- Runtime latest-active resolution by immutable technical name.
- Studio management and picker UX in the paired `elsa-studio` module.
## Reference a secret from workflow or module settings
Workflow definitions and module settings store a secret reference, not a value:
```json
{
"technicalName": "orders-db-connection",
"requiredType": "Text",
"requiredScope": "ConnectionString"
}
```
At runtime, callers resolve the latest active version:
```csharp
var secret = await secretResolver.ResolveAsync(new SecretReference
{
TechnicalName = "orders-db-connection",
RequiredType = "Text",
RequiredScope = "ConnectionString"
}, cancellationToken);
```
## Configure read-only secrets
```json
{
"Elsa": {
"Secrets": {
"orders-db-connection": "Host=localhost;Database=orders;Username=elsa;Password=..."
}
}
}
```
Configuration-backed secrets can be selected and resolved, but not rotated or deleted through Elsa.
## Security rules
- Technical names are immutable.
- Current cleartext values cannot be revealed after creation.
- General API responses are metadata-only.
- Tests and audit events never contain raw values.
- Import conflicts require explicit create-new, update/rotate, or skip behavior.
## Studio
Register the paired Studio module with the same backend configuration:
```csharp
services.AddSecretsModule(backendApiConfig);
```
The Studio module adds `/security/secrets` for management plus a reusable `SecretPicker` component for sensitive editors.
## Validation
Run targeted checks after implementation:
```bash
dotnet build src/modules/Elsa.Secrets/Elsa.Secrets.csproj
dotnet test test/unit/Elsa.Secrets.UnitTests/Elsa.Secrets.UnitTests.csproj
dotnet build /Users/sipke/.codex/worktrees/ae40/elsa-studio/src/modules/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj
```

View file

@ -0,0 +1,73 @@
# Research: Secrets Module
## Decision: Redesign the existing extension module before promotion
**Rationale**: The `elsa-extensions` secrets module already has useful pieces: simple runtime resolution, management services, versioning, expiration, EF Core persistence, API endpoints, Studio pages, and JavaScript helpers. It also exposes clear gaps for the requested Orchard-style model: per-secret store choice, extensible types/editors, picker contracts, import/export, complete shell features, and safer API boundaries. Promoting a redesigned module preserves compatibility while avoiding a leaky first-party API.
**Alternatives considered**:
- Copy the extension module as-is: rejected because it keeps Data Protection portability limits, cleartext edit endpoints, and module-level store selection.
- Start from scratch: rejected because the extension already proves useful lifecycle and persistence concepts.
## Decision: Use immutable technical names as serialized references
**Rationale**: The user clarified that technical names cannot be changed; a user creates a new secret when a different technical name is needed. This makes workflow definitions and module settings readable, export-friendly, and stable without needing rename semantics.
**Alternatives considered**:
- Store database IDs: rejected because exports become less readable and cross-environment mapping becomes harder.
- Store mutable names: rejected because renames would break existing consumers.
## Decision: Resolve latest active version only
**Rationale**: Secret rotation should update consumers automatically. Always resolving the latest active version keeps workflow definitions stable and avoids stale credential pinning.
**Alternatives considered**:
- Version pinning: rejected for v1 because it increases UX and lifecycle complexity and can keep consumers on retired credentials.
- Explicit version policy per reference: rejected as unnecessary for current requirements.
## Decision: Do not support cleartext reveal after creation
**Rationale**: A no-reveal model reduces the highest-risk UI/API surface. Operators can replace, rotate, test, use, and encrypted-export secrets without reading the current cleartext value.
**Alternatives considered**:
- Reveal with permission and audit: rejected because it still creates a cleartext disclosure path.
- Reveal only for Elsa-managed stores: rejected because it complicates user expectations and provider capability rules.
## Decision: V1 includes Elsa-managed encrypted and configuration-backed read-only stores
**Rationale**: These two stores cover the most important first-party scenarios: Elsa-owned values and deployment-owned values. They also exercise both writable and read-only store capability paths without adding cloud/provider dependencies.
**Alternatives considered**:
- Include cloud vault and OS certificate stores in v1: rejected because provider-specific behavior should follow after the stable contract lands.
- Define abstractions only: rejected because the module needs a complete usable default.
## Decision: Keep Data Protection for local-at-rest protection, not portable exports
**Rationale**: Data Protection is suitable for values staying in one deployment environment. The transcript's import/export case requires portable encryption material, so exports use an explicit key/certificate/asymmetric target rather than shared application Data Protection keys.
**Alternatives considered**:
- Use Data Protection for export: rejected because isolated environments often do not share keys.
- Store raw values in export with transport security only: rejected because packages are often stored and transferred outside the original channel.
## Decision: Treat import name conflicts as explicit operator choices
**Rationale**: Same-technical-name conflicts can either overwrite an operational secret or leave workflows pointing to stale credentials. Failing by default until the import request chooses create-new, update/rotate, or skip prevents hidden behavior.
**Alternatives considered**:
- Skip by default: rejected because imports can appear successful while leaving missing values.
- Update by default: rejected because imports can unexpectedly rotate production secrets.
## Decision: Define Studio contracts here and implement UI in `elsa-studio`
**Rationale**: Server/Core contracts must shape picker, metadata, permission, and inline-create behavior. The concrete UX belongs in the Studio repository, so this feature adds Studio source code in the paired `elsa-studio` worktree rather than `elsa-core`.
**Alternatives considered**:
- Implement Studio UI in `elsa-core`: rejected because the repository boundary is wrong.
- Exclude Studio entirely: rejected because picker contracts affect server API shape.

View file

@ -0,0 +1,231 @@
# Feature Specification: Secrets Module
**Feature Branch**: `007-secrets-module`
**Created**: 2026-05-19
**Status**: Draft
**Input**: User description: "Design a revamped Secrets module for Elsa Workflows and Elsa Studio inspired by Orchard Core secrets: named secrets, pluggable stores, extensible secret types and editors, secret picker UX, permissions, import/export encryption support, and migration from existing sensitive fields."
## Product Context
Elsa needs a first-class secrets capability for workflow authors, module developers, and operators. The goal is to replace ad hoc secret handling with named secret references that can be resolved consistently at runtime, managed safely in Elsa Studio, and stored in the right backend for each deployment.
The existing `elsa-extensions` implementation provides a useful baseline:
- Core resolution through a simple secret provider.
- Management services for create, update, versioning, expiration, revocation, and name validation.
- In-memory and EF Core persistence.
- API endpoints and a basic Elsa Studio management screen.
- JavaScript expression helpers.
The revamped module should preserve the useful concepts, but expand the model toward the Orchard Core pattern from the transcript: secret values are named, typed, store-backed, extensible, selectable from Studio pickers, and usable by other modules without those modules knowing how or where the secret is stored.
This feature's delivery boundary is the Elsa Core/server module plus the paired Elsa Studio implementation in the sibling `elsa-studio` repository.
## Clarifications
### Session 2026-05-19
- Q: How should workflow definitions and module settings persist secret references? → A: Secret technical names are immutable; users create a new secret when a different technical name is needed.
- Q: Should Studio/API support revealing current cleartext secret values? → A: No cleartext reveal after creation; users can only replace, rotate, use, test, or encrypted-export secrets.
- Q: Which secret version should references resolve? → A: References always resolve the latest active version for the immutable technical name.
- Q: How should import handle existing secret technical-name conflicts? → A: Existing-name conflicts are errors unless the import request explicitly chooses create-new, update/rotate, or skip.
- Q: Which secret stores are in v1 scope? → A: V1 includes an Elsa-managed encrypted store and configuration-backed read-only store; other stores are optional later packages.
- Q: Is Studio implementation part of this feature's delivery boundary? → A: Studio implementation is included in the paired `elsa-studio` repository work.
### Existing Module Gaps To Address
- The current model treats a secret primarily as one encrypted text value with optional scope; it does not distinguish logical secret metadata, typed payload metadata, store metadata, and versioned value material cleanly enough for external stores.
- Store selection is a module-level persistence choice rather than a per-secret authoring decision.
- The current implementation encrypts Elsa-owned values with Data Protection, which is useful for local persistence but not sufficient for moving encrypted secret payloads across isolated environments.
- General API models include encrypted value payloads, and the edit flow can retrieve cleartext through an input model endpoint; the revamped API should use explicit metadata, update, use, test, and encrypted-export boundaries without cleartext reveal.
- The existing extension Studio package provides basic list/create/edit pages, but not a reusable picker for sensitive workflow inputs or module settings; this spec defines the required Studio contracts and UX, while the concrete implementation belongs in `elsa-studio`.
- Secret scopes are hardcoded UI choices rather than a typed, extensible classification system.
- Shell feature classes in the extension are placeholders in several packages and need complete feature registration before promotion.
- JavaScript helpers are generated from secret names, which is convenient but brittle for arbitrary names and should be stabilized.
- There is no import/export contract for references, encrypted value movement, conflict handling, or external store behavior.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Manage Named Secrets (Priority: P1)
An operator can create, view, update, rotate, retire, revoke, and delete named secrets in Elsa Studio without exposing secret values by default. Each secret has metadata that helps users understand what it is for and whether it is usable.
**Why this priority**: This is the core product value. Without a trusted management experience, workflow authors and module developers keep storing sensitive values in workflow definitions, settings, or app configuration.
**Independent Test**: Enable the secrets module, open Studio, create a secret, update it, verify a new active version is available, revoke it, and verify runtime resolution no longer returns a usable value.
**Acceptance Scenarios**:
1. **Given** an authorized operator opens Studio, **When** they create a text secret with a name, value, type, store, and description, **Then** the secret appears in the secrets list without showing its value.
2. **Given** an existing active secret, **When** the operator updates its value, **Then** the module creates a new active version and marks the previous active version as retired.
3. **Given** a revoked or expired secret, **When** runtime code attempts to resolve it, **Then** resolution fails with a clear non-secret error.
4. **Given** a user without read permission opens Studio, **When** they navigate to the secrets section, **Then** they cannot list secret metadata.
5. **Given** a user with metadata read permission, **When** they inspect a secret, **Then** they can see metadata but cannot reveal the current cleartext value.
---
### User Story 2 - Use Secrets From Workflows And Modules (Priority: P1)
A workflow author or module developer can reference a secret by immutable technical name from sensitive inputs, shell settings, and module configuration. The consuming module receives the resolved value at runtime without knowing whether it came from a database, configuration, key vault, certificate store, or another provider.
**Why this priority**: The module exists to remove duplicated password handling from each Elsa module and to keep sensitive workflow inputs out of workflow definition payloads.
**Independent Test**: Configure a workflow activity with a secret reference instead of a literal connection string, execute the workflow, and verify the activity receives the resolved value while the workflow definition stores only the reference.
**Acceptance Scenarios**:
1. **Given** an activity input is marked as sensitive, **When** a workflow author edits the activity in Studio, **Then** Studio offers a secret picker and optional inline secret creation.
2. **Given** a workflow definition contains a secret reference, **When** the workflow runs, **Then** the runtime resolves the latest active version through the secrets service and passes only the resolved value to the consuming activity.
3. **Given** a module setting supports secret references, **When** an operator selects a secret in Studio or shell configuration, **Then** the module stores the immutable technical name and resolves the value only when needed.
4. **Given** a secret value is unavailable because the store is unreachable, **When** runtime resolution is attempted, **Then** the caller receives a clear resolution failure without leaking the reference target's value or backend details.
---
### User Story 3 - Choose Secret Types And Stores (Priority: P2)
An operator can choose what kind of secret they are creating and where it is stored. Different secret types have different editors, validation rules, display metadata, and resolution behavior.
**Why this priority**: Orchard's design is valuable because it separates the logical secret from its storage backend and type-specific authoring experience. Elsa needs the same extension points for cloud stores, certificates, keys, and module-specific secret shapes.
**Independent Test**: Register the v1 stores and secret types, create one secret in the built-in encrypted store and one secret referencing a configuration-backed read-only value, then resolve both through the same runtime service.
**Acceptance Scenarios**:
1. **Given** multiple secret stores are available, **When** an operator creates a secret, **Then** Studio requires them to choose a supported store or accepts a configured default.
2. **Given** a text secret type is selected, **When** the editor is shown, **Then** Studio presents a value editor and validates required text input.
3. **Given** an RSA key type is selected, **When** the editor is shown, **Then** Studio supports generating or entering key material according to the registered type rules.
4. **Given** an X.509 certificate reference type is selected, **When** the editor is shown, **Then** Studio supports selecting or entering certificate identity metadata without copying certificate private material into Elsa-managed storage.
5. **Given** an external store does not support writing values from Studio, **When** an operator creates a secret reference for that store, **Then** Studio only asks for the lookup metadata the store supports.
---
### User Story 4 - Export, Import, And Move Securely (Priority: P2)
An operator can export workflows and configuration that contain secret references without accidentally exporting raw secret values. When secret values must move between environments, the export process encrypts them for an explicit import target or key.
**Why this priority**: The transcript identifies import/export as a key reason for the module. Elsa needs a safe way to move workflow packages between isolated staging and production environments without relying on shared Data Protection keys.
**Independent Test**: Export a workflow package containing a secret-backed setting, inspect the package to verify no raw secret value exists, import into another environment with the expected decryption material, and verify the imported reference resolves.
**Acceptance Scenarios**:
1. **Given** a workflow contains secret references, **When** the workflow is exported without value export enabled, **Then** the export contains only references and metadata safe for transport.
2. **Given** an authorized operator explicitly exports secret values, **When** they choose an export encryption target, **Then** exported values are encrypted for that target and cannot be read as raw payload text.
3. **Given** an import package contains encrypted secret payloads, **When** the target environment has the matching import key or certificate, **Then** the import can create or update the corresponding secrets.
4. **Given** the import target cannot decrypt a secret payload, **When** import runs, **Then** the importer reports which secret could not be imported without revealing the value.
5. **Given** an environment uses external store references, **When** export runs, **Then** the operator can choose whether to export only references or include encrypted values where the store permits reading.
6. **Given** an import package contains a secret whose technical name already exists, **When** the operator has not selected create-new, update/rotate, or skip for that conflict, **Then** import fails that item without changing the existing secret.
---
### User Story 5 - Govern Access And Audit Use (Priority: P3)
Administrators can delegate secret metadata management, value updates, encrypted export, and runtime use independently. The system records security-relevant events so operators can answer who changed, used, tested, exported, or deleted a secret.
**Why this priority**: Secrets are a security boundary. The module should avoid granting broad access just because a user can manage the consuming feature, such as SMTP, SQL, or Service Bus configuration.
**Independent Test**: Assign a user permissions for metadata management but not encrypted export, verify they can rotate a secret without seeing the old value, then verify audit events are emitted for create, update, use/test, export, revoke, and delete.
**Acceptance Scenarios**:
1. **Given** a user can manage a module that consumes a secret, **When** they edit that module's settings, **Then** they can select allowed secrets without gaining permission to update, test, or encrypted-export those secrets.
2. **Given** a user tests or encrypted-exports a secret value, **When** the operation succeeds, **Then** the module records an audit event containing actor, action, secret identity, time, and reason where provided.
3. **Given** a user can update a secret value, **When** they rotate or replace it, **Then** Studio lets them submit a replacement value without showing the current value.
### Edge Cases
- A secret technical name collides with another secret after trimming or case normalization.
- A user wants to change a secret's technical name; the system requires creating a new secret and repointing consumers.
- A secret store supports reading but not writing, listing, deletion, versioning, testing, or encrypted export.
- A workflow references a secret that was deleted, revoked, expired, or moved to an unavailable store.
- A workflow was created before a secret was rotated; the next run resolves the new latest active version automatically.
- Multiple versions exist and more than one is incorrectly marked latest.
- A secret value is too large for the selected store.
- A secret type editor is unavailable in Studio because the related plugin is not installed.
- Import attempts to create or update a secret whose technical name already exists.
- A user can use a secret through a workflow but cannot read its metadata in Studio.
- Logs, validation errors, API responses, export packages, and Studio notifications must not contain raw secret values.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST support named logical secrets that can be referenced by immutable technical name from workflows, module settings, and other Elsa features without embedding raw secret values in those artifacts.
- **FR-002**: The system MUST distinguish secret metadata from secret values so callers can list and inspect safe metadata without retrieving or revealing values.
- **FR-003**: The system MUST support explicit value resolution by immutable technical name through a runtime service available to workflows and modules.
- **FR-004**: The system MUST support secret versioning, including a single active latest version per logical secret and retired previous versions after rotation.
- **FR-005**: The system MUST support secret statuses including active, retired, expired, and revoked.
- **FR-006**: The system MUST prevent expired, revoked, deleted, or otherwise inactive secrets from resolving as usable values.
- **FR-007**: The system MUST support pluggable secret stores behind a common abstraction.
- **FR-008**: The system MUST include a built-in encrypted Elsa-managed store suitable for development and simple production deployments.
- **FR-009**: The system MUST support read-only or externally managed stores where Elsa stores lookup metadata but does not own the underlying value.
- **FR-010**: V1 MUST support configuration-backed read-only secrets so deployments can reference values from existing application configuration.
- **FR-011**: Cloud vault, operating-system certificate stores, and other external stores SHOULD be supported through optional later provider packages after the v1 store abstraction is stable.
- **FR-012**: The system MUST support extensible secret types with type metadata, validation, and Studio editor registration.
- **FR-013**: The first supported secret types MUST include text value, RSA key material, and X.509 certificate reference.
- **FR-014**: The system MUST let secret type providers declare which stores they support and which operations they allow.
- **FR-015**: The system MUST expose management APIs for listing metadata, creating, updating, rotating, revoking, deleting, and validating secret names.
- **FR-016**: The system MUST require explicit permission for encrypted value export, value update, metadata read, metadata write, deletion, runtime use, and test operations.
- **FR-017**: The system MUST not return raw secret values from general list, get, validation, or picker APIs.
- **FR-018**: The system MUST NOT support user-initiated cleartext reveal of current secret values after creation.
- **FR-019**: The system MUST record audit events for create, update, rotate, revoke, delete, runtime use/test where observable, encrypted export, import, and failed privileged operations.
- **FR-020**: The Core/server feature MUST define the Studio contract for a Secrets area under security or settings where authorized users can search, filter, create, edit, rotate, revoke, delete, and inspect metadata.
- **FR-021**: The Core/server feature MUST define the Studio contract for a reusable secret picker component that can be used by workflow activity editors and module settings editors.
- **FR-022**: The secret picker contract MUST support filtering by allowed type, allowed scope, status, store capability, and consuming context.
- **FR-023**: The secret picker contract MUST support creating a new compatible secret inline when the user has permission.
- **FR-024**: Sensitive workflow inputs marked by existing metadata MUST be eligible for secret reference editing in Studio.
- **FR-025**: Workflow definitions MUST persist secret references rather than resolved values when a secret is selected.
- **FR-026**: Runtime workflow execution MUST resolve selected secret references only at the point of use.
- **FR-027**: The system MUST provide import/export behavior that never includes raw secret values unless an authorized operator explicitly chooses an encrypted value export.
- **FR-028**: Encrypted secret export MUST use an explicit import/export key, certificate, or asymmetric target that can work across environments without shared application data-protection keys.
- **FR-029**: Import MUST support creating missing secrets, updating compatible existing secrets, skipping conflicting secrets, and reporting conflicts without revealing values.
- **FR-030**: The system MUST provide a migration path from the existing `elsa-extensions` secrets model to the revamped model.
- **FR-031**: The migration path MUST preserve secret names, descriptions, scopes, expiration, statuses, latest-version semantics, and EF Core persisted values where practical.
- **FR-032**: Existing `ISecretProvider.GetSecretAsync(name)` consumers SHOULD continue to work through an adapter while newer code moves to richer secret reference and resolution APIs.
- **FR-033**: Shell feature registration MUST be complete and usable for Core, Management, API, Scripting, and Studio-facing capabilities.
- **FR-034**: The system MUST avoid logging raw secret values in success paths, failures, validation messages, audit records, and import/export diagnostics.
- **FR-035**: JavaScript expression integration SHOULD expose stable secret access helpers without generating unsafe identifiers from arbitrary secret names.
- **FR-036**: The system MUST not expose encrypted payloads, external-store lookup secrets, or provider-private metadata through general metadata APIs.
- **FR-037**: The system MUST treat the secret technical name as immutable after creation; changing the technical name requires creating a new secret and updating consumers to reference it.
- **FR-038**: The system MUST allow users to replace or rotate a secret value without retrieving the previous cleartext value.
- **FR-039**: The system MUST resolve secret references to the latest active version for the referenced immutable technical name.
- **FR-040**: Import MUST treat same-technical-name conflicts as errors unless the import request explicitly chooses create-new, update/rotate, or skip behavior for the conflict.
- **FR-041**: V1 MUST include exactly two production-oriented store implementations: an Elsa-managed encrypted store and a configuration-backed read-only store.
- **FR-042**: The concrete Studio UI implementation MUST be delivered in the paired `elsa-studio` repository.
### Key Entities *(include if feature involves data)*
- **Secret**: A logical named item users and modules reference. It has an immutable technical name, optional display metadata, type, scope/tags, status, owner/tenant metadata, and links to versions.
- **Secret Version**: A specific version of a secret's value or external reference metadata. It has version number, status, creation time, optional expiration, and store-specific payload metadata.
- **Secret Type**: Describes how a secret is authored, validated, displayed, resolved, and optionally exported. Examples: text, RSA key, X.509 certificate reference.
- **Secret Store**: A backend capable of reading or writing secret payloads or references. Examples: Elsa encrypted database store, configuration store, key vault, OS certificate store.
- **Secret Reference**: The serializable value stored in workflow definitions or module settings. It identifies the logical secret by immutable technical name and optionally constrains type or required scope; runtime resolution uses the latest active version.
- **Secret Picker Context**: Metadata supplied by a consuming UI so Studio can filter compatible secrets and inline creation options.
- **Secret Export Package Item**: A safe representation of a secret in an export package, containing references only or values encrypted for an explicit target.
- **Audit Event**: A security record describing privileged secret operations without containing the secret value.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An authorized user can create a text secret in Studio, select it from a sensitive workflow input, run the workflow, and verify the workflow definition contains no raw secret value.
- **SC-002**: Metadata list and detail API responses contain zero raw secret values in automated contract tests.
- **SC-003**: A secret rotation produces exactly one active latest version and preserves at least one retired previous version in tests.
- **SC-004**: A user can manage metadata and replace values without any API or Studio path exposing the current cleartext value.
- **SC-005**: Export tests can scan package content and verify raw secret values are absent unless encrypted export was explicitly requested.
- **SC-006**: Import tests can move an encrypted secret export between two environments without shared Data Protection keys.
- **SC-007**: At least three secret types and the two v1 store implementations can be registered and exercised through the same Studio and runtime resolution surfaces.
- **SC-008**: Existing `elsa-extensions` secret records can be migrated or adapted with no loss of name, latest active value, status, expiration, or description in migration tests.
- **SC-009**: All privileged operations produce audit events without raw values.
- **SC-010**: Runtime resolution of unavailable, revoked, expired, or unauthorized secrets fails deterministically with non-secret error messages.
## Assumptions
- The revamped module will live in Elsa Core or be promoted from `elsa-extensions` into the main Elsa module structure.
- Elsa Studio implementation happens in the `elsa-studio` repository, while server contracts live with the Core module.
- Existing `InputAttribute.CanContainSecrets` metadata is the primary signal for replacing plain editors with a secret-aware editor.
- Data Protection remains acceptable for encrypting values that stay in one deployment environment, but import/export must use explicit portable cryptographic material.
- Database persistence remains optional; in-memory storage is acceptable for tests and development only.
- Secret values are not shown in Studio or API after creation, even to administrators.
- Hashing-only identity credentials remain separate from retrievable secrets unless a future migration explicitly changes that boundary.
- Provider-specific integrations such as Azure Key Vault and operating-system certificate stores are optional packages after the base abstraction is stable.

View file

@ -0,0 +1,72 @@
# Tasks: Secrets Module
**Input**: Design documents from `/specs/007-secrets-module/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Included because the feature is security-sensitive and the specification requires automated safety checks.
## Phase 1: Setup
- [x] T001 Create `src/modules/Elsa.Secrets` and `test/unit/Elsa.Secrets.UnitTests` project structure.
- [x] T002 Add the Secrets projects to `Elsa.sln`.
- [x] T003 Add paired Studio module structure in `/Users/sipke/.codex/worktrees/ae40/elsa-studio/src/modules/Elsa.Studio.Secrets`.
## Phase 2: Foundational
- [x] T004 Implement secret models, status, references, descriptors, and request/response DTOs in `src/modules/Elsa.Secrets/Models`.
- [x] T005 Implement runtime and management contracts in `src/modules/Elsa.Secrets/Contracts`.
- [x] T006 Implement secret value protection, name validation, type registry, and store registry services in `src/modules/Elsa.Secrets/Services`.
- [x] T007 Implement the Elsa-managed encrypted store and configuration-backed read-only store in `src/modules/Elsa.Secrets/Stores`.
- [x] T008 Register module and shell features in `src/modules/Elsa.Secrets/Features`, `src/modules/Elsa.Secrets/ShellFeatures`, and `src/modules/Elsa.Secrets/Extensions`.
## Phase 3: User Story 1 - Manage Named Secrets (P1)
**Goal**: Operators can create, inspect safe metadata, rotate, revoke, and delete secrets without cleartext reveal.
**Independent Test**: Create a text secret, rotate it, verify the old version is retired, revoke it, and verify resolution fails.
- [x] T009 [US1] Add unit tests for name immutability, rotation, revocation, and no-reveal metadata in `test/unit/Elsa.Secrets.UnitTests/SecretManagerTests.cs`.
- [x] T010 [US1] Implement secret manager lifecycle operations in `src/modules/Elsa.Secrets/Services/DefaultSecretManager.cs`.
- [x] T011 [US1] Implement list/get/create/rotate/revoke/delete/test endpoints in `src/modules/Elsa.Secrets/Endpoints/Secrets`.
## Phase 4: User Story 2 - Use Secrets From Workflows And Modules (P1)
**Goal**: Workflows and modules can resolve immutable secret references to latest active values.
**Independent Test**: Resolve a reference before and after rotation and verify the latest active value is returned.
- [x] T012 [US2] Add unit tests for latest-active reference resolution in `test/unit/Elsa.Secrets.UnitTests/SecretResolverTests.cs`.
- [x] T013 [US2] Implement `ISecretResolver` and legacy `ISecretProvider` adapter in `src/modules/Elsa.Secrets/Services`.
- [x] T014 [US2] Add Studio picker contract DTOs and endpoint in `src/modules/Elsa.Secrets/Endpoints/Secrets/Picker`.
## Phase 5: User Story 3 - Choose Secret Types And Stores (P2)
**Goal**: Operators can pick compatible types and stores, including read-only configuration-backed references.
**Independent Test**: Resolve one encrypted text secret and one configuration-backed secret through the same resolver.
- [x] T015 [US3] Add unit tests for type descriptors, store descriptors, and configuration store resolution in `test/unit/Elsa.Secrets.UnitTests/SecretStoreTests.cs`.
- [x] T016 [US3] Implement text, RSA key, and X.509 reference descriptors in `src/modules/Elsa.Secrets/Types`.
- [x] T017 [US3] Implement descriptors endpoint in `src/modules/Elsa.Secrets/Endpoints/Secrets/Descriptors`.
## Phase 6: Studio UX
**Goal**: Elsa Studio provides a Security > Secrets area plus reusable picker/create UX.
**Independent Test**: Build the Studio module and inspect the management pages/components compile against the server API contract.
- [x] T018 Add Studio API client and models in `/Users/sipke/.codex/worktrees/ae40/elsa-studio/src/modules/Elsa.Studio.Secrets`.
- [x] T019 Add Studio menu, service registration, list/detail/create dialogs, and picker component in `/Users/sipke/.codex/worktrees/ae40/elsa-studio/src/modules/Elsa.Studio.Secrets`.
- [x] T020 Add Studio module project to `/Users/sipke/.codex/worktrees/ae40/elsa-studio/Elsa.Studio.sln` and host/bundle project references.
## Phase 7: Polish
- [x] T021 Update quickstart/docs for server and Studio configuration.
- [x] T022 Run targeted server and Studio builds/tests and fix compile errors.
## Dependencies
- Phase 1 before all implementation.
- Phase 2 blocks all user stories.
- US1 and US2 are the MVP and must pass before Studio UX is considered complete.
- Studio UX depends on the REST contract from US1-US3.
## Implementation Strategy
Implement the smallest secure end-to-end slice first: in-memory metadata, encrypted Elsa-managed values, configuration-backed references, safe management endpoints, runtime resolver, Studio list/detail/create/rotate/revoke UX, and picker component. EF persistence and additional providers can follow once the API and UX shape is validated.

View file

@ -0,0 +1,16 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretManager
{
Task<Secret> CreateAsync(CreateSecretRequest request, CancellationToken cancellationToken = default);
Task<Secret?> GetAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<Secret>> ListAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<ListSecretsResult> ListPageAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<long> CountAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default);
Task<Secret?> RevokeAsync(string name, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string name, CancellationToken cancellationToken = default);
Task<SecretTestResponse> TestAsync(string name, CancellationToken cancellationToken = default);
Task<SecretPayload> ResolvePayloadAsync(string name, CancellationToken cancellationToken = default);
Task<SecretPayload> ResolvePayloadAsync(Secret secret, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretNameValidator
{
bool IsValid(string? name, out string? error);
string Normalize(string name);
}

View file

@ -0,0 +1,9 @@
namespace Elsa.Secrets.Contracts;
/// <summary>
/// Backward-compatible adapter surface for existing extension consumers.
/// </summary>
public interface ISecretProvider
{
Task<string?> GetSecretAsync(string name, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretRepository
{
Task<Secret?> GetAsync(string normalizedName, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<Secret>> ListAsync(CancellationToken cancellationToken = default);
Task AddAsync(Secret secret, CancellationToken cancellationToken = default);
Task<bool> TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default);
Task SaveAsync(Secret secret, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretResolver
{
Task<string> ResolveAsync(string name, CancellationToken cancellationToken = default);
Task<string> ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,11 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretStore
{
string Name { get; }
SecretStoreDescriptor Descriptor { get; }
Task<SecretPayload> WriteAsync(Secret secret, SecretVersion version, SecretPayload payload, CancellationToken cancellationToken = default);
Task<SecretPayload?> ReadAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default);
Task DeleteAsync(Secret secret, CancellationToken cancellationToken = default);
Task<bool> TestAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretStoreRegistry
{
IReadOnlyCollection<ISecretStore> List();
ISecretStore Get(string name);
bool TryGet(string name, out ISecretStore? store);
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretTypeProvider
{
SecretTypeDescriptor Descriptor { get; }
bool Validate(CreateSecretRequest request, out string? error);
bool ValidateRotation(RotateSecretRequest request, string storeName, out string? error);
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretTypeRegistry
{
IReadOnlyCollection<SecretTypeDescriptor> List();
ISecretTypeProvider Get(string name);
bool TryGet(string name, out ISecretTypeProvider? provider);
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Secrets.Contracts;
public interface ISecretValueProtector
{
string Protect(string value);
string Unprotect(string protectedValue);
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides named secret management, runtime resolution, and Studio-facing APIs for Elsa hosts.
</Description>
<PackageTags>elsa module secrets security workflow studio</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,33 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Create;
internal class Endpoint(ISecretManager manager) : ElsaEndpoint<CreateSecretRequest, SecretModel>
{
public override void Configure()
{
Post("/secrets");
ConfigurePermissions(SecretsPermissions.Write);
}
public override async Task HandleAsync(CreateSecretRequest request, CancellationToken cancellationToken)
{
try
{
var secret = await manager.CreateAsync(request, cancellationToken);
await Send.OkAsync(secret.ToModel(), cancellationToken);
}
catch (InvalidOperationException e)
{
AddError(e.Message);
await Send.ErrorsAsync(cancellation: cancellationToken);
}
catch (ArgumentException e)
{
AddError(e.Message);
await Send.ErrorsAsync(cancellation: cancellationToken);
}
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
namespace Elsa.Secrets.Endpoints.Secrets.Delete;
internal class Endpoint(ISecretManager manager) : ElsaEndpointWithoutRequest
{
public override void Configure()
{
Delete("/secrets/{name}");
ConfigurePermissions(SecretsPermissions.Delete);
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var deleted = await manager.DeleteAsync(Route<string>("name")!, cancellationToken);
if (deleted)
await Send.NoContentAsync(cancellationToken);
else
await Send.NotFoundAsync(cancellationToken);
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
namespace Elsa.Secrets.Endpoints.Secrets.Descriptors;
internal class Endpoint(ISecretTypeRegistry typeRegistry, ISecretStoreRegistry storeRegistry) : ElsaEndpointWithoutRequest<SecretDescriptorsResponse>
{
public override void Configure()
{
Get("/secrets/descriptors");
ConfigurePermissions(SecretsPermissions.Read);
}
public override Task<SecretDescriptorsResponse> ExecuteAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new SecretDescriptorsResponse
{
Types = typeRegistry.List().ToList(),
Stores = storeRegistry.List().Select(x => x.Descriptor).ToList()
});
}
}

View file

@ -0,0 +1,26 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Get;
internal class Endpoint(ISecretManager manager) : ElsaEndpointWithoutRequest<SecretModel>
{
public override void Configure()
{
Get("/secrets/{name}");
ConfigurePermissions(SecretsPermissions.Read);
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var secret = await manager.GetAsync(Route<string>("name")!, cancellationToken);
if (secret == null)
{
await Send.NotFoundAsync(cancellationToken);
return;
}
await Send.OkAsync(secret.ToModel(), cancellationToken);
}
}

View file

@ -0,0 +1,21 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.List;
internal class Endpoint(ISecretManager manager) : ElsaEndpoint<ListSecretsRequest, ListSecretsResponse>
{
public override void Configure()
{
Get("/secrets");
ConfigurePermissions(SecretsPermissions.Read);
}
public override async Task<ListSecretsResponse> ExecuteAsync(ListSecretsRequest request, CancellationToken cancellationToken)
{
var result = await manager.ListPageAsync(request, cancellationToken);
var models = result.Items.Select(x => x.ToModel()).ToList();
return new ListSecretsResponse { Items = models, TotalCount = result.TotalCount };
}
}

View file

@ -0,0 +1,36 @@
using Elsa.Abstractions;
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Picker;
internal class Endpoint(ISecretManager manager, ISecretStoreRegistry storeRegistry) : ElsaEndpoint<SecretPickerRequest, SecretPickerResponse>
{
public override void Configure()
{
Post("/secrets/picker");
ConfigurePermissions(SecretsPermissions.Read);
}
public override async Task<SecretPickerResponse> ExecuteAsync(SecretPickerRequest request, CancellationToken cancellationToken)
{
var listRequest = new ListSecretsRequest
{
Search = request.Search,
TypeNames = request.TypeNames,
StoreNames = request.StoreNames,
Scope = request.Scope,
Status = request.ActiveOnly ? SecretStatus.Active : null,
PageSize = 100
};
var items = await manager.ListAsync(listRequest, cancellationToken);
var models = items
.Select(x => x.ToModel())
.ToList();
var canCreate = storeRegistry.List().Any(x => !x.Descriptor.IsReadOnly);
return new SecretPickerResponse { Items = models, CanCreateInline = canCreate };
}
}

View file

@ -0,0 +1,27 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Revoke;
internal class Endpoint(ISecretManager manager) : ElsaEndpointWithoutRequest<SecretModel>
{
public override void Configure()
{
Post("/secrets/{name}/revoke");
ConfigurePermissions(SecretsPermissions.Write);
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var name = Route<string>("name")!;
var secret = await manager.RevokeAsync(name, cancellationToken);
if (secret == null)
{
await Send.NotFoundAsync(cancellationToken);
return;
}
await Send.OkAsync(secret.ToModel(), cancellationToken);
}
}

View file

@ -0,0 +1,32 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Rotate;
internal class Endpoint(ISecretManager manager) : ElsaEndpoint<RotateSecretRequest, SecretModel>
{
public override void Configure()
{
Post("/secrets/{name}/rotate");
ConfigurePermissions(SecretsPermissions.Write);
}
public override async Task HandleAsync(RotateSecretRequest request, CancellationToken cancellationToken)
{
try
{
var secret = await manager.RotateAsync(Route<string>("name")!, request, cancellationToken);
await Send.OkAsync(secret.ToModel(), cancellationToken);
}
catch (InvalidOperationException e)
{
AddError(e.Message);
await Send.ErrorsAsync(cancellation: cancellationToken);
}
catch (KeyNotFoundException)
{
await Send.NotFoundAsync(cancellationToken);
}
}
}

View file

@ -0,0 +1,18 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
namespace Elsa.Secrets.Endpoints.Secrets.Test;
internal class Endpoint(ISecretManager manager) : ElsaEndpointWithoutRequest<SecretTestResponse>
{
public override void Configure()
{
Post("/secrets/{name}/test");
ConfigurePermissions(SecretsPermissions.Test);
}
public override Task<SecretTestResponse> ExecuteAsync(CancellationToken cancellationToken)
{
return manager.TestAsync(Route<string>("name")!, cancellationToken);
}
}

View file

@ -0,0 +1,13 @@
using Elsa.Features.Services;
using Elsa.Secrets.Features;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
public static class ModuleExtensions
{
public static IModule UseSecrets(this IModule module, Action<SecretsFeature>? configure = null)
{
return module.Use(configure);
}
}

View file

@ -0,0 +1,34 @@
using Elsa.Secrets.Repositories;
using Elsa.Secrets.Services;
using Elsa.Secrets.Stores;
using Elsa.Secrets.Types;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Elsa.Secrets.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSecretsServices(this IServiceCollection services, Action<SecretsOptions>? configureOptions = null)
{
if (configureOptions != null)
services.Configure(configureOptions);
services.AddOptions<SecretsOptions>();
services.TryAddSingleton<ISecretNameValidator, DefaultSecretNameValidator>();
services.TryAddSingleton<ISecretRepository, FileSecretRepository>();
services.TryAddSingleton<ISecretValueProtector, DefaultSecretValueProtector>();
services.TryAddSingleton<ISecretManager, DefaultSecretManager>();
services.TryAddSingleton<ISecretResolver, DefaultSecretResolver>();
services.TryAddSingleton<ISecretProvider, SecretProviderAdapter>();
services.TryAddSingleton<ISecretStoreRegistry, SecretStoreRegistry>();
services.TryAddSingleton<ISecretTypeRegistry, SecretTypeRegistry>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretStore, EncryptedSecretStore>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretStore, ConfigurationSecretStore>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretTypeProvider, TextSecretTypeProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretTypeProvider, RsaKeySecretTypeProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretTypeProvider, X509CertificateSecretTypeProvider>());
return services;
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Secrets.Extensions;
namespace Elsa.Secrets.Features;
public class SecretsFeature(IModule module) : FeatureBase(module)
{
public Action<SecretsOptions>? ConfigureOptions { get; set; }
public override void Configure()
{
Module.AddFastEndpointsAssembly<SecretsFeature>();
}
public override void Apply()
{
Services.AddSecretsServices(ConfigureOptions);
Module.AddFastEndpointsFromModule();
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

View file

@ -0,0 +1,40 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Secrets.Models;
public class CaseInsensitiveHashSetConverter : JsonConverter<HashSet<string>>
{
public override HashSet<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (reader.TokenType != JsonTokenType.StartArray)
throw new JsonException("Expected an array of strings.");
var values = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
return values;
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("Expected a string tag.");
var value = reader.GetString();
if (!string.IsNullOrWhiteSpace(value))
values.Add(value);
}
throw new JsonException("Unexpected end of JSON while reading secret tags.");
}
public override void Write(Utf8JsonWriter writer, HashSet<string> value, JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var item in value.Order(StringComparer.OrdinalIgnoreCase))
writer.WriteStringValue(item);
writer.WriteEndArray();
}
}

View file

@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Secrets.Models;
public class OrdinalIgnoreCaseDictionaryConverter : JsonConverter<IDictionary<string, string>>
{
public override IDictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected an object of string metadata values.");
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return values;
if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException("Expected a metadata property name.");
var key = reader.GetString()!;
if (!reader.Read())
throw new JsonException("Unexpected end of JSON while reading secret metadata.");
if (reader.TokenType == JsonTokenType.Null)
continue;
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("Expected a string metadata value.");
var value = reader.GetString();
if (value != null)
values[key] = value;
}
throw new JsonException("Unexpected end of JSON while reading secret metadata.");
}
public override void Write(Utf8JsonWriter writer, IDictionary<string, string> value, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var item in value.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
writer.WriteString(item.Key, item.Value);
writer.WriteEndObject();
}
}

View file

@ -0,0 +1,23 @@
namespace Elsa.Secrets.Models;
public class Secret
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string? Description { get; set; }
public string TypeName { get; set; } = SecretTypeNames.Text;
public string StoreName { get; set; } = SecretStoreNames.Encrypted;
public string? Scope { get; set; }
[System.Text.Json.Serialization.JsonConverter(typeof(CaseInsensitiveHashSetConverter))]
public HashSet<string> Tags { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public SecretStatus Status { get; set; } = SecretStatus.Active;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
public IList<SecretVersion> Versions { get; set; } = [];
public SecretVersion? LatestActiveVersion => Versions
.Where(x => x.Status == SecretStatus.Active && !x.IsExpired())
.OrderByDescending(x => x.Version)
.FirstOrDefault();
}

View file

@ -0,0 +1,93 @@
namespace Elsa.Secrets.Models;
public class SecretModel
{
public string Id { get; set; } = default!;
public string Name { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string? Description { get; set; }
public string TypeName { get; set; } = default!;
public string StoreName { get; set; } = default!;
public string? Scope { get; set; }
public ICollection<string> Tags { get; set; } = [];
public SecretStatus Status { get; set; }
public int? CurrentVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
}
public class CreateSecretRequest
{
public string Name { get; set; } = default!;
public string? DisplayName { get; set; }
public string? Description { get; set; }
public string TypeName { get; set; } = SecretTypeNames.Text;
public string StoreName { get; set; } = SecretStoreNames.Encrypted;
public string? Scope { get; set; }
public ICollection<string> Tags { get; set; } = [];
public string? Value { get; set; }
public string? ConfigurationKey { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public IDictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public class RotateSecretRequest
{
public string? Value { get; set; }
public string? ConfigurationKey { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public IDictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public class ListSecretsRequest
{
public string? Search { get; set; }
public string? TypeName { get; set; }
public ICollection<string> TypeNames { get; set; } = [];
public string? StoreName { get; set; }
public ICollection<string> StoreNames { get; set; } = [];
public string? Scope { get; set; }
public SecretStatus? Status { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
}
public class ListSecretsResponse
{
public ICollection<SecretModel> Items { get; set; } = [];
public long TotalCount { get; set; }
}
public class ListSecretsResult
{
public IReadOnlyCollection<Secret> Items { get; set; } = [];
public long TotalCount { get; set; }
}
public class SecretDescriptorsResponse
{
public ICollection<SecretTypeDescriptor> Types { get; set; } = [];
public ICollection<SecretStoreDescriptor> Stores { get; set; } = [];
}
public class SecretPickerRequest
{
public string? Search { get; set; }
public ICollection<string> TypeNames { get; set; } = [];
public ICollection<string> StoreNames { get; set; } = [];
public string? Scope { get; set; }
public bool ActiveOnly { get; set; } = true;
}
public class SecretPickerResponse
{
public ICollection<SecretModel> Items { get; set; } = [];
public bool CanCreateInline { get; set; } = true;
}
public class SecretTestResponse
{
public bool Succeeded { get; set; }
public string? Error { get; set; }
}

View file

@ -0,0 +1,28 @@
namespace Elsa.Secrets.Models;
public static class SecretStoreNames
{
public const string Encrypted = "encrypted";
public const string Configuration = "configuration";
}
public static class SecretTypeNames
{
public const string Text = "text";
public const string RsaKey = "rsa-key";
public const string X509Certificate = "x509-certificate";
}
public record SecretStoreDescriptor(
string Name,
string DisplayName,
string Description,
SecretStoreCapabilities Capabilities,
bool IsReadOnly);
public record SecretTypeDescriptor(
string Name,
string DisplayName,
string Description,
string EditorHint,
IReadOnlyCollection<string> SupportedStoreNames);

View file

@ -0,0 +1,10 @@
namespace Elsa.Secrets.Models;
public class SecretPayload
{
public string? Value { get; set; }
[System.Text.Json.Serialization.JsonConverter(typeof(OrdinalIgnoreCaseDictionaryConverter))]
public IDictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public static SecretPayload FromValue(string? value) => new() { Value = value };
}

View file

@ -0,0 +1,3 @@
namespace Elsa.Secrets.Models;
public record SecretReference(string Name, string? TypeName = null, string? Scope = null);

View file

@ -0,0 +1,10 @@
namespace Elsa.Secrets.Models;
public enum SecretStatus
{
Active,
Retired,
Expired,
Revoked,
Deleted
}

View file

@ -0,0 +1,13 @@
namespace Elsa.Secrets.Models;
[Flags]
public enum SecretStoreCapabilities
{
None = 0,
Read = 1,
Write = 2,
Delete = 4,
Test = 8,
ExportEncrypted = 16,
Versioned = 32
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Secrets.Models;
public class SecretVersion
{
public int Version { get; set; }
public SecretStatus Status { get; set; } = SecretStatus.Active;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ExpiresAt { get; set; }
public SecretPayload Payload { get; set; } = new();
public bool IsExpired() => ExpiresAt <= DateTimeOffset.UtcNow || Status == SecretStatus.Expired;
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Secrets.Options;
public class SecretsOptions
{
public static string DefaultRepositoryFilePath { get; } = Path.Join(AppContext.BaseDirectory, "App_Data", "elsa-secrets.json");
public string ConfigurationSectionName { get; set; } = "Elsa:Secrets";
public string? RepositoryFilePath { get; set; }
public byte[]? EncryptionKey { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Secrets.Permissions;
public static class SecretsPermissions
{
public const string Read = "read:secrets";
public const string Write = "write:secrets";
public const string Delete = "delete:secrets";
public const string Test = "test:secrets";
public const string Use = "use:secrets";
public const string Export = "export:secrets";
public const string Import = "import:secrets";
}

View file

@ -0,0 +1,146 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Secrets.Repositories;
public class FileSecretRepository(IOptions<SecretsOptions> options, ILogger<FileSecretRepository>? logger = null) : ISecretRepository
{
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
WriteIndented = true
};
public async Task<Secret?> GetAsync(string normalizedName, CancellationToken cancellationToken = default)
{
var secrets = await ReadAllAsync(cancellationToken);
return secrets.FirstOrDefault(x => string.Equals(x.Name, normalizedName, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyCollection<Secret>> ListAsync(CancellationToken cancellationToken = default)
{
return await ReadAllAsync(cancellationToken);
}
public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken);
try
{
var secrets = await ReadAllUnsafeAsync(cancellationToken);
if (secrets.Any(x => string.Equals(x.Name, secret.Name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
secrets.Add(secret);
await WriteAllUnsafeAsync(secrets, cancellationToken);
}
finally
{
_lock.Release();
}
}
public async Task<bool> TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken);
try
{
var secrets = await ReadAllUnsafeAsync(cancellationToken);
var index = secrets.FindIndex(x => string.Equals(x.Name, secret.Name, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
if (secrets[index].Status != SecretStatus.Deleted)
return false;
secrets[index] = secret;
}
else
{
secrets.Add(secret);
}
await WriteAllUnsafeAsync(secrets, cancellationToken);
return true;
}
finally
{
_lock.Release();
}
}
public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken);
try
{
var secrets = await ReadAllUnsafeAsync(cancellationToken);
var index = secrets.FindIndex(x => string.Equals(x.Name, secret.Name, StringComparison.OrdinalIgnoreCase));
if (index < 0)
secrets.Add(secret);
else
secrets[index] = secret;
await WriteAllUnsafeAsync(secrets, cancellationToken);
}
finally
{
_lock.Release();
}
}
private async Task<List<Secret>> ReadAllAsync(CancellationToken cancellationToken)
{
await _lock.WaitAsync(cancellationToken);
try
{
return await ReadAllUnsafeAsync(cancellationToken);
}
finally
{
_lock.Release();
}
}
private async Task<List<Secret>> ReadAllUnsafeAsync(CancellationToken cancellationToken)
{
var path = GetPath();
if (!File.Exists(path))
return [];
await using var stream = File.OpenRead(path);
try
{
return await JsonSerializer.DeserializeAsync<List<Secret>>(stream, _jsonOptions, cancellationToken) ?? [];
}
catch (JsonException e)
{
logger?.LogError(e, "The secrets repository file '{Path}' could not be read because it contains invalid JSON.", path);
return [];
}
}
private async Task WriteAllUnsafeAsync(List<Secret> secrets, CancellationToken cancellationToken)
{
var path = GetPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
await using (var stream = File.Create(temporaryPath))
await JsonSerializer.SerializeAsync(stream, secrets.OrderBy(x => x.Name).ToList(), _jsonOptions, cancellationToken);
File.Move(temporaryPath, path, true);
}
finally
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
private string GetPath() => options.Value.RepositoryFilePath ?? SecretsOptions.DefaultRepositoryFilePath;
}

View file

@ -0,0 +1,85 @@
using System.Collections.Concurrent;
namespace Elsa.Secrets.Repositories;
public class InMemorySecretRepository : ISecretRepository
{
private readonly ConcurrentDictionary<string, Secret> _secrets = new(StringComparer.OrdinalIgnoreCase);
public Task<Secret?> GetAsync(string normalizedName, CancellationToken cancellationToken = default)
{
_secrets.TryGetValue(normalizedName, out var secret);
return Task.FromResult(secret == null ? null : Clone(secret));
}
public Task<IReadOnlyCollection<Secret>> ListAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyCollection<Secret>>(_secrets.Values.Select(Clone).ToList());
}
public Task AddAsync(Secret secret, CancellationToken cancellationToken = default)
{
if (!_secrets.TryAdd(secret.Name, Clone(secret)))
throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
return Task.CompletedTask;
}
public Task<bool> TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default)
{
var secretClone = Clone(secret);
while (true)
{
if (!_secrets.TryGetValue(secret.Name, out var existingSecret))
return Task.FromResult(_secrets.TryAdd(secret.Name, secretClone));
if (existingSecret.Status != SecretStatus.Deleted)
return Task.FromResult(false);
if (_secrets.TryUpdate(secret.Name, secretClone, existingSecret))
return Task.FromResult(true);
}
}
public Task SaveAsync(Secret secret, CancellationToken cancellationToken = default)
{
_secrets[secret.Name] = Clone(secret);
return Task.CompletedTask;
}
private static Secret Clone(Secret secret)
{
return new Secret
{
Id = secret.Id,
Name = secret.Name,
DisplayName = secret.DisplayName,
Description = secret.Description,
TypeName = secret.TypeName,
StoreName = secret.StoreName,
Scope = secret.Scope,
Tags = secret.Tags.ToHashSet(StringComparer.OrdinalIgnoreCase),
Status = secret.Status,
CreatedAt = secret.CreatedAt,
UpdatedAt = secret.UpdatedAt,
Versions = secret.Versions.Select(Clone).ToList()
};
}
private static SecretVersion Clone(SecretVersion version)
{
return new SecretVersion
{
Version = version.Version,
Status = version.Status,
CreatedAt = version.CreatedAt,
ExpiresAt = version.ExpiresAt,
Payload = new SecretPayload
{
Value = version.Payload.Value,
Metadata = new Dictionary<string, string>(version.Payload.Metadata, StringComparer.OrdinalIgnoreCase)
}
};
}
}

View file

@ -0,0 +1,267 @@
namespace Elsa.Secrets.Services;
public class DefaultSecretManager(ISecretNameValidator nameValidator, ISecretStoreRegistry storeRegistry, ISecretTypeRegistry typeRegistry, ISecretRepository repository) : ISecretManager
{
public async Task<Secret> CreateAsync(CreateSecretRequest request, CancellationToken cancellationToken = default)
{
ValidateName(request.Name);
var secret = await CreateSecretAsync(request, cancellationToken);
if (!await repository.TryAddOrReplaceDeletedAsync(secret, cancellationToken))
throw new InvalidOperationException($"A secret named '{request.Name}' already exists.");
return secret;
}
public async Task<Secret?> GetAsync(string name, CancellationToken cancellationToken = default)
{
var secret = await repository.GetAsync(nameValidator.Normalize(name), cancellationToken);
return secret is { Status: SecretStatus.Deleted } ? null : secret;
}
public async Task<IReadOnlyCollection<Secret>> ListAsync(ListSecretsRequest request, CancellationToken cancellationToken = default)
{
return (await ListPageAsync(request, cancellationToken)).Items;
}
public async Task<ListSecretsResult> ListPageAsync(ListSecretsRequest request, CancellationToken cancellationToken = default)
{
var secrets = await repository.ListAsync(cancellationToken);
var query = ApplyFilters(secrets, request);
var totalCount = query.LongCount();
var pageSize = request.PageSize is > 0 ? Math.Min(request.PageSize.Value, 200) : 100;
var page = request.Page is > 0 ? request.Page.Value : 0;
var items = query
.OrderBy(x => x.Name)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
return new ListSecretsResult { Items = items, TotalCount = totalCount };
}
public async Task<long> CountAsync(ListSecretsRequest request, CancellationToken cancellationToken = default)
{
var secrets = await repository.ListAsync(cancellationToken);
return ApplyFilters(secrets, request).LongCount();
}
public async Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default)
{
var secret = await GetExistingAsync(name, cancellationToken);
if (secret.Status == SecretStatus.Revoked)
throw new InvalidOperationException($"Secret '{secret.Name}' is revoked and cannot be rotated.");
var provider = typeRegistry.Get(secret.TypeName);
if (!provider.ValidateRotation(request, secret.StoreName, out var error))
throw new InvalidOperationException(error);
var store = storeRegistry.Get(secret.StoreName);
EnsureCanWrite(store);
var nextVersion = secret.Versions.Count == 0 ? 1 : secret.Versions.Max(x => x.Version) + 1;
var version = new SecretVersion { Version = nextVersion, ExpiresAt = request.ExpiresAt };
version.Payload = await store.WriteAsync(secret, version, CreatePayload(request), cancellationToken);
foreach (var activeVersion in secret.Versions.Where(x => x.Status == SecretStatus.Active))
activeVersion.Status = SecretStatus.Retired;
secret.Versions.Add(version);
secret.Status = SecretStatus.Active;
secret.UpdatedAt = DateTimeOffset.UtcNow;
await repository.SaveAsync(secret, cancellationToken);
return secret;
}
public async Task<Secret?> RevokeAsync(string name, CancellationToken cancellationToken = default)
{
var secret = await GetAsync(name, cancellationToken);
if (secret == null)
return null;
secret.Status = SecretStatus.Revoked;
secret.UpdatedAt = DateTimeOffset.UtcNow;
foreach (var version in secret.Versions.Where(x => x.Status == SecretStatus.Active))
version.Status = SecretStatus.Revoked;
await repository.SaveAsync(secret, cancellationToken);
return secret;
}
public async Task<bool> DeleteAsync(string name, CancellationToken cancellationToken = default)
{
var secret = await GetAsync(name, cancellationToken);
if (secret == null)
return false;
await storeRegistry.Get(secret.StoreName).DeleteAsync(secret, cancellationToken);
secret.Status = SecretStatus.Deleted;
secret.UpdatedAt = DateTimeOffset.UtcNow;
await repository.SaveAsync(secret, cancellationToken);
return true;
}
public async Task<SecretTestResponse> TestAsync(string name, CancellationToken cancellationToken = default)
{
try
{
var secret = await GetExistingAsync(name, cancellationToken);
var version = GetLatestActiveVersion(secret);
var succeeded = await storeRegistry.Get(secret.StoreName).TestAsync(secret, version, cancellationToken);
return new SecretTestResponse { Succeeded = succeeded, Error = succeeded ? null : "Secret value is unavailable." };
}
catch (InvalidOperationException e)
{
return new SecretTestResponse { Succeeded = false, Error = e.Message };
}
catch (KeyNotFoundException e)
{
return new SecretTestResponse { Succeeded = false, Error = e.Message };
}
catch (ArgumentException e)
{
return new SecretTestResponse { Succeeded = false, Error = e.Message };
}
catch (System.Security.Cryptography.CryptographicException e)
{
return new SecretTestResponse { Succeeded = false, Error = e.Message };
}
catch (FormatException e)
{
return new SecretTestResponse { Succeeded = false, Error = e.Message };
}
}
public async Task<SecretPayload> ResolvePayloadAsync(string name, CancellationToken cancellationToken = default)
{
var secret = await GetExistingAsync(name, cancellationToken);
return await ResolvePayloadAsync(secret, cancellationToken);
}
public async Task<SecretPayload> ResolvePayloadAsync(Secret secret, CancellationToken cancellationToken = default)
{
var version = GetLatestActiveVersion(secret);
var store = storeRegistry.Get(secret.StoreName);
var payload = await store.ReadAsync(secret, version, cancellationToken);
if (payload?.Value == null)
throw new InvalidOperationException($"Secret '{secret.Name}' could not be resolved.");
return payload;
}
private async Task<Secret> CreateSecretAsync(CreateSecretRequest request, CancellationToken cancellationToken)
{
var typeProvider = typeRegistry.Get(request.TypeName);
var store = storeRegistry.Get(request.StoreName);
EnsureCanWrite(store);
if (!typeProvider.Descriptor.SupportedStoreNames.Contains(store.Name, StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException($"Secret type '{request.TypeName}' does not support store '{request.StoreName}'.");
if (!typeProvider.Validate(request, out var error))
throw new InvalidOperationException(error);
var secret = new Secret
{
Name = nameValidator.Normalize(request.Name),
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? request.Name.Trim() : request.DisplayName.Trim(),
Description = request.Description,
TypeName = typeProvider.Descriptor.Name,
StoreName = store.Name,
Scope = request.Scope,
Tags = request.Tags.ToHashSet(StringComparer.OrdinalIgnoreCase)
};
var version = new SecretVersion { Version = 1, ExpiresAt = request.ExpiresAt };
version.Payload = await store.WriteAsync(secret, version, CreatePayload(request), cancellationToken);
secret.Versions.Add(version);
return secret;
}
private async Task<Secret> GetExistingAsync(string name, CancellationToken cancellationToken)
{
var secret = await GetAsync(name, cancellationToken);
return secret == null ? throw new KeyNotFoundException($"Secret '{name}' was not found.") : secret;
}
private static SecretVersion GetLatestActiveVersion(Secret secret)
{
if (secret.Status != SecretStatus.Active)
throw new InvalidOperationException($"Secret '{secret.Name}' is not active.");
return secret.LatestActiveVersion ?? throw new InvalidOperationException($"Secret '{secret.Name}' has no active version.");
}
private void ValidateName(string name)
{
if (!nameValidator.IsValid(name, out var error))
throw new InvalidOperationException(error);
}
private static void EnsureCanWrite(ISecretStore store)
{
if (!store.Descriptor.Capabilities.HasFlag(SecretStoreCapabilities.Write))
throw new InvalidOperationException($"Secret store '{store.Name}' does not support writing secrets.");
}
private static IEnumerable<Secret> ApplyFilters(IEnumerable<Secret> secrets, ListSecretsRequest request)
{
var query = secrets.Where(x => x.Status != SecretStatus.Deleted);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var search = request.Search.Trim();
query = query.Where(x =>
x.Name.Contains(search, StringComparison.OrdinalIgnoreCase) ||
x.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
(x.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false));
}
var typeNames = GetFilterValues(request.TypeName, request.TypeNames);
if (typeNames.Count > 0)
query = query.Where(x => typeNames.Contains(x.TypeName));
var storeNames = GetFilterValues(request.StoreName, request.StoreNames);
if (storeNames.Count > 0)
query = query.Where(x => storeNames.Contains(x.StoreName));
if (!string.IsNullOrWhiteSpace(request.Scope))
query = query.Where(x => string.Equals(x.Scope, request.Scope, StringComparison.OrdinalIgnoreCase));
if (request.Status != null)
query = query.Where(x => x.Status == request.Status);
return query;
}
private static HashSet<string> GetFilterValues(string? value, IEnumerable<string> values)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(value))
result.Add(value.Trim());
foreach (var item in values.Where(x => !string.IsNullOrWhiteSpace(x)))
result.Add(item.Trim());
return result;
}
private static SecretPayload CreatePayload(CreateSecretRequest request)
{
var payload = new SecretPayload { Value = request.Value, Metadata = new Dictionary<string, string>(request.Metadata, StringComparer.OrdinalIgnoreCase) };
if (!string.IsNullOrWhiteSpace(request.ConfigurationKey))
payload.Metadata["configurationKey"] = request.ConfigurationKey.Trim();
return payload;
}
private static SecretPayload CreatePayload(RotateSecretRequest request)
{
var payload = new SecretPayload { Value = request.Value, Metadata = new Dictionary<string, string>(request.Metadata, StringComparer.OrdinalIgnoreCase) };
if (!string.IsNullOrWhiteSpace(request.ConfigurationKey))
payload.Metadata["configurationKey"] = request.ConfigurationKey.Trim();
return payload;
}
}

View file

@ -0,0 +1,29 @@
using System.Text.RegularExpressions;
namespace Elsa.Secrets.Services;
public partial class DefaultSecretNameValidator : ISecretNameValidator
{
public bool IsValid(string? name, out string? error)
{
if (string.IsNullOrWhiteSpace(name))
{
error = "Secret name is required.";
return false;
}
if (!SecretNameRegex().IsMatch(name.Trim()))
{
error = "Secret name must be 2-200 characters, start with a letter, and contain only letters, numbers, dots, dashes, underscores, and colons.";
return false;
}
error = null;
return true;
}
public string Normalize(string name) => name.Trim().ToLowerInvariant();
[GeneratedRegex("^[A-Za-z][A-Za-z0-9._:-]{1,199}$", RegexOptions.Compiled)]
private static partial Regex SecretNameRegex();
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Secrets.Services;
public class DefaultSecretResolver(ISecretManager secretManager) : ISecretResolver
{
public Task<string> ResolveAsync(string name, CancellationToken cancellationToken = default) => ResolveAsync(new SecretReference(name), cancellationToken);
public async Task<string> ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default)
{
var secret = await secretManager.GetAsync(reference.Name, cancellationToken);
if (secret == null)
throw new InvalidOperationException($"Secret '{reference.Name}' was not found.");
if (!string.IsNullOrWhiteSpace(reference.TypeName) && !string.Equals(secret.TypeName, reference.TypeName, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Secret '{reference.Name}' is not compatible with required type '{reference.TypeName}'.");
if (!string.IsNullOrWhiteSpace(reference.Scope) && !string.Equals(secret.Scope, reference.Scope, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Secret '{reference.Name}' is not compatible with required scope '{reference.Scope}'.");
var payload = await secretManager.ResolvePayloadAsync(secret, cancellationToken);
return payload.Value!;
}
}

View file

@ -0,0 +1,52 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
namespace Elsa.Secrets.Services;
public class DefaultSecretValueProtector(IOptions<SecretsOptions> options) : ISecretValueProtector
{
private const int NonceSize = 12;
private const int TagSize = 16;
public string Protect(string value)
{
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var plaintext = System.Text.Encoding.UTF8.GetBytes(value);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
using var aes = new AesGcm(GetKey(), TagSize);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return string.Join(".", "v1", Convert.ToBase64String(nonce), Convert.ToBase64String(tag), Convert.ToBase64String(ciphertext));
}
public string Unprotect(string protectedValue)
{
var parts = protectedValue.Split('.');
if (parts.Length != 4 || parts[0] != "v1")
throw new InvalidOperationException("The protected secret payload is not supported.");
var nonce = Convert.FromBase64String(parts[1]);
var tag = Convert.FromBase64String(parts[2]);
var ciphertext = Convert.FromBase64String(parts[3]);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(GetKey(), TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return System.Text.Encoding.UTF8.GetString(plaintext);
}
private byte[] GetKey()
{
var key = options.Value.EncryptionKey;
if (key == null || key.Length == 0)
throw new InvalidOperationException("Elsa Secrets encryption key is not configured. Configure SecretsOptions.EncryptionKey before using the encrypted secrets store.");
if (key.Length is not (16 or 24 or 32))
throw new InvalidOperationException("Elsa Secrets encryption key must be exactly 16, 24, or 32 bytes.");
return key;
}
}

View file

@ -0,0 +1,33 @@
namespace Elsa.Secrets.Services;
public static class SecretModelMapper
{
public static SecretModel ToModel(this Secret secret)
{
var current = secret.LatestActiveVersion;
return new SecretModel
{
Id = secret.Id,
Name = secret.Name,
DisplayName = secret.DisplayName,
Description = secret.Description,
TypeName = secret.TypeName,
StoreName = secret.StoreName,
Scope = secret.Scope,
Tags = secret.Tags.ToList(),
Status = ResolveStatus(secret),
CurrentVersion = current?.Version,
CreatedAt = secret.CreatedAt,
UpdatedAt = secret.UpdatedAt,
ExpiresAt = current?.ExpiresAt
};
}
private static SecretStatus ResolveStatus(Secret secret)
{
if (secret.Status != SecretStatus.Active)
return secret.Status;
return secret.LatestActiveVersion == null && secret.Versions.Any(x => x.IsExpired()) ? SecretStatus.Expired : secret.Status;
}
}

View file

@ -0,0 +1,34 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace Elsa.Secrets.Services;
public class SecretProviderAdapter(ISecretResolver resolver, ILogger<SecretProviderAdapter>? logger = null) : ISecretProvider
{
public async Task<string?> GetSecretAsync(string name, CancellationToken cancellationToken = default)
{
try
{
return await resolver.ResolveAsync(name, cancellationToken);
}
catch (KeyNotFoundException)
{
return null;
}
catch (InvalidOperationException e)
{
logger?.LogWarning(e, "Secret '{SecretName}' is unavailable.", name);
return null;
}
catch (CryptographicException e)
{
logger?.LogWarning(e, "Secret '{SecretName}' could not be decrypted.", name);
return null;
}
catch (FormatException e)
{
logger?.LogWarning(e, "Secret '{SecretName}' has a malformed encrypted payload.", name);
return null;
}
}
}

View file

@ -0,0 +1,18 @@
namespace Elsa.Secrets.Services;
public class SecretStoreRegistry(IEnumerable<ISecretStore> stores) : ISecretStoreRegistry
{
private readonly Lazy<Dictionary<string, ISecretStore>> _stores = new(() => stores.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase));
public IReadOnlyCollection<ISecretStore> List() => _stores.Value.Values.ToList();
public ISecretStore Get(string name)
{
if (TryGet(name, out var store))
return store!;
throw new InvalidOperationException($"Secret store '{name}' is not registered.");
}
public bool TryGet(string name, out ISecretStore? store) => _stores.Value.TryGetValue(name, out store);
}

View file

@ -0,0 +1,18 @@
namespace Elsa.Secrets.Services;
public class SecretTypeRegistry(IEnumerable<ISecretTypeProvider> providers) : ISecretTypeRegistry
{
private readonly Lazy<Dictionary<string, ISecretTypeProvider>> _providers = new(() => providers.ToDictionary(x => x.Descriptor.Name, StringComparer.OrdinalIgnoreCase));
public IReadOnlyCollection<SecretTypeDescriptor> List() => _providers.Value.Values.Select(x => x.Descriptor).ToList();
public ISecretTypeProvider Get(string name)
{
if (TryGet(name, out var provider))
return provider!;
throw new InvalidOperationException($"Secret type '{name}' is not registered.");
}
public bool TryGet(string name, out ISecretTypeProvider? provider) => _providers.Value.TryGetValue(name, out provider);
}

View file

@ -0,0 +1,28 @@
using CShells.FastEndpoints.Features;
using CShells.Features;
using Elsa.Secrets.Extensions;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Secrets.ShellFeatures;
[ShellFeature(
DisplayName = "Secrets",
Description = "Provides named secret management, secret stores, and runtime secret resolution.",
DependsOn = ["ElsaFastEndpoints"])]
[UsedImplicitly]
public class SecretsFeature : IFastEndpointsShellFeature
{
public string ConfigurationSectionName { get; set; } = "Elsa:Secrets";
public byte[]? EncryptionKey { get; set; }
public void ConfigureServices(IServiceCollection services)
{
services.AddSecretsServices(options =>
{
options.ConfigurationSectionName = ConfigurationSectionName;
if (EncryptionKey != null)
options.EncryptionKey = EncryptionKey;
});
}
}

View file

@ -0,0 +1,43 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace Elsa.Secrets.Stores;
public class ConfigurationSecretStore(IConfiguration configuration, IOptions<SecretsOptions> options) : ISecretStore
{
private const string ConfigurationKeyMetadataName = "configurationKey";
public string Name => SecretStoreNames.Configuration;
public SecretStoreDescriptor Descriptor { get; } = new(
SecretStoreNames.Configuration,
"Configuration",
"Reads values from application configuration without storing the value in Elsa.",
SecretStoreCapabilities.Read | SecretStoreCapabilities.Write | SecretStoreCapabilities.Test,
true);
public Task<SecretPayload> WriteAsync(Secret secret, SecretVersion version, SecretPayload payload, CancellationToken cancellationToken = default)
{
if (!payload.Metadata.TryGetValue(ConfigurationKeyMetadataName, out var key) || string.IsNullOrWhiteSpace(key))
throw new InvalidOperationException("A configuration key is required.");
return Task.FromResult(new SecretPayload { Metadata = new Dictionary<string, string>(payload.Metadata, StringComparer.OrdinalIgnoreCase) });
}
public Task<SecretPayload?> ReadAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default)
{
if (!version.Payload.Metadata.TryGetValue(ConfigurationKeyMetadataName, out var key) || string.IsNullOrWhiteSpace(key))
return Task.FromResult<SecretPayload?>(null);
var configuredValue = configuration[$"{options.Value.ConfigurationSectionName}:{key}"] ?? configuration[key];
return configuredValue == null ? Task.FromResult<SecretPayload?>(null) : Task.FromResult<SecretPayload?>(SecretPayload.FromValue(configuredValue));
}
public Task DeleteAsync(Secret secret, CancellationToken cancellationToken = default) => Task.CompletedTask;
public async Task<bool> TestAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default)
{
var payload = await ReadAsync(secret, version, cancellationToken);
return payload?.Value != null;
}
}

View file

@ -0,0 +1,51 @@
namespace Elsa.Secrets.Stores;
public class EncryptedSecretStore(ISecretValueProtector protector) : ISecretStore
{
private const string ProtectedValueKey = "protectedValue";
public string Name => SecretStoreNames.Encrypted;
public SecretStoreDescriptor Descriptor { get; } = new(
SecretStoreNames.Encrypted,
"Elsa Encrypted Store",
"Stores values in Elsa-managed encrypted payloads.",
SecretStoreCapabilities.Read | SecretStoreCapabilities.Write | SecretStoreCapabilities.Delete | SecretStoreCapabilities.Test | SecretStoreCapabilities.Versioned,
false);
public Task<SecretPayload> WriteAsync(Secret secret, SecretVersion version, SecretPayload payload, CancellationToken cancellationToken = default)
{
if (payload.Value == null)
throw new InvalidOperationException("A value is required for encrypted secrets.");
var protectedPayload = new SecretPayload();
protectedPayload.Metadata[ProtectedValueKey] = protector.Protect(payload.Value);
foreach (var item in payload.Metadata.Where(x => !string.Equals(x.Key, ProtectedValueKey, StringComparison.OrdinalIgnoreCase)))
protectedPayload.Metadata[item.Key] = item.Value;
return Task.FromResult(protectedPayload);
}
public Task<SecretPayload?> ReadAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default)
{
if (!version.Payload.Metadata.TryGetValue(ProtectedValueKey, out var protectedValue))
return Task.FromResult<SecretPayload?>(null);
return Task.FromResult<SecretPayload?>(SecretPayload.FromValue(protector.Unprotect(protectedValue)));
}
public Task DeleteAsync(Secret secret, CancellationToken cancellationToken = default)
{
foreach (var version in secret.Versions)
version.Payload.Metadata.Remove(ProtectedValueKey);
return Task.CompletedTask;
}
public async Task<bool> TestAsync(Secret secret, SecretVersion version, CancellationToken cancellationToken = default)
{
var payload = await ReadAsync(secret, version, cancellationToken);
return payload?.Value != null;
}
}

View file

@ -0,0 +1,33 @@
namespace Elsa.Secrets.Types;
public class RsaKeySecretTypeProvider : ISecretTypeProvider
{
public SecretTypeDescriptor Descriptor { get; } = new(
SecretTypeNames.RsaKey,
"RSA Key",
"RSA key material stored as encrypted text or referenced from configuration.",
"secret-rsa-key",
[SecretStoreNames.Encrypted, SecretStoreNames.Configuration]);
public bool Validate(CreateSecretRequest request, out string? error) => ValidatePayload(request.StoreName, request.Value, request.ConfigurationKey, out error);
public bool ValidateRotation(RotateSecretRequest request, string storeName, out string? error) => ValidatePayload(storeName, request.Value, request.ConfigurationKey, out error);
private static bool ValidatePayload(string storeName, string? value, string? configurationKey, out string? error)
{
if (storeName == SecretStoreNames.Encrypted && string.IsNullOrWhiteSpace(value))
{
error = "RSA key material is required for encrypted secrets.";
return false;
}
if (storeName == SecretStoreNames.Configuration && string.IsNullOrWhiteSpace(configurationKey))
{
error = "A configuration key is required for configuration-backed RSA key secrets.";
return false;
}
error = null;
return true;
}
}

View file

@ -0,0 +1,47 @@
namespace Elsa.Secrets.Types;
public class TextSecretTypeProvider : ISecretTypeProvider
{
public SecretTypeDescriptor Descriptor { get; } = new(
SecretTypeNames.Text,
"Text",
"A text value such as a password, token, or connection string.",
"secret-text",
[SecretStoreNames.Encrypted, SecretStoreNames.Configuration]);
public bool Validate(CreateSecretRequest request, out string? error)
{
if (request.StoreName == SecretStoreNames.Encrypted && string.IsNullOrEmpty(request.Value))
{
error = "A text value is required for encrypted secrets.";
return false;
}
if (request.StoreName == SecretStoreNames.Configuration && string.IsNullOrWhiteSpace(request.ConfigurationKey))
{
error = "A configuration key is required for configuration-backed secrets.";
return false;
}
error = null;
return true;
}
public bool ValidateRotation(RotateSecretRequest request, string storeName, out string? error)
{
if (storeName == SecretStoreNames.Encrypted && string.IsNullOrEmpty(request.Value))
{
error = "A replacement value is required.";
return false;
}
if (storeName == SecretStoreNames.Configuration && string.IsNullOrWhiteSpace(request.ConfigurationKey))
{
error = "A replacement configuration key is required.";
return false;
}
error = null;
return true;
}
}

View file

@ -0,0 +1,34 @@
namespace Elsa.Secrets.Types;
public class X509CertificateSecretTypeProvider : ISecretTypeProvider
{
public SecretTypeDescriptor Descriptor { get; } = new(
SecretTypeNames.X509Certificate,
"X.509 Certificate",
"A certificate reference, such as a thumbprint or configuration-backed certificate identity.",
"secret-x509-certificate",
[SecretStoreNames.Encrypted, SecretStoreNames.Configuration]);
public bool Validate(CreateSecretRequest request, out string? error) => ValidatePayload(request.StoreName, request.Value, request.ConfigurationKey, request.Metadata, out error);
public bool ValidateRotation(RotateSecretRequest request, string storeName, out string? error) => ValidatePayload(storeName, request.Value, request.ConfigurationKey, request.Metadata, out error);
private static bool ValidatePayload(string storeName, string? value, string? configurationKey, IDictionary<string, string> metadata, out string? error)
{
var hasThumbprint = metadata.TryGetValue("thumbprint", out var thumbprint) && !string.IsNullOrWhiteSpace(thumbprint);
if (storeName == SecretStoreNames.Encrypted && string.IsNullOrWhiteSpace(value) && !hasThumbprint)
{
error = "Certificate material or a thumbprint metadata value is required.";
return false;
}
if (storeName == SecretStoreNames.Configuration && string.IsNullOrWhiteSpace(configurationKey))
{
error = "A configuration key is required for configuration-backed certificate secrets.";
return false;
}
error = null;
return true;
}
}

View file

@ -0,0 +1,3 @@
global using Elsa.Secrets.Contracts;
global using Elsa.Secrets.Models;
global using Elsa.Secrets.Options;

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Secrets]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Secrets\Elsa.Secrets.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,137 @@
using Elsa.Secrets.Models;
using Xunit;
namespace Elsa.Secrets.UnitTests;
public class SecretManagerTests
{
private readonly SecretTestFixture _fixture = new();
[Fact]
public async Task CreateAsync_NormalizesTechnicalName_AndDoesNotExposeValueInModel()
{
var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest
{
Name = "Smtp:Password",
DisplayName = "SMTP password",
Value = "p@ssword"
});
var model = Elsa.Secrets.Services.SecretModelMapper.ToModel(secret);
Assert.Equal("smtp:password", secret.Name);
Assert.Equal("SMTP password", model.DisplayName);
Assert.Equal(1, model.CurrentVersion);
Assert.DoesNotContain(model.GetType().GetProperties(), x => x.Name.Contains("Value", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task RotateAsync_RetiresPreviousVersion_AndKeepsOneActiveVersion()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
var rotated = await _fixture.Manager.RotateAsync("smtp:password", new RotateSecretRequest { Value = "two" });
Assert.Equal(2, rotated.Versions.Count);
Assert.Single(rotated.Versions, x => x.Status == SecretStatus.Active);
Assert.Single(rotated.Versions, x => x.Status == SecretStatus.Retired);
}
[Fact]
public async Task RevokeAsync_PreventsResolution()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await _fixture.Manager.RevokeAsync("smtp:password");
await Assert.ThrowsAsync<InvalidOperationException>(() => _fixture.Resolver.ResolveAsync("smtp:password"));
}
[Fact]
public async Task RotateAsync_RejectsRevokedSecret()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await _fixture.Manager.RevokeAsync("smtp:password");
await Assert.ThrowsAsync<InvalidOperationException>(() => _fixture.Manager.RotateAsync("smtp:password", new RotateSecretRequest { Value = "two" }));
var secret = await _fixture.Manager.GetAsync("smtp:password");
Assert.Equal(SecretStatus.Revoked, secret!.Status);
Assert.All(secret.Versions, x => Assert.Equal(SecretStatus.Revoked, x.Status));
}
[Fact]
public async Task CreateAsync_RejectsDuplicateTechnicalName()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await Assert.ThrowsAsync<InvalidOperationException>(() => _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = " SMTP:PASSWORD ", Value = "two" }));
}
[Fact]
public async Task CreateAsync_AllowsReusingDeletedSecretName()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await _fixture.Manager.DeleteAsync("smtp:password");
var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = " SMTP:PASSWORD ", Value = "two" });
Assert.Equal("smtp:password", secret.Name);
Assert.Equal(SecretStatus.Active, secret.Status);
Assert.Single(secret.Versions);
Assert.Equal("two", await _fixture.Resolver.ResolveAsync("smtp:password"));
}
[Fact]
public async Task CreateAsync_AllowsOnlyOneConcurrentReuseOfDeletedSecretName()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await _fixture.Manager.DeleteAsync("smtp:password");
var createTasks = Enumerable.Range(0, 2)
.Select(x => TryCreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = x.ToString() }))
.ToArray();
var results = await Task.WhenAll(createTasks);
var stored = await _fixture.Manager.GetAsync("smtp:password");
Assert.Single(results, true);
Assert.NotNull(stored);
Assert.Equal(SecretStatus.Active, stored.Status);
}
[Fact]
public async Task DeleteAsync_RemovesEncryptedPayloadMaterial()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" });
await _fixture.Manager.DeleteAsync("smtp:password");
var stored = await _fixture.Repository.GetAsync("smtp:password");
Assert.NotNull(stored);
Assert.Equal(SecretStatus.Deleted, stored.Status);
Assert.All(stored.Versions, x => Assert.False(x.Payload.Metadata.ContainsKey("protectedValue")));
}
[Fact]
public async Task CountAsync_ReturnsTotalMatchingItems_NotPageSize()
{
for (var i = 0; i < 3; i++)
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = $"smtp:password:{i}", Value = "one" });
var items = await _fixture.Manager.ListAsync(new ListSecretsRequest { PageSize = 1 });
var count = await _fixture.Manager.CountAsync(new ListSecretsRequest { PageSize = 1 });
Assert.Single(items);
Assert.Equal(3, count);
}
private async Task<bool> TryCreateAsync(CreateSecretRequest request)
{
try
{
await _fixture.Manager.CreateAsync(request);
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
}

View file

@ -0,0 +1,85 @@
using Elsa.Secrets.Models;
using Xunit;
namespace Elsa.Secrets.UnitTests;
public class SecretResolverTests
{
private readonly SecretTestFixture _fixture = new();
[Fact]
public async Task ResolveAsync_ReturnsLatestActiveVersion()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "one" });
await _fixture.Manager.RotateAsync("api:key", new RotateSecretRequest { Value = "two" });
var value = await _fixture.Resolver.ResolveAsync("api:key");
Assert.Equal("two", value);
}
[Fact]
public async Task ResolveAsync_ValidatesReferenceType()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", TypeName = SecretTypeNames.Text, Value = "one" });
await Assert.ThrowsAsync<InvalidOperationException>(() => _fixture.Resolver.ResolveAsync(new SecretReference("api:key", SecretTypeNames.RsaKey)));
}
[Fact]
public async Task ProviderAdapter_ReturnsNull_WhenSecretIsUnavailable()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "one" });
await _fixture.Manager.RevokeAsync("api:key");
var provider = new Elsa.Secrets.Services.SecretProviderAdapter(_fixture.Resolver);
var value = await provider.GetSecretAsync("api:key");
Assert.Null(value);
}
[Fact]
public async Task ProviderAdapter_ReturnsNull_WhenEncryptedPayloadCannotBeDecrypted()
{
var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "one" });
var version = secret.Versions.Single();
version.Payload.Metadata["protectedValue"] = string.Join(
".",
"v1",
Convert.ToBase64String(new byte[12]),
Convert.ToBase64String(new byte[16]),
Convert.ToBase64String(new byte[1]));
await _fixture.Repository.SaveAsync(secret);
var provider = new Elsa.Secrets.Services.SecretProviderAdapter(_fixture.Resolver);
var value = await provider.GetSecretAsync("api:key");
Assert.Null(value);
}
[Fact]
public async Task ProviderAdapter_ReturnsNull_WhenEncryptedPayloadIsMalformed()
{
var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "one" });
secret.Versions.Single().Payload.Metadata["protectedValue"] = "v1.not-base64.not-base64.not-base64";
await _fixture.Repository.SaveAsync(secret);
var provider = new Elsa.Secrets.Services.SecretProviderAdapter(_fixture.Resolver);
var value = await provider.GetSecretAsync("api:key");
Assert.Null(value);
}
[Fact]
public async Task TestAsync_ReturnsFailedResult_WhenEncryptedPayloadIsMalformed()
{
var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "one" });
secret.Versions.Single().Payload.Metadata["protectedValue"] = "v1.not-base64.not-base64.not-base64";
await _fixture.Repository.SaveAsync(secret);
var result = await _fixture.Manager.TestAsync("api:key");
Assert.False(result.Succeeded);
Assert.NotNull(result.Error);
}
}

View file

@ -0,0 +1,120 @@
using Elsa.Secrets.Models;
using Elsa.Secrets.Options;
using Elsa.Secrets.Repositories;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace Elsa.Secrets.UnitTests;
public class SecretStoreTests
{
[Fact]
public void Registries_ExposeBuiltInTypesAndStores()
{
var fixture = new SecretTestFixture();
Assert.Contains(fixture.TypeRegistry.List(), x => x.Name == SecretTypeNames.Text);
Assert.Contains(fixture.TypeRegistry.List(), x => x.Name == SecretTypeNames.RsaKey);
Assert.Contains(fixture.TypeRegistry.List(), x => x.Name == SecretTypeNames.X509Certificate);
Assert.Contains(fixture.StoreRegistry.List(), x => x.Name == SecretStoreNames.Encrypted);
Assert.Contains(fixture.StoreRegistry.List(), x => x.Name == SecretStoreNames.Configuration);
}
[Fact]
public async Task ConfigurationStore_ResolvesConfiguredValue()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Elsa:Secrets:SmtpPassword"] = "configured-secret" })
.Build();
var fixture = new SecretTestFixture(configuration);
await fixture.Manager.CreateAsync(new CreateSecretRequest
{
Name = "smtp:password",
StoreName = SecretStoreNames.Configuration,
ConfigurationKey = "SmtpPassword"
});
var value = await fixture.Resolver.ResolveAsync("smtp:password");
Assert.Equal("configured-secret", value);
}
[Fact]
public async Task FileRepository_PersistsSecretAggregate()
{
var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.json");
try
{
var repository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path }));
var secret = new Secret
{
Name = "smtp:password",
DisplayName = "SMTP password",
Tags = ["API-Key"],
Versions = { new SecretVersion { Version = 1, Payload = new SecretPayload { Value = "stored", Metadata = { ["ProtectedValue"] = "ciphertext" } } } }
};
await repository.AddAsync(secret);
var reloadedRepository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path }));
var reloaded = await reloadedRepository.GetAsync("smtp:password");
Assert.NotNull(reloaded);
Assert.Equal("SMTP password", reloaded.DisplayName);
Assert.Contains("api-key", reloaded.Tags);
Assert.True(reloaded.Versions.Single().Payload.Metadata.ContainsKey("protectedvalue"));
Assert.Equal(1, reloaded.Versions.Single().Version);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public async Task FileRepository_RecoversFromCorruptJson()
{
var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.json");
try
{
await File.WriteAllTextAsync(path, "{not-valid-json");
var repository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path }));
var secrets = await repository.ListAsync();
await repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "SMTP password" });
var reloaded = await repository.GetAsync("smtp:password");
Assert.Empty(secrets);
Assert.NotNull(reloaded);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public async Task InMemoryRepository_ReturnsCopies()
{
var repository = new InMemorySecretRepository();
await repository.AddAsync(new Secret
{
Name = "smtp:password",
DisplayName = "SMTP password",
Versions = { new SecretVersion { Version = 1, Payload = new SecretPayload { Metadata = { ["protectedValue"] = "ciphertext" } } } }
});
var loaded = await repository.GetAsync("smtp:password");
loaded!.Versions.Clear();
loaded.DisplayName = "Changed";
var reloaded = await repository.GetAsync("smtp:password");
Assert.Equal("SMTP password", reloaded!.DisplayName);
Assert.Single(reloaded.Versions);
Assert.True(reloaded.Versions.Single().Payload.Metadata.ContainsKey("protectedValue"));
}
}

View file

@ -0,0 +1,43 @@
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Models;
using Elsa.Secrets.Options;
using Elsa.Secrets.Repositories;
using Elsa.Secrets.Services;
using Elsa.Secrets.Stores;
using Elsa.Secrets.Types;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace Elsa.Secrets.UnitTests;
public class SecretTestFixture
{
public SecretTestFixture(IConfiguration? configuration = null)
{
var options = Microsoft.Extensions.Options.Options.Create(new SecretsOptions { EncryptionKey = "0123456789abcdef0123456789abcdef"u8.ToArray() });
var protector = new DefaultSecretValueProtector(options);
var stores = new ISecretStore[]
{
new EncryptedSecretStore(protector),
new ConfigurationSecretStore(configuration ?? new ConfigurationBuilder().Build(), options)
};
var types = new ISecretTypeProvider[]
{
new TextSecretTypeProvider(),
new RsaKeySecretTypeProvider(),
new X509CertificateSecretTypeProvider()
};
StoreRegistry = new SecretStoreRegistry(stores);
TypeRegistry = new SecretTypeRegistry(types);
Repository = new InMemorySecretRepository();
Manager = new DefaultSecretManager(new DefaultSecretNameValidator(), StoreRegistry, TypeRegistry, Repository);
Resolver = new DefaultSecretResolver(Manager);
}
public DefaultSecretManager Manager { get; }
public ISecretRepository Repository { get; }
public ISecretResolver Resolver { get; }
public ISecretStoreRegistry StoreRegistry { get; }
public ISecretTypeRegistry TypeRegistry { get; }
}