From d4535300a645bb3d418158baf981f19f3f7f4324 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:28:51 +0200 Subject: [PATCH 1/2] Finish the audit follow-ups Codex left unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit changes were written but never compiled or run to completion. This brings them to a green state and applies the product decisions taken since. Completed: - Add the missing `using ModelContextProtocol` in DbAdminMcpTools — two McpException references failed to compile, which blocked the whole solution. - Name the protected surface in ExternalAgentSqlPolicy.RejectionMessage. 43 tests across Api and Mcp asserted that the refusal says what it protects; the constant was generic, so every one of them failed. Product decisions: - Drop the Admin gate on global variables in the SCOrch import. Operators are trusted automation authors who may already run script under the service identity, so the gate split Orchestrator migrations into two passes without removing a capability. The Operator test now guards the opposite contract. - Gate main on CI unconditionally (`push: [main]`). The duplicate run against a merge commit is accepted deliberately; the alternative left direct pushes and release cuts resting on a manual workflow_dispatch. CI hygiene: - Let VSTest name TRX files. Every test project wrote the same test_results.trx into one results directory, so the uploaded artifact held only the last one. - Apply the documented -assemblyfilters in the coverage report step, so the gated number stops counting NodePilot.TestCommons and NodePilot.LoadTests as production code. - Lower the lint ceiling from 13 to the measured 11. Verified: solution builds; Ai 504, Api 2438, Cli 496, Mcp 192, Engine 1831, Data 291 backend tests green; tsc -b clean; 174 frontend tests across the 9 touched spec files green. Remaining audit items (workflow-mutation service, DB-side custom-activity invariants, WinRM credential rotation, N+1 batching, real integration boundaries, UI modularisation) are recorded in docs/roadmap.md. --- .github/workflows/ci.yml | 30 +- CLAUDE.md | 4 +- README.md | 19 +- docs/ai-features.md | 19 +- docs/claude-reference.md | 16 +- docs/custom-activities.md | 11 +- docs/deployment-guide.md | 9 + docs/enterprise-features.md | 27 +- docs/mcp-server.md | 27 +- docs/roadmap.md | 11 +- docs/secrets-providers.md | 39 +- src/NodePilot.Ai/AiContracts.cs | 6 +- src/NodePilot.Ai/ScriptGenerationService.cs | 5 +- src/NodePilot.Ai/WorkflowDefinitionMerge.cs | 61 ++- src/NodePilot.Api/Ai/SqlKnowledgeReader.cs | 53 ++- src/NodePilot.Api/Controllers/AiController.cs | 1 + .../Controllers/AiKnowledgeController.cs | 18 +- .../Controllers/AuthController.cs | 64 +-- .../Controllers/SecretsController.cs | 16 +- .../Controllers/WorkflowEditingController.cs | 21 +- .../WorkflowImportExportController.cs | 104 ++++- .../Controllers/WorkflowsController.cs | 8 +- src/NodePilot.Api/Dtos/AiDtos.cs | 9 +- src/NodePilot.Api/Dtos/AuthDtos.cs | 7 +- src/NodePilot.Api/Dtos/SecretsDtos.cs | 3 + src/NodePilot.Api/Program.cs | 11 + .../Security/AuthSessionIssuer.cs | 221 ++++++++-- .../Security/IAuthSessionIssuer.cs | 21 +- .../Services/Backup/BackupFileReader.cs | 2 +- .../Services/Backup/BackupRestoreService.cs | 163 +++++-- .../Services/Backup/BackupService.cs | 11 +- .../Services/Backup/IBackupPart.cs | 11 +- .../Backup/Parts/CustomActivityBackupPart.cs | 13 +- .../Backup/Parts/WorkflowBackupPart.cs | 8 +- .../Services/Backup/RestoreState.cs | 6 +- .../WorkflowDefinitionSecretRewriter.cs | 170 +++++--- .../DbAdmin/DbAdminReadOnlySqlGuard.cs | 229 ++-------- .../Services/DbAdmin/DbAdminSecretColumns.cs | 29 +- .../WorkflowVersionDefinitionProtector.cs | 145 +++++++ src/NodePilot.Cli/Api/Dtos/AuthDtos.cs | 7 +- src/NodePilot.Cli/Api/Dtos/NewSurfaceDtos.cs | 3 + src/NodePilot.Cli/Api/TokenRefreshHandler.cs | 312 ++++++++++++-- src/NodePilot.Cli/Auth/TokenStore.cs | 57 ++- src/NodePilot.Cli/CommandRegistration.cs | 2 +- .../Commands/Auth/AuthCommands.cs | 9 +- .../Commands/Secrets/SecretsCommands.cs | 22 +- .../Clients/ClientSessionFileCoordinator.cs | 215 ++++++++++ .../Clients/ClientSessionSecurity.cs | 122 ++++++ .../Interfaces/ISqlKnowledgeReader.cs | 14 +- src/NodePilot.Core/Models/WorkflowVersion.cs | 11 +- .../Security/ExternalAgentSqlPolicy.cs | 106 +++++ .../Security/SqlStatementInspector.cs | 236 +++++++++++ .../WorkflowSecretRedactor.cs | 117 +++++- src/NodePilot.Mcp/Api/Dtos/Dtos.cs | 7 +- src/NodePilot.Mcp/Api/TokenRefreshHandler.cs | 303 +++++++++++-- src/NodePilot.Mcp/Auth/TokenStore.cs | 60 ++- .../Mapping/WorkflowDefinitionPatcher.cs | 56 ++- src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs | 55 ++- .../content/api/authentication.md | 2 +- .../content/api/endpoints.md | 2 + .../content/deployment/production.md | 11 + .../content/enterprise/secrets-providers.md | 14 +- .../content/import-export.md | 2 +- src/nodepilot-ui/package.json | 2 +- src/nodepilot-ui/src/__tests__/api/ai.test.ts | 7 +- .../components/AiPromptDialog.test.tsx | 8 +- .../ScriptEditorDialog.aistream.test.tsx | 6 +- .../components/ScriptEditorDialog.test.tsx | 55 ++- .../admin-settings/SecuritySection.test.tsx | 33 +- .../hooks/useAiScriptStream.test.tsx | 5 +- .../hooks/useWorkflowPersistence.test.tsx | 147 ++++++- .../pages/WorkflowEditorPage.test.tsx | 98 +++++ .../src/__tests__/stores/aiChatStore.test.ts | 38 +- src/nodepilot-ui/src/api/ai.ts | 4 + .../admin-settings/SecuritySection.tsx | 73 +++- .../src/components/ai/AiPromptDialog.tsx | 39 +- .../components/designer/EditorOverlays.tsx | 5 +- .../designer/ScriptEditorDialog.tsx | 15 +- .../properties/activities/RunScriptConfig.tsx | 7 +- .../src/hooks/useAiScriptStream.ts | 40 +- .../src/hooks/useWorkflowPersistence.ts | 290 ++++++++++--- .../src/i18n/locales/de/adminSettings.json | 13 +- src/nodepilot-ui/src/i18n/locales/de/ai.json | 2 + .../src/i18n/locales/de/editor.json | 1 + .../src/i18n/locales/en/adminSettings.json | 13 +- src/nodepilot-ui/src/i18n/locales/en/ai.json | 2 + .../src/i18n/locales/en/editor.json | 1 + .../src/pages/WorkflowEditorPage.tsx | 102 +++-- src/nodepilot-ui/src/stores/aiChatStore.ts | 46 +- .../ScriptGenerationServiceTests.cs | 20 +- .../WorkflowAssistantServiceTests.cs | 10 +- .../WorkflowDefinitionMergeTests.cs | 60 +++ .../WorkflowSecretRedactorTests.cs | 203 +++++++-- .../Ai/SqlKnowledgeReaderTests.cs | 166 +++++++- .../BackupSectionCoverageTests.cs | 3 +- .../Controllers/AiKnowledgeControllerTests.cs | 2 + .../Controllers/AuthControllerTests.cs | 2 + .../Controllers/BackupControllerTests.cs | 5 +- .../Controllers/SecretsControllerTests.cs | 60 ++- .../Controllers/WorkflowControllerHarness.cs | 11 +- .../WorkflowImportExportControllerTests.cs | 191 +++++++++ .../Controllers/WorkflowsControllerTests.cs | 35 +- .../Controllers/WorkflowsEditLockTests.cs | 6 + .../Rbac/WorkflowResponseCapabilitiesTests.cs | 73 +++- .../Rbac/WorkflowStepTestRbacTests.cs | 3 +- .../Rbac/WorkflowsControllerRbacTests.cs | 3 +- .../AuthSessionIssuerSecureCookieTests.cs | 230 ++++++++++ .../Security/ProvisioningSeederTests.cs | 4 +- .../Services/Backup/BackupAlertingTests.cs | 19 +- .../Backup/BackupRestoreServiceTests.cs | 53 ++- .../Backup/BackupServiceExportTests.cs | 107 ++++- .../Backup/CustomActivityBackupTests.cs | 114 ++++- .../DbAdmin/DbAdminReadOnlySqlGuardTests.cs | 25 ++ ...WorkflowVersionDefinitionProtectorTests.cs | 188 +++++++++ .../Api/NodePilotApiClientNewSurfaceTests.cs | 12 + .../Api/NodePilotApiClientTests.cs | 5 + .../Api/TokenRefreshHandlerTests.cs | 397 ++++++++++++++++++ .../Auth/ClientSessionFileCoordinatorTests.cs | 40 ++ .../Auth/TokenStoreTests.cs | 58 +++ .../CommandIntegrationNewSurfaceTests.cs | 38 ++ .../Commands/CommandIntegrationTests.cs | 43 ++ tests/NodePilot.Mcp.Tests/Api/InfraTests.cs | 328 +++++++++++++++ .../Auth/CliSessionInteropTests.cs | 16 + .../Mapping/WorkflowDefinitionPatcherTests.cs | 48 +++ .../Tools/DbAdminMcpToolsTests.cs | 84 +++- .../Tools/DefinitionRedactionTests.cs | 11 +- 126 files changed, 6183 insertions(+), 915 deletions(-) create mode 100644 src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs create mode 100644 src/NodePilot.Core/Clients/ClientSessionFileCoordinator.cs create mode 100644 src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs create mode 100644 src/NodePilot.Core/Security/SqlStatementInspector.cs create mode 100644 tests/NodePilot.Api.Tests/Services/WorkflowVersionDefinitionProtectorTests.cs create mode 100644 tests/NodePilot.Cli.Tests/Auth/ClientSessionFileCoordinatorTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36c81455..b5eaa9fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,19 @@ name: CI on: - # Pull requests only. A merge commit on main re-tests the exact tree the PR run already - # covered, so a `push: [main]` trigger doubled every change's CI for no new signal — a - # measured ~35 job-minutes per change, with both runs drawing on the same runner pool. - # Coverage for the paths a PR does not take: - # - direct pushes to main (release cuts) -> `workflow_dispatch`, plus the nightly suite - # (`scripts/nightly-tests.ps1`, 22:00 daily) which runs all four suites against main. + # main is gated unconditionally: every commit that lands on the default branch gets a full CI + # run, no matter how it got there. A merge commit does re-test the tree its PR run already + # covered (~35 job-minutes per change, both runs drawing on the same runner pool) — that + # duplication is accepted deliberately, because the alternative leaves direct pushes and + # release cuts resting on a `workflow_dispatch` nobody is forced to trigger. The local nightly + # suite is not a substitute: it runs against its currently checked-out tree and never pulls, + # so it is not an origin/main status check. # - CodeQL keeps its own `push: [main]` trigger: only a run on the default branch # updates the repository's code-scanning dashboard. pull_request: branches: [main, master] + push: + branches: [main] workflow_dispatch: jobs: @@ -84,11 +87,20 @@ jobs: # [ExcludeFromCodeCoverage]-attributed code from the denominator) — the same filter # the local measurement uses. Previously CI measured without it, so the enforced # number and the documented measurement procedure disagreed. - run: dotnet test --configuration Release --no-build --settings coverage.runsettings --logger "trx;LogFileName=test_results.trx" --collect:"XPlat Code Coverage" --results-directory TestResults + # The trx logger gets no LogFileName on purpose: every test project in the solution wrote + # the same test_results.trx into the shared results directory, so each one overwrote its + # predecessor and the uploaded artifact held a single project's results. VSTest's default + # name is per-run unique, which keeps all of them. + run: dotnet test --configuration Release --no-build --settings coverage.runsettings --logger trx --collect:"XPlat Code Coverage" --results-directory TestResults - name: Generate coverage report if: always() - run: dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:coverage-report -reporttypes:"Html;Cobertura;TextSummary" + # -assemblyfilters mirrors the documented local measurement exactly. Without it the test + # scaffolding itself (NodePilot.TestCommons, NodePilot.LoadTests) counted as production + # code, so the gated number and the procedure in docs/claude-reference.md disagreed. + # The two +np / +nodepilot-mcp entries are not optional: the CLI assembly is named `np` + # and the MCP server `nodepilot-mcp`, so a plain NodePilot.* filter drops both. + run: dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:coverage-report -reporttypes:"Html;Cobertura;TextSummary" "-assemblyfilters:+NodePilot.*;+np;+nodepilot-mcp;-NodePilot.*.Tests;-NodePilot.TestCommons;-NodePilot.LoadTests" - name: Enforce minimum coverage shell: pwsh @@ -126,7 +138,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: backend-test-results - path: '**/test_results.trx' + path: '**/*.trx' - name: Upload coverage report if: always() diff --git a/CLAUDE.md b/CLAUDE.md index dd2f735b..fe695447 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,12 +355,12 @@ Initial-Admin: erster Login bei leerer DB (One-Shot-Token `admin-setup.token`). ## Security -- **Session:** absolute Lebensdauer **8h** (`Authentication:SessionAbsoluteLifetimeHours`, default 8; `AuthController.TokenLifetime`). Refresh verlängert die absolute Grenze **nicht**. `jti`-Revocation. Key aus `Jwt:Key` oder auto-generiertes `jwt-secret.key`. +- **Session:** absolute Lebensdauer **8h** (`Authentication:SessionAbsoluteLifetimeHours`, default 8; `AuthSessionIssuer`). Refresh verlängert die absolute Grenze **nicht**. `jti`-Revocation. Key aus `Jwt:Key` oder auto-generiertes `jwt-secret.key`. - **Auth-Pfade:** Local-BCrypt (`Authentication:LocalLoginMode`, Produktionsdefault **`BreakGlassOnly`** — nur explizit markierte Notfallkonten; `Enabled`/`Disabled` möglich) + LDAP (`Authentication:Ldap:Enabled`) + Windows-Negotiate (`Authentication:Windows:Enabled`) + OIDC (`Authentication:Oidc:Enabled`, release-gated, + SCIM-Controller). Alle konvergieren auf JWT-Cookie + CSRF-Token. Siehe `docs/ldap-windows-sso.md`. - **External Trigger:** `X-Api-Key` wird bevorzugt gegen SHA-256-Hashes unter `ExternalTrigger:Keys:` geprüft; jeder Eintrag hat eine GUID-only `AllowedWorkflowIds`-Liste. Die komplette `Keys`-Map kommt atomar aus dem höchstprioren Provider, der sie deklariert (`Keys: {}` widerruft alle niedrigeren Keys); auch Scope-Arrays sind provider-atomar (`[]` = deny-all). Zusätzlich braucht der Workflow einen aktiven `manualTrigger`. Legacy-`ApiKey` ist ohne eigene `AllowedWorkflowIds`-Liste inert. Idempotency wird per kanonischer Integration-ID + Key-Fingerprint + Workflow domain-separiert; die DB speichert nur den Digest. - **Rate-Limiting:** login 50/Min, refresh 20/Min, webhook 60/Min, trigger 30/Min, ai-generate 20/Min, audit 60/Min, backup 10/Min (per-IP, Sliding-Window). - **Output-Redaction:** `OutputRedactor` maskiert Secrets. Immer aktiv. Custom-Patterns via `Logging:Redaction:Patterns`. -- **Localhost-Bypass:** ohne Credentials läuft in-process. **Produkt-Feature, kein Guard einziehen.** +- **Localhost-Bypass / Operator-Trust:** ohne Credentials läuft in-process unter der NodePilot-Service-Identität. `Operator` ist bewusst ein vertrauenswürdiger Automation-Author und darf solchen Workflow-Code publizieren/ausführen. Folder-RBAC ist keine Code-Sandbox. **Produkt-Feature, keinen Require-Target-Guard einziehen.** - **Security-Headers (Non-Dev):** HSTS, CSP, X-Frame-Options=DENY, nosniff, Referrer-Policy. - **SignalR-Auth:** httpOnly `np_auth`-Cookie wird beim WebSocket-Upgrade automatisch mitgeschickt (nur `/hubs/`); kein `?access_token=`-Querystring. - **REST-API-Proxy:** `RestApi:Proxy:Enabled` (default `false`). Per-Step-Override via `proxyMode`. diff --git a/README.md b/README.md index 2c5e1c88..7b40a059 100644 --- a/README.md +++ b/README.md @@ -684,7 +684,7 @@ NodePilot ships **27 built-in activities** in two scopes — *Remote* (executed | Type | Description | Key Config | |---|---|---| -| `runScript` | Execute a PowerShell script locally when no target/localhost is selected, or through NodePilot's WinRM wrapper when a non-local target is selected. With no target the script runs on the API host and may open its own WinRM session (`Invoke-Command`/`New-PSSession`, SCOrch-style self-managed remoting) — at the cost of NodePilot's managed session pool, credential store and machine audit. Auto-captures script-scope variables as `param.*` outputs. Fails only on a terminating PowerShell error (`throw`/`Write-Error`) — an `exit N` does not fail the step unless `successExitCodes` is set; `isolated: true` runs it in its own Windows Job Object process. | `script`, `engine` (`auto`/`pwsh`/`powershell`), `timeoutSeconds`, `successExitCodes`, `isolated`, `memoryLimitMb`, `maxProcesses` | +| `runScript` | Execute a PowerShell script locally when no target/localhost is selected, or through NodePilot's WinRM wrapper when a non-local target is selected. With no target the script runs on the API host **as the NodePilot service identity** and may open its own WinRM session (`Invoke-Command`/`New-PSSession`, SCOrch-style self-managed remoting) — at the cost of NodePilot's managed session pool, credential store and machine audit. Operators are intentionally trusted automation authors and may publish/run this code. Auto-captures script-scope variables as `param.*` outputs. Fails only on a terminating PowerShell error (`throw`/`Write-Error`) — an `exit N` does not fail the step unless `successExitCodes` is set; `isolated: true` runs it in its own Windows Job Object process. | `script`, `engine` (`auto`/`pwsh`/`powershell`), `timeoutSeconds`, `successExitCodes`, `isolated`, `memoryLimitMb`, `maxProcesses` | | `fileOperation` | Copy / move / delete / test-exists / rename — **files only** (asserts `-PathType Leaf`) | `operation`, `path`, `destination`, `newName` | | `folderOperation` | Copy / move / delete / test-exists / list / create / rename — **folders only** (asserts `-PathType Container`) | `operation`, `path`, `destination`, `newName` | | `textFileEdit` | Line-oriented text edit — append / prepend / insert / delete / replace / replaceLine — BOM-aware encoding, atomic write, optional backup, dry-run | `operation`, `path`, `content`, `matchPattern`, `replace`, `lineNumber` | @@ -993,7 +993,7 @@ HTTPS is mandatory by default. `--allow-insecure` is an explicit development-onl for HTTP loopback URLs and must be supplied on every command that uses such a profile; it never permits plaintext connections to remote hosts. -**Tokens** are stored DPAPI-encrypted (`CurrentUser` scope) under `%APPDATA%\NodePilot\session-.dat`. A `TokenRefreshHandler` `DelegatingHandler` transparently refreshes on `401` and replays the original request. +**Tokens** are stored DPAPI-encrypted (`CurrentUser` scope) under `%APPDATA%\NodePilot\session-.dat`, including the server-issued absolute expiry. A `TokenRefreshHandler` rotates a still-valid token shortly before that deadline. CLI and MCP coordinate through the same origin-bound file lease, so concurrent processes perform one refresh and losers reload the winner's token; atomic same-directory replacement keeps each stored session generation complete. A transient proactive-refresh failure enters a token-bound 15-second cooldown while requests continue with the still-valid token. The rotated token is used by all future requests, and a new login is required after absolute expiry. Rotation never extends the server-side session lifetime. --- @@ -1074,8 +1074,8 @@ During a runtime PostgreSQL or SQL Server outage, NodePilot stays up: APIs fail - **Rate limiting** — per-IP **sliding window** (IPv4 partitioned by /32, IPv6 by /64): login **50/min**, refresh **20/min**, webhook **60/min**, external trigger **30/min**, AI generate **20/min**. - **SSRF guard** — `restApi` blocks RFC 1918 / loopback / metadata IPs (opt-in for prod), re-validates on every redirect, strips auth headers cross-origin. - **Localhost bypass** — `localhost` / `127.0.0.1` / `::1` without credentials runs in-process, skipping WinRM. Documented product feature. -- **Roles** — Admin (full), Operator (run + manage machines/credentials), Viewer (read-only). -- **SignalR auth** — JWT via `?access_token=` (only for `/hubs/` paths). +- **Roles** — Admin (full), Operator (trusted automation author: create/edit/publish/run workflows, manage machines/credentials, and intentionally execute local activities as the NodePilot service identity), Viewer (read-only). Folder RBAC governs workflow access; it is not a sandbox around Operator-authored code. +- **SignalR auth** — the browser's httpOnly `np_auth` cookie is sent automatically during the `/hubs/` WebSocket upgrade; no JWT query string is used. ### Hardening flags (shipped on — relaxed in Development) @@ -1130,7 +1130,7 @@ What you get: - **Direct Kestrel HTTPS** — cert from `LocalMachine\My` by thumbprint, **no IIS / reverse proxy**, SPA + API on one origin - **Install-dir / data-dir split** — binaries in `C:\Program Files\NodePilot` (Read), mutable state in `C:\ProgramData\NodePilot` (Modify) - **Cert private-key ACL grant** to the gMSA, firewall rule, health-check probe before unblocking -- **In-place upgrades with auto-rollback** via `deploy/Update-NodePilot.ps1` +- **In-place upgrades with auto-rollback** via `deploy/Update-NodePilot.ps1` (legacy workflow-history encryption is an explicit post-health-check cutover so rollback remains safe) ### Prerequisites (one-time) @@ -1319,9 +1319,10 @@ dotnet test tests/NodePilot.Cli.Tests - Backend DB tests use **SQLite in-memory** (`DataSource=:memory:`) — only as a test backend; the production app does not support SQLite. **CI pipeline** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs five jobs on every pull -request — and on demand via `workflow_dispatch`. There is deliberately no `push` trigger: a merge -commit re-tests the tree the PR run already covered. Direct pushes to `main` (release cuts) are -covered by the nightly suite (`scripts/nightly-tests.ps1`). +request — and on demand via `workflow_dispatch`. There is deliberately no `push` trigger: repository +owner-controlled release cuts use the manual workflow when another run is wanted. The local nightly +suite (`scripts/nightly-tests.ps1`) is a safety net for the **currently checked-out working tree**; it +does not run `git pull` and therefore does not claim to validate the latest `origin/main`. 1. **Backend** *(Windows)* — restore, build Release, `dotnet test --collect:"XPlat Code Coverage"`, ReportGenerator, **85 % line / 70 % branch coverage** gate 2. **Frontend** *(Ubuntu)* — `npm ci`, lint, type-check + build, vitest with coverage thresholds @@ -1342,7 +1343,7 @@ The full OpenAPI spec is served at `GET /openapi/v1.json`; Swagger UI at `GET /s | Workflows | `GET/POST/PUT/DELETE /api/workflows`, `/{id}/execute`, `/{id}/enable`, `/{id}/disable`, `/{id}/duplicate`, `/{id}/cancel-all` | | Edit lock | `POST /{id}/lock`, `POST /{id}/unlock`, `POST /{id}/publish` *(atomic save+enable+unlock)*, `POST /{id}/force-unlock` *(Admin)* | | Versions | `GET /{id}/versions`, `GET /{id}/versions/{v}`, `POST /{id}/rollback/{v}` | -| Import / Export | `GET /api/workflows/export`, `GET /{id}/export`, `POST /api/workflows/import?folderId={guid}`, `POST /api/workflows/import-scorch?folderId={guid}` *(SCOrch XML, ≤50 MiB; `folderId` optional → Root, RBAC = Edit on the target folder)* | +| Import / Export | `GET /api/workflows/export`, `GET /{id}/export`, `POST /api/workflows/import?folderId={guid}`, `POST /api/workflows/import-scorch?folderId={guid}` *(SCOrch XML, ≤50 MiB and ≤500 combined workflows/variables; `folderId` optional → Root, RBAC = Edit on the target folder; Admins and Operators both import workflows and their global variables — an existing variable of the same name is never overwritten)* | | Executions | `GET /api/executions`, `GET /{id}/steps`, `POST /{id}/cancel`, `POST /{id}/retry`, `POST /{id}/resume` | | Designer telemetry | `GET /api/workflows/{id}/step-health` (sparkline), `GET /api/workflows/{id}/step-stats?windowDays=30` | | Machines | `GET/POST/PUT/DELETE /api/machines`, `POST /{id}/test` | diff --git a/docs/ai-features.md b/docs/ai-features.md index 1a70f4c6..f81831dc 100644 --- a/docs/ai-features.md +++ b/docs/ai-features.md @@ -345,6 +345,10 @@ beim Startup eine Hardening-Warnung in den Logs aus. sind nur für `Admin` und `Operator` zugänglich. Der Chat-Assistent (`POST /api/ai/chat`) ist für alle Rollen lesbar (Erklären), aber das **Anwenden** von Vorschlägen bleibt Admin/Operator. Viewer sehen die Schreib-KI-Buttons im UI nicht (der Script-Editor-KI-Button wird für Viewer zusätzlich zum LLM-Gating ausgeblendet). +- **Script-Kontext**: Das aktuelle PowerShell-Skript wird standardmäßig nicht an das LLM + gesendet. Der Dialog nennt den aktiven Zielhost und verlangt pro Aufruf eine standardmäßig + abgewählte Freigabe mit Secret-Warnung. Der Server wertet `CurrentScript` nur aus, wenn + `IncludeCurrentScript=true`; alte Clients bleiben dadurch fail-closed. - **Rate-Limit**: 20 Anfragen/Min pro IP — schützt gegen Cost-Runaway bei Cloud-Modellen und gegen versehentliche Spam-Loops im UI. - **SSRF-Block**: Beim Startup wird die `BaseUrl` **jedes** Profils gegen Cloud-Metadata-IPs @@ -473,7 +477,7 @@ Source-Code + DB default aus: |---|---|---|---| | Dokumentation | `DocsEnabled` | `search_docs`, `read_doc` | — | | Workflows & Betrieb | `OperationalEnabled` | `get_workflow_definition`, `analyze_workflow`, `get_next_scheduled_fires` | RBAC-folder-scoped | -| Workflows & Betrieb (Listen) | via DB-Quelle | "Welche Workflows/Läufe/Maschinen gibt es" → `list_db_tables` + `execute_readonly_sql` | ausschließlich globaler Admin (text2sql) | +| Betrieb (Listen) | via DB-Quelle | "Welche Läufe/Maschinen gibt es" → `list_db_tables` + `execute_readonly_sql` | ausschließlich globaler Admin (text2sql) | | Systemkonfiguration | (immer, wenn privilegiert) | `read_settings` | Admin/Operator | | Quellcode | `SourceCodeEnabled` | `search_source`, `read_source` | Admin/Operator | | **DB / text2sql** | `DbEnabled` | `list_db_tables`, `get_db_table`, `execute_readonly_sql` | ausschließlich globaler Admin | @@ -485,10 +489,15 @@ DbAdmin-Services). `execute_readonly_sql` nimmt ein einzelnes Statement bis 64 K Executor (nicht nur am HTTP-Controller), erlaubt als erstes Keyword nur `SELECT`/`WITH`/`EXPLAIN`/`SHOW`/ `VALUES`/`TABLE` und lehnt mutierende Keywords, gefährliche Routinen, Multi-Statements sowie `EXPLAIN ANALYZE` ab. PostgreSQL setzt zusätzlich `SET TRANSACTION READ ONLY`; alle Provider rollen die -Transaktion zurück. **Secret-Schutz mehrlagig**: Schema-Tools verbergen `IsHidden`-Spalten; jede SQL-Referenz -auf eine geschützte Spalte wird bereits vor Ausführung abgelehnt (auch Alias-/Ausdrucksvarianten); -Result-Spalten werden zusätzlich nach Namen maskiert und übrige Zellen durch den `IAuditDetailsRedactor` -geführt. Row-Cap 200. Übergroße Tool-Resultate bleiben valides JSON mit explizitem Truncation-Hinweis. +Transaktion zurück. **Secret-Schutz mehrlagig**: Schema-Tools verbergen `IsHidden`-Spalten und lassen +`Workflows`, `WorkflowVersions`, `CustomActivityDefinitions` sowie deren Versionstabelle vollständig aus. +Der AI-SQL-Adapter lehnt jede Referenz auf diese vier Tabellen vor Ausführung ab; Workflow-Definitionen +bleiben über das dedizierte, RBAC-geprüfte `get_workflow_definition` erreichbar. Damit muss kein +provider-neutraler Lexer beweisen, dass Composite Rows nicht über Casts, LATERAL-Funktionen oder andere +Wrapper abfließen. PostgreSQL-`U&"…"`-Identifier werden an dieser Grenze ebenfalls abgelehnt. Das ist +absichtlich strenger als die forensische DbAdmin-Ansicht. Result-Spalten werden zusätzlich nach Namen +maskiert und übrige Zellen durch den `IAuditDetailsRedactor` geführt. Row-Cap 200. +Übergroße Tool-Resultate bleiben valides JSON mit explizitem Truncation-Hinweis. DB-Tools nutzen Strict Function Schemas; inkompatible lokale Endpoints erhalten automatisch einen Best-Effort-Retry. SQL-Text wird nicht auditiert, stattdessen nur Anzahl und SHA-256-Kurzfingerprints. Text2SQL ist nur als Capability sichtbar, wenn das aktive Profil `EnableToolCalling=true` hat. diff --git a/docs/claude-reference.md b/docs/claude-reference.md index 1ae89104..836d7ba6 100644 --- a/docs/claude-reference.md +++ b/docs/claude-reference.md @@ -318,19 +318,19 @@ Drei opt-in Helfer (Default `Llm:Enabled=false`): **Streaming (SSE):** `chat` + `generate-script` antworten als `text/event-stream` (Events `delta`/`building` (chat)/`proposal` (chat)/`done`/`error`) — Ausgabe ab dem ersten Token. Geteilte Infrastruktur: `ILlmClient.StreamAsync` (`IAsyncEnumerable`, OpenAI `stream:true` + `stream_options.include_usage`, HTTP-400-Fallback ohne `stream_options`, HTTP-400-Fallback `max_tokens`→`max_completion_tokens` für neuere OpenAI-Modelle (o-Serie/GPT-5-Ära), 16-MiB-Byte-Cap), [SseResponseWriter.cs](src/NodePilot.Api/Ai/SseResponseWriter.cs) (Header + Event-Schreiben), [LlmErrorCodes.cs](src/NodePilot.Api/Ai/LlmErrorCodes.cs). Controller-Lifecycle pro Stream: **erstes Event peeken** (Pre-Stream-`LlmException` → normaler HTTP-Status, greift in `authedFetch`), dann Events; drei Ausgänge — Erfolg (Success-Audit + Metrik), Fehler (`event:error` + `LlmCalls result=error`), **Abbruch** (Client trennt → kein Error-Event, Audit `cancelled=true` + `result=cancelled`). Frontend liest via `postEventStream` (client.ts, `Accept: text/event-stream`) + robustem SSE-Frame-Parser in [ai.ts](src/nodepilot-ui/src/api/ai.ts); `AbortController` = Stop/Dialog-Close. `generate-workflow` bleibt **non-streaming** (JSON). -- **`POST /api/ai/generate-script`** (SSE) — Sparkles-Button im `runScript`-Editor (beide Call-Sites: Properties-Panel + Doppelklick, via `useAiScriptStream`-Hook). Backend ruft LLM mit Prompt + Upstream-Variablen-Schema (Cap `LlmOptions.MaxUpstreamVariables=30`) + dem **aktuellen Editor-Skript** (`GenerateScriptRequest.CurrentScript`, untrusted Kontextblock) als **Refactor-Basis** — ohne das halluziniert der LLM bei „refactor/fix das Skript" aus der Variablen-Liste. **Streaming-aware Fence-Stripping**. Frontend tippt die Tokens **live in Monaco** ([ScriptEditorDialog.tsx](src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx)): Prompt-Dialog schließt **sofort** beim Klick auf Generieren (Editor-Overlay „Code wird generiert…" bis zum ersten Token, dann „generiert"-Pill + Stopp), Editor read-only während Streaming, Inserts an einer **explizit getrackten Position** (`advanceStreamPosition`, **nicht** `getSelection` — sonst verwürfeln die Tokens), gebatcht pro `requestAnimationFrame` als **eine Undo-Gruppe**, ReplaceAll leert erst beim ersten Token (Pre-Token-Fehler bleibt erhalten); Fehler erscheinen als Banner im Editor. +- **`POST /api/ai/generate-script`** (SSE) — Sparkles-Button im `runScript`-Editor (beide Call-Sites: Properties-Panel + Doppelklick, via `useAiScriptStream`-Hook). Backend ruft das LLM standardmäßig nur mit Prompt + Upstream-Variablen-Schema (Cap `LlmOptions.MaxUpstreamVariables=30`) auf. Das **aktuelle Editor-Skript** wird nur nach einer nicht persistierten, standardmäßig abgewählten Einzelfreigabe an den im Dialog genannten Zielhost gesendet; `GenerateScriptRequest.IncludeCurrentScript=false` ist der serverseitige Default und ein bloß mitgesendetes `CurrentScript` wird ignoriert. **Streaming-aware Fence-Stripping**. Frontend tippt die Tokens **live in Monaco** ([ScriptEditorDialog.tsx](src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx)): Prompt-Dialog schließt **sofort** beim Klick auf Generieren (Editor-Overlay „Code wird generiert…" bis zum ersten Token, dann „generiert"-Pill + Stopp), Editor read-only während Streaming, Inserts an einer **explizit getrackten Position** (`advanceStreamPosition`, **nicht** `getSelection` — sonst verwürfeln die Tokens), gebatcht pro `requestAnimationFrame` als **eine Undo-Gruppe**, ReplaceAll leert erst beim ersten Token (Pre-Token-Fehler bleibt erhalten); Fehler erscheinen als Banner im Editor. - **`POST /api/ai/generate-workflow`** — „KI generieren"-Button auf [WorkflowsPage](src/nodepilot-ui/src/pages/WorkflowsPage.tsx). Backend ruft LLM mit JSON-Mode + Few-Shot aus `workflow-example.json`, parser-pipeline mit Single-Retry (`LlmOptions.MaxJsonRetries=1`), Schema-Validierung gegen `nodes[]+edges[]`. UI zeigt Stats-Preview vor dem Anlegen. **Non-streaming.** - **`POST /api/ai/chat`** (SSE) — KI-Workflow-Assistent: lila Button neben dem Standard/Experte-Toggle öffnet ein angedocktes Chat-Panel ([AiWorkflowChatPanel.tsx](src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx)). Multi-Turn (`LlmRequest.Conversation`): erklärt den **aktuellen** Workflow (Markdown via `react-markdown`, live gestreamt) und schlägt auf Wunsch komplette Definitions-Umbauten vor (**Proposal-Karte** „poppt" am Ende — strukturiertes Changelog, selektives Übernehmen, Refine; da Merge/Validierung die volle Antwort braucht). Eigener Controller [AiChatController.cs](src/NodePilot.Api/Controllers/AiChatController.cs) mit `[Authorize]` (alle Rollen) — Änderungs-Proposals nur für Admin/Operator (`User.IsPrivileged()`), sonst serverseitig verworfen. Pipeline ([WorkflowAssistantService.cs](src/NodePilot.Ai/WorkflowAssistantService.cs)): - **Ausgabeformat** (statt JSON-Envelope, streamfreundlich): Markdown-Prosa, dann optional der Delimiter `===NODEPILOT-DEFINITION===` + `{nodes,edges}`. Die Prosa wird Token für Token als `delta` ausgegeben; alles nach dem Delimiter wird gepuffert und am Ende verarbeitet. - **System-Prompt** = `assistant-system.md` (Rolle, Schema, Secret-/Erhaltungs-/Injection-Regeln, Delimiter-Kontrakt) + `PromptCatalog.ActivityReference` (aus `workflow-system.md` herausgelöster Activity-Katalog **ohne** Generierungs-Output-Regeln) + dynamische `ActivityCatalog`-Metadaten der vorkommenden Node-Typen. Untrusted-Daten (das aktuelle, **secret-redigierte** Workflow-JSON) stehen in der **User-Message**, nicht im System-Prompt. - **Empty-Canvas-Design-Mode** (`IsEmptyCanvas`: 0 Nodes oder nur Trigger-Nodes — `activityType` endet auf `Trigger`): bei faktischer Erst-Erstellung hängt `BuildSystemPrompt` eine Design-Sektion + das **reiche Few-Shot-Beispiel `workflow-example.json`** an („mimic this structure & richness"), damit der Chat einen **verzweigten** Workflow vorschlägt statt einer dünnen linearen Kette (Parität zum `generate-workflow`-Pfad). Bei nicht-leerem Canvas bleibt der konservative „möglichst wenig ändern"-Edit-Modus. - - **Secret-Redaktion**: `WorkflowDefinitionSecretRewriter.Rewrite(..., Redact, null)` maskiert `SecretConfigKeys` zu `***` vor jedem LLM-Call — Inline-Secrets verlassen die Instanz nie. - - **Merge** ([WorkflowDefinitionMerge.cs](src/NodePilot.Ai/WorkflowDefinitionMerge.cs)): per Node-/Edge-`id` zurück aufs **unredigierte** Original — ausgelassene Felder (position, sourceHandle/targetHandle, parentId, group/sticky-Styles, credentialId, conditionExpression) werden erhalten; Secrets immer aus dem Original wiederhergestellt, von der KI gesetzte/abweichende Secret-Werte verworfen (+ Reply-Hinweis). Danach `WorkflowDefinitionStructuralValidator` + AI-Checks (Positionen, Trigger-Erhalt). + - **Secret-Redaktion**: `WorkflowDefinitionSecretRewriter.Rewrite(..., Redact, null)` delegiert an den strukturellen `WorkflowSecretRedactor`: benannte Secret-Keys sowie opaque ausführbare/runtime Payloads (Scripts, Bodies, Header, Argumente, Queries, Prompts, Bedingungs-Literale und Custom-Activity-Stringinputs) werden vor jedem LLM-Call vollständig zu `***` maskiert. + - **Merge** ([WorkflowDefinitionMerge.cs](src/NodePilot.Ai/WorkflowDefinitionMerge.cs)): per Node-/Edge-`id` zurück aufs **unredigierte** Original — ausgelassene Felder (position, sourceHandle/targetHandle, parentId, group/sticky-Styles, credentialId, conditionExpression) werden erhalten; Masken werden auch in verschachtelten Condition-/Case-Arrays konservativ aus dem Original wiederhergestellt, von der KI gesetzte/abweichende benannte Secret-Werte verworfen (+ Reply-Hinweis). Danach `WorkflowDefinitionStructuralValidator` + AI-Checks (Positionen, Trigger-Erhalt). - **Apply** läuft rein clientseitig auf den Canvas (kein DB-Write); Persistenz über den normalen Edit-Lock/Publish-Flow. Stale-Schutz: das Frontend hasht den Canvas-Stand (`hashDefinition`) und blockt das Apply, wenn er sich seit der Frage geändert hat. - **Tool-Calling** (opt-in `Llm:Profiles::EnableToolCalling`, am aktiven Profil): ist es an, läuft `WorkflowAssistantService.StreamChatAsync` eine OpenAI-Function-Calling-Schleife (`tool_choice:auto`, nur wenn es hilft). Das Modell darf read-only Tools auf der **secret-redigierten** Definition callen — `analyze_workflow` (deterministische Static-Analysis: fehlender Trigger, unreachable/orphan Steps, Zyklen, Remote-Step ohne Target-Machine, Strukturfehler — gleiche Codes wie der Canvas-Linter, via `WorkflowReviewAnalyzer` in `NodePilot.Core`) und `list_activity_types` (Activity-Katalog); Registry: `ChatToolRegistry`. Dazu drei **Execution-Log-Tools** — `list_recent_executions` (jüngste Läufe des geöffneten Workflows), `get_execution_steps` (Step-Details inkl. Output/ErrorOutput) und `get_failure_context` (One-Call: jüngster Failed-Run + Failed-Steps) — gespeist über `IExecutionLogReader` (Core-Interface) / `ExecutionLogReader` (Data, redigiert **immer** via `IAuditDetailsRedactor`, unabhängig vom Caller-Privileg — Outputs gehen ans externe LLM), Truncation in der Registry (1500/500 Zeichen, 2000 im Failure-Context, 100 Steps). **RBAC-Gate im Controller:** die WorkflowId ist client-kontrolliert; `AiChatController.Chat` prüft vor dem Stream Folder-Read (`IResourceAuthorizationService`) und reicht das Verdikt als `allowExecutionTools` an `StreamChatAsync` — kein Zugriff/unbekannt/ungespeichert → Reader wandert nicht in den `ChatToolContext`, die Execution-Tools werden nicht angeboten (`GetTools(context)` filtert) und ihre Handler antworten defensiv mit Error-JSON. Der Ownership-Check (`executionId` gehört zum autorisierten Workflow) lebt im Reader. Ergebnisse fließen als Tool-Messages zurück, dann produziert das Modell die finale Antwort/den Proposal. Gecappt durch `ToolCallMaxDepth` des aktiven Profils (Default 6, gültig 1–10): max LLM-Runden mit Tool-Calls pro Turn — in der **letzten erlaubten Runde** sendet der Server **keine** `tools` (erzwingt Text-Antwort; vermeidet den `tool_choice:none`-Literal, den manche lokalen Endpoints mit HTTP 400 ablehnen). SSE-Stream erhält `tool_call`/`tool_result`-Events; das UI zeigt eine „🔧 analyze_workflow — running…/checked"-Anzeige. Braucht ein Modell, das Function-Calling zuverlässig kann (viele kleine lokale Modelle nicht); aus → Chat verhält sich exakt wie vorher (keine `tools` gesendet). - **`POST /api/ai/chat/applied`** (Admin/Operator, Folder-RBAC Edit) — schreibt Audit `AI_PROPOSAL_APPLIED` (mit Node-/Edge-Counts), wenn ein KI-Vorschlag auf den Canvas übernommen wird. **`GET /api/ai/chat/activity/{workflowId}`** (Admin/Operator, Folder-RBAC Read) — die KI-Audit-Einträge (`AI_WORKFLOW_EXPLAINED`/`AI_PROPOSAL_APPLIED`) eines Workflows, neueste zuerst; bewusst getrennt vom Admin-only `/api/audit`, damit Operatoren ihre eigene KI-Aktivität ohne globalen Audit-Zugriff sehen. - **Rechter Panel-Slot**: Chat und `EditorRightPanel` (Node-/Edge-Properties, BulkEdit) belegen dieselbe Fläche — der geöffnete Chat überlagert sie. Jede **Einzel**-Selektion gibt den Slot zurück: ein `useEffect` auf `selected` in `WorkflowEditorPage` schließt den Chat (deckt Canvas-Klick, Marquee mit einem Treffer, Drop, Suche/`jumpToNode`, Tastatur-Navigation, Kontextmenü ab), zusätzlich schließt `onNodeClick` den Fall „Klick auf den bereits selektierten Node" (ReactFlow feuert dabei kein `onSelectionChange`). **Mehrfachauswahl** hält den Chat offen (`selected` ist dann `null`, und `onNodeClick` ignoriert Shift/Ctrl/Meta-Klicks) — sie ist der „Auswahl (N)"-Kontext des Chats. - - **Chat-UX (PR3)**: benannte Threads je Workflow (wechseln/umbenennen/löschen/neuer Chat), reload-persistenter Verlauf in localStorage (privacy-aware: strippt Canvas-Snapshots + Proposal-Definition-JSON, nie für ungespeicherte Workflows, gecappt auf ~200 Messages/Thread, Logout leert), Markdown-Export eines Threads und eine in-panel workflow-scoped „AI-Aktivität"-Ansicht. (Slash-Commands wurden bewusst **nicht** gebaut.) + - **Chat-UX (PR3)**: benannte Threads je Workflow (wechseln/umbenennen/löschen/neuer Chat), reload-persistenter Verlauf in `sessionStorage` (privacy-aware: strippt Canvas-Snapshots und persistiert Proposal-Definition-JSON immer nur als leeren/abgelaufenen Stub, nie für ungespeicherte Workflows, gecappt auf ~200 Messages/Thread, Logout leert), Markdown-Export eines Threads und eine in-panel workflow-scoped „AI-Aktivität"-Ansicht. (Slash-Commands wurden bewusst **nicht** gebaut.) **Transport**: OpenAI-kompatible HTTP-API über raw `HttpClient` — läuft gegen OpenAI Cloud, Ollama, LM Studio, vLLM, LocalAI, llama.cpp. Lokale Endpoints bevorzugt. **Zwei Wire-Dialekte**, ohne Config-Key aus dem `BaseUrl`-Pfad abgeleitet (`LlmEndpointGuard.ResolveEndpoint` → `LlmEndpointTarget`): Pfad endet auf `/responses` → [OpenAiResponsesLlmClient.cs](src/NodePilot.Ai/OpenAiResponsesLlmClient.cs), sonst [OpenAiCompatibleLlmClient.cs](src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs); endet der Pfad bereits auf `/chat/completions`, wird nichts mehr angehängt. Beide teilen [LlmHttpTransport.cs](src/NodePilot.Ai/LlmHttpTransport.cs) (Send/Auth/Timeout/Fehler-Mapping/16-MiB-Cap/SSE-Framing); die vier Kompatibilitäts-Fallbacks (`max_tokens`, `stream_options`, `response_format`, `strict`) sind Chat-Completions-only und im Responses-Client bewusst nicht vorhanden. Der Responses-Client sendet immer `store: false` (die API defaultet auf 30 Tage Retention, Chat Completions speichert nichts). Konfigurations-Keys + Dialekt-Tabelle + Modell-Empfehlungen siehe [docs/ai-features.md](docs/ai-features.md). Für Chat-Edits an großen Workflows ggf. `MaxTokens` des aktiven Profils erhöhen. @@ -393,7 +393,7 @@ Globale Flags: `--server`, `--profile`, `-o table|json|yaml`, `--no-color`, `-v` **Settings-Spezialfall:** `np settings ...` arbeitet section-basiert, file-roundtrip, ETag-gegated. Workflow: `np settings get Smtp --etag-only > etag.txt`, dann `np settings put Smtp --file smtp.json --etag $(cat etag.txt)`. Kein `set key=value`. -**Token-Storage:** DPAPI-encrypted (`CurrentUser`-Scope) unter `%APPDATA%\NodePilot\session-.dat`. Refresh transparent via `TokenRefreshHandler`. Klartext-Config (Server-URL, Default-Profile) liegt daneben in `config.json`. +**Token-Storage:** DPAPI-encrypted (`CurrentUser`-Scope) unter `%APPDATA%\NodePilot\session-.dat`, inklusive serverseitigem `expiresAt`. `TokenRefreshHandler` rotiert einen noch gültigen Token kurz vor der absoluten Deadline. CLI und MCP koordinieren parallele Prozesse über denselben origin-gebundenen File-Lease: genau ein Prozess refresht, wartende Verlierer laden dessen Token. Atomarer Replace im selben Verzeichnis verhindert partielle Session-Blobs. Transiente proactive Fehler (408/429/5xx) aktivieren pro Token 15 Sekunden Cooldown; noch gültige Requests laufen weiter und ein späterer Request versucht erneut zu rotieren. Nach absolutem Ablauf ist ein neuer Login nötig; Refresh verlängert die Session nicht. Klartext-Config (Server-URL, Default-Profile) liegt daneben in `config.json`. **Architektur-Konvention:** Wer einen neuen API-Endpoint hinzufügt, der für Operatoren-Workflows relevant ist, legt parallel eine Methode in [NodePilotApiClient.cs](src/NodePilot.Cli/Api/NodePilotApiClient.cs) + ein Command unter `Commands//` an. DTOs werden in `Cli/Api/Dtos/` **dupliziert** (kein ProjectReference auf `NodePilot.Api`). @@ -569,7 +569,7 @@ Voller DR-Snapshot der **Konfiguration** — getrennt vom redigierten Workflow-E **Scope.** Enthalten: `folders` (Struktur + Grants), `users` (inkl. BCrypt-Hash), `credentials`, `machines`, `globalVariables` (+ Global-Variable-Ordner), `workflows`, `customActivities` (`CustomActivityBackupPart`, siehe `docs/custom-activities.md`), `alerting` (Custom-Regeln + System-Policies, ADR 0008), `settings` (nur `appsettings.runtime.json`). **Nicht** enthalten: AuditLog, Execution-History, StepExecutions, WorkflowVersions, Stats, SupportEvents, Alerting-Ledger/Suppression/Policy-State (transient) — dafür gilt der DB-eigene Backup-Pfad. -**Datei.** `.npbackup`, JSON-Envelope **`nodepilot-system-backup/v2`** — v2 fügt die `alerting`-Sektion hinzu; der Reader importiert v1 **und** v2, ältere Builds lehnen v2 sichtbar ab (`BackupSections.SupportedSchemas`). Struktur lesbar; nur Secret-Felder als `{"$enc":""}`. Header `crypto` (kdf/iterations/salt/verifier) + Top-Level `mac`. +**Datei.** `.npbackup`, JSON-Envelope **`nodepilot-system-backup/v3`** — v2 fügte die `alerting`-Sektion hinzu; v3 verschlüsselt jede vollständige Workflowdefinition als `$encDefinition`, damit auch unbekannte Literale in Scripts und Import-Payloads geschützt sind. Custom-Activity-`scriptTemplate` und `inputParametersJson` werden ebenfalls vollständig als `$enc` versiegelt; Workflows ziehen die referenzierten Definitionen als harte Backup-Abhängigkeit mit. Der Reader importiert v1, v2 und v3 (inklusive früherer Plaintext-Custom-Activity-Felder); ältere Builds lehnen unbekannte neuere Schemas sichtbar ab (`BackupSections.SupportedSchemas`). Andere Secret-Felder bleiben `{"$enc":""}`. Header `crypto` (kdf/iterations/salt/verifier) + Top-Level `mac`. **Alerting-Sektion (v2).** `AlertingBackupPart` exportiert jede `NotificationRule` mit Routen (Route-Secret-Rewrap wie Credentials) + Scope-Targets. Restore remappt Targets über `FolderMap`/`WorkflowMap` und stempelt bei restaurierten enabled System-Policies ein frisches `ActivatedAt` (verhindert Back-Alerting der Historie). @@ -585,12 +585,12 @@ Voller DR-Snapshot der **Konfiguration** — getrennt vom redigierten Workflow-E | | | *UI:* feuert bereits beim Dateiauswählen (Struktur-Vorschau), der „Vorschau"-Button ist der Re-Run nach Passphrase-Eingabe | | `POST /api/backup/restore` | multipart `file` + `passphrase` + `policy` | wendet an | -**Export** zieht harte Dependencies automatisch mit (Workflows → Folders/Machines/Credentials) und versiegelt mit dem Whole-file-MAC. **Workflow-Secrets** liegen inline in `DefinitionJson` (`secret`/`apiKey`/`password`/`authToken`/`bearer`/`connectionString`) und werden über `WorkflowDefinitionSecretRewriter` mit `SecretHandling = Redact | EncryptForBackup | PlainInternal` behandelt — dieselbe Klasse, die der redigierte Workflow-Export nutzt. `targetMachineId`/`credentialId` sind GUID-Referenzen, kein Secret → ID-Remap beim Restore. +**Export** zieht harte Dependencies automatisch mit (Workflows → Folders/Machines/Credentials/CustomActivities) und versiegelt mit dem Whole-file-MAC. Der Sharing-Export redigiert Definitionen strukturell; das DR-Backup verschlüsselt jede vollständige Workflowdefinition als `$encDefinition` und Custom-Activity-Skripte/-Inputdefaults als `$enc`. `targetMachineId`/`credentialId` sowie `config.__customDefinitionId` sind harte GUID-Referenzen → pfadgebundene Validierung und ID-Remap beim Restore; gleichnamige verschachtelte Nutzdaten bleiben unverändert. **Restore** (Service: [BackupRestoreService.cs](src/NodePilot.Api/Services/Backup/BackupRestoreService.cs)): 1. Passphrase via Verifier prüfen → sonst Abbruch. Whole-file-MAC prüfen → Mismatch = Abbruch (Tamper). 2. **Referenz-Validierung** (vor jedem Schreiben): jede harte Ref muss im Backup **oder** in der Ziel-DB (per `sourceId`) auflösbar sein, sonst Abbruch. -3. Eine DB-Transaktion, **gekapselt in `db.Database.CreateExecutionStrategy().ExecuteAsync(...)`** — Pflicht, weil Postgres/SQL Server eine Retrying-Strategy nutzen, die direkte `BeginTransaction` ablehnen. Reihenfolge: Users → Folder-Struktur → Credentials → Machines → Globals → Workflows → Folder-Grants. Jede Section füllt eine `sourceId→targetId`-Map; Folgereferenzen werden darüber remappt (`Machine.DefaultCredentialId`, `Folder.ParentFolderId`/`CreatedByUserId`, Workflow-Def-GUIDs, Grant-Principals). AD-Group-SIDs in Grants bleiben unverändert. +3. Eine DB-Transaktion, **gekapselt in `db.Database.CreateExecutionStrategy().ExecuteAsync(...)`** — Pflicht, weil Postgres/SQL Server eine Retrying-Strategy nutzen, die direkte `BeginTransaction` ablehnen. Reihenfolge: Users → Folder-Struktur → Credentials → Machines → Globals → Custom Activities → Workflows → Folder-Grants. Jede Section füllt eine `sourceId→targetId`-Map; Folgereferenzen werden darüber remappt (`Machine.DefaultCredentialId`, `Folder.ParentFolderId`/`CreatedByUserId`, Workflow-Def-GUIDs, Custom-Definition-IDs, Grant-Principals). AD-Group-SIDs in Grants bleiben unverändert. 4. **Settings** danach, **außerhalb** der Transaktion (File via `RuntimeOverridesWriter`), eigene Ergebniszeile. **Replace, nicht Merge**: Top-Level-Overrides, die im Ziel existieren aber nicht im Backup, werden entfernt (`__meta` bleibt); `enc:v1:`-Werte werden re-sealed. **Konflikt-Policy** (by-name-Match; default `skip`): `skip` / `rename` (Suffix `(Restored N)`) / `overwrite`. Format im `policy`-Feld: bare Wert (global) und/oder `section=policy`-Paare, komma-getrennt (z. B. `skip,users=overwrite`). diff --git a/docs/custom-activities.md b/docs/custom-activities.md index 695dfaa3..bae1bbaf 100644 --- a/docs/custom-activities.md +++ b/docs/custom-activities.md @@ -103,13 +103,16 @@ Audit codes: `CUSTOM_ACTIVITY_CREATED|UPDATED|DELETED|ENABLED|DISABLED|IMPORTED| ## System-configuration backup (ADR 0001) Custom activities are part of the `.npbackup` DR snapshot (`CustomActivityBackupPart`, section -`customActivities`). The live definition of each (including disabled drafts) is exported — script -template in cleartext, like a workflow's runScript `script` field; version-history snapshots are -excluded. Restore is full-fidelity (the enabled state is preserved, unlike the `.npca` import which +`customActivities`). The live definition of each (including disabled drafts) is exported. The +PowerShell script template and the complete input-schema JSON (whose defaults become runtime +values) are encrypted under the backup passphrase; v1/v2 plaintext fields remain restore-compatible. +Version-history snapshots are excluded. Restore is full-fidelity (the enabled state is preserved, unlike the `.npca` import which forces disabled), conflict policies skip/overwrite apply by `Key` (rename is unsupported — a key is embedded in workflow references — and falls back to skip with a warning). A workflow node's `config.__customDefinitionId` is **remapped** to the restored definition id, so overwrite-merge -restores keep references intact (a no-op for a clean restore, which preserves source ids). +restores keep references intact (a no-op for a clean restore, which preserves source ids). Selecting +workflows automatically includes custom-activity definitions; a missing hard reference aborts before +any restore write. ## AI awareness diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 7ff95b21..58f37a2e 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -479,6 +479,15 @@ The health probe follows the port in the installed configuration, so a non-defau - A **successful update always leaves the service running**, regardless of whether it was running before. A failed update restores the pre-update state instead — a service that was deliberately stopped is not started by a rollback. +- Releases that introduce encrypted workflow-history envelopes keep the startup health-check + rollback safe: startup reports legacy plaintext history but does not rewrite it. During a mixed + HA rollout, pause workflow editing/rollback writes (or disable failback to old nodes after the + first such write), because a new node immediately writes new history snapshots as `np:wfv:v1:` + and an old binary cannot read those rows. After the health check has succeeded and **every HA + node** runs the new release, take the normal database backup and run + `np secrets reencrypt --yes` (or the Admin Security action). This audited cutover protects old + `WorkflowVersions`. Once either a new-binary history write or this cutover succeeds, do not + fail back to a release that predates `np:wfv:v1:` history support. - The binary backup deliberately **excludes** `appsettings.Production.json` (it holds secrets). It is the last file removed during the swap, so an aborted upgrade leaves it in place — but if it is ever lost, do not re-run the update: it refuses a layout without a diff --git a/docs/enterprise-features.md b/docs/enterprise-features.md index f0642f1e..f96208aa 100644 --- a/docs/enterprise-features.md +++ b/docs/enterprise-features.md @@ -137,13 +137,15 @@ Erwartung: 40–60 s bis `/healthz/leader` auf node-b grün wird. Audit-Log zeig ### Was es kann -- Verschlüsselt **Credentials** und **Global Variables** at rest. Bisher hart an Windows +- Verschlüsselt **Credentials**, **Global Variables** und vollständige historische + **Workflow-Version-Definitionen** at rest. Bisher hart an Windows DPAPI gekoppelt; das Feature führt eine Provider-Abstraktion ein und liefert eine zweite Implementierung gegen AES-GCM mit Key aus Env-Variable. - **Provider-Migration** über einen `MigratingSecretProtector`-Wrapper: für die Dauer der Rotation läuft ein zweiter (Legacy-)Provider parallel. Reads probieren Active zuerst, fallen auf Legacy zurück; Writes nutzen immer Active. Ein admin-getriggerter - Bulk-Sweep (`POST /api/secrets/reencrypt`) zieht jede Row durch Decrypt→Encrypt und + Bulk-Sweep (`POST /api/secrets/reencrypt`) zieht Credentials, Secret-Globals und + Workflow-History durch Decrypt→Encrypt und beendet das Migration-Fenster. Skipped Rows (z.B. korrupte Ciphertexte) werden im Response namentlich gelistet; HTTP `207 Multi-Status` signalisiert „nicht alles migriert", `200 OK` nur bei sauberem Cutover. @@ -159,7 +161,8 @@ Erwartung: 40–60 s bis `/healthz/leader` auf node-b grün wird. Audit-Log zeig - **Audit der Crypto-Operationen** über Metrics: `nodepilot_credential_crypto_calls{operation,result}` unterscheidet `encrypt`/`decrypt` × `success`/`failure`. `nodepilot_credential_crypto_legacy_reads` zählt Decrypts die vom Legacy-Provider (Migrations-Window) bedient wurden — wenn der - Counter auf null ist, kann der Operator das Legacy-Config sicher wegwerfen. + Counter auf null ist. Das Legacy-Config darf trotzdem erst nach einem sauberen Sweep mit + `workflowVersionsSkipped=0` entfernt werden. ### Wie es umgesetzt ist @@ -185,10 +188,12 @@ Erwartung: 40–60 s bis `/healthz/leader` auf node-b grün wird. Audit-Log zeig aktiven Implementierung greift die Legacy-Implementierung; bleibt das Plaintext leer, wird ein kombinierter `CryptographicException`-Diagnostic geworfen, der beide Versuche benennt. -- **`POST /api/secrets/reencrypt`** (Admin-only) liest jede Credential + jede Secret- - Global-Variable, dechiffriert über den (ggf. wrappenden) Protector, re-enkryptiert - unter dem Active-Provider und schreibt zurück. Skipped Rows landen mit `(id, name, reason)` - im Response. +- **`POST /api/secrets/reencrypt`** (Admin-only) liest jede Credential, jede Secret- + Global-Variable und jede verschlüsselte `WorkflowVersion.DefinitionJson`, dechiffriert über + den (ggf. wrappenden) Protector, re-enkryptiert unter dem Active-Provider und schreibt + zurück. Alle drei Bereiche liefern eigene Rewritten-/Skipped-Zähler und + `(id, name, reason)`-Details. `LegacyProvider` bleibt gesetzt, solange insbesondere ein + History-Skip offen ist. - **DI-Disambiguierung über `[ActivatorUtilitiesConstructor]`**: `CredentialStore` und `GlobalVariableStore` haben mehrere Konstruktoren (Legacy + neuer Single-Arg-Pfad mit Protector). Microsoft.Extensions.DependencyInjection würde sonst mit @@ -239,7 +244,7 @@ Key im Klartext im `appsettings.json` steht. | Endpoint | Auth | Zweck | |---|---|---| -| `POST /api/secrets/reencrypt` | Admin | Bulk-Sweep aller Credentials + Secret-Globals durch Decrypt→Re-Encrypt unter dem aktiven Provider. Liefert `200 OK` (clean) oder `207 Multi-Status` (skipped rows mit Details) zurück. | +| `POST /api/secrets/reencrypt` | Admin | Bulk-Sweep aller Credentials, Secret-Globals und Workflow-Version-Definitionen unter dem aktiven Provider. Liefert `200 OK` (clean) oder `207 Multi-Status` (separate Skip-Details je Bereich) zurück. | ### Wichtige Dateien @@ -251,6 +256,7 @@ Key im Klartext im `appsettings.json` steht. - [src/NodePilot.Api/Controllers/SecretsController.cs](../src/NodePilot.Api/Controllers/SecretsController.cs) - [src/NodePilot.Data/CredentialStore.cs](../src/NodePilot.Data/CredentialStore.cs) (`ReencryptAllCredentialsAsync`) - [src/NodePilot.Data/GlobalVariableStore.cs](../src/NodePilot.Data/GlobalVariableStore.cs) (`ReencryptAllSecretsAsync`) +- [src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs](../src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs) (`ReencryptAllAsync`) - [docs/secrets-providers.md](secrets-providers.md) — Operator-Doku mit Migrations-Runbook ### Bewusst nicht in Scope @@ -291,9 +297,10 @@ $body = @{username='admin'; password='admin123'} | ConvertTo-Json $login = Invoke-RestMethod -Uri http://localhost:5000/api/auth/login -Method POST -Body $body -ContentType 'application/json' $headers = @{ Authorization = "Bearer $($login.token)" } Invoke-RestMethod -Uri http://localhost:5000/api/secrets/reencrypt -Method POST -Headers $headers -# → 200 OK + { credentialsRewritten: 1, ..., partialSuccess: false } +# → Legacy-Config nur bei 200 OK + partialSuccess:false + workflowVersionsSkipped:0 entfernen -# 5. Stoppen, Legacy-Config entfernen, neu booten — Provider ist jetzt rein AES-GCM. +# 5. Erst nach sauberem Credential-/Global-/History-Sweep stoppen, Legacy-Config entfernen +# und neu booten — Provider ist jetzt rein AES-GCM. Remove-Item Env:Secrets__LegacyProvider, Env:Secrets__LegacyDpapiScope ``` diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 9ccde770..c078fed4 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -51,7 +51,7 @@ environment variables, falling back to the CLI's on-disk config/session: |---|---| | Server URL | `NODEPILOT_MCP_SERVER` › `NODEPILOT_SERVER` › CLI `config.json` profile | | Profile | `NODEPILOT_MCP_PROFILE` › `NODEPILOT_PROFILE` › CLI default profile › `default` | -| Token | `NODEPILOT_MCP_TOKEN` (raw bearer, CI/headless escape — no refresh) › DPAPI `np auth login` session (auto-refreshed on 401) | +| Token | `NODEPILOT_MCP_TOKEN` (raw bearer, CI/headless escape — no refresh) › DPAPI `np auth login` session (cross-process single-flight rotation shared with CLI before its server-issued absolute expiry; transient proactive failures use a 15-second token-bound cooldown; re-login after expiry) | The server starts even when unconfigured/unauthenticated; tools then return an actionable error (`run np auth login`, or set `NODEPILOT_MCP_SERVER`). @@ -75,12 +75,13 @@ Every tool also carries MCP annotations: read tools are `readOnly`, the gated on ## Secret handling -- Workflow definitions are **redacted** before they reach the agent: inline secret config values - (`secret`, `apiKey`, `password`, `authToken`, `bearer`, `connectionString`) are masked to `***`. - (The API only redacts for non-privileged roles; the MCP server re-applies it for everyone, - using the shared `NodePilot.Core.WorkflowDefinitions.WorkflowSecretKeys`.) +- Workflow definitions are **redacted** before they reach the agent. Named secret keys plus opaque + executable/runtime payloads (scripts, bodies, headers, arguments, queries, prompts, condition + literals and Custom-Activity string inputs) are masked to `***`. The API only redacts by role; + MCP re-applies the shared `WorkflowSecretRedactor` for everyone. - On `publish_workflow`/`update_workflow_definition`/`apply_workflow_patch`, real secrets are - **restored from the stored version** by node id — the agent's `***` never overwrites them, and a + **restored from the stored version** by node id (including masks nested in condition/case arrays) + — the agent's `***` never overwrites them, and a secret the agent invents on a new node is rejected (masked + noted). - Credentials carry no password field; secret global values arrive masked. Create/update accept secrets write-only. @@ -129,9 +130,15 @@ gerade“. Gefensterte Gesamtzahlen kommen ohnehin aus `density[]`, nicht aus de `list_db_tables` · `get_db_info` · `run_readonly_sql`. Schema discovery + single read-only SQL statement against the NodePilot App-DB (the agent does the NL→SQL translation). Read keyword whitelist + rollback enforced server-side; no write tool. `list_db_tables` hides secret columns -(`PasswordHash`/`EncryptedPassword`), masks `GlobalVariable.Value`. `run_readonly_sql` cannot reach -them either — drei Schichten, alle im `DbAdminSecretColumns`-Contract, den auch der Row-Browser und -der In-App-text2sql-Reader fahren: +(`PasswordHash`/`EncryptedPassword`), masks `GlobalVariable.Value` und lässt die vier Tabellen mit +opaquen Automations-Payloads vollständig aus: `Workflows`, `WorkflowVersions`, +`CustomActivityDefinitions`, `CustomActivityDefinitionVersions`. `run_readonly_sql` lehnt jede +Referenz auf diese Tabellen bereits im MCP-Prozess vor dem HTTP-Request ab; passende Ergebnis- +Spaltennamen werden zusätzlich maskiert. So können Composite Rows weder über Casts/LATERAL noch +über provider-spezifische Wrapper zum Agent gelangen. PostgreSQL-`U&"…"`-Identifier sind an dieser +Grenze ebenfalls gesperrt. Dedizierte Workflow-/Custom-Activity-Tools bleiben der Zugriffspfad. + +Für die übrigen Secret-Spalten gelten drei serverseitige Schichten im `DbAdminSecretColumns`-Contract: 1. Nennt das Statement eine geschützte Spalte → Ablehnung (`protected_column`). 2. Wildcard-Select → die geschützten Ergebnis-Spalten kommen als `***` zurück. @@ -142,7 +149,7 @@ der In-App-text2sql-Reader fahren: aus — sie umging damit beide auf einen Schlag (Security-Audit 2026-07-26). So landet kein Secret im Kontext des Agents. Schicht 3 ist bewusst grob und greift auch bei -harmlosen Casts auf diesen Tabellen; explizit benannte Spalten funktionieren immer. +harmlosen Casts auf diesen Secret-Tabellen; explizit benannte, nicht geschützte Spalten funktionieren. ### Supporting resources (secrets never surfaced) `list_machines` · `get_machine` · `create_machine` · `update_machine` · `test_machine` · diff --git a/docs/roadmap.md b/docs/roadmap.md index efa6cf17..ccbc69ea 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,7 +33,7 @@ Hier steht nur, *was* geändert wird. | # | Posten | Inhalt | |---|---|---| -| 1 | **Lokale Skript-Ausführung härten** | `runScript` ohne `targetMachineId` läuft im API-Prozess mit dessen Identität. Zwei Stufen: (a) die Rollenmatrix in README/CLAUDE.md ehrlich dokumentieren — `Operator` ist heute faktisch mächtiger als die Tabelle suggeriert; (b) Hardening-Flag `Engine:RequireTargetMachineForScripts` (default wie die übrigen Flags), das auch `CustomActivityExecutor`, `startProgram` und lokale File-Ops erfasst. **Wenn dieser Posten geschlossen wird, muss der `llmQuery`-Per-Node-`baseUrl`-Override mitgezogen werden** — er umgeht den SSRF-Guard heute bewusst und wird danach selbst zum Finding. | +| 1 | ~~**Lokale Skript-Ausführung härten**~~ **Entschieden (2026-08-15)** | `Operator` ist bewusst ein vertrauenswürdiger Automation-Author. Lokale Activities dürfen unter der NodePilot-Service-Identität laufen; Folder-RBAC ist keine Code-Sandbox. README und CLAUDE.md dokumentieren diese Trust Boundary. Ein `Engine:RequireTargetMachineForScripts`-Flag ist daher nicht vorgesehen. | | 2 | **SSRF-Guard: Adressabdeckung vervollständigen** | `NetworkGuard.IsPrivateNetwork` deckt nicht alle Adressformen ab, die auf Windows beim lokalen Host landen. Zwei Zeilen (IPv4- und IPv6-Zweig) plus Regressionstest. Betrifft `RestApiActivity` und `WebhookNotificationSink`. | | 3 | **Passwortpolicy für Break-Glass-Konten** | `AuthController.MinPasswordLength` steht auf 8 ohne Komplexitätsregel. Für explizit als Break-Glass markierte Konten auf 12 anheben. | | 4 | **`restApi` bekommt ein First-Class-Credential-Feld** | Heute landen Auth-Header als Klartext im `config.headers` und werden nur nachgelagert redigiert. Ein `credentialId`-Feld analog zu den Remote-Activities löst die Ursache statt des Symptoms. Backend + `RestApiConfig.tsx`. | @@ -243,7 +243,14 @@ Beschreibung, Nutzerproblem und Sicherheitsgrenzen je Idee: [`ai-feature-ideas.m | Posten | Auslöser | |---|---| -| Backend-Line-Coverage 89 % → 90 % | Die Ratsche steht auf 88 %. Anheben, wenn ohnehin breit getestet wird — kein Selbstzweck. | +| Backend-Line-Coverage 89 % → 90 % | Die Ratsche steht in `ci.yml` auf 85 % Line / 70 % Branch. Anheben, wenn ohnehin breit getestet wird — kein Selbstzweck. | +| `IWorkflowDefinitionMutator` — ein Mutations-Pfad für Workflow-Definitionen | Aus dem Audit 2026-08-15 (P1). Version, `UpdatedAt`, berechnete Metadaten, History und Trigger-Sync liegen heute mehrfach nebeneinander in Create/Publish/Duplicate/Restore. Auslöser: der nächste Bug, der nur einen dieser Pfade trifft. | +| Custom-Activity-Invarianten DB-seitig erzwingen | Aus dem Audit 2026-08-15 (P2). Heute nur App-Level-`ConcurrencyToken` in `CustomActivityDefinitionStore`; DB-seitig fehlen `IsConcurrencyToken()` und Live-Key-Eindeutigkeit. Braucht EF-Migration + Parallel-Test mit zwei DbContexts. Auslöser: erste beobachtete Race in der Praxis. | +| Credential-Rotation an den WinRM-Pool koppeln | Aus dem Audit 2026-08-15 (P2). Pool-Key um Credential-Fingerprint erweitern, Idle-Sessions bei Update invalidieren, Semantik für ausgeliehene Sessions dokumentieren. Auslöser: erste Rotation, nach der eine alte Identität weiterlief. | +| N+1 bei Notifications und Workflow-Statistiken bündeln | Aus dem Audit 2026-08-15 (P2). Ziel-Größenordnung laut Betreiber: ~100 parallele Läufe. Auslöser: gemessene Latenz, nicht Verdacht — vorher `docs/performance-improvements.md` gegenlesen. | +| Reale Integrationsgrenzen (Container-Migrationen, Browser↔echte API, Electron-Smoke) | Aus dem Audit 2026-08-15 (P2). Alle bestehenden Suiten sind hermetisch gemockt. Größter Einzelposten der Liste; sinnvoll nur stückweise. | +| `WorkflowEditorPage` in Hooks zerlegen + Gzip-Budgets in CI | Aus dem Audit 2026-08-15 (P2). Die Seite ist zuletzt weiter gewachsen. Monaco und ELK bleiben erlaubte Lazy-Ausnahmen. Auslöser: wenn der Initial-Chunk spürbar wird. | +| xUnit1051 abbauen, produktive Nullable-Warnungen als Fehler | Aus dem Audit 2026-08-15 (P3). Bewusst schrittweise — als Big-Bang bricht es die halbe Suite auf einmal. | | `WorkflowEditorPage.test.tsx` splitten (1558 Zeilen / 89 Tests) | Wenn die Frontend-Suite in CI zum Zeitfaktor wird. Die Flakiness selbst ist gefixt. | | E2E-Spec für `/metrics/:section` | Einzige Seite ohne Playwright-Spec. Mock-Smoke-testbar. | | `/operations` (Live-Ops) über den Snapshot-Happy-Path hinaus | RBAC-Cross-Checks, Drilldown und Health-Rail sind unasserted; SignalR-Live bleibt untestbar (404-Stub). | diff --git a/docs/secrets-providers.md b/docs/secrets-providers.md index c808f56a..320bb067 100644 --- a/docs/secrets-providers.md +++ b/docs/secrets-providers.md @@ -94,8 +94,8 @@ Operators MUST: - Rotate the key via the migration path described in [§ Rotating the AES-GCM master key](#rotating-the-aes-gcm-master-key) below — set `Secrets:LegacyMasterKey` or `Secrets:LegacyMasterKeyFile` alongside the new key, run - `POST /api/secrets/reencrypt`, - then drop the legacy entries on the next restart. + `POST /api/secrets/reencrypt`, and drop the legacy entries only after the response confirms + a clean credential, global-secret **and workflow-history** sweep. The startup hardening warning emits a SECURITY log line on boot whenever a plaintext master key is detected, so operators get a daily reminder if they forget to harden. @@ -140,6 +140,9 @@ Clean-success response (status `200 OK`): "globalSecretsRewritten": 12, "globalSecretsSkipped": 0, "globalSecretSkipDetails": [], + "workflowVersionsRewritten": 86, + "workflowVersionsSkipped": 0, + "workflowVersionSkipDetails": [], "partialSuccess": false } ``` @@ -158,27 +161,34 @@ decrypted under any configured protector: "globalSecretSkipDetails": [ { "id": "def...", "name": "STRIPE_KEY", "reason": "FormatException" } ], + "workflowVersionsRewritten": 84, + "workflowVersionsSkipped": 2, + "workflowVersionSkipDetails": [ + { "id": "123...", "name": "Deploy v3", "reason": "CryptographicException" }, + { "id": "456...", "name": "Rollback v7", "reason": "FormatException" } + ], "partialSuccess": true } ``` -The endpoint walks every credential password and every secret-flagged global, decrypts -through the migrating wrapper (active first, falls back to legacy when the bytes don't -parse under active), and re-encrypts under the active provider. Successfully migrated -rows are committed regardless of skip outcomes — a partial sweep still moves the -deployment forward. **`partialSuccess=true` (status 207) is the operator's signal to -re-enter the listed rows manually before dropping the legacy config in Step 3.** +The endpoint walks every credential password, every secret-flagged global and every encrypted +`WorkflowVersion.DefinitionJson`, decrypts through the migrating wrapper (active first, falls +back to legacy when the bytes don't parse under active), and re-encrypts under the active +provider. Successfully migrated rows are committed regardless of skip outcomes — a partial +sweep still moves the deployment forward. **`partialSuccess=true` (status 207) means the legacy +provider must remain configured.** Re-enter listed credentials/globals; for a workflow-history +skip, restore or otherwise repair the named version before re-running the sweep. CI / Ansible can branch on the status line directly: `200` = clean cutover, `207` = manual follow-up needed for the named rows. ### Step 3 — drop the legacy config -Pre-conditions: response from Step 2 was `200 OK` with `partialSuccess=false` AND the -`nodepilot.credential.crypto.legacy_reads` counter is zero (every read now hits the -active provider directly). If Step 2 returned `207`, deal with the rows in -`*SkipDetails` first — re-enter them through the credentials/global-variables UI, then -re-run Step 2 until clean. +Pre-conditions: response from Step 2 was `200 OK` with `partialSuccess=false`; all three +skip counters — including `workflowVersionsSkipped` — are zero; and the +`nodepilot.credential.crypto.legacy_reads` counter remains zero during post-sweep checks +(every read now hits the active provider directly). A `207` or any workflow-version skip blocks +removal of `LegacyProvider`. Resolve every `*SkipDetails` entry and re-run Step 2 until clean. Once clean, remove the `Secrets:LegacyProvider` / `Secrets:LegacyDpapiScope` / `Secrets:LegacyMasterKey` keys and restart. The deployment is now pure-active-provider. @@ -190,6 +200,7 @@ Once clean, remove the `Secrets:LegacyProvider` / `Secrets:LegacyDpapiScope` / | `legacy_reads` keeps climbing after the sweep | New rows being written somewhere in the legacy format | Investigate — should not happen after Step 2; possibly a parallel deployment branch still running DPAPI | | `CryptographicException: Decrypt failed under both protectors` | Row written under a third provider, OR ciphertext corrupted | Re-enter the affected secret manually; check `LegacyDpapiScope` matches what wrote the row | | `Re-encrypt skipped credential 'X'` warning during Step 2 | Single row's ciphertext is unrecoverable | Re-enter that credential; sweep continues for the rest | +| `workflowVersionsSkipped` is non-zero | A historic workflow definition could not be decrypted | Keep `LegacyProvider`; restore/repair every named version and repeat the sweep before cutover | ### Rotating the AES-GCM master key @@ -206,6 +217,8 @@ DPAPI in Step 1. Step 2 + 3 unchanged. - [ ] Boot log (logger category `Secrets`) shows the expected provider line. Two shapes: - Single provider, no migration: `Secret protector enabled. Provider: AesGcm.` - Migration window with legacy fallback: `Migrating secret protector enabled: active=AesGcm, legacy=Dpapi. Run POST /api/secrets/reencrypt then remove Secrets:LegacyProvider once the legacy_reads counter is zero.` +- [ ] The re-encrypt response is `200`, `partialSuccess=false`, and credential/global/history + skip counters are all zero before removing any legacy-provider setting. - [ ] After cluster-mode switch, smoke-test one credential decrypt on each node. ## Bewusst nicht in V1 diff --git a/src/NodePilot.Ai/AiContracts.cs b/src/NodePilot.Ai/AiContracts.cs index 07e5db3c..fde3e6fa 100644 --- a/src/NodePilot.Ai/AiContracts.cs +++ b/src/NodePilot.Ai/AiContracts.cs @@ -25,14 +25,16 @@ public sealed record UpstreamVariableDto( /// is the current editor content — it gives requests like /// "refactor this script" / "fix the error" something to work from (otherwise the LLM would have /// to hallucinate from the variable list alone). It is forwarded as-is inside an untrusted context -/// block; since the user is editing their own script, it is not redacted. +/// block only when is explicitly true. The flag defaults +/// to false so merely supplying cannot disclose it. /// public sealed record GenerateScriptRequest( string Prompt, Guid? WorkflowId, string? StepId, IReadOnlyList UpstreamVariables, - string? CurrentScript); + string? CurrentScript, + bool IncludeCurrentScript = false); /// /// Request body for POST /api/ai/generate-workflow. Just a free-text prompt — all activity diff --git a/src/NodePilot.Ai/ScriptGenerationService.cs b/src/NodePilot.Ai/ScriptGenerationService.cs index c7155ed2..c70f7bf5 100644 --- a/src/NodePilot.Ai/ScriptGenerationService.cs +++ b/src/NodePilot.Ai/ScriptGenerationService.cs @@ -42,7 +42,10 @@ public async IAsyncEnumerable StreamAsync( capped = capped.Take(LlmOptions.MaxUpstreamVariables).ToList(); truncated = true; } - var userPrompt = BuildUserPrompt(request.Prompt, request.CurrentScript, capped, truncated); + // Presence of CurrentScript alone is never consent: older or non-UI clients must opt in + // explicitly before editor contents may leave NodePilot for the configured LLM endpoint. + var currentScript = request.IncludeCurrentScript ? request.CurrentScript : null; + var userPrompt = BuildUserPrompt(request.Prompt, currentScript, capped, truncated); var firstLine = new StringBuilder(); // collects the first line up to \n (fence check) var pending = new StringBuilder(); // body text, holding back TailHold chars for the closing fence diff --git a/src/NodePilot.Ai/WorkflowDefinitionMerge.cs b/src/NodePilot.Ai/WorkflowDefinitionMerge.cs index 097c0c29..842a9cad 100644 --- a/src/NodePilot.Ai/WorkflowDefinitionMerge.cs +++ b/src/NodePilot.Ai/WorkflowDefinitionMerge.cs @@ -119,6 +119,12 @@ private static void MergeObject(JsonObject target, JsonObject? source, List + /// Reconciles nested arrays used by grouped conditions and decision cases. Arrays have no + /// stable identity contract, so if the redacted proposal still contains a mask the complete + /// original array is retained rather than pairing a secret with the wrong item after a reorder. + /// Arrays without masks are traversed by index so named-secret protection still applies to + /// newly proposed nested objects. + /// + private static void MergeNestedArray(JsonArray target, JsonArray? source, List notes) + { + if (source is not null && ContainsMask(target)) + { + target.Clear(); + foreach (var item in source) + target.Add(item?.DeepClone()); + return; + } + + for (var index = 0; index < target.Count; index++) + { + var targetItem = target[index]; + var sourceItem = source is not null && index < source.Count ? source[index] : null; + switch (targetItem) + { + case JsonObject targetObject: + MergeObject(targetObject, sourceItem as JsonObject, notes); + break; + case JsonArray targetArray: + MergeNestedArray(targetArray, sourceItem as JsonArray, notes); + break; + case JsonValue targetValue when targetValue.TryGetValue(out string? value) + && value == SecretMask + && sourceItem is not null: + target[index] = sourceItem.DeepClone(); + break; + } + } + } + + private static bool ContainsMask(JsonNode node) + { + if (node is JsonValue value) + return value.TryGetValue(out string? text) && text == SecretMask; + if (node is JsonObject obj) + return obj.Any(property => property.Value is not null && ContainsMask(property.Value)); + return node is JsonArray array + && array.Any(item => item is not null && ContainsMask(item)); + } } diff --git a/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs b/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs index 2e19f175..917f6790 100644 --- a/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs +++ b/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs @@ -2,6 +2,7 @@ using NodePilot.Api.Services.DbAdmin; using NodePilot.Core.Audit; using NodePilot.Core.Interfaces; +using NodePilot.Core.Security; namespace NodePilot.Api.Ai; @@ -9,19 +10,21 @@ namespace NodePilot.Api.Ai; /// over the existing DbAdmin services. Reuses /// (singleton — schema is stable) for the catalog and /// (scoped — owns the request DbContext) for read-only execution, -/// then redacts every cell before it leaves the reader. Scoped, matching -/// . +/// then redacts every cell before it leaves the reader. Tables holding Workflow Definitions or +/// custom-activity implementations are excluded from this generic source and remain available only +/// through dedicated, RBAC-aware tools. +/// Scoped, matching . /// -/// Redaction (three layers): first, refuses statements -/// that name a protected column and replaces protected result columns with "***"; second, it -/// refuses whole-row serializers over those tables, which would otherwise carry the secret past the -/// name-based mask; third, every remaining cell is stringified and run through -/// . Result rows are capped (token budget) and cells truncated. -/// Only string? ever leaves this reader. +/// Redaction layers: the external-agent policy first removes and rejects opaque +/// automation tables. Then refuses statements that name a protected +/// column, masks protected result columns, and rejects whole-row serializers over protected tables. +/// Finally, every remaining cell is stringified and run through . +/// Result rows are capped (token budget) and cells truncated. Only string? ever leaves this +/// reader. /// -/// This closes the secret-leak gap that raw SQL otherwise opens. The same -/// guard runs on the /api/dbadmin/query endpoint, so the -/// MCP/CLI/UI raw-SQL path enforces the identical contract. +/// The shared secret-column guard also runs on /api/dbadmin/query. The external-agent +/// table policy intentionally does not: DbAdmin keeps those rows visible to administrators +/// for forensic inspection and never forwards its response to an LLM. /// public sealed class SqlKnowledgeReader : ISqlKnowledgeReader { @@ -50,12 +53,17 @@ public SqlKnowledgeReader( public Task> ListTablesAsync(CancellationToken ct) { var rows = _metadata.GetAllTables() + .Where(t => ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .Select(t => new DbTableKnowledgeSummary( t.Name, t.DbTableName, t.PkColumns, - t.Columns.Where(c => !c.IsHidden).Select(c => c.Name).ToList())) + t.Columns + .Where(c => !c.IsHidden + && ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) + .Select(c => c.Name) + .ToList())) .ToList(); return Task.FromResult>(rows); } @@ -63,9 +71,11 @@ public Task> ListTablesAsync(Cancellation public Task GetTableAsync(string name, CancellationToken ct) { var t = _metadata.GetTable(name); - if (t is null) return Task.FromResult(null); + if (t is null || !ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) + return Task.FromResult(null); var cols = t.Columns - .Where(c => !c.IsHidden) + .Where(c => !c.IsHidden + && ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) .Select(c => new DbColumnKnowledge(c.Name, FriendlyType(c), c.IsNullable, c.IsPrimaryKey)) .ToList(); var visibleNames = cols.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -82,6 +92,16 @@ public async Task ExecuteReadAsync(string sql, Cancella { var sw = Stopwatch.StartNew(); + // DbAdmin keeps opaque automation rows visible for forensic administrators. This reader + // sends results to an external LLM, so it rejects every mention of those tables before a + // database connection opens. Dedicated tools own RBAC and payload redaction. + if (ExternalAgentSqlPolicy.ReferencesProtectedProjection(sql)) + { + return new SqlQueryKnowledgeResult( + Array.Empty(), Array.Empty>(), false, + sw.ElapsedMilliseconds, ExternalAgentSqlPolicy.RejectionMessage); + } + // Result-column masking cannot recover source lineage after aliases/expressions, so a // statement that mentions a protected identifier is refused before it reaches the database. if (_secretColumns.ReferencesProtectedColumn(sql)) @@ -116,6 +136,11 @@ public async Task ExecuteReadAsync(string sql, Cancella var columns = result.Columns.Select(c => c.Name).ToList(); var masked = _secretColumns.BuildColumnMask(columns); + for (var c = 0; c < columns.Count; c++) + { + if (ExternalAgentSqlPolicy.IsProtectedResultColumn(columns[c])) + masked[c] = true; + } var rows = new List>(result.Rows.Count); var truncated = result.Truncated; diff --git a/src/NodePilot.Api/Controllers/AiController.cs b/src/NodePilot.Api/Controllers/AiController.cs index c62d6932..c5a459dc 100644 --- a/src/NodePilot.Api/Controllers/AiController.cs +++ b/src/NodePilot.Api/Controllers/AiController.cs @@ -143,6 +143,7 @@ private Task ScriptAuditAsync(string model, int durationMs, int responseChars, ("model", model), ("promptChars", request.Prompt.Length), ("upstreamVarCount", request.UpstreamVariables.Count), + ("currentScriptIncluded", request.IncludeCurrentScript && !string.IsNullOrWhiteSpace(request.CurrentScript)), ("responseChars", responseChars), ("durationMs", durationMs), ("cancelled", cancelled), diff --git a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs index 90c5ebff..ff765b0f 100644 --- a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs +++ b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs @@ -71,18 +71,30 @@ public ActionResult Capabilities() var k = _knowledgeOptions.CurrentValue; // "Usable" = kill-switch on AND an active profile resolves — without one every call would // 503 anyway, so reporting the sources as available would be a lie. - var llmUsable = _llmOptions.CurrentValue.IsUsable; + var llm = _llmOptions.CurrentValue; + var llmUsable = llm.IsUsable; var enabled = llmUsable && k.Enabled; var toolSourcesEnabled = enabled - && _llmOptions.CurrentValue.TryResolveActiveProfile(out var activeProfile) + && llm.TryResolveActiveProfile(out var activeProfile) && activeProfile.EnableToolCalling; + var scriptContextTargetHost = llmUsable && User.IsPrivileged() + && llm.TryResolveActiveProfile(out var scriptProfile) + ? DisplayHost(scriptProfile.BaseUrl) + : null; return Ok(new KnowledgeCapabilitiesDto( Enabled: enabled, Llm: llmUsable, Docs: toolSourcesEnabled && k.DocsEnabled, Operational: toolSourcesEnabled && k.OperationalEnabled, SourceCode: toolSourcesEnabled && k.SourceCodeEnabled && User.IsPrivileged(), - Db: toolSourcesEnabled && k.DbEnabled && User.IsAdmin())); + Db: toolSourcesEnabled && k.DbEnabled && User.IsAdmin(), + ScriptContextTargetHost: scriptContextTargetHost)); + } + + private static string? DisplayHost(string? baseUrl) + { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) return null; + return uri.IsDefaultPort ? uri.IdnHost : $"{uri.IdnHost}:{uri.Port}"; } /// Streams one knowledge-chat turn as Server-Sent Events (delta/tool_call/tool_result/done/error). diff --git a/src/NodePilot.Api/Controllers/AuthController.cs b/src/NodePilot.Api/Controllers/AuthController.cs index 896b7817..bce748bd 100644 --- a/src/NodePilot.Api/Controllers/AuthController.cs +++ b/src/NodePilot.Api/Controllers/AuthController.cs @@ -129,8 +129,9 @@ private static void RecordLoginAttempt(string result, string reason) => /// Stages a JWT revocation for : derives the expiry from the token's /// exp claim (falling back to the configured lifetime) and adds a /// row when the jti is not already revoked. Returns true when a - /// row was staged. The caller keeps SaveChanges and the revocation metric, because logout - /// and refresh deliberately order those two differently. + /// row was staged. Logout and the DbContext-free refresh compatibility path keep + /// SaveChanges and the revocation metric at their call sites; the production refresh path + /// stages its revocation inside instead. /// private async Task TryStageTokenRevocationAsync( string jti, Guid userId, string? expClaim, string reason, CancellationToken ct) @@ -162,7 +163,7 @@ private async Task TryStageTokenRevocationAsync( internal const string CsrfCookieName = "np_csrf"; // H-5 completion: the JWT must never reach a browser response body, or an XSS could - // exfiltrate a portable 12h bearer token via /auth/refresh and defeat the httpOnly-cookie + // exfiltrate a portable session bearer token via /auth/refresh and defeat the httpOnly-cookie // design entirely. We hand the token back ONLY to provably non-browser callers, using two // different discriminators because the two situations differ: // @@ -199,9 +200,14 @@ private bool AuthenticatedViaBearerHeader() /// Builds the auth success body: (with JWT) for /// programmatic callers, (identity only) for browsers. - private OkObjectResult SessionResult(string token, User user, bool includeToken) + private OkObjectResult SessionResult(IssuedSession session, User user, bool includeToken) => includeToken - ? Ok(new LoginResponse(token, user.Id, user.Username, user.Role.ToString())) + ? Ok(new LoginResponse( + session.Token, + user.Id, + user.Username, + user.Role.ToString(), + session.ExpiresAt)) : Ok(new AuthIdentityResponse(user.Id, user.Username, user.Role.ToString())); /// @@ -573,7 +579,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, // for the BCrypt path; LDAP/Windows pass their own value. var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Local, HttpContext, ct); RecordLoginAttempt("success", "ok"); - return SessionResult(session.Token, user, TokenInBodyRequested()); + return SessionResult(session, user, TokenInBodyRequested()); } private async Task TryCreateBootstrapAdminAsync( @@ -788,7 +794,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Ldap, HttpContext, ct); RecordLoginAttempt("success", "ldap_ok"); - return SessionResult(session.Token, user, TokenInBodyRequested()); + return SessionResult(session, user, TokenInBodyRequested()); } case LdapAuthOutcome.InvalidCredentials: // The directory cleanly rejected the credentials. If a local row exists we'd @@ -1073,7 +1079,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, // handshake — i.e. an XSS could trigger it without knowing any secret. So, unlike the // password-gated login paths, the token is NEVER returned in the body here: always // identity-only, the JWT stays in the httpOnly cookie. - return SessionResult(session.Token, user, includeToken: false); + return SessionResult(session, user, includeToken: false); } /// @@ -1178,12 +1184,11 @@ public async Task> Refresh(CancellationToken ct) var user = await _db.Users.FindAsync([id], ct); if (user is null) return Unauthorized(new { message = "User no longer exists" }); - // Mint the new token first so a failure below (e.g. DB unavailable) leaves the old - // token valid — we prefer "client keeps using current token" over "user gets locked - // out mid-session". Routed through IAuthSessionIssuer.RefreshAsync so the rotated - // JWT carries the same baseline claims as the original login. Group authorization - // is deliberately not token-borne (server-side DirectoryMemberships), so a refresh - // can never gain or lose group-folder permissions. + // The production issuer commits the new session JTI and old-token revocation as one + // unit before generating response cookies. A database failure therefore rolls both + // writes back and leaves the presented token usable instead of locking the client out + // with no replacement. The rotated JWT keeps the original baseline claim shape; group + // authorization remains server-side in DirectoryMemberships. IssuedSession session; try { @@ -1194,20 +1199,27 @@ public async Task> Refresh(CancellationToken ct) ClearAuthCookies(); return Unauthorized(new { message = "Session is no longer active" }); } - var newToken = session.Token; - - // Revoke the presented token. If the caller racingly refreshes twice the second - // request will already find the jti revoked and return 401 from the middleware; - // that's the intended behavior. - var presentedJti = User.FindFirstValue(JwtRegisteredClaimNames.Jti); - var expClaim = User.FindFirstValue("exp"); - if (!string.IsNullOrEmpty(presentedJti) - && await TryStageTokenRevocationAsync(presentedJti, id, expClaim, "rotated", ct)) + // The production issuer has already persisted CurrentJti/RefreshGeneration and the + // old-JTI revocation in one SaveChanges transaction. The fallback exists only for + // DbContext-free controller unit fixtures; it is not a supported production issuer + // path and must never duplicate the production revocation write. + if (session.TokenRotationCommitted) { - await _db.SaveChangesAsync(ct); ApiMetrics.AuthTokenRevocations.Add(1, new KeyValuePair("reason", "rotated")); } + else + { + var presentedJti = User.FindFirstValue(JwtRegisteredClaimNames.Jti); + var expClaim = User.FindFirstValue("exp"); + if (!string.IsNullOrEmpty(presentedJti) + && await TryStageTokenRevocationAsync(presentedJti, id, expClaim, "rotated", ct)) + { + await _db.SaveChangesAsync(ct); + ApiMetrics.AuthTokenRevocations.Add(1, + new KeyValuePair("reason", "rotated")); + } + } // Cookies were already rotated by RefreshAsync above (np_auth + np_csrf both set // on this response). The H-5 invariant — XSS that stole the old CSRF value loses @@ -1216,7 +1228,7 @@ public async Task> Refresh(CancellationToken ct) // Audit the refresh so a stolen-token-being-renewed scenario leaves a forensic // trail. Distinct from LOGIN_SUCCESS to avoid double-counting active sessions in - // SIEM dashboards (12h token lifetime × N sessions would otherwise dwarf real + // SIEM dashboards (session lifetime × N sessions would otherwise dwarf real // login signal). MapEventCategory maps TOKEN_REFRESHED to event.category=iam so // it groups with the rest of the auth events. await _audit.LogAsync(AuditActions.TokenRefreshed, "User", id, @@ -1226,7 +1238,7 @@ await _audit.LogAsync(AuditActions.TokenRefreshed, "User", id, // cookie-authenticated browser — or an XSS riding the browser's np_auth cookie — gets // identity-only; the rotated token reaches it solely through the refreshed httpOnly // cookie. This is the H-5 invariant: no browser-reachable endpoint hands out the token. - return SessionResult(newToken, user, AuthenticatedViaBearerHeader()); + return SessionResult(session, user, AuthenticatedViaBearerHeader()); } private enum BootstrapAdminCreationStatus diff --git a/src/NodePilot.Api/Controllers/SecretsController.cs b/src/NodePilot.Api/Controllers/SecretsController.cs index 6979d036..11a36db3 100644 --- a/src/NodePilot.Api/Controllers/SecretsController.cs +++ b/src/NodePilot.Api/Controllers/SecretsController.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NodePilot.Api.Dtos; +using NodePilot.Api.Services; using NodePilot.Core.Audit; using NodePilot.Core.Interfaces; +using NodePilot.Data; namespace NodePilot.Api.Controllers; @@ -21,15 +23,21 @@ public class SecretsController : ControllerBase { private readonly ICredentialStore _credentials; private readonly IGlobalVariableStore _globals; + private readonly NodePilotDbContext _db; + private readonly WorkflowVersionDefinitionProtector _workflowVersions; private readonly IAuditWriter _audit; public SecretsController( ICredentialStore credentials, IGlobalVariableStore globals, + NodePilotDbContext db, + WorkflowVersionDefinitionProtector workflowVersions, IAuditWriter audit) { _credentials = credentials; _globals = globals; + _db = db; + _workflowVersions = workflowVersions; _audit = audit; } @@ -51,8 +59,9 @@ public async Task> Reencrypt(CancellationToken ct) { var creds = await _credentials.ReencryptAllCredentialsAsync(ct); var globals = await _globals.ReencryptAllSecretsAsync(ct); + var versions = await _workflowVersions.ReencryptAllAsync(_db, ct); - var partial = creds.Skipped > 0 || globals.Skipped > 0; + var partial = creds.Skipped > 0 || globals.Skipped > 0 || versions.Skipped > 0; var result = new ReencryptResult( CredentialsRewritten: creds.Rewritten, CredentialsSkipped: creds.Skipped, @@ -60,6 +69,9 @@ public async Task> Reencrypt(CancellationToken ct) GlobalSecretsRewritten: globals.Rewritten, GlobalSecretsSkipped: globals.Skipped, GlobalSecretSkipDetails: globals.SkippedDetails, + WorkflowVersionsRewritten: versions.Rewritten, + WorkflowVersionsSkipped: versions.Skipped, + WorkflowVersionSkipDetails: versions.SkippedDetails, PartialSuccess: partial); await _audit.LogAsync(AuditActions.SecretsReencrypted, "Secrets", null, @@ -68,6 +80,8 @@ await _audit.LogAsync(AuditActions.SecretsReencrypted, "Secrets", null, ("credentialsSkipped", creds.Skipped), ("globalsRewritten", globals.Rewritten), ("globalsSkipped", globals.Skipped), + ("workflowVersionsRewritten", versions.Rewritten), + ("workflowVersionsSkipped", versions.Skipped), ("partialSuccess", partial)), ct); diff --git a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs index 2df676c9..34a53047 100644 --- a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs @@ -31,6 +31,7 @@ public class WorkflowEditingController : WorkflowsControllerBase { private readonly IStepTester _stepTester; private readonly IStepTestContextProvider _testContextProvider; + private readonly NodePilot.Api.Services.WorkflowVersionDefinitionProtector _versionDefinitions; public WorkflowEditingController( NodePilotDbContext db, @@ -38,11 +39,13 @@ public WorkflowEditingController( IAuditWriter audit, NodePilot.Core.Interfaces.IResourceAuthorizationService authz, IStepTester stepTester, - IStepTestContextProvider testContextProvider) + IStepTestContextProvider testContextProvider, + NodePilot.Api.Services.WorkflowVersionDefinitionProtector versionDefinitions) : base(db, logger, audit, authz) { _stepTester = stepTester; _testContextProvider = testContextProvider; + _versionDefinitions = versionDefinitions; } // --- Optimistic-concurrency core (shared by Publish + Rollback) ------------------------ @@ -172,9 +175,10 @@ public async Task> GetVersion(Guid id, int v .FirstOrDefaultAsync(v => v.WorkflowId == id && v.Version == version, ct); if (row is null) return NotFound(); + var historicDefinition = _versionDefinitions.Unprotect(row.DefinitionJson); return Ok(new WorkflowVersionDetail( row.Version, row.Name, row.Description, - ScopedDefinitionJson(row.DefinitionJson, capabilities.CanEdit), + ScopedDefinitionJson(historicDefinition, capabilities.CanEdit), row.CreatedAt, row.CreatedBy, row.ChangeNote, IsCurrent: false)); } @@ -202,9 +206,10 @@ public async Task> Rollback( // rolling forward would push a definition that the engine refuses to load at fire time. // Surface the failure to the operator now instead of letting the next trigger discover it. // Lint warnings are non-fatal — same semantics as Create/Update. - var rollbackSizeError = ValidateDefinitionJson(target.DefinitionJson); + var targetDefinitionJson = _versionDefinitions.Unprotect(target.DefinitionJson); + var rollbackSizeError = ValidateDefinitionJson(targetDefinitionJson); if (rollbackSizeError is not null) return rollbackSizeError; - LintAndLogWarnings(target.DefinitionJson); + LintAndLogWarnings(targetDefinitionJson); // Pre-rollback values captured for the append-only history snapshot. var oldVersion = workflow.Version; @@ -214,7 +219,7 @@ public async Task> Rollback( var now = DateTime.UtcNow; var updatedBy = this.GetCurrentUsername(); - var computed = new Workflow { DefinitionJson = target.DefinitionJson }; + var computed = new Workflow { DefinitionJson = targetDefinitionJson }; PopulateComputedColumns(computed); // Roll-forward: snapshot the live row, apply the target as a new version — through the @@ -230,7 +235,7 @@ public async Task> Rollback( s => s .SetProperty(w => w.Name, target.Name) .SetProperty(w => w.Description, target.Description) - .SetProperty(w => w.DefinitionJson, target.DefinitionJson) + .SetProperty(w => w.DefinitionJson, targetDefinitionJson) .SetProperty(w => w.Version, oldVersion + 1) .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) .SetProperty(w => w.ActivityCount, computed.ActivityCount) @@ -243,7 +248,7 @@ public async Task> Rollback( Version = oldVersion, Name = oldName, Description = oldDescription, - DefinitionJson = oldDefinitionJson, + DefinitionJson = _versionDefinitions.Protect(oldDefinitionJson), CreatedAt = now, CreatedBy = updatedBy, ChangeNote = $"Superseded by rollback to v{version}", @@ -512,7 +517,7 @@ public async Task> Publish( Version = oldVersion, Name = oldName, Description = oldDescription, - DefinitionJson = oldDefinitionJson, + DefinitionJson = _versionDefinitions.Protect(oldDefinitionJson), CreatedAt = now, CreatedBy = oldCreatedBy ?? updatedBy, }, diff --git a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs index 9c17ad06..25212517 100644 --- a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs @@ -1,8 +1,10 @@ using System.Diagnostics; +using System.Data; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Dtos; @@ -326,8 +328,12 @@ public async Task> ImportScorch([FromQuery] G }); const int MaxImportItems = 500; - if (parsed.Workflows.Count > MaxImportItems) - return BadRequest(new { error = $"Too many runbooks in one import (got {parsed.Workflows.Count}, max {MaxImportItems})." }); + var importItemCount = (long)parsed.Workflows.Count + parsed.Variables.Count; + if (importItemCount > MaxImportItems) + return BadRequest(new + { + error = $"Too many workflows and variables in one import (got {importItemCount}, max {MaxImportItems}).", + }); // 1. Create global variables first so workflow-import and any {{globals.X}} references // already resolve when the operator opens the imported workflow. We never overwrite @@ -335,7 +341,13 @@ public async Task> ImportScorch([FromQuery] G var existingGlobals = await _globals.GetAllAsync(ct); var existingGlobalNames = new HashSet(existingGlobals.Select(g => g.Name), StringComparer.OrdinalIgnoreCase); var importedVariables = new List(); + var variablesToCreate = new List(); var triggeredBy = User.Identity?.Name; + // Operators create global variables here just like Admins do. Gating this on Admin was + // considered and rejected: an Operator may already run arbitrary script under the service + // identity, so anyone able to import a runbook can put the same value straight into a + // step. The gate would only split Orchestrator migrations into two manual passes without + // removing a capability. Collisions are still never overwritten (see above). foreach (var v in parsed.Variables) { if (existingGlobalNames.Contains(v.Name)) @@ -345,20 +357,13 @@ public async Task> ImportScorch([FromQuery] G SkipReason: "A global variable with this name already exists.")); continue; } - try - { - // Scorch-imported globals land in the Root folder — the importer has no folder concept. - await _globals.CreateAsync(v.Name, v.Value, v.IsSecret, v.Description, - GlobalVariableFolder.RootFolderId, triggeredBy, ct); - existingGlobalNames.Add(v.Name); - importedVariables.Add(new ScorchImportedVariableInfo( - v.Name, null, CreatedNow: true, Skipped: false, SkipReason: null)); - } - catch (Exception ex) - { - importedVariables.Add(new ScorchImportedVariableInfo( - v.Name, null, CreatedNow: false, Skipped: true, SkipReason: ex.Message)); - } + // Plan all writes before opening the transaction. Adding the name now also makes a + // duplicate within the same SCOrch file a deterministic collision rather than a + // database-provider-specific unique-constraint failure halfway through the batch. + variablesToCreate.Add(v); + existingGlobalNames.Add(v.Name); + importedVariables.Add(new ScorchImportedVariableInfo( + v.Name, null, CreatedNow: true, Skipped: false, SkipReason: null)); } // 2. Create workflows (disabled). @@ -367,6 +372,7 @@ await _globals.CreateAsync(v.Name, v.Value, v.IsSecret, v.Description, var takenNames = new HashSet(existingNames, StringComparer.Ordinal); var created = new List(); + var workflowsToCreate = new List(); var errors = new List(parsed.Errors); foreach (var rb in parsed.Workflows) @@ -396,17 +402,69 @@ await _globals.CreateAsync(v.Name, v.Value, v.IsSecret, v.Description, UpdatedAt = DateTime.UtcNow, }; PopulateComputedColumns(workflow); - _db.Workflows.Add(workflow); + workflowsToCreate.Add(workflow); created.Add(new ScorchImportedWorkflowInfo( workflow.Id, finalName, finalName == rb.Name ? null : rb.Name, rb.ActivityCount, rb.HeuristicCount, rb.FallbackCount)); } - var variablesCreated = importedVariables.Count(v => v.CreatedNow); - if (created.Count > 0) + var variablesCreated = variablesToCreate.Count; + if (workflowsToCreate.Count > 0 || variablesToCreate.Count > 0) { - await _db.SaveChangesAsync(ct); + // GlobalVariableStore is scoped with this controller and therefore shares _db. + // Its per-variable SaveChanges calls and the workflow insert must commit as one + // unit: a later encryption/database failure must not leave a half-imported batch. + // ExecuteInTransaction verifies exact row identities after an ambiguous commit + // acknowledgement, so a retry never collides with an import that already committed. + var strategy = _db.Database.CreateExecutionStrategy(); + var attempt = new ScorchImportAttempt( + workflowsToCreate, + variablesToCreate, + triggeredBy, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + await strategy.ExecuteInTransactionAsync( + attempt, + async (state, token) => + { + // A retried attempt must not inherit entities whose state changed to + // Unchanged before an ambiguous commit. Variable IDs are attempt-local. + _db.ChangeTracker.Clear(); + state.CreatedVariableIds.Clear(); + _db.Workflows.AddRange(state.Workflows); + foreach (var v in state.Variables) + { + var createdVariable = await _globals.CreateAsync( + v.Name, v.Value, v.IsSecret, v.Description, + GlobalVariableFolder.RootFolderId, state.TriggeredBy, token); + state.CreatedVariableIds[v.Name] = createdVariable.Id; + } + + await _db.SaveChangesAsync(token); + }, + async (state, token) => + { + // A commit acknowledgement can be lost after the database committed. Verify + // the exact pre-generated workflow and captured variable identities before + // allowing the execution strategy to replay the import. + _db.ChangeTracker.Clear(); + if (state.CreatedVariableIds.Count != state.Variables.Count) return false; + + var workflowIds = state.Workflows.Select(workflow => workflow.Id).ToArray(); + var variableIds = state.CreatedVariableIds.Values.ToArray(); + var workflowsCommitted = workflowIds.Length == 0 + || await _db.Workflows.AsNoTracking() + .CountAsync(workflow => workflowIds.Contains(workflow.Id), token) + == workflowIds.Length; + if (!workflowsCommitted) return false; + + return variableIds.Length == 0 + || await _db.GlobalVariables.AsNoTracking() + .CountAsync(variable => variableIds.Contains(variable.Id), token) + == variableIds.Length; + }, + IsolationLevel.Serializable, + ct); } if (created.Count > 0 || variablesCreated > 0) @@ -488,6 +546,12 @@ private static WorkflowExportItem ToExportItem(Workflow w) return new WorkflowExportItem(w.Name, w.Description, definition, IsEnabled: w.IsEnabled); } + private sealed record ScorchImportAttempt( + IReadOnlyList Workflows, + IReadOnlyList Variables, + string? TriggeredBy, + Dictionary CreatedVariableIds); + private IActionResult ExportEnvelopeResult(WorkflowExportEnvelope envelope, string filename) { var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions diff --git a/src/NodePilot.Api/Controllers/WorkflowsController.cs b/src/NodePilot.Api/Controllers/WorkflowsController.cs index 91ec60ad..391465e2 100644 --- a/src/NodePilot.Api/Controllers/WorkflowsController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowsController.cs @@ -28,16 +28,19 @@ namespace NodePilot.Api.Controllers; public class WorkflowsController : WorkflowsControllerBase { private readonly NodePilot.Api.Services.IWorkflowContractDeriver _contractDeriver; + private readonly NodePilot.Api.Services.WorkflowVersionDefinitionProtector _versionDefinitions; public WorkflowsController( NodePilotDbContext db, ILogger logger, IAuditWriter audit, IResourceAuthorizationService authz, - NodePilot.Api.Services.IWorkflowContractDeriver contractDeriver) + NodePilot.Api.Services.IWorkflowContractDeriver contractDeriver, + NodePilot.Api.Services.WorkflowVersionDefinitionProtector versionDefinitions) : base(db, logger, audit, authz) { _contractDeriver = contractDeriver; + _versionDefinitions = versionDefinitions; } /// @@ -418,7 +421,7 @@ public async Task Update(Guid id, UpdateWorkflowRequest request, Version = workflow.Version, Name = workflow.Name, Description = workflow.Description, - DefinitionJson = workflow.DefinitionJson, + DefinitionJson = _versionDefinitions.Protect(workflow.DefinitionJson), CreatedAt = updatedAt, CreatedBy = workflow.CreatedBy ?? updatedBy, }); @@ -684,6 +687,7 @@ public async Task> Duplicate(Guid id, Cancellatio }; copy.CreatedBy = this.GetCurrentUsername(); + PopulateComputedColumns(copy); _db.Workflows.Add(copy); await _db.SaveChangesAsync(ct); diff --git a/src/NodePilot.Api/Dtos/AiDtos.cs b/src/NodePilot.Api/Dtos/AiDtos.cs index 55874f11..b8300077 100644 --- a/src/NodePilot.Api/Dtos/AiDtos.cs +++ b/src/NodePilot.Api/Dtos/AiDtos.cs @@ -21,4 +21,11 @@ public sealed record AiActivityEntryDto( /// assistant, script-editor generate, AI workflow generation), while keeps gating /// only the knowledge chat itself. /// -public sealed record KnowledgeCapabilitiesDto(bool Enabled, bool Llm, bool Docs, bool Operational, bool SourceCode, bool Db); +public sealed record KnowledgeCapabilitiesDto( + bool Enabled, + bool Llm, + bool Docs, + bool Operational, + bool SourceCode, + bool Db, + string? ScriptContextTargetHost); diff --git a/src/NodePilot.Api/Dtos/AuthDtos.cs b/src/NodePilot.Api/Dtos/AuthDtos.cs index 0db8cb3c..0f3c5e39 100644 --- a/src/NodePilot.Api/Dtos/AuthDtos.cs +++ b/src/NodePilot.Api/Dtos/AuthDtos.cs @@ -17,7 +17,12 @@ public record LoginRequest(string Username, string Password) /// on the password-gated login paths, or a real Bearer header on refresh). Browser flows get /// instead. /// -public record LoginResponse(string Token, Guid UserId, string Username, string Role); +public record LoginResponse( + string Token, + Guid UserId, + string Username, + string Role, + DateTimeOffset ExpiresAt); /// /// Browser-facing auth response (audit H-5 completion). Carries only the caller's identity — diff --git a/src/NodePilot.Api/Dtos/SecretsDtos.cs b/src/NodePilot.Api/Dtos/SecretsDtos.cs index 2d38cc30..7de78d23 100644 --- a/src/NodePilot.Api/Dtos/SecretsDtos.cs +++ b/src/NodePilot.Api/Dtos/SecretsDtos.cs @@ -13,4 +13,7 @@ public sealed record ReencryptResult( int GlobalSecretsRewritten, int GlobalSecretsSkipped, IReadOnlyList GlobalSecretSkipDetails, + int WorkflowVersionsRewritten, + int WorkflowVersionsSkipped, + IReadOnlyList WorkflowVersionSkipDetails, bool PartialSuccess); diff --git a/src/NodePilot.Api/Program.cs b/src/NodePilot.Api/Program.cs index c53795e5..32e25c52 100644 --- a/src/NodePilot.Api/Program.cs +++ b/src/NodePilot.Api/Program.cs @@ -176,6 +176,7 @@ // deployments. Picks the implementation from Secrets:Provider; both CredentialStore and // GlobalVariableStore route their encrypt/decrypt through it transparently. builder.Services.AddNodePilotSecretProtector(builder.Configuration); +builder.Services.AddSingleton(); // RBAC Tier A: scoped per-request authorization service. Folder-permission lookups are // cached for the lifetime of the request so list endpoints with N workflows resolve the // accessible-folder set once, then do O(1) set-membership tests per row. @@ -492,6 +493,16 @@ await DatabaseReadinessGate.WaitForDatabaseAsync( // boot log without grepping config. Single line, INFO level, only at startup. scope.ServiceProvider.GetRequiredService().Log(); + // WorkflowVersions predate at-rest protection and may contain arbitrary scripts, HTTP bodies, + // or imported SCOrch payloads with inline credentials. Do not rewrite legacy rows during + // startup: the production updater's health-check rollback restores binaries, not database + // contents, and an upgraded HA passive node must remain data-compatible with the old active + // node. Once every node is upgraded, the explicit secrets re-encryption sweep performs the + // audited cutover. New snapshots are protected immediately by their write paths. + await scope.ServiceProvider + .GetRequiredService() + .WarnIfExplicitMigrationRequiredAsync(db, CancellationToken.None); + // Sweep orphaned Running executions left over from a previous process instance // (crash / kill / upgrade). Without this the UI would show ghost "Running" rows // forever because there is no in-memory CancellationTokenSource for them anymore. diff --git a/src/NodePilot.Api/Security/AuthSessionIssuer.cs b/src/NodePilot.Api/Security/AuthSessionIssuer.cs index fa2825ff..75e04b5e 100644 --- a/src/NodePilot.Api/Security/AuthSessionIssuer.cs +++ b/src/NodePilot.Api/Security/AuthSessionIssuer.cs @@ -1,8 +1,10 @@ +using System.Data; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using NodePilot.Core.Audit; @@ -31,6 +33,21 @@ public sealed class AuthSessionIssuer : IAuthSessionIssuer private readonly NodePilotDbContext? _db; private readonly AuthenticationPolicyOptions _policy; + private sealed record RefreshRotationAttempt( + Guid UserId, + int AuthorizationVersion, + string PresentedJti, + string NewJti, + Guid SessionId, + bool HasServerSession, + DateTimeOffset AttemptedAt, + DateTimeOffset FallbackExpiresAt, + DateTime PresentedTokenExpiresAt, + string FallbackAuthenticationMethod) + { + public DateTimeOffset CommittedExpiresAt { get; set; } = FallbackExpiresAt; + } + // Optional IHostEnvironment is null-default so existing test sites (10 fixtures across // AuthControllerLdap*Tests / AuthControllerWindowsTests / AuthControllerMethodsTests) // keep their `new AuthSessionIssuer(cfg, key, audit)` shape. In real DI the container @@ -98,33 +115,41 @@ private async Task MintAndSetCookiesAsync( var sessionId = Guid.NewGuid(); var tokenJti = Guid.NewGuid().ToString("N"); var authMethod = source?.ToString() ?? AuthSource.Local.ToString(); + var tokenRotationCommitted = false; if (_db is not null) { - AuthSession? persisted = null; - if (isRefresh - && Guid.TryParse(httpContext.User.FindFirstValue(SessionIdClaim), out var currentSessionId)) + if (isRefresh) { - persisted = await _db.AuthSessions - .FirstOrDefaultAsync(s => s.Id == currentSessionId && s.UserId == user.Id, ct); - if (persisted is null || persisted.RevokedAt is not null || persisted.ExpiresAt <= now.UtcDateTime) - throw new UnauthorizedAccessException("The authentication session is no longer active."); var presentedJti = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Jti); - if (string.IsNullOrEmpty(presentedJti) - || !string.Equals(persisted.CurrentJti, presentedJti, StringComparison.Ordinal)) - throw new UnauthorizedAccessException("The authentication token was already rotated."); - - sessionId = persisted.Id; - expiresAt = new DateTimeOffset(DateTime.SpecifyKind(persisted.ExpiresAt, DateTimeKind.Utc)); - authMethod = persisted.AuthenticationMethod; - persisted.LastSeenAt = now.UtcDateTime; - persisted.AuthorizationVersion = user.SecurityStamp; - persisted.CurrentJti = tokenJti; - persisted.RefreshGeneration++; + if (string.IsNullOrEmpty(presentedJti)) + throw new UnauthorizedAccessException("The authentication token has no identifier."); + + var hasServerSession = Guid.TryParse( + httpContext.User.FindFirstValue(SessionIdClaim), out var currentSessionId); + if (hasServerSession) sessionId = currentSessionId; + long.TryParse(httpContext.User.FindFirstValue("exp"), out var expSeconds); + var presentedTokenExpiresAt = expSeconds > 0 + ? DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime + : expiresAt.UtcDateTime; + var attempt = new RefreshRotationAttempt( + user.Id, + user.SecurityStamp, + presentedJti, + tokenJti, + sessionId, + hasServerSession, + now, + expiresAt, + presentedTokenExpiresAt, + authMethod); + + expiresAt = await PersistRefreshRotationAsync(_db, attempt, ct); + tokenRotationCommitted = true; } else { - persisted = new AuthSession + _db.AuthSessions.Add(new AuthSession { Id = sessionId, UserId = user.Id, @@ -134,18 +159,9 @@ private async Task MintAndSetCookiesAsync( ExpiresAt = expiresAt.UtcDateTime, AuthorizationVersion = user.SecurityStamp, CurrentJti = tokenJti, - }; - _db.AuthSessions.Add(persisted); - } - - try - { + }); await _db.SaveChangesAsync(ct); } - catch (DbUpdateConcurrencyException ex) when (isRefresh) - { - throw new UnauthorizedAccessException("The authentication token was already rotated.", ex); - } } var token = GenerateJwtToken(user, now, expiresAt, sessionId, tokenJti); @@ -154,7 +170,152 @@ private async Task MintAndSetCookiesAsync( // which silently skipped cookies when HttpContext was null. if (httpContext is not null) SetAuthCookies(httpContext, token, expiresAt, _environment); - return new IssuedSession(token, user.Id, expiresAt); + return new IssuedSession(token, user.Id, expiresAt, tokenRotationCommitted); + } + + private static async Task PersistRefreshRotationAsync( + NodePilotDbContext db, + RefreshRotationAttempt attempt, + CancellationToken ct) + { + var strategy = db.Database.CreateExecutionStrategy(); + try + { + await strategy.ExecuteInTransactionAsync( + attempt, + async (state, token) => + { + // Every retry starts from committed database state. This is essential + // after a lost COMMIT acknowledgement: EF may already have accepted the + // first attempt's tracked entities even though the strategy must verify it. + db.ChangeTracker.Clear(); + AuthSession persisted; + if (state.HasServerSession) + { + persisted = await db.AuthSessions.FirstOrDefaultAsync( + session => session.Id == state.SessionId + && session.UserId == state.UserId, + token) + ?? throw new UnauthorizedAccessException( + "The authentication session is no longer active."); + if (persisted.RevokedAt is not null + || persisted.ExpiresAt <= state.AttemptedAt.UtcDateTime) + { + throw new UnauthorizedAccessException( + "The authentication session is no longer active."); + } + if (!string.Equals( + persisted.CurrentJti, state.PresentedJti, StringComparison.Ordinal)) + { + throw new UnauthorizedAccessException( + "The authentication token was already rotated."); + } + + state.CommittedExpiresAt = new DateTimeOffset( + DateTime.SpecifyKind(persisted.ExpiresAt, DateTimeKind.Utc)); + persisted.LastSeenAt = state.AttemptedAt.UtcDateTime; + persisted.AuthorizationVersion = state.AuthorizationVersion; + persisted.CurrentJti = state.NewJti; + persisted.RefreshGeneration++; + } + else + { + persisted = new AuthSession + { + Id = state.SessionId, + UserId = state.UserId, + AuthenticationMethod = state.FallbackAuthenticationMethod, + CreatedAt = state.AttemptedAt.UtcDateTime, + LastSeenAt = state.AttemptedAt.UtcDateTime, + ExpiresAt = state.FallbackExpiresAt.UtcDateTime, + AuthorizationVersion = state.AuthorizationVersion, + CurrentJti = state.NewJti, + }; + state.CommittedExpiresAt = state.FallbackExpiresAt; + db.AuthSessions.Add(persisted); + } + + if (await db.RevokedTokens.AsNoTracking().AnyAsync( + revoked => revoked.Jti == state.PresentedJti, token)) + { + throw new UnauthorizedAccessException( + "The authentication token was already rotated."); + } + db.RevokedTokens.Add(new RevokedToken + { + Jti = state.PresentedJti, + UserId = state.UserId, + RevokedAt = state.AttemptedAt.UtcDateTime, + ExpiresAt = state.PresentedTokenExpiresAt, + Reason = "rotated", + }); + + await db.SaveChangesAsync(token); + }, + async (state, token) => await VerifyRefreshRotationAsync(db, state, token), + IsolationLevel.ReadCommitted, + ct); + return attempt.CommittedExpiresAt; + } + catch (DbUpdateConcurrencyException ex) + { + if (await VerifyRefreshRotationAsync(db, attempt, ct)) + return attempt.CommittedExpiresAt; + throw new UnauthorizedAccessException( + "The authentication token was already rotated.", ex); + } + catch (DbUpdateException ex) when (DbErrorClassifier.IsUniqueConstraintViolation(ex)) + { + if (await VerifyRefreshRotationAsync(db, attempt, ct)) + return attempt.CommittedExpiresAt; + throw new UnauthorizedAccessException( + "The authentication token was already rotated.", ex); + } + catch (DbUpdateException ex) + { + if (await VerifyRefreshRotationAsync(db, attempt, ct)) + return attempt.CommittedExpiresAt; + if (await PresentedTokenWasRotatedAsync(db, attempt, ct)) + { + throw new UnauthorizedAccessException( + "The authentication token was already rotated.", ex); + } + throw; + } + } + + private static async Task VerifyRefreshRotationAsync( + NodePilotDbContext db, + RefreshRotationAttempt attempt, + CancellationToken ct) + { + db.ChangeTracker.Clear(); + var committedSession = await db.AuthSessions.AsNoTracking() + .Where(session => session.Id == attempt.SessionId + && session.UserId == attempt.UserId + && session.CurrentJti == attempt.NewJti) + .Select(session => new { session.ExpiresAt }) + .FirstOrDefaultAsync(ct); + if (committedSession is null) return false; + if (!await db.RevokedTokens.AsNoTracking() + .AnyAsync(revoked => revoked.Jti == attempt.PresentedJti, ct)) + { + return false; + } + + attempt.CommittedExpiresAt = new DateTimeOffset( + DateTime.SpecifyKind(committedSession.ExpiresAt, DateTimeKind.Utc)); + return true; + } + + private static async Task PresentedTokenWasRotatedAsync( + NodePilotDbContext db, + RefreshRotationAttempt attempt, + CancellationToken ct) + { + db.ChangeTracker.Clear(); + return await db.RevokedTokens.AsNoTracking() + .AnyAsync(revoked => revoked.Jti == attempt.PresentedJti, ct); } private string GenerateJwtToken( diff --git a/src/NodePilot.Api/Security/IAuthSessionIssuer.cs b/src/NodePilot.Api/Security/IAuthSessionIssuer.cs index 2ba5212a..572cb0ba 100644 --- a/src/NodePilot.Api/Security/IAuthSessionIssuer.cs +++ b/src/NodePilot.Api/Security/IAuthSessionIssuer.cs @@ -26,7 +26,9 @@ Task IssueAsync( /// Token rotation on POST /api/auth/refresh: mint a fresh JWT (carrying the /// same compact baseline claims as ) and set the /// np_auth + np_csrf cookies. Directory groups remain server-side and - /// are never copied into the browser cookie. Does + /// are never copied into the browser cookie. When the issuer owns a database context, + /// the new session JTI and old-token revocation commit atomically before either cookie + /// is written. Does /// not write a LOGIN_SUCCESS audit row — refresh is a session-rotation, /// not a fresh login; the controller writes its own rotation metric. /// @@ -53,4 +55,19 @@ public enum AuthSource /// Result of a successful session issue. The token is also persisted into the /// httpOnly cookie on the response; callers that need the bearer-string for tests or /// API responses can read it from . -public sealed record IssuedSession(string Token, Guid UserId, DateTimeOffset ExpiresAt); +/// The minted bearer token. +/// The authenticated user's identifier. +/// The absolute session expiration. +/// +/// True only when a refresh persisted both the new server-side session JTI and the +/// presented-token revocation in one database unit of work. The default remains false so +/// DbContext-free unit-test fixtures can use the controller's compatibility fallback without +/// pretending that they committed production state. This fallback is a test seam, not a +/// supported production issuer contract: production refresh issuers must atomically update +/// the server-side session family and revoke the presented token before returning. +/// +public sealed record IssuedSession( + string Token, + Guid UserId, + DateTimeOffset ExpiresAt, + bool TokenRotationCommitted = false); diff --git a/src/NodePilot.Api/Services/Backup/BackupFileReader.cs b/src/NodePilot.Api/Services/Backup/BackupFileReader.cs index b74ff1a7..e9f1ff21 100644 --- a/src/NodePilot.Api/Services/Backup/BackupFileReader.cs +++ b/src/NodePilot.Api/Services/Backup/BackupFileReader.cs @@ -9,7 +9,7 @@ namespace NodePilot.Api.Services.Backup; public sealed class BackupFormatException(string message) : Exception(message); /// -/// Parses and validates a nodepilot-system-backup/v1 file (ADR 0001). Parsing is +/// Parses and validates a supported version of the NodePilot system-backup file (ADR 0001). Parsing is /// passphrase-free (so preview can run without one); unlocking the secrets and verifying the /// whole-file MAC requires the passphrase. /// diff --git a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs index 29c052f2..60486cb5 100644 --- a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs +++ b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs @@ -1,5 +1,7 @@ +using System.Data; using System.Text.Json.Nodes; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using NodePilot.Api.Configuration; using NodePilot.Api.Security; using NodePilot.Core.Enums; @@ -22,8 +24,11 @@ public sealed class BackupRestoreService( NodePilotDbContext db, ISecretProtector atRest, RuntimeOverridesWriter overrides, - ILogger logger) + ILogger logger, + NodePilot.Api.Services.WorkflowVersionDefinitionProtector versionDefinitions) { + private const string RestoreCommitMarkerAction = "BACKUP_RESTORE_DB_COMMITTED"; + // ---- Preview ------------------------------------------------------------ public async Task PreviewAsync(byte[] content, string? passphrase, CancellationToken ct) @@ -748,7 +753,7 @@ private async Task RestoreCustomActivitiesAsync(RestoreSta { if (policy == RestoreConflictPolicy.Overwrite) { - ApplyCustomActivityFields(existing, item); + ApplyCustomActivityFields(existing, item, s.Protector); existing.UpdatedAt = DateTime.UtcNow; s.CustomActivityMap[sourceId] = existing.Id; overwritten++; continue; @@ -769,7 +774,7 @@ private async Task RestoreCustomActivitiesAsync(RestoreSta Id = id, Key = key, ConcurrencyToken = Guid.NewGuid(), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; - ApplyCustomActivityFields(def, item); + ApplyCustomActivityFields(def, item, s.Protector); db.CustomActivityDefinitions.Add(def); s.CustomActivities[key] = def; s.ExistingCustomActivityIds.Add(id); s.CustomActivityMap[sourceId] = id; } @@ -777,13 +782,17 @@ private async Task RestoreCustomActivitiesAsync(RestoreSta return new SectionRestoreResult(BackupSections.CustomActivities, created, overwritten, skipped, renamed); } - private static void ApplyCustomActivityFields(CustomActivityDefinition def, JsonNode item) + private static void ApplyCustomActivityFields( + CustomActivityDefinition def, + JsonNode item, + PassphraseSecretProtector protector) { def.Name = item["name"]!.GetValue(); def.Description = item["description"]?.GetValue(); def.Icon = item["icon"]?.GetValue() ?? "extension"; def.Color = item["color"]?.GetValue(); - def.ScriptTemplate = item["scriptTemplate"]?.GetValue() ?? ""; + def.ScriptTemplate = RestoreEncryptedOrLegacyPlaintext( + item["scriptTemplate"], protector, "custom activity scriptTemplate"); def.Engine = item["engine"]?.GetValue() ?? "auto"; def.RunsRemote = item["runsRemote"]?.GetValue() ?? false; def.Isolated = item["isolated"]?.GetValue() ?? false; @@ -791,7 +800,10 @@ private static void ApplyCustomActivityFields(CustomActivityDefinition def, Json def.MaxProcesses = item["maxProcesses"]?.GetValue(); def.DefaultTimeoutSeconds = item["defaultTimeoutSeconds"]?.GetValue(); def.SuccessExitCodes = item["successExitCodes"]?.GetValue(); - def.InputParametersJson = item["inputParametersJson"]?.GetValue() ?? "[]"; + def.InputParametersJson = item["inputParametersJson"] is null + ? "[]" + : RestoreEncryptedOrLegacyPlaintext( + item["inputParametersJson"], protector, "custom activity inputParametersJson"); def.OutputParametersJson = item["outputParametersJson"]?.GetValue() ?? "[]"; def.IsEnabled = item["isEnabled"]?.GetValue() ?? false; def.Version = item["version"]?.GetValue() ?? 1; @@ -824,14 +836,39 @@ private Task RestoreWorkflowsAsync(RestoreState s, Cancell if (existing.CheckedOutByUserId is not null) throw new BackupRestoreException( $"Restore aborted: workflow '{existing.Name}' is locked for editing. Publish, unlock, or force-unlock it before overwrite restore."); - existing.Description = description; existing.DefinitionJson = definitionJson; - existing.IsEnabled = isEnabled; existing.FolderId = folderTarget; existing.UpdatedAt = DateTime.UtcNow; + var now = DateTime.UtcNow; + db.WorkflowVersions.Add(new WorkflowVersion + { + Id = Guid.NewGuid(), + WorkflowId = existing.Id, + Version = existing.Version, + Name = existing.Name, + Description = existing.Description, + DefinitionJson = versionDefinitions.Protect(existing.DefinitionJson), + CreatedAt = now, + CreatedBy = existing.UpdatedBy ?? existing.CreatedBy ?? "restore", + ChangeNote = "Superseded by system backup restore", + }); + + existing.Description = description; + existing.DefinitionJson = definitionJson; + existing.Version = checked(existing.Version + 1); + existing.IsEnabled = isEnabled; + existing.FolderId = folderTarget; + existing.UpdatedAt = now; + existing.UpdatedBy = "restore"; + WorkflowMetadata.PopulateComputedColumns(existing); }, - (id, finalName) => new Workflow + (id, finalName) => { - Id = id, Name = finalName, Description = description, DefinitionJson = definitionJson, - Version = version, IsEnabled = isEnabled, FolderId = folderTarget, - CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + var created = new Workflow + { + Id = id, Name = finalName, Description = description, DefinitionJson = definitionJson, + Version = Math.Max(1, version), IsEnabled = isEnabled, FolderId = folderTarget, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }; + WorkflowMetadata.PopulateComputedColumns(created); + return created; }); }, ct); @@ -1072,9 +1109,16 @@ private void ValidateReferences(RestoreState s) if (f is not null && f != SharedWorkflowFolder.RootFolderId && !s.FolderResolvable(f.Value)) unresolved.Add($"workflow '{w["name"]}' → folder {f}"); if (w["definition"] is JsonNode def) - foreach (var (kind, id) in ExtractDefinitionRefs(def)) + foreach (var (kind, id) in ExtractDefinitionRefs( + WorkflowDefinitionSecretRewriter.UnsealBackupDefinition(def, s.Protector))) { - var ok = kind == "targetMachineId" ? s.MachineResolvable(id) : s.CredentialResolvable(id); + var ok = kind switch + { + "targetMachineId" => s.MachineResolvable(id), + "credentialId" => s.CredentialResolvable(id), + "__customDefinitionId" => s.CustomActivityResolvable(id), + _ => false, + }; if (!ok) unresolved.Add($"workflow '{w["name"]}' → {kind} {id}"); } } @@ -1265,47 +1309,64 @@ private JsonNode RewrapSettingValue(JsonNode node, PassphraseSecretProtector pro return s.ExistingUserIds.Contains(source.Value) ? source.Value : null; // K17 — null when unresolvable } - /// Recursively remaps config.__customDefinitionId GUIDs onto their restored target ids. + /// + /// Remaps the authoritative custom-activity reference only on custom workflow nodes. A + /// same-named nested child parameter is application data and must remain untouched. + /// private static void RemapCustomActivityRefs(JsonNode node, RestoreState s) { - switch (node) + if (node is not JsonObject root || root["nodes"] is not JsonArray nodes) return; + foreach (var candidate in nodes) { - case JsonObject obj: - if (obj["__customDefinitionId"] is JsonValue v && v.TryGetValue(out string? idStr) - && Guid.TryParse(idStr, out var oldId) - && s.ResolveCustomActivity(oldId) is { } target && target != oldId) - { - obj["__customDefinitionId"] = target.ToString(); - } - foreach (var (_, child) in obj) if (child is not null) RemapCustomActivityRefs(child, s); - break; - case JsonArray arr: - foreach (var item in arr) if (item is not null) RemapCustomActivityRefs(item, s); - break; + if (candidate is not JsonObject nodeObject || nodeObject["data"] is not JsonObject data) + continue; + var activityType = NodeActivityType(nodeObject, data); + if (!NodePilot.Core.Activities.CustomActivityType.IsCustomType(activityType) + || data["config"] is not JsonObject config + || config["__customDefinitionId"] is not JsonValue idValue + || !idValue.TryGetValue(out string? idString) + || !Guid.TryParse(idString, out var sourceId)) continue; + + var target = s.ResolveCustomActivity(sourceId) + ?? throw new BackupRestoreException( + $"Workflow custom activity reference {sourceId} is not resolvable."); + config["__customDefinitionId"] = target.ToString(); } } - private static IEnumerable<(string kind, Guid id)> ExtractDefinitionRefs(JsonNode node, string? parentName = null) + private static IEnumerable<(string kind, Guid id)> ExtractDefinitionRefs(JsonNode node) { - switch (node) + if (node is not JsonObject root || root["nodes"] is not JsonArray nodes) yield break; + foreach (var candidate in nodes) { - case JsonObject obj: - foreach (var (name, value) in obj) - if (value is not null) - foreach (var r in ExtractDefinitionRefs(value, name)) yield return r; - break; - case JsonArray arr: - foreach (var item in arr) - if (item is not null) - foreach (var r in ExtractDefinitionRefs(item, parentName)) yield return r; - break; - case JsonValue val when (parentName == "targetMachineId" || parentName == "credentialId") - && val.TryGetValue(out string? str) && Guid.TryParse(str, out var g): - yield return (parentName!, g); - break; + if (candidate is not JsonObject nodeObject || nodeObject["data"] is not JsonObject data) + continue; + if (TryGuid(data["targetMachineId"], out var machineId)) + yield return ("targetMachineId", machineId); + if (TryGuid(data["credentialId"], out var credentialId)) + yield return ("credentialId", credentialId); + + var activityType = NodeActivityType(nodeObject, data); + if (NodePilot.Core.Activities.CustomActivityType.IsCustomType(activityType) + && data["config"] is JsonObject config + && TryGuid(config["__customDefinitionId"], out var definitionId)) + { + yield return ("__customDefinitionId", definitionId); + } } } + private static string? NodeActivityType(JsonObject node, JsonObject data) + => data["activityType"]?.GetValue() ?? node["type"]?.GetValue(); + + private static bool TryGuid(JsonNode? node, out Guid id) + { + id = default; + return node is JsonValue value + && value.TryGetValue(out string? text) + && Guid.TryParse(text, out id); + } + private static string? DecryptField(JsonNode? field, PassphraseSecretProtector protector) { if (field is JsonObject obj && obj.TryGetPropertyValue(WorkflowDefinitionSecretRewriter.EncKey, out var b64) @@ -1314,6 +1375,20 @@ private static void RemapCustomActivityRefs(JsonNode node, RestoreState s) return null; } + private static string RestoreEncryptedOrLegacyPlaintext( + JsonNode? field, + PassphraseSecretProtector protector, + string fieldName) + { + if (field is null) return string.Empty; + if (field is JsonValue value && value.TryGetValue(out string? plaintext)) + return plaintext ?? string.Empty; // v1/v2 compatibility + + var decrypted = DecryptField(field, protector); + if (decrypted is not null) return decrypted; + throw new BackupRestoreException($"Backup field '{fieldName}' is malformed."); + } + private static JsonArray Items(BackupFileReader reader, string section) => (reader.Sections[section] as JsonObject)?["items"] as JsonArray ?? []; diff --git a/src/NodePilot.Api/Services/Backup/BackupService.cs b/src/NodePilot.Api/Services/Backup/BackupService.cs index 2ca165bf..5b79ee67 100644 --- a/src/NodePilot.Api/Services/Backup/BackupService.cs +++ b/src/NodePilot.Api/Services/Backup/BackupService.cs @@ -24,7 +24,7 @@ public sealed record BackupExportResult( /// /// Orchestrates the system-configuration backup (ADR 0001). Phase 1 covers the manifest and the /// export: it resolves the requested sections + their transitive dependencies (K12), drives each -/// , assembles the nodepilot-system-backup/v1 envelope, and seals it +/// , assembles the current versioned system-backup envelope, and seals it /// with a passphrase-derived whole-file HMAC (K5). /// public sealed class BackupService(IEnumerable parts) @@ -120,8 +120,13 @@ public async Task ExportAsync( // Accurate audit signal: a backup "contains secrets" only if at least one field was actually // sealed (the $enc marker). A globals-only export with no secret variables, for example, // legitimately carries none — reporting true unconditionally would muddy the audit trail. - var containsSecrets = sections.ToJsonString() - .Contains("\"" + WorkflowDefinitionSecretRewriter.EncKey + "\"", StringComparison.Ordinal); + var sectionsJson = sections.ToJsonString(); + var containsSecrets = sectionsJson.Contains( + "\"" + WorkflowDefinitionSecretRewriter.EncKey + "\"", + StringComparison.Ordinal) + || sectionsJson.Contains( + "\"" + WorkflowDefinitionSecretRewriter.DefinitionEncKey + "\"", + StringComparison.Ordinal); return new BackupExportResult(content, includedOrdered, autoIncluded, counts, ctx.Warnings, containsSecrets); } diff --git a/src/NodePilot.Api/Services/Backup/IBackupPart.cs b/src/NodePilot.Api/Services/Backup/IBackupPart.cs index ecb8c1c4..ba442e03 100644 --- a/src/NodePilot.Api/Services/Backup/IBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/IBackupPart.cs @@ -44,10 +44,15 @@ public static class BackupSections public const string Schema = "nodepilot-system-backup/v1"; /// Current envelope schema — adds the section. New exports write this. public const string SchemaV2 = "nodepilot-system-backup/v2"; + /// + /// Protects each complete workflow definition in a passphrase envelope. Older readers must + /// reject this schema instead of treating $encDefinition as an ordinary definition. + /// + public const string SchemaV3 = "nodepilot-system-backup/v3"; /// The schema every new export writes. - public const string CurrentSchema = SchemaV2; - /// Schemas this build can import. Older builds reject (unknown) — visible refusal. - public static readonly string[] SupportedSchemas = [Schema, SchemaV2]; + public const string CurrentSchema = SchemaV3; + /// Schemas this build can import. Older builds reject unknown newer schemas visibly. + public static readonly string[] SupportedSchemas = [Schema, SchemaV2, SchemaV3]; } /// diff --git a/src/NodePilot.Api/Services/Backup/Parts/CustomActivityBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/CustomActivityBackupPart.cs index 4fff6a43..5a26376e 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/CustomActivityBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/CustomActivityBackupPart.cs @@ -5,9 +5,9 @@ namespace NodePilot.Api.Services.Backup.Parts; /// /// Exports custom-activity definitions (the live row of each, including disabled drafts). The -/// PowerShell scriptTemplate is exported in cleartext — exactly like a workflow's runScript -/// script field (neither is a known secret config key); the whole backup is integrity-sealed -/// and secrets are expected to live in globals/credentials, not inline. Version-history snapshots are +/// PowerShell scriptTemplate is encrypted as one opaque value under the backup passphrase. +/// Like a workflow's runScript field it can contain arbitrary legacy literals that no key-name +/// heuristic can classify safely. Version-history snapshots are /// intentionally excluded (DR snapshot = live config, not history). Restored faithfully with their /// enabled state — unlike the dedicated .npca import, which forces disabled. /// @@ -33,7 +33,7 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok ["description"] = d.Description, ["icon"] = d.Icon, ["color"] = d.Color, - ["scriptTemplate"] = d.ScriptTemplate, + ["scriptTemplate"] = ctx.Enc(d.ScriptTemplate), ["engine"] = d.Engine, ["runsRemote"] = d.RunsRemote, ["isolated"] = d.Isolated, @@ -41,7 +41,10 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok ["maxProcesses"] = d.MaxProcesses, ["defaultTimeoutSeconds"] = d.DefaultTimeoutSeconds, ["successExitCodes"] = d.SuccessExitCodes, - ["inputParametersJson"] = d.InputParametersJson, + // Defaults are injected as runtime PowerShell variables and may contain the same + // arbitrary legacy literals as the script itself. Seal the complete schema blob; + // output metadata does not carry executable input values. + ["inputParametersJson"] = ctx.Enc(d.InputParametersJson), ["outputParametersJson"] = d.OutputParametersJson, ["isEnabled"] = d.IsEnabled, ["version"] = d.Version, diff --git a/src/NodePilot.Api/Services/Backup/Parts/WorkflowBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/WorkflowBackupPart.cs index 387d6a2b..736cc8b0 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/WorkflowBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/WorkflowBackupPart.cs @@ -14,7 +14,13 @@ namespace NodePilot.Api.Services.Backup.Parts; public sealed class WorkflowBackupPart(NodePilotDbContext db) : IBackupPart { public string Key => BackupSections.Workflows; - public IReadOnlyList DependsOn => [BackupSections.Folders, BackupSections.Machines, BackupSections.Credentials]; + public IReadOnlyList DependsOn => + [ + BackupSections.Folders, + BackupSections.Machines, + BackupSections.Credentials, + BackupSections.CustomActivities, + ]; public Task CountAsync(CancellationToken ct) => db.Workflows.CountAsync(ct); diff --git a/src/NodePilot.Api/Services/Backup/RestoreState.cs b/src/NodePilot.Api/Services/Backup/RestoreState.cs index da268c02..fdd0543f 100644 --- a/src/NodePilot.Api/Services/Backup/RestoreState.cs +++ b/src/NodePilot.Api/Services/Backup/RestoreState.cs @@ -49,6 +49,7 @@ internal sealed class RestoreState private readonly HashSet _backupCredentialIds; private readonly HashSet _backupMachineIds; + private readonly HashSet _backupCustomActivityIds; private readonly HashSet _backupFolderIds; private readonly HashSet _backupGlobalFolderIds; @@ -60,6 +61,7 @@ public RestoreState(BackupFileReader reader, PassphraseSecretProtector protector _policies = policies; _backupCredentialIds = SourceIds(reader, BackupSections.Credentials, "items"); _backupMachineIds = SourceIds(reader, BackupSections.Machines, "items"); + _backupCustomActivityIds = SourceIds(reader, BackupSections.CustomActivities, "items"); _backupFolderIds = SourceIds(reader, BackupSections.Folders, "structure"); _backupGlobalFolderIds = SourceIds(reader, BackupSections.GlobalVariableFolders, "structure"); } @@ -70,6 +72,8 @@ public RestoreConflictPolicy Policy(string section) => // ---- resolvability (validation, before any write) ---- public bool CredentialResolvable(Guid g) => _backupCredentialIds.Contains(g) || ExistingCredentialIds.Contains(g); public bool MachineResolvable(Guid g) => _backupMachineIds.Contains(g) || ExistingMachineIds.Contains(g); + public bool CustomActivityResolvable(Guid g) => + _backupCustomActivityIds.Contains(g) || ExistingCustomActivityIds.Contains(g); public bool FolderResolvable(Guid g) => g == SharedWorkflowFolder.RootFolderId || _backupFolderIds.Contains(g) || ExistingFolderIds.Contains(g); public bool GlobalFolderResolvable(Guid g) => @@ -89,7 +93,7 @@ public bool GlobalFolderResolvable(Guid g) => /// Maps a backed-up custom-activity definition id to its restored target id (for /// config.__customDefinitionId in workflow node configs). Null when neither in the backup - /// nor the target DB — the reference is left as-is and resolves (or fails cleanly) at run time. + /// nor the target DB — validation rejects that restore before any write. public Guid? ResolveCustomActivity(Guid g) => CustomActivityMap.TryGetValue(g, out var t) ? t : ExistingCustomActivityIds.Contains(g) ? g : null; diff --git a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs index 19a4d830..f10a7511 100644 --- a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs +++ b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs @@ -5,25 +5,24 @@ namespace NodePilot.Api.Services.Backup; /// -/// How secret-bearing string values inside a workflow DefinitionJson are treated when -/// the definition leaves the system (ADR 0001 K2). The same key list -/// () drives both modes so the -/// contextual "share one workflow" export and the system backup never disagree about what's a secret. +/// How a workflow DefinitionJson is protected when it leaves the system (ADR 0001 K2). +/// Collaboration exports use the shared structural redactor; DR backups encrypt the complete +/// definition because arbitrary executable payloads cannot be classified safely by key name. /// public enum SecretHandling { /// Replace secret values with "***" — the share/export-for-collaboration path. Redact, - /// Replace secret values with an {"$enc":"<base64>"} object encrypted under - /// the backup passphrase — the DR backup path. + /// Seal the complete definition as an authenticated $encDefinition envelope + /// under the backup passphrase — the DR backup path. EncryptForBackup, } /// -/// Structure-preserving rewrite of a workflow definition that handles the inline secret-bearing -/// config keys uniformly. Generalises the former WorkflowsControllerBase.RedactSecretsInDefinition -/// so both the workflow-sharing export and the system backup share one implementation. +/// Redacts a structure-preserving sharing copy or seals the complete definition for DR backup. +/// Generalises the former WorkflowsControllerBase.RedactSecretsInDefinition while keeping +/// collaboration and backup confidentiality policies explicit and independently fail-closed. /// public static class WorkflowDefinitionSecretRewriter { @@ -39,6 +38,13 @@ public static class WorkflowDefinitionSecretRewriter /// The marker object key used for passphrase-encrypted values across the whole backup. public const string EncKey = "$enc"; + /// + /// Marker for a backup-passphrase-encrypted complete workflow definition. Whole-definition + /// sealing is deliberate: executable/free-form fields cannot be classified safely by inspecting + /// their contents, and GUID references can be decrypted and remapped during restore. + /// + public const string DefinitionEncKey = "$encDefinition"; + /// /// Rewrites according to . For /// , must be supplied. @@ -53,9 +59,14 @@ public static JsonNode Rewrite(JsonElement root, SecretHandling handling, Passph if (handling == SecretHandling.EncryptForBackup && protector is null) throw new ArgumentNullException(nameof(protector), "EncryptForBackup requires a passphrase protector."); - var node = JsonNode.Parse(root.GetRawText()) - ?? throw new InvalidOperationException("Workflow definition is not valid JSON."); - return Walk(node, parentName: null, isHttpHeaderValue: false, protector); + // Seal the complete definition. Selective encryption can never be sound for arbitrary + // PowerShell, request bodies, custom headers, or imported SCOrch payloads: an unrecognised + // literal is still a potential credential. Keeping a single encrypted blob also avoids + // leaking structure and identifiers through a DR archive. + return new JsonObject + { + [DefinitionEncKey] = Convert.ToBase64String(protector!.Protect(root.GetRawText())), + }; } /// @@ -73,17 +84,55 @@ public static JsonNode RestoreDefinition( Func resolveCredential, List unresolved) { - return RestoreWalk(definition, parentName: null, protector, resolveMachine, resolveCredential, unresolved); + var wholeDefinitionEnvelope = TryUnsealBackupDefinition(definition, protector, out var plaintextDefinition); + var restored = RestoreWalk( + plaintextDefinition, protector, + decryptLegacyFieldEnvelopes: !wholeDefinitionEnvelope); + RemapNodeInfrastructureReferences( + restored, resolveMachine, resolveCredential, unresolved); + return restored; + } + + /// + /// Decrypts the whole-definition envelope used by current backups. A deep clone is returned for + /// older per-field-encrypted backups so the existing recursive restore remains backward compatible. + /// + public static JsonNode UnsealBackupDefinition(JsonNode definition, PassphraseSecretProtector protector) + { + TryUnsealBackupDefinition(definition, protector, out var plaintextDefinition); + return plaintextDefinition; + } + + private static bool TryUnsealBackupDefinition( + JsonNode definition, PassphraseSecretProtector protector, out JsonNode plaintextDefinition) + { + if (definition is JsonObject envelope + && envelope.Count == 1 + && envelope.TryGetPropertyValue(DefinitionEncKey, out var ciphertext) + && ciphertext is JsonValue value + && value.TryGetValue(out string? encoded) + && !string.IsNullOrEmpty(encoded)) + { + var plaintext = protector.Unprotect(Convert.FromBase64String(encoded)); + plaintextDefinition = JsonNode.Parse(plaintext) + ?? throw new InvalidOperationException("Encrypted workflow definition contained JSON null."); + return true; + } + + plaintextDefinition = definition.DeepClone(); + return false; } private static JsonNode RestoreWalk( - JsonNode node, string? parentName, PassphraseSecretProtector protector, - Func resolveMachine, Func resolveCredential, List unresolved) + JsonNode node, + PassphraseSecretProtector protector, + bool decryptLegacyFieldEnvelopes) { switch (node) { // An {"$enc":""} object is a sealed secret — decrypt it back to its string value. - case JsonObject enc when enc.Count == 1 && enc.TryGetPropertyValue(EncKey, out var b64) + case JsonObject enc when decryptLegacyFieldEnvelopes + && enc.Count == 1 && enc.TryGetPropertyValue(EncKey, out var b64) && b64 is JsonValue bv && bv.TryGetValue(out string? s) && s is not null: return JsonValue.Create(protector.Unprotect(Convert.FromBase64String(s))); case JsonObject obj: @@ -91,7 +140,7 @@ private static JsonNode RestoreWalk( var result = new JsonObject(); foreach (var (name, value) in obj) result[name] = value is null ? null - : RestoreWalk(value, name, protector, resolveMachine, resolveCredential, unresolved); + : RestoreWalk(value, protector, decryptLegacyFieldEnvelopes); return result; } case JsonArray arr: @@ -99,68 +148,51 @@ private static JsonNode RestoreWalk( var result = new JsonArray(); foreach (var item in arr) result.Add(item is null ? null - : RestoreWalk(item, parentName, protector, resolveMachine, resolveCredential, unresolved)); + : RestoreWalk(item, protector, decryptLegacyFieldEnvelopes)); return result; } - case JsonValue val when val.TryGetValue(out string? str) && str is not null: - { - var resolver = parentName switch - { - "targetMachineId" => resolveMachine, - "credentialId" => resolveCredential, - _ => (Func?)null, - }; - if (resolver is not null && Guid.TryParse(str, out var sourceId)) - { - var target = resolver(sourceId); - if (target is null) - { - unresolved.Add($"{parentName}={str}"); - return JsonValue.Create(str); - } - return JsonValue.Create(target.Value.ToString()); - } - return JsonValue.Create(str); - } default: return node.DeepClone(); } } - // Only reached for EncryptForBackup: Rewrite short-circuits Redact to Core's - // WorkflowSecretRedactor, and the protector was null-checked there. - private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeaderValue, - PassphraseSecretProtector? protector) + /// + /// Runtime resolves infrastructure references only from each node's data object. Do + /// not recursively rewrite same-named keys inside config payloads: they are ordinary child + /// parameters/return data and changing them would silently corrupt application data. + /// + private static void RemapNodeInfrastructureReferences( + JsonNode definition, + Func resolveMachine, + Func resolveCredential, + List unresolved) { - switch (node) + if (definition is not JsonObject root || root["nodes"] is not JsonArray nodes) return; + foreach (var node in nodes) { - case JsonObject obj: - { - var result = new JsonObject(); - var isHeadersObject = string.Equals(parentName, "headers", StringComparison.OrdinalIgnoreCase); - foreach (var (name, value) in obj) - result[name] = value is null ? null : Walk(value, name, isHeadersObject, protector); - return result; - } - case JsonArray arr: - { - var result = new JsonArray(); - foreach (var item in arr) - result.Add(item is null ? null : Walk(item, parentName, isHttpHeaderValue, protector)); - return result; - } - case JsonValue val when val.TryGetValue(out string? s) && s is not null: - { - if (!NodePilot.Core.WorkflowDefinitions.WorkflowSecretKeys.IsSecretValue(parentName, s, isHttpHeaderValue)) - return JsonValue.Create(s); + if (node is not JsonObject nodeObject || nodeObject["data"] is not JsonObject data) continue; + RemapNodeReference(data, "targetMachineId", resolveMachine, unresolved); + RemapNodeReference(data, "credentialId", resolveCredential, unresolved); + } + } - return new JsonObject - { - [EncKey] = Convert.ToBase64String(protector!.Protect(s)), - }; - } - default: - return node.DeepClone(); + private static void RemapNodeReference( + JsonObject data, + string key, + Func resolver, + List unresolved) + { + if (data[key] is not JsonValue value + || !value.TryGetValue(out string? raw) + || !Guid.TryParse(raw, out var sourceId)) return; + + var target = resolver(sourceId); + if (target is null) + { + unresolved.Add($"{key}={raw}"); + return; } + data[key] = target.Value.ToString(); } + } diff --git a/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs b/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs index 8ca9c0ea..2be6aee1 100644 --- a/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs +++ b/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs @@ -1,4 +1,4 @@ -using System.Text; +using NodePilot.Core.Security; namespace NodePilot.Api.Services.DbAdmin; @@ -9,15 +9,26 @@ namespace NodePilot.Api.Services.DbAdmin; /// internal static class DbAdminReadOnlySqlGuard { - /// Pseudo-token emitted by the tokenizer for PostgreSQL's :: cast operator. + /// Pseudo-token emitted by the shared inspector for PostgreSQL's :: cast operator. public const string CastOperator = "::"; + /// + /// Constructs that can collapse a complete row into one innocently named result column. This + /// list supports the forensic DbAdmin secret-column guard; external-agent SQL additionally uses + /// bare composite-row detection in . + /// + private static readonly HashSet WholeRowProjectionIdentifiers = new(StringComparer.OrdinalIgnoreCase) + { + "to_json", "row_to_json", "to_jsonb", "json_agg", "jsonb_agg", + "json_build_object", "jsonb_build_object", "json_object", "jsonb_object", + "row_to_xml", "table_to_xml", "query_to_xml", "hstore", + CastOperator, + }; + private static readonly HashSet DangerousKeywords = new(StringComparer.OrdinalIgnoreCase) { // REPLACE is deliberately absent: it is a standard string function on every supported - // backend (`SELECT REPLACE(Name,'a','b')`). The MySQL `REPLACE INTO` write form it would - // guard against is not a supported backend, is already blocked by the INTO token below, - // and could never pass FirstKeyword/IsReadOnlyKeyword as a leading keyword either. + // backend. The unsupported MySQL REPLACE INTO form is still stopped by INTO. "INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "GRANT", "REVOKE", "DENY", @@ -59,192 +70,38 @@ first is null ? "Could not detect a SQL keyword in the input." : $"Statement starts with '{first.ToUpperInvariant()}' which is not allowed in read mode."); - foreach (var token in Tokenize(sql)) - { - if (!token.Quoted && DangerousKeywords.Contains(token.Value)) - throw new InvalidOperationException( - $"Keyword '{token.Value.ToUpperInvariant()}' is not allowed in read mode."); - if (DangerousRoutines.Contains(token.Value)) - throw new InvalidOperationException( - $"Routine '{token.Value}' is not allowed in read mode."); - } + if (SqlStatementInspector.ContainsUnicodeEscapedIdentifier(sql)) + throw new InvalidOperationException( + "Unicode-escaped identifiers are not allowed in read mode."); + + var dangerousKeyword = SqlStatementInspector.FindFirstIdentifier( + sql, DangerousKeywords, includeQuoted: false); + if (dangerousKeyword is not null) + throw new InvalidOperationException( + $"Keyword '{dangerousKeyword.ToUpperInvariant()}' is not allowed in read mode."); + + var dangerousRoutine = SqlStatementInspector.FindFirstIdentifier(sql, DangerousRoutines) + ?? SqlStatementInspector.FindDynamicDataExporter(sql); + if (dangerousRoutine is not null) + throw new InvalidOperationException( + $"Routine '{dangerousRoutine}' is not allowed in read mode."); } public static bool ReferencesAnyIdentifier(string sql, IReadOnlySet identifiers) - => Tokenize(sql).Any(token => identifiers.Contains(token.Value)); + => SqlStatementInspector.ReferencesAnyIdentifier(sql, identifiers); /// - /// True when follows as consecutive unquoted - /// tokens — the shape needed to spot multi-word constructs such as SQL Server's - /// FOR JSON. A quoted identifier breaks the chain so "FOR" JSON (two identifiers - /// that merely look like the keyword pair) does not match. + /// True when a query combines a protected table with a provider-specific complete-row + /// serializer. Deliberately conservative because result columns no longer retain lineage. /// - public static bool ReferencesIdentifierPair(string sql, string first, string second) - { - string? previous = null; - foreach (var token in Tokenize(sql)) - { - if (previous is not null - && !token.Quoted - && previous.Equals(first, StringComparison.OrdinalIgnoreCase) - && token.Value.Equals(second, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - previous = token.Quoted ? null : token.Value; - } - return false; - } - - private static IEnumerable Tokenize(string sql) - { - for (var i = 0; i < sql.Length;) - { - if (char.IsWhiteSpace(sql[i]) || sql[i] is ',' or '(' or ')' or '.' or ';') - { - i++; - continue; - } - - if (i + 1 < sql.Length && sql[i] == '-' && sql[i + 1] == '-') - { - i += 2; - while (i < sql.Length && sql[i] != '\n') i++; - continue; - } - - if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*') - { - i += 2; - var depth = 1; - while (i < sql.Length && depth > 0) - { - if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*') - { - depth++; - i += 2; - } - else if (i + 1 < sql.Length && sql[i] == '*' && sql[i + 1] == '/') - { - depth--; - i += 2; - } - else i++; - } - continue; - } - - if (sql[i] == '\'') - { - SkipQuotedLiteral(sql, ref i, '\''); - continue; - } - - // PostgreSQL's cast operator, emitted as its own token. `SELECT u::text FROM "Users" u` - // casts a whole ROW to text and therefore returns every column — including the hidden - // ones — under a harmless result-column name. The row-projection guard needs to see it; - // it is not a keyword, so Validate's keyword/routine checks ignore it. - if (sql[i] == ':' && i + 1 < sql.Length && sql[i + 1] == ':') - { - i += 2; - yield return new SqlToken(CastOperator, Quoted: false); - continue; - } + public static bool ReferencesWholeRowProjection( + string sql, + IReadOnlySet protectedTableIdentifiers) + => ReferencesAnyIdentifier(sql, protectedTableIdentifiers) + && (ReferencesAnyIdentifier(sql, WholeRowProjectionIdentifiers) + || ReferencesIdentifierPair(sql, "FOR", "JSON") + || ReferencesIdentifierPair(sql, "FOR", "XML")); - if (sql[i] == '$' && TryReadDollarQuoteTag(sql, i, out var tag)) - { - i += tag.Length; - var end = sql.IndexOf(tag, i, StringComparison.Ordinal); - i = end < 0 ? sql.Length : end + tag.Length; - continue; - } - - if (sql[i] is '"' or '`') - { - var quote = sql[i++]; - var value = ReadEscapedIdentifier(sql, ref i, quote); - if (value.Length > 0) yield return new SqlToken(value, Quoted: true); - continue; - } - - if (sql[i] == '[') - { - i++; - var value = ReadBracketIdentifier(sql, ref i); - if (value.Length > 0) yield return new SqlToken(value, Quoted: true); - continue; - } - - if (char.IsLetter(sql[i]) || sql[i] is '_' or '#') - { - var start = i++; - while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] is '_' or '$' or '#')) i++; - yield return new SqlToken(sql[start..i], Quoted: false); - continue; - } - - i++; - } - } - - private static void SkipQuotedLiteral(string sql, ref int i, char quote) - { - i++; - while (i < sql.Length) - { - if (sql[i] != quote) { i++; continue; } - if (i + 1 < sql.Length && sql[i + 1] == quote) { i += 2; continue; } - i++; - return; - } - } - - private static string ReadEscapedIdentifier(string sql, ref int i, char quote) - { - var value = new StringBuilder(); - while (i < sql.Length) - { - if (sql[i] != quote) { value.Append(sql[i++]); continue; } - if (i + 1 < sql.Length && sql[i + 1] == quote) - { - value.Append(quote); - i += 2; - continue; - } - i++; - break; - } - return value.ToString(); - } - - private static string ReadBracketIdentifier(string sql, ref int i) - { - var value = new StringBuilder(); - while (i < sql.Length) - { - if (sql[i] != ']') { value.Append(sql[i++]); continue; } - if (i + 1 < sql.Length && sql[i + 1] == ']') - { - value.Append(']'); - i += 2; - continue; - } - i++; - break; - } - return value.ToString(); - } - - private static bool TryReadDollarQuoteTag(string sql, int start, out string tag) - { - tag = ""; - if (sql[start] != '$') return false; - var end = start + 1; - while (end < sql.Length && (char.IsLetterOrDigit(sql[end]) || sql[end] == '_')) end++; - if (end >= sql.Length || sql[end] != '$') return false; - tag = sql[start..(end + 1)]; - return true; - } - - private readonly record struct SqlToken(string Value, bool Quoted); + public static bool ReferencesIdentifierPair(string sql, string first, string second) + => SqlStatementInspector.ReferencesIdentifierPair(sql, first, second); } diff --git a/src/NodePilot.Api/Services/DbAdmin/DbAdminSecretColumns.cs b/src/NodePilot.Api/Services/DbAdmin/DbAdminSecretColumns.cs index a93fd345..0ba0104b 100644 --- a/src/NodePilot.Api/Services/DbAdmin/DbAdminSecretColumns.cs +++ b/src/NodePilot.Api/Services/DbAdmin/DbAdminSecretColumns.cs @@ -42,20 +42,6 @@ public sealed class DbAdminSecretColumns private static readonly HashSet GlobalVariableValueIdentifier = new(["Value"], StringComparer.OrdinalIgnoreCase); - /// - /// Constructs that return a whole row under one result column. Neither the statement text nor - /// the result-column name mentions the protected column, so both name-based layers miss them. - /// :: is the PostgreSQL cast operator (u::text serializes the entire row); - /// SQL Server's FOR JSON is matched separately as a token pair. - /// - private static readonly HashSet RowProjectionIdentifiers = new(StringComparer.OrdinalIgnoreCase) - { - "to_json", "row_to_json", "to_jsonb", "json_agg", "jsonb_agg", - "json_build_object", "jsonb_build_object", "json_object", "jsonb_object", - "row_to_xml", "table_to_xml", "query_to_xml", "hstore", - DbAdminReadOnlySqlGuard.CastOperator, - }; - /// Result-column names that get masked: every hidden column plus GlobalVariable.Value. private readonly HashSet _maskedColumnNames; @@ -69,6 +55,16 @@ public sealed class DbAdminSecretColumns /// private readonly HashSet _protectedTableIdentifiers; + /// + /// Entity and mapped SQL table identifiers whose rows contain a hidden/masked secret column. + /// External-agent adapters deny these complete tables because provider-neutral SQL cannot prove + /// that a composite row was not serialized under an alias. + /// + public IReadOnlySet ProtectedTableIdentifiers => _protectedTableIdentifiers; + + public bool IsProtectedTableIdentifier(string identifier) + => _protectedTableIdentifiers.Contains(identifier); + public DbAdminSecretColumns(DbAdminMetadataService metadata) { _maskedColumnNames = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -124,10 +120,7 @@ public bool ReferencesProtectedColumn(string sql) /// docs/security-findings.md). /// public bool ReferencesProtectedRowProjection(string sql) - => DbAdminReadOnlySqlGuard.ReferencesAnyIdentifier(sql, _protectedTableIdentifiers) - && (DbAdminReadOnlySqlGuard.ReferencesAnyIdentifier(sql, RowProjectionIdentifiers) - || DbAdminReadOnlySqlGuard.ReferencesIdentifierPair(sql, "FOR", "JSON") - || DbAdminReadOnlySqlGuard.ReferencesIdentifierPair(sql, "FOR", "XML")); + => DbAdminReadOnlySqlGuard.ReferencesWholeRowProjection(sql, _protectedTableIdentifiers); /// /// Per-result-column flags: true where the cell must be replaced with . diff --git a/src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs b/src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs new file mode 100644 index 00000000..b3a67167 --- /dev/null +++ b/src/NodePilot.Api/Services/WorkflowVersionDefinitionProtector.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using NodePilot.Core.Interfaces; +using NodePilot.Data; + +namespace NodePilot.Api.Services; + +/// +/// Protects complete historic workflow definitions before they enter WorkflowVersions. +/// History is not executed directly and is only materialised through authorised API paths, so +/// storing an opaque envelope is safer than trying to identify individual literals inside arbitrary +/// scripts and migration payloads. Legacy plaintext rows remain readable during rolling upgrades. +/// +public sealed class WorkflowVersionDefinitionProtector( + ISecretProtector protector, + ILogger logger) +{ + internal const string StoragePrefix = "np:wfv:v1:"; + private const int MigrationBatchSize = 100; + + public bool IsProtected(string value) => value.StartsWith(StoragePrefix, StringComparison.Ordinal); + + public string Protect(string plaintext) + { + ArgumentNullException.ThrowIfNull(plaintext); + if (IsProtected(plaintext)) return plaintext; + return ProtectWithActiveProvider(plaintext); + } + + /// + /// Returns plaintext for both current envelopes and legacy rows. A malformed/current envelope + /// is never treated as plaintext: decoding or authentication failures propagate fail-closed. + /// + public string Unprotect(string stored) + { + ArgumentNullException.ThrowIfNull(stored); + if (!IsProtected(stored)) return stored; + + var encoded = stored[StoragePrefix.Length..]; + if (encoded.Length == 0) + throw new InvalidOperationException("Workflow version definition has an empty encrypted envelope."); + + return protector.Unprotect(Convert.FromBase64String(encoded)); + } + + /// + /// Re-wraps every history definition with the active provider. When the registered protector is + /// a migrating wrapper, can read the legacy provider while protection + /// always writes the active one. Per-row crypto failures are reported without preventing other + /// recoverable rows from rotating. + /// + public async Task ReencryptAllAsync(NodePilotDbContext db, CancellationToken ct) + { + var rewritten = 0; + var skipped = new List(); + Guid? lastWorkflowId = null; + var lastVersion = 0; + var lastId = Guid.Empty; + + while (true) + { + var query = db.WorkflowVersions.AsQueryable(); + if (lastWorkflowId is { } workflowCursor) + { + query = query.Where(v => + v.WorkflowId.CompareTo(workflowCursor) > 0 + || (v.WorkflowId == workflowCursor + && (v.Version > lastVersion + || (v.Version == lastVersion && v.Id.CompareTo(lastId) > 0)))); + } + + var batch = await query + .OrderBy(v => v.WorkflowId) + .ThenBy(v => v.Version) + .ThenBy(v => v.Id) + .Take(MigrationBatchSize) + .ToListAsync(ct); + if (batch.Count == 0) break; + + foreach (var row in batch) + { + try + { + var plaintext = Unprotect(row.DefinitionJson); + row.DefinitionJson = ProtectWithActiveProvider(plaintext); + rewritten++; + } + catch (Exception ex) when (ex is CryptographicException + or FormatException + or ArgumentException + or InvalidOperationException) + { + logger.LogWarning(ex, + "Re-encrypt skipped workflow version '{Name}' v{Version} (id={Id}, error={ErrorType}); " + + "stored definition could not be decrypted.", + row.Name, row.Version, row.Id, ex.GetType().Name); + skipped.Add(new ReencryptionSkip( + row.Id, $"{row.Name} v{row.Version}", ex.GetType().Name)); + } + } + + if (batch.Any(row => db.Entry(row).Property(v => v.DefinitionJson).IsModified)) + await db.SaveChangesAsync(ct); + + // Advance by the last stable key, not an offset. Retention may delete already-processed + // rows while a long rotation runs; such deletes must not shift a live OFFSET window and + // make an untouched legacy-provider row disappear from the sweep. + var cursor = batch[^1]; + lastWorkflowId = cursor.WorkflowId; + lastVersion = cursor.Version; + lastId = cursor.Id; + db.ChangeTracker.Clear(); + logger.LogDebug( + "Re-encrypted workflow-version batch through workflow {WorkflowId}, version {Version}, id {Id}.", + lastWorkflowId, lastVersion, lastId); + } + + return new ReencryptionSummary(rewritten, skipped.Count, skipped); + } + + /// + /// Reports whether an upgraded database still contains legacy plaintext history. The startup + /// path is deliberately read-only: rewriting rows before the updater's health check would make + /// its binary rollback unsafe, and in HA a newly upgraded passive node must not mutate data that + /// the still-active older binary cannot read. Administrators perform the cutover explicitly via + /// POST /api/secrets/reencrypt after every node is on the new version. + /// + public async Task WarnIfExplicitMigrationRequiredAsync(NodePilotDbContext db, CancellationToken ct) + { + var required = await db.WorkflowVersions.AsNoTracking() + .AnyAsync(v => !v.DefinitionJson.StartsWith(StoragePrefix), ct); + if (required) + { + logger.LogWarning( + "Legacy plaintext workflow-version definitions remain. After every NodePilot node " + + "has been upgraded and the binary rollback window has closed, run POST " + + "/api/secrets/reencrypt (or 'np secrets reencrypt') to protect workflow history."); + } + + return required; + } + + private string ProtectWithActiveProvider(string plaintext) => + StoragePrefix + Convert.ToBase64String(protector.Protect(plaintext)); +} diff --git a/src/NodePilot.Cli/Api/Dtos/AuthDtos.cs b/src/NodePilot.Cli/Api/Dtos/AuthDtos.cs index 8919b96b..d1535e63 100644 --- a/src/NodePilot.Cli/Api/Dtos/AuthDtos.cs +++ b/src/NodePilot.Cli/Api/Dtos/AuthDtos.cs @@ -2,5 +2,10 @@ namespace NodePilot.Cli.Api.Dtos; // Mirrors src/NodePilot.Api/Dtos/WorkflowDtos.cs LoginRequest / LoginResponse. public sealed record LoginRequest(string Username, string Password); -public sealed record LoginResponse(string Token, Guid UserId, string Username, string Role); +public sealed record LoginResponse( + string Token, + Guid UserId, + string Username, + string Role, + DateTimeOffset? ExpiresAt = null); public sealed record MeResponse(Guid Id, string Username, string Role); diff --git a/src/NodePilot.Cli/Api/Dtos/NewSurfaceDtos.cs b/src/NodePilot.Cli/Api/Dtos/NewSurfaceDtos.cs index 7e4098e3..1dba2e8c 100644 --- a/src/NodePilot.Cli/Api/Dtos/NewSurfaceDtos.cs +++ b/src/NodePilot.Cli/Api/Dtos/NewSurfaceDtos.cs @@ -55,6 +55,9 @@ public sealed record ReencryptResult( int GlobalSecretsRewritten, int GlobalSecretsSkipped, IReadOnlyList GlobalSecretSkipDetails, + int WorkflowVersionsRewritten, + int WorkflowVersionsSkipped, + IReadOnlyList WorkflowVersionSkipDetails, bool PartialSuccess); // ---- Shared workflow folders (RBAC) ----------------------------------------- diff --git a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs index 12e6ab9d..3a765f7f 100644 --- a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs +++ b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs @@ -9,11 +9,10 @@ namespace NodePilot.Cli.Api; /// -/// DelegatingHandler that intercepts 401 responses, attempts a single -/// POST /api/auth/refresh with the current bearer token, persists the rotated -/// token to and replays the original request once. Any second -/// 401 surfaces as a normal so the command can prompt the -/// user to re-login. +/// Keeps the profile's bearer credential current. A still-valid token is rotated shortly +/// before its absolute expiry, concurrent requests share one refresh, and every request +/// reads the latest profile-bound token before it is sent. An expired or rejected refresh +/// clears the unusable local session so callers receive the normal re-login path. /// [SupportedOSPlatform("windows")] public sealed class TokenRefreshHandler : DelegatingHandler @@ -21,61 +20,296 @@ public sealed class TokenRefreshHandler : DelegatingHandler private readonly TokenStore _tokens; private readonly string _profile; private readonly Action? _onTokenRefreshed; - private bool _refreshAttempted; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private string? _lastNearExpiryRotation; + private long _lastNearExpiryRotationAtUnixMs; + private string? _transientRefreshFailureToken; + private DateTimeOffset _transientRefreshRetryAfter; - public TokenRefreshHandler(TokenStore tokens, string profile, Action? onTokenRefreshed = null) + public TokenRefreshHandler( + TokenStore tokens, + string profile, + Action? onTokenRefreshed = null, + TimeProvider? timeProvider = null) { _tokens = tokens; _profile = profile; _onTokenRefreshed = onTokenRefreshed; + _timeProvider = timeProvider ?? TimeProvider.System; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + // The refresh endpoint itself returning 401 must not loop. + if (request.RequestUri?.AbsolutePath?.EndsWith("/api/auth/refresh", StringComparison.OrdinalIgnoreCase) == true) + return await base.SendAsync(request, cancellationToken); + + var existing = LoadForRequest(request); + if (existing is null) + return await base.SendAsync(request, cancellationToken); + + if (IsExpired(existing)) + { + existing = await RevalidateExpiredSessionAsync( + existing.Token, request.RequestUri!, cancellationToken); + if (existing is null) + return ReauthenticationRequired(request); + } + + if (NeedsProactiveRefresh(existing)) + { + existing = await RefreshSingleFlightAsync( + existing.Token, request.RequestUri!, proactive: true, cancellationToken); + if (existing is null) + return ReauthenticationRequired(request); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", existing.Token); var response = await base.SendAsync(request, cancellationToken); - if (response.StatusCode != HttpStatusCode.Unauthorized || _refreshAttempted) + if (response.StatusCode != HttpStatusCode.Unauthorized) return response; - // The refresh endpoint itself returning 401 must not loop. - if (request.RequestUri?.AbsolutePath?.EndsWith("/api/auth/refresh", StringComparison.OrdinalIgnoreCase) == true) + var recovered = await RefreshSingleFlightAsync( + existing.Token, request.RequestUri!, proactive: false, cancellationToken); + if (recovered is null || string.Equals(recovered.Token, existing.Token, StringComparison.Ordinal)) return response; - var existing = _tokens.Load(_profile); - if (existing is null - || !SessionContext.HasSameServerOrigin(existing.Server, request.RequestUri?.AbsoluteUri)) + response.Dispose(); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", recovered.Token); + return await base.SendAsync(request, cancellationToken); + } + + private StoredSession? LoadForRequest(HttpRequestMessage request) + { + var session = _tokens.Load(_profile); + return session is not null + && SessionContext.HasSameServerOrigin(session.Server, request.RequestUri?.AbsoluteUri) + ? session + : null; + } + + private bool IsExpired(StoredSession session) + => session.ExpiresAt <= _timeProvider.GetUtcNow(); + + private bool NeedsProactiveRefresh(StoredSession session) + { + var now = _timeProvider.GetUtcNow(); + return session.ExpiresAt - now <= ClientSessionSecurity.ProactiveRefreshLeadTime + && !WasRecentlyRotated(session, now); + } + + private async Task RefreshSingleFlightAsync( + string observedToken, + Uri requestUri, + bool proactive, + CancellationToken cancellationToken) + { + await _refreshGate.WaitAsync(cancellationToken); + try { - // The store may have been changed after this client was created. Never use a - // freshly loaded token unless it is still bound to the request origin. - return response; + using var profileLock = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + _tokens.PathFor(_profile), requestUri.AbsoluteUri, cancellationToken); + var current = _tokens.Load(_profile); + if (current is null || !SessionContext.HasSameServerOrigin(current.Server, requestUri.AbsoluteUri)) + return null; + + // Another CLI/MCP process won the origin-bound refresh lease and persisted its + // rotation while this request was waiting. Reuse that generation. + if (!string.Equals(current.Token, observedToken, StringComparison.Ordinal)) + { + if (IsExpired(current)) + return ClearRejectedSession(current.Token, requestUri); + + MarkNearExpiryRotation(current); + return current; + } + if (IsExpired(current)) + { + _tokens.DeleteIfCurrent(_profile, current.Token); + return null; + } + + if (proactive) + { + if (WasRecentlyRotated(current, _timeProvider.GetUtcNow()) + || IsInTransientFailureCooldown(current.Token)) + { + return current; + } + } + + using var refreshMsg = new HttpRequestMessage( + HttpMethod.Post, new Uri(requestUri, "/api/auth/refresh")); + refreshMsg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", current.Token); + using var refreshRes = await base.SendAsync(refreshMsg, cancellationToken); + if (refreshRes.StatusCode == HttpStatusCode.Unauthorized) + return ClearRejectedSession(current.Token, requestUri); + if (!refreshRes.IsSuccessStatusCode) + { + if (proactive && IsTransientRefreshFailure(refreshRes.StatusCode)) + StartTransientFailureCooldown(current.Token); + return LoadUsableSession(requestUri); + } + + var rotated = await refreshRes.Content.ReadFromJsonAsync( + NodePilotApiClient.JsonOptions, cancellationToken); + if (rotated is null + || !ClientSessionSecurity.TryResolveExpiration( + rotated.Token, rotated.ExpiresAt, out var rotatedExpiresAt) + || rotatedExpiresAt <= _timeProvider.GetUtcNow()) + { + return ClearRejectedSession(current.Token, requestUri); + } + + var updated = new StoredSession + { + Server = current.Server, + Token = rotated.Token, + Username = rotated.Username, + UserId = rotated.UserId, + Role = rotated.Role, + ExpiresAt = rotatedExpiresAt, + }; + if (!_tokens.TrySaveIfCurrent(_profile, current.Token, updated)) + return LoadUsableSession(requestUri); + + _onTokenRefreshed?.Invoke(rotated.Token); + MarkNearExpiryRotation(updated); + return updated; } + finally + { + _refreshGate.Release(); + } + } - _refreshAttempted = true; - response.Dispose(); + private async Task RevalidateExpiredSessionAsync( + string observedToken, + Uri requestUri, + CancellationToken cancellationToken) + { + await _refreshGate.WaitAsync(cancellationToken); + try + { + using var profileLock = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + _tokens.PathFor(_profile), requestUri.AbsoluteUri, cancellationToken); + var current = _tokens.Load(_profile); + if (current is null + || !SessionContext.HasSameServerOrigin(current.Server, requestUri.AbsoluteUri)) + { + return null; + } - // Build a refresh request reusing the bearer header from the original request. - using var refreshMsg = new HttpRequestMessage(HttpMethod.Post, - new Uri(request.RequestUri!, "/api/auth/refresh")); - refreshMsg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", existing.Token); - using var refreshRes = await base.SendAsync(refreshMsg, cancellationToken); - if (!refreshRes.IsSuccessStatusCode) return await base.SendAsync(request, cancellationToken); + if (!IsExpired(current)) + { + if (!string.Equals(current.Token, observedToken, StringComparison.Ordinal)) + MarkNearExpiryRotation(current); + return current; + } - var rotated = await refreshRes.Content.ReadFromJsonAsync(NodePilotApiClient.JsonOptions, cancellationToken); - if (rotated is null) return await base.SendAsync(request, cancellationToken); + _tokens.DeleteIfCurrent(_profile, current.Token); + return LoadUsableSession(requestUri); + } + finally + { + _refreshGate.Release(); + } + } - var updated = new StoredSession + private StoredSession? ClearRejectedSession(string rejectedToken, Uri requestUri) + { + var latest = _tokens.Load(_profile); + if (latest is null + || !SessionContext.HasSameServerOrigin(latest.Server, requestUri.AbsoluteUri)) { - Server = existing.Server, - Token = rotated.Token, - Username = rotated.Username, - UserId = rotated.UserId, - Role = rotated.Role, - ExpiresAt = DateTime.UtcNow.AddHours(12), - }; - _tokens.Save(_profile, updated); - _onTokenRefreshed?.Invoke(rotated.Token); + return null; + } - // Swap bearer header on the original request and replay. - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rotated.Token); - return await base.SendAsync(request, cancellationToken); + if (!string.Equals(latest.Token, rejectedToken, StringComparison.Ordinal) + && !IsExpired(latest)) + { + MarkNearExpiryRotation(latest); + return latest; + } + + _tokens.DeleteIfCurrent(_profile, latest.Token); + return LoadUsableSession(requestUri); + } + + private StoredSession? LoadUsableSession(Uri requestUri) + { + var latest = _tokens.Load(_profile); + if (latest is null + || !SessionContext.HasSameServerOrigin(latest.Server, requestUri.AbsoluteUri)) + { + return null; + } + + if (!IsExpired(latest)) + return latest; + + _tokens.DeleteIfCurrent(_profile, latest.Token); + return null; } + + private void MarkNearExpiryRotation(StoredSession session) + { + var now = _timeProvider.GetUtcNow(); + if (session.ExpiresAt - now <= ClientSessionSecurity.ProactiveRefreshLeadTime) + { + Volatile.Write(ref _lastNearExpiryRotationAtUnixMs, now.ToUnixTimeMilliseconds()); + Volatile.Write(ref _lastNearExpiryRotation, session.Token); + } + } + + private bool WasRecentlyRotated(StoredSession session, DateTimeOffset now) + => ClientSessionSecurity.WasIssuedRecently( + session.Token, now, ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow) + || WasMarkedRecently(session.Token, now); + + private bool WasMarkedRecently(string token, DateTimeOffset now) + { + if (!string.Equals( + Volatile.Read(ref _lastNearExpiryRotation), token, StringComparison.Ordinal)) + { + return false; + } + + var ageMilliseconds = now.ToUnixTimeMilliseconds() + - Volatile.Read(ref _lastNearExpiryRotationAtUnixMs); + return ageMilliseconds >= -TimeSpan.FromMinutes(1).TotalMilliseconds + && ageMilliseconds + < ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow.TotalMilliseconds; + } + + private bool IsInTransientFailureCooldown(string token) + { + if (!string.Equals(_transientRefreshFailureToken, token, StringComparison.Ordinal)) + return false; + if (_timeProvider.GetUtcNow() < _transientRefreshRetryAfter) + return true; + + _transientRefreshFailureToken = null; + _transientRefreshRetryAfter = default; + return false; + } + + private void StartTransientFailureCooldown(string token) + { + _transientRefreshFailureToken = token; + _transientRefreshRetryAfter = + _timeProvider.GetUtcNow() + ClientSessionSecurity.TransientRefreshFailureCooldown; + } + + private static bool IsTransientRefreshFailure(HttpStatusCode statusCode) + => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests + || (int)statusCode >= 500; + + private static HttpResponseMessage ReauthenticationRequired(HttpRequestMessage request) + => new(HttpStatusCode.Unauthorized) + { + RequestMessage = request, + ReasonPhrase = "Authentication session expired", + }; } diff --git a/src/NodePilot.Cli/Auth/TokenStore.cs b/src/NodePilot.Cli/Auth/TokenStore.cs index 95c696f0..d0e4664d 100644 --- a/src/NodePilot.Cli/Auth/TokenStore.cs +++ b/src/NodePilot.Cli/Auth/TokenStore.cs @@ -31,10 +31,16 @@ public TokenStore(string baseDir) public StoredSession? Load(string profile) { var path = PathFor(profile); - if (!File.Exists(path)) return null; + using var mutation = NodePilot.Core.Clients.ClientSessionFileCoordinator.AcquireMutationLock(path); + return LoadPath(path); + } + + private static StoredSession? LoadPath(string path) + { try { - var encrypted = File.ReadAllBytes(path); + var encrypted = NodePilot.Core.Clients.ClientSessionFileCoordinator.ReadAllBytesIfExists(path); + if (encrypted is null) return null; var plain = ProtectedData.Unprotect(encrypted, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); return JsonSerializer.Deserialize(plain, JsonOptions); } @@ -51,16 +57,53 @@ public TokenStore(string baseDir) public void Save(string profile, StoredSession session) { - var plain = JsonSerializer.SerializeToUtf8Bytes(session, JsonOptions); - var encrypted = ProtectedData.Protect(plain, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); var path = PathFor(profile); - File.WriteAllBytes(path, encrypted); + using var mutation = NodePilot.Core.Clients.ClientSessionFileCoordinator.AcquireMutationLock(path); + Write(path, session); } public void Delete(string profile) { var path = PathFor(profile); - if (File.Exists(path)) File.Delete(path); + using var mutation = NodePilot.Core.Clients.ClientSessionFileCoordinator.AcquireMutationLock(path); + NodePilot.Core.Clients.ClientSessionFileCoordinator.DeleteIfExists(path); + } + + /// + /// Persists a rotation only while the session generation that was presented to the API is + /// still current. This prevents a refresh response from resurrecting a concurrent logout or + /// overwriting a newer login performed while the HTTP request was in flight. + /// + internal bool TrySaveIfCurrent(string profile, string expectedToken, StoredSession session) + { + var path = PathFor(profile); + using var mutation = NodePilot.Core.Clients.ClientSessionFileCoordinator.AcquireMutationLock(path); + var current = LoadPath(path); + if (current is null || !string.Equals(current.Token, expectedToken, StringComparison.Ordinal)) + return false; + + Write(path, session); + return true; + } + + internal bool DeleteIfCurrent(string profile, string expectedToken) + { + var path = PathFor(profile); + using var mutation = NodePilot.Core.Clients.ClientSessionFileCoordinator.AcquireMutationLock(path); + var current = LoadPath(path); + if (current is null || !string.Equals(current.Token, expectedToken, StringComparison.Ordinal)) + return false; + + NodePilot.Core.Clients.ClientSessionFileCoordinator.DeleteIfExists(path); + return true; + } + + private static void Write(string path, StoredSession session) + { + var plain = JsonSerializer.SerializeToUtf8Bytes(session, JsonOptions); + var encrypted = ProtectedData.Protect( + plain, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); + NodePilot.Core.Clients.ClientSessionFileCoordinator.WriteAllBytesAtomically(path, encrypted); } // Constant entropy distinguishes our blob from anything else the same user has DPAPI-encrypted, @@ -84,5 +127,5 @@ public sealed class StoredSession public string Username { get; set; } = ""; public Guid UserId { get; set; } public string Role { get; set; } = ""; - public DateTime ExpiresAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } } diff --git a/src/NodePilot.Cli/CommandRegistration.cs b/src/NodePilot.Cli/CommandRegistration.cs index 47f1eaaf..99800ffb 100644 --- a/src/NodePilot.Cli/CommandRegistration.cs +++ b/src/NodePilot.Cli/CommandRegistration.cs @@ -250,7 +250,7 @@ public static void Register(IConfigurator config) config.AddBranch("secrets", s => { s.SetDescription("Operate on the secret-protector layer (Admin only)."); - s.AddCommand("reencrypt").WithDescription("Bulk re-encrypt every credential + secret-flagged global variable."); + s.AddCommand("reencrypt").WithDescription("Bulk re-encrypt credentials, secret globals, and workflow history."); }); // -- shared-folder (RBAC org tree) -------------------------------------- diff --git a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs index 041add5e..a0326c63 100644 --- a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs +++ b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs @@ -71,6 +71,13 @@ protected override async Task ExecuteAsync(CommandContext context, LoginSet { var api = _factory.CreateAnonymous(server, settings.AllowInsecureLoopback); var response = await api.LoginAsync(new LoginRequest(username, password), settings.SetupToken, ct); + if (!ClientSessionSecurity.TryResolveExpiration( + response.Token, response.ExpiresAt, out var expiresAt) + || expiresAt <= DateTimeOffset.UtcNow) + { + writer.Error("Login fehlgeschlagen: Serverantwort enthält keine gültige Token-Ablaufzeit."); + return ExitCodes.Error; + } // Persist server URL into the active profile so subsequent calls don't need --server. cfg.Profiles[profile] = new ProfileEntry { Server = server }; @@ -84,7 +91,7 @@ protected override async Task ExecuteAsync(CommandContext context, LoginSet Username = response.Username, UserId = response.UserId, Role = response.Role, - ExpiresAt = DateTime.UtcNow.AddHours(12), + ExpiresAt = expiresAt, }); writer.Success($"Eingeloggt als [bold]{response.Username}[/] ({response.Role}) → {server}"); diff --git a/src/NodePilot.Cli/Commands/Secrets/SecretsCommands.cs b/src/NodePilot.Cli/Commands/Secrets/SecretsCommands.cs index 662f03c3..bc530518 100644 --- a/src/NodePilot.Cli/Commands/Secrets/SecretsCommands.cs +++ b/src/NodePilot.Cli/Commands/Secrets/SecretsCommands.cs @@ -17,10 +17,10 @@ public sealed class SecretsReencryptSettings : GlobalSettings } /// -/// Triggers the bulk re-encrypt sweep after rotating the AES-GCM master key or -/// migrating between secret protectors. Admin-only on the server. Returns exit code 0 -/// on a clean sweep, 1 when the server reported partial success (some rows could -/// not be migrated — see the printed skip-detail list). +/// Triggers the credential, global-secret and workflow-history re-encrypt sweep after +/// rotating the AES-GCM master key or migrating between secret protectors. Admin-only +/// on the server. Returns exit code 0 on a clean sweep, 1 when the server reported +/// partial success (some rows could not be migrated — see the printed skip-detail list). /// [SupportedOSPlatform("windows")] public sealed class SecretsReencryptCommand : BaseCommand @@ -32,7 +32,7 @@ protected override async Task RunAsync(CommandContext _, SecretsReencryptSe if (!settings.Yes && !Console.IsInputRedirected) { var ok = await AnsiConsole.ConfirmAsync( - "Re-encrypt sweep über alle Credentials + Global-Secrets ausführen?\n " + + "Re-encrypt sweep über alle Credentials, Global-Secrets + Workflow-History ausführen?\n " + "[grey](Empfohlen nur direkt nach AES-GCM-Key-Rotation oder Provider-Migration.)[/]", defaultValue: false); if (!ok) { writer.Info("Abgebrochen."); return ExitCodes.Success; } @@ -50,6 +50,9 @@ protected override async Task RunAsync(CommandContext _, SecretsReencryptSe grid.AddRow("Global Secrets Rewritten", value.GlobalSecretsRewritten.ToString()); grid.AddRow("Global Secrets Skipped", value.GlobalSecretsSkipped == 0 ? "0" : $"[yellow]{value.GlobalSecretsSkipped}[/]"); + grid.AddRow("Workflow Versions Rewritten", value.WorkflowVersionsRewritten.ToString()); + grid.AddRow("Workflow Versions Skipped", + value.WorkflowVersionsSkipped == 0 ? "0" : $"[yellow]{value.WorkflowVersionsSkipped}[/]"); grid.AddRow("Status", value.PartialSuccess ? "[yellow]partial — some rows need manual re-entry[/]" : "[green]clean[/]"); @@ -73,6 +76,15 @@ protected override async Task RunAsync(CommandContext _, SecretsReencryptSe t.AddRow(s.Id.ToString()[..8], Markup.Escape(s.Name), Markup.Escape(s.Reason)); console.Write(t); } + if (value.WorkflowVersionSkipDetails.Count > 0) + { + console.WriteLine(); + var t = new Table().Title("Workflow-version skips").Border(TableBorder.Rounded) + .AddColumn("Id").AddColumn("Name").AddColumn("Reason"); + foreach (var s in value.WorkflowVersionSkipDetails) + t.AddRow(s.Id.ToString()[..8], Markup.Escape(s.Name), Markup.Escape(s.Reason)); + console.Write(t); + } }); return result.PartialSuccess ? ExitCodes.Error : ExitCodes.Success; diff --git a/src/NodePilot.Core/Clients/ClientSessionFileCoordinator.cs b/src/NodePilot.Core/Clients/ClientSessionFileCoordinator.cs new file mode 100644 index 00000000..397e7ad7 --- /dev/null +++ b/src/NodePilot.Core/Clients/ClientSessionFileCoordinator.cs @@ -0,0 +1,215 @@ +using System.Security.Cryptography; +using System.Text; + +namespace NodePilot.Core.Clients; + +/// +/// Coordinates the DPAPI session file shared by the CLI and MCP processes. Refresh uses an +/// origin-bound cross-process lock because the API rotates a bearer token exactly once; file +/// mutations use a shorter path-bound lock and same-directory atomic replacement so readers +/// observe either the old complete blob or the new complete blob, never a truncated generation. +/// Lock files intentionally remain on disk. Ownership is the open with +/// ; Windows releases that handle automatically when a process exits. +/// +public static class ClientSessionFileCoordinator +{ + private const int RetryDelayMilliseconds = 15; + private const int IoRetryCount = 100; + + /// + /// Acquires the refresh lease shared by every process using the same canonical session file + /// and server origin. Waiting is cancellable and does not have the thread-affinity problem of + /// holding a named across asynchronous HTTP work. + /// + public static Task AcquireRefreshLockAsync( + string sessionPath, + string server, + CancellationToken cancellationToken) + => AcquireAsync(RefreshLockPath(sessionPath, server), cancellationToken); + + /// Serializes short Save/Delete mutations across CLI and MCP processes. + public static IDisposable AcquireMutationLock( + string sessionPath, + CancellationToken cancellationToken = default) + => Acquire(MutationLockPath(sessionPath), cancellationToken); + + /// Reads a complete generation, retrying transient Windows sharing violations. + public static byte[]? ReadAllBytesIfExists(string path) + { + var canonicalPath = CanonicalPath(path); + for (var attempt = 0; ; attempt++) + { + try + { + return File.Exists(canonicalPath) ? File.ReadAllBytes(canonicalPath) : null; + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (IOException) when (attempt < IoRetryCount) + { + Thread.Sleep(RetryDelayMilliseconds); + } + } + } + + /// + /// Writes to a unique file beside the destination, flushes it, then atomically replaces or + /// moves it on the same volume. The caller should hold . + /// + public static void WriteAllBytesAtomically(string path, ReadOnlySpan contents) + { + var canonicalPath = CanonicalPath(path); + var directory = Path.GetDirectoryName(canonicalPath) + ?? throw new InvalidOperationException("Session file must have a parent directory."); + Directory.CreateDirectory(directory); + var tempPath = Path.Combine( + directory, + $".{Path.GetFileName(canonicalPath)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + + try + { + using (var temp = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough)) + { + temp.Write(contents); + temp.Flush(flushToDisk: true); + } + + for (var attempt = 0; ; attempt++) + { + try + { + if (File.Exists(canonicalPath)) + File.Replace(tempPath, canonicalPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + else + File.Move(tempPath, canonicalPath); + break; + } + catch (IOException) when (attempt < IoRetryCount) + { + // A reader may hold the previous generation without FileShare.Delete for a + // few microseconds, or the destination may have appeared/disappeared between + // Exists and Move/Replace. Retry the same complete same-volume temp file. + Thread.Sleep(RetryDelayMilliseconds); + } + } + } + finally + { + try + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } + catch (IOException) + { + // The destination was never exposed partially. A crash-style orphaned temp is + // harmless and its unique name prevents it from blocking a later session save. + } + } + } + + /// Deletes the complete session generation. Caller holds the mutation lock. + public static void DeleteIfExists(string path) + { + var canonicalPath = CanonicalPath(path); + for (var attempt = 0; ; attempt++) + { + try + { + if (File.Exists(canonicalPath)) File.Delete(canonicalPath); + return; + } + catch (IOException) when (attempt < IoRetryCount) + { + Thread.Sleep(RetryDelayMilliseconds); + } + } + } + + private static async Task AcquireAsync(string lockPath, CancellationToken ct) + { + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + return OpenLockFile(lockPath); + } + catch (IOException) + { + await Task.Delay(RetryDelayMilliseconds, ct).ConfigureAwait(false); + } + } + } + + private static IDisposable Acquire(string lockPath, CancellationToken ct) + { + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + return OpenLockFile(lockPath); + } + catch (IOException) + { + if (ct.WaitHandle.WaitOne(RetryDelayMilliseconds)) + ct.ThrowIfCancellationRequested(); + } + } + } + + private static FileStream OpenLockFile(string lockPath) + { + var directory = Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException("Session lock file must have a parent directory."); + Directory.CreateDirectory(directory); + return new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + } + + private static string RefreshLockPath(string sessionPath, string server) + { + var canonicalPath = CanonicalPath(sessionPath); + var originHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(CanonicalOrigin(server)))[..12]); + return $"{canonicalPath}.{originHash}.refresh.lock"; + } + + private static string MutationLockPath(string sessionPath) + => $"{CanonicalPath(sessionPath)}.mutation.lock"; + + private static string CanonicalPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return Path.GetFullPath(path); + } + + private static string CanonicalOrigin(string server) + { + ArgumentException.ThrowIfNullOrWhiteSpace(server); + if (!Uri.TryCreate(server, UriKind.Absolute, out var uri) + || string.IsNullOrWhiteSpace(uri.IdnHost)) + { + throw new ArgumentException("Server must be an absolute URI.", nameof(server)); + } + + return $"{uri.Scheme.ToLowerInvariant()}://{uri.IdnHost.ToLowerInvariant()}:{uri.Port}"; + } +} diff --git a/src/NodePilot.Core/Clients/ClientSessionSecurity.cs b/src/NodePilot.Core/Clients/ClientSessionSecurity.cs index 0a7f0bb4..8d3e75d7 100644 --- a/src/NodePilot.Core/Clients/ClientSessionSecurity.cs +++ b/src/NodePilot.Core/Clients/ClientSessionSecurity.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace NodePilot.Core.Clients; /// @@ -18,4 +20,124 @@ public static class ClientSessionSecurity /// blob is now shared by two executables. /// public const string DpapiSessionEntropy = "NodePilot.Cli/v1"; + + /// + /// Bearer clients rotate a still-valid token shortly before the server-side absolute + /// session deadline. Refresh never extends that deadline; the lead time only guarantees + /// that rotation is attempted while the presented JWT can still authenticate the refresh + /// endpoint. + /// + public static readonly TimeSpan ProactiveRefreshLeadTime = TimeSpan.FromMinutes(5); + + /// + /// A transient proactive-refresh failure must not turn a burst of already queued client + /// calls into an equivalent burst against the refresh endpoint. The cooldown is deliberately + /// short and token-bound: normal API calls continue with the still-valid credential, then a + /// later request retries rotation. + /// + public static readonly TimeSpan TransientRefreshFailureCooldown = TimeSpan.FromSeconds(15); + + /// + /// Suppresses repeated proactive rotations of the same freshly minted JWT across short-lived + /// CLI processes. One minute still leaves multiple retry opportunities inside the five-minute + /// lead window, while keeping a command burst well below the server's refresh rate limit. + /// + public static readonly TimeSpan SuccessfulRefreshDeduplicationWindow = TimeSpan.FromMinutes(1); + + /// + /// Resolves the absolute session deadline advertised by a new server, or (for rolling upgrades + /// against an older server) from the returned JWT's signed-on-use exp claim. This method + /// only parses the payload; it never treats JWT claims as authorization or origin evidence. + /// The API validates the token when it is subsequently used. + /// + public static bool TryResolveExpiration( + string jwt, + DateTimeOffset? advertisedExpiration, + out DateTimeOffset expiration) + { + if (advertisedExpiration.HasValue) + { + expiration = advertisedExpiration.Value; + return true; + } + + return TryReadUnixTimestamp(jwt, "exp", milliseconds: false, out expiration); + } + + /// + /// Returns true when the current token generation was minted within . + /// Used only to deduplicate proactive refresh attempts between CLI/MCP processes. A small future + /// tolerance accommodates host clock skew; a far-future or malformed claim is ignored. + /// + public static bool WasIssuedRecently( + string jwt, + DateTimeOffset now, + TimeSpan window) + { + if (!TryReadUnixTimestamp(jwt, "np_iat_ms", milliseconds: true, out var issuedAt) + && !TryReadUnixTimestamp(jwt, "iat", milliseconds: false, out issuedAt)) + { + return false; + } + + var age = now - issuedAt; + return age >= TimeSpan.FromMinutes(-1) && age < window; + } + + private static bool TryReadUnixTimestamp( + string jwt, + string claim, + bool milliseconds, + out DateTimeOffset timestamp) + { + timestamp = default; + if (string.IsNullOrWhiteSpace(jwt)) return false; + + var firstDot = jwt.IndexOf('.'); + if (firstDot < 0) return false; + var secondDot = jwt.IndexOf('.', firstDot + 1); + if (secondDot < 0) return false; + var encodedPayload = jwt[(firstDot + 1)..secondDot]; + if (encodedPayload.Length == 0) return false; + + try + { + var base64 = encodedPayload.Replace('-', '+').Replace('_', '/'); + base64 = (base64.Length % 4) switch + { + 0 => base64, + 2 => base64 + "==", + 3 => base64 + "=", + _ => throw new FormatException("Invalid base64url payload length."), + }; + + using var payload = JsonDocument.Parse(Convert.FromBase64String(base64)); + if (!payload.RootElement.TryGetProperty(claim, out var value)) return false; + long unixValue; + if (value.ValueKind == JsonValueKind.Number) + { + if (!value.TryGetInt64(out unixValue)) return false; + } + else if (value.ValueKind == JsonValueKind.String) + { + if (!long.TryParse(value.GetString(), out unixValue)) return false; + } + else + { + return false; + } + + timestamp = milliseconds + ? DateTimeOffset.FromUnixTimeMilliseconds(unixValue) + : DateTimeOffset.FromUnixTimeSeconds(unixValue); + return true; + } + catch (Exception ex) when (ex is FormatException + or JsonException + or ArgumentOutOfRangeException + or OverflowException) + { + return false; + } + } } diff --git a/src/NodePilot.Core/Interfaces/ISqlKnowledgeReader.cs b/src/NodePilot.Core/Interfaces/ISqlKnowledgeReader.cs index c28215ec..c15d6e90 100644 --- a/src/NodePilot.Core/Interfaces/ISqlKnowledgeReader.cs +++ b/src/NodePilot.Core/Interfaces/ISqlKnowledgeReader.cs @@ -9,20 +9,22 @@ namespace NodePilot.Core.Interfaces; /// scoped — exactly the pattern. /// /// Redaction is the contract: schema tools omit hidden secret columns -/// (PasswordHash, EncryptedPassword, byte[] blobs); query results redact any column -/// whose name matches a hidden/masked column in the schema to "***" and run every cell through -/// the audit details redactor. Only string? leaves the reader — never raw object? — so -/// the model never sees an unredacted value. Restricted to Admin/Operator at the tool layer. +/// (PasswordHash, EncryptedPassword, byte[] blobs) and entirely omit the four tables +/// holding opaque Workflow Definitions or custom-activity implementations. Any SQL reference to +/// those tables is refused; callers use dedicated, RBAC-aware tools instead. Result columns are +/// masked by name as defence in depth and every other cell passes through the audit details +/// redactor. Only string? leaves the reader — never raw object?. Restricted to global +/// Admins at the tool layer. /// public interface ISqlKnowledgeReader { /// Active SQL dialect token (postgres, sqlserver, ...). string Provider { get; } - /// All tracked tables, with their non-hidden columns named. Secret columns are omitted. + /// All tracked tables with the columns safe for generic AI discovery. Task> ListTablesAsync(CancellationToken ct); - /// One table's non-hidden columns with type/nullable/PK, or null if unknown. Secret columns omitted. + /// One table's AI-safe columns with type/nullable/PK, or null if unknown. Task GetTableAsync(string name, CancellationToken ct); /// Runs a single read-only SQL statement and returns redacted columns + rows. Never throws for diff --git a/src/NodePilot.Core/Models/WorkflowVersion.cs b/src/NodePilot.Core/Models/WorkflowVersion.cs index 7d4f606a..98043e80 100644 --- a/src/NodePilot.Core/Models/WorkflowVersion.cs +++ b/src/NodePilot.Core/Models/WorkflowVersion.cs @@ -7,8 +7,9 @@ namespace NodePilot.Core.Models; /// Enables rollback + diff + blame ("who changed step X from A to B, and when?"). /// /// -/// The table is append-only; there is no update path. Rows are removed only when the -/// parent is deleted (FK cascade). Rollback does not purge history +/// The history is semantically append-only; the only in-place update re-wraps the opaque +/// definition envelope during an explicit secret-provider migration. Rows are removed when the +/// parent is deleted (FK cascade) or by configured retention. Rollback does not purge history /// — restoring a prior version increments the Workflow's Version counter and /// emits a fresh snapshot so the roll-forward remains auditable. /// @@ -27,6 +28,12 @@ public class WorkflowVersion public string Name { get; set; } = string.Empty; public string? Description { get; set; } + /// + /// At rest this contains a versioned authenticated-ciphertext envelope. Authorised history and + /// rollback paths decrypt it before parsing. Legacy plaintext JSON remains readable during a + /// rolling upgrade and is converted only by the explicit post-upgrade + /// secrets reencrypt cutover, after every HA node supports this envelope. + /// public string DefinitionJson { get; set; } = "{}"; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs b/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs new file mode 100644 index 00000000..daf7439d --- /dev/null +++ b/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs @@ -0,0 +1,106 @@ +using NodePilot.Core.Models; + +namespace NodePilot.Core.Security; + +/// +/// Shared trust-boundary policy for generic SQL whose schema/results are sent to an external agent +/// (AI Knowledge or MCP). Browser DbAdmin is intentionally outside this policy: administrators may +/// inspect raw automation payloads there for forensics, while agent adapters expose those payloads +/// only through their dedicated, RBAC-aware tools. +/// +public static class ExternalAgentSqlPolicy +{ + public const string Mask = "***"; + + /// + /// Names the protected surface on purpose. The recipient is an LLM deciding what to try next: + /// a generic "protected data" refusal invites it to rephrase the same query, while naming the + /// workflow definition / custom activity implementation and pointing at the dedicated tool + /// routes it somewhere that actually works. + /// + public const string RejectionMessage = + "Query references a workflow definition or custom activity implementation. " + + "Generic SQL cannot expose those to an external agent — " + + "use the dedicated RBAC-aware API or tool for that data instead."; + + private static readonly ProtectedTable[] ProtectedTables = + [ + CreateTable("Workflows", nameof(Workflow.DefinitionJson)), + CreateTable("WorkflowVersions", nameof(WorkflowVersion.DefinitionJson)), + CreateTable( + "CustomActivityDefinitions", + nameof(CustomActivityDefinition.ScriptTemplate), + nameof(CustomActivityDefinition.InputParametersJson)), + CreateTable( + "CustomActivityDefinitionVersions", + nameof(CustomActivityDefinitionVersion.ScriptTemplate), + nameof(CustomActivityDefinitionVersion.InputParametersJson)), + ]; + + private static readonly HashSet AllProtectedTableIdentifiers = ProtectedTables + .SelectMany(table => table.Identifiers) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet AllProtectedColumnIdentifiers = ProtectedTables + .SelectMany(table => table.Columns) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// Built-in opaque automation tables; callers may add metadata-derived secret tables. + public static IReadOnlySet BuiltInProtectedTableIdentifiers => AllProtectedTableIdentifiers; + + /// + /// Generic agent SQL has no reliable provider-neutral way to prove projection lineage for these + /// opaque rows. The complete tables are therefore absent from schema discovery; dedicated tools + /// remain the only external-agent access path. + /// + public static bool IsSchemaTableVisible( + string entityOrTableName, + IReadOnlySet? additionalProtectedTableIdentifiers = null) + => !AllProtectedTableIdentifiers.Contains(entityOrTableName) + && (additionalProtectedTableIdentifiers is null + || !additionalProtectedTableIdentifiers.Contains(entityOrTableName)); + + /// Whether a column may appear in generic external-agent schema discovery. + public static bool IsSchemaColumnVisible( + string entityOrTableName, + string columnName, + IReadOnlySet? additionalProtectedTableIdentifiers = null) + => IsSchemaTableVisible(entityOrTableName, additionalProtectedTableIdentifiers) + && !AllProtectedColumnIdentifiers.Contains(columnName); + + /// + /// Rejects every mention of a protected table, rather than attempting a provider-neutral SQL + /// allow-list. Composite rows can flow through SELECT expressions, LATERAL sources, casts, + /// aggregates and extension functions; lexical projection analysis cannot prove those safe. + /// Protected column names and dynamic XML exporters are also rejected in case a view obscures + /// the source table. + /// + public static bool ReferencesProtectedProjection( + string sql, + IReadOnlySet? additionalProtectedTableIdentifiers = null) + { + return SqlStatementInspector.ContainsUnicodeEscapedIdentifier(sql) + || SqlStatementInspector.FindDynamicDataExporter(sql) is not null + || SqlStatementInspector.ReferencesAnyIdentifier(sql, AllProtectedTableIdentifiers) + || (additionalProtectedTableIdentifiers is not null + && SqlStatementInspector.ReferencesAnyIdentifier(sql, additionalProtectedTableIdentifiers)) + || SqlStatementInspector.ReferencesAnyIdentifier(sql, AllProtectedColumnIdentifiers); + } + + /// + /// Result-name defence in depth for views/provider projections whose submitted SQL hides source + /// lineage. A matching column is masked even when it belongs to an unrelated table; ambiguity at + /// this point must resolve toward non-disclosure. + /// + public static bool IsProtectedResultColumn(string columnName) + => AllProtectedColumnIdentifiers.Contains(columnName); + + private static ProtectedTable CreateTable(string dbTableName, params string[] columns) + => new( + new HashSet([typeof(T).Name, dbTableName], StringComparer.OrdinalIgnoreCase), + new HashSet(columns, StringComparer.OrdinalIgnoreCase)); + + private sealed record ProtectedTable( + IReadOnlySet Identifiers, + IReadOnlySet Columns); +} diff --git a/src/NodePilot.Core/Security/SqlStatementInspector.cs b/src/NodePilot.Core/Security/SqlStatementInspector.cs new file mode 100644 index 00000000..76e1a47e --- /dev/null +++ b/src/NodePilot.Core/Security/SqlStatementInspector.cs @@ -0,0 +1,236 @@ +using System.Text; + +namespace NodePilot.Core.Security; + +/// +/// Provider-neutral SQL identifier lexer shared by the API and external-agent adapters. It is not a +/// validating SQL parser; it deliberately exposes only conservative security predicates while +/// consistently ignoring comments and string literals and retaining quoted identifiers. +/// +public static class SqlStatementInspector +{ + private const string UnicodeEscapedIdentifierMarker = ""; + + private static readonly HashSet UnicodeEscapedIdentifierMarkers = + new([UnicodeEscapedIdentifierMarker], StringComparer.Ordinal); + + private static readonly HashSet DynamicDataExporters = new(StringComparer.OrdinalIgnoreCase) + { + // PostgreSQL functions that accept a table/query/schema/database name (often in a string) + // and return its data or schema. Source lineage cannot be established outside that string, + // so these are never safe in generic read-SQL surfaces. + "query_to_xml", "table_to_xml", "cursor_to_xml", "schema_to_xml", "database_to_xml", + "query_to_xmlschema", "table_to_xmlschema", "schema_to_xmlschema", "database_to_xmlschema", + "query_to_xml_and_xmlschema", "table_to_xml_and_xmlschema", + "schema_to_xml_and_xmlschema", "database_to_xml_and_xmlschema", + }; + + public static string? FindFirstIdentifier( + string sql, + IReadOnlySet identifiers, + bool includeQuoted = true) + { + foreach (var token in Tokenize(sql)) + { + if ((includeQuoted || !token.Quoted) && identifiers.Contains(token.Value)) + return token.Value; + } + + return null; + } + + public static bool ReferencesAnyIdentifier(string sql, IReadOnlySet identifiers) + => FindFirstIdentifier(sql, identifiers) is not null; + + public static string? FindDynamicDataExporter(string sql) + => FindFirstIdentifier(sql, DynamicDataExporters); + + /// + /// PostgreSQL's U&"..." identifier form can encode every protected name. Agent-facing + /// lexical policies reject the form rather than trying to duplicate provider unescaping rules. + /// + public static bool ContainsUnicodeEscapedIdentifier(string sql) + => ReferencesAnyIdentifier(sql, UnicodeEscapedIdentifierMarkers); + + /// + /// True when follows as consecutive + /// unquoted identifier tokens. Punctuation and comments are ignored, matching normal SQL + /// keyword-pair parsing; a quoted identifier breaks the chain. + /// + public static bool ReferencesIdentifierPair(string sql, string first, string second) + { + string? previous = null; + foreach (var token in Tokenize(sql)) + { + if (previous is not null + && !token.Quoted + && previous.Equals(first, StringComparison.OrdinalIgnoreCase) + && token.Value.Equals(second, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + previous = token.Quoted ? null : token.Value; + } + + return false; + } + + private static IEnumerable Tokenize(string sql) + { + for (var i = 0; i < sql.Length;) + { + if (char.IsWhiteSpace(sql[i]) || sql[i] is ',' or '(' or ')' or '.' or ';' or '*') + { + i++; + continue; + } + + if (i + 1 < sql.Length && sql[i] == '-' && sql[i + 1] == '-') + { + i += 2; + while (i < sql.Length && sql[i] != '\n') i++; + continue; + } + + if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*') + { + i += 2; + var depth = 1; + while (i < sql.Length && depth > 0) + { + if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*') + { + depth++; + i += 2; + } + else if (i + 1 < sql.Length && sql[i] == '*' && sql[i + 1] == '/') + { + depth--; + i += 2; + } + else i++; + } + continue; + } + + if (sql[i] == '\'') + { + SkipQuotedLiteral(sql, ref i, '\''); + continue; + } + + if (sql[i] == ':' && i + 1 < sql.Length && sql[i + 1] == ':') + { + i += 2; + yield return new SqlIdentifier("::", Quoted: false); + continue; + } + + if (sql[i] == '$' && TryReadDollarQuoteTag(sql, i, out var tag)) + { + i += tag.Length; + var end = sql.IndexOf(tag, i, StringComparison.Ordinal); + i = end < 0 ? sql.Length : end + tag.Length; + continue; + } + + if ((sql[i] == 'U' || sql[i] == 'u') + && i + 2 < sql.Length + && sql[i + 1] == '&' + && sql[i + 2] == '"') + { + i += 3; + _ = ReadEscapedIdentifier(sql, ref i, '"'); + yield return new SqlIdentifier(UnicodeEscapedIdentifierMarker, Quoted: false); + continue; + } + + if (sql[i] is '"' or '`') + { + var quote = sql[i++]; + var value = ReadEscapedIdentifier(sql, ref i, quote); + if (value.Length > 0) yield return new SqlIdentifier(value, Quoted: true); + continue; + } + + if (sql[i] == '[') + { + i++; + var value = ReadBracketIdentifier(sql, ref i); + if (value.Length > 0) yield return new SqlIdentifier(value, Quoted: true); + continue; + } + + if (char.IsLetter(sql[i]) || sql[i] is '_' or '#') + { + var start = i++; + while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] is '_' or '$' or '#')) i++; + yield return new SqlIdentifier(sql[start..i], Quoted: false); + continue; + } + + i++; + } + } + + private static void SkipQuotedLiteral(string sql, ref int i, char quote) + { + i++; + while (i < sql.Length) + { + if (sql[i] != quote) { i++; continue; } + if (i + 1 < sql.Length && sql[i + 1] == quote) { i += 2; continue; } + i++; + return; + } + } + + private static string ReadEscapedIdentifier(string sql, ref int i, char quote) + { + var value = new StringBuilder(); + while (i < sql.Length) + { + if (sql[i] != quote) { value.Append(sql[i++]); continue; } + if (i + 1 < sql.Length && sql[i + 1] == quote) + { + value.Append(quote); + i += 2; + continue; + } + i++; + break; + } + return value.ToString(); + } + + private static string ReadBracketIdentifier(string sql, ref int i) + { + var value = new StringBuilder(); + while (i < sql.Length) + { + if (sql[i] != ']') { value.Append(sql[i++]); continue; } + if (i + 1 < sql.Length && sql[i + 1] == ']') + { + value.Append(']'); + i += 2; + continue; + } + i++; + break; + } + return value.ToString(); + } + + private static bool TryReadDollarQuoteTag(string sql, int start, out string tag) + { + tag = string.Empty; + var end = start + 1; + while (end < sql.Length && (char.IsLetterOrDigit(sql[end]) || sql[end] == '_')) end++; + if (end >= sql.Length || sql[end] != '$') return false; + tag = sql[start..(end + 1)]; + return true; + } + + private readonly record struct SqlIdentifier(string Value, bool Quoted); +} diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowSecretRedactor.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowSecretRedactor.cs index cf3616f8..9d07aafe 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowSecretRedactor.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowSecretRedactor.cs @@ -14,20 +14,64 @@ namespace NodePilot.Core.WorkflowDefinitions; /// like an inline secret (: a restApi headers string, body, or /// script). A masked value is replaced whole with "***", so the redact→edit /// round-trip stays intact via the merge layers' universal "***"-restore. +/// +/// Free-form payloads are masked as complete values. A small global set covers unambiguously +/// opaque fields such as scripts and HTTP bodies; an activity-aware policy covers executable +/// arguments, queries, URLs, prompts, trigger defaults and similar fields without hiding unrelated +/// metadata that happens to use the same property name. Literal operands in edge conditions are +/// opaque as well. Their grammars are open-ended, so an unmatched literal cannot be classified as +/// safe by a heuristic detector. +/// /// public static class WorkflowSecretRedactor { private const string Mask = "***"; + private static readonly IReadOnlySet OpaqueDefinitionKeys = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "script", "body", "headers", "scorchRaw", "content", + }; + + private static readonly IReadOnlyDictionary> OpaqueActivityConfigKeys = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["startProgram"] = Keys("arguments"), + ["scheduledTask"] = Keys("arguments"), + ["wmiQuery"] = Keys("arguments", "query", "filter"), + ["sql"] = Keys("query", "parameters"), + ["databaseTrigger"] = Keys("query", "parameters"), + ["restApi"] = Keys("url", "proxyAddress"), + ["waitForCondition"] = Keys("url"), + ["emailNotification"] = Keys("subject"), + ["log"] = Keys("message"), + ["jsonQuery"] = Keys("jsonPath"), + ["xmlQuery"] = Keys("xpath"), + ["textFileEdit"] = Keys("replace", "matchPattern"), + ["startWorkflow"] = Keys("parameters"), + ["forEach"] = Keys("items", "parameters"), + ["returnData"] = Keys("data"), + ["registryOperation"] = Keys("value"), + ["llmQuery"] = Keys("prompt", "systemPrompt", "baseUrl"), + ["serviceManagement"] = Keys("binaryPath"), + ["manualTrigger"] = Keys("parameters"), + ["powerManagement"] = Keys("message"), + ["eventLogTrigger"] = Keys("messagePattern"), + }; + /// Returns a redacted copy of with secret config values masked to "***". public static JsonNode Redact(JsonElement root) { var node = JsonNode.Parse(root.GetRawText()) ?? throw new InvalidOperationException("Workflow definition is not valid JSON."); - return Walk(node, parentName: null, isHttpHeaderValue: false); + return Walk(node, parentName: null, isHttpHeaderValue: false, activityType: null); } - private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeaderValue) + private static JsonNode Walk( + JsonNode node, + string? parentName, + bool isHttpHeaderValue, + string? activityType) { switch (node) { @@ -35,15 +79,30 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade { var result = new JsonObject(); var isHeadersObject = string.Equals(parentName, "headers", StringComparison.OrdinalIgnoreCase); + // Only a direct item of the top-level nodes array may establish activity context. + // Nested payloads are user-controlled and may legitimately contain an unrelated + // property named activityType; allowing that value to override the inherited node + // type would bypass the activity-aware policy for the remaining config fields. + var localActivityType = string.Equals(parentName, "nodes", StringComparison.Ordinal) + ? TryGetNodeActivityType(obj) ?? activityType + : activityType; + var isLiteralOperand = string.Equals( + TryGetString(obj, "kind"), "literal", StringComparison.OrdinalIgnoreCase); foreach (var (name, value) in obj) - result[name] = value is null ? null : Walk(value, name, isHeadersObject); + result[name] = value is null + ? null + : OpaqueDefinitionKeys.Contains(name) + || IsOpaqueActivityConfigValue(parentName, localActivityType, name, value) + || (isLiteralOperand && string.Equals(name, "value", StringComparison.OrdinalIgnoreCase)) + ? JsonValue.Create(Mask) + : Walk(value, name, isHeadersObject, localActivityType); return result; } case JsonArray arr: { var result = new JsonArray(); foreach (var item in arr) - result.Add(item is null ? null : Walk(item, parentName, isHttpHeaderValue)); + result.Add(item is null ? null : Walk(item, parentName, isHttpHeaderValue, activityType)); return result; } case JsonValue val when val.TryGetValue(out string? s) && s is not null: @@ -55,4 +114,54 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade return node.DeepClone(); } } + + private static bool IsOpaqueActivityConfigValue( + string? parentName, + string? activityType, + string key, + JsonNode value) + { + if (!string.Equals(parentName, "config", StringComparison.OrdinalIgnoreCase) + || activityType is null) return false; + + if (OpaqueActivityConfigKeys.TryGetValue(activityType, out var keys) && keys.Contains(key)) + return true; + + // Custom-activity string/multiline/select inputs have definition-specific names and are + // injected verbatim into the PowerShell runspace. Preserve only the two structural + // identity fields; arbitrary string inputs are opaque even when their key looks harmless. + return NodePilot.Core.Activities.CustomActivityType.IsCustomType(activityType) + && key is not "__customDefinitionId" and not "__customKey" + && value is JsonValue jsonValue + && jsonValue.TryGetValue(out string? _); + } + + private static string? TryGetString(JsonObject obj, string name) + => obj.TryGetPropertyValue(name, out var value) + && value is JsonValue jsonValue + && jsonValue.TryGetValue(out string? text) + ? text + : null; + + private static string? TryGetNodeActivityType(JsonObject obj) + { + if (!obj.TryGetPropertyValue("data", out var dataNode) || dataNode is not JsonObject data) + return null; + + var explicitActivityType = TryGetString(data, "activityType"); + if (!string.IsNullOrWhiteSpace(explicitActivityType)) return explicitActivityType; + + // Valid workflow definitions may use a concrete node.type instead of + // data.activityType. Restrict the fallback to types for which this redactor has an + // activity policy; generic "activity" and annotation node types then remain inert. + var concreteNodeType = TryGetString(obj, "type"); + return concreteNodeType is not null + && (OpaqueActivityConfigKeys.ContainsKey(concreteNodeType) + || NodePilot.Core.Activities.CustomActivityType.IsCustomType(concreteNodeType)) + ? concreteNodeType + : null; + } + + private static IReadOnlySet Keys(params string[] values) + => new HashSet(values, StringComparer.OrdinalIgnoreCase); } diff --git a/src/NodePilot.Mcp/Api/Dtos/Dtos.cs b/src/NodePilot.Mcp/Api/Dtos/Dtos.cs index 59ccd395..ef64ad5f 100644 --- a/src/NodePilot.Mcp/Api/Dtos/Dtos.cs +++ b/src/NodePilot.Mcp/Api/Dtos/Dtos.cs @@ -7,7 +7,12 @@ namespace NodePilot.Mcp.Api.Dtos; // ---- Auth ---- public sealed record LoginRequest(string Username, string Password); -public sealed record LoginResponse(string Token, Guid UserId, string Username, string Role); +public sealed record LoginResponse( + string Token, + Guid UserId, + string Username, + string Role, + DateTimeOffset? ExpiresAt = null); public sealed record MeResponse(Guid Id, string Username, string Role); // ---- Workflows ---- diff --git a/src/NodePilot.Mcp/Api/TokenRefreshHandler.cs b/src/NodePilot.Mcp/Api/TokenRefreshHandler.cs index 202d3869..8c89a579 100644 --- a/src/NodePilot.Mcp/Api/TokenRefreshHandler.cs +++ b/src/NodePilot.Mcp/Api/TokenRefreshHandler.cs @@ -5,70 +5,303 @@ using NodePilot.Mcp.Api.Dtos; using NodePilot.Mcp.Auth; using NodePilot.Mcp.Config; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Api; /// -/// DelegatingHandler that intercepts 401s, attempts a single POST /api/auth/refresh -/// with the current bearer, persists the rotated token back to and -/// replays the original request once. Copied/adapted from the CLI. Only wired up when the -/// token came from the DPAPI store (a raw env bearer is not refreshable). +/// Keeps a DPAPI-backed MCP bearer credential current. A still-valid token is rotated +/// shortly before its absolute expiry, concurrent tool calls share one refresh, and every +/// request reads the latest profile-bound token before it is sent. Raw environment bearers +/// bypass this handler entirely. /// [SupportedOSPlatform("windows")] public sealed class TokenRefreshHandler : DelegatingHandler { private readonly TokenStore _tokens; private readonly string _profile; - private bool _refreshAttempted; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private string? _lastNearExpiryRotation; + private long _lastNearExpiryRotationAtUnixMs; + private string? _transientRefreshFailureToken; + private DateTimeOffset _transientRefreshRetryAfter; - public TokenRefreshHandler(TokenStore tokens, string profile) + public TokenRefreshHandler(TokenStore tokens, string profile, TimeProvider? timeProvider = null) { _tokens = tokens; _profile = profile; + _timeProvider = timeProvider ?? TimeProvider.System; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + if (request.RequestUri?.AbsolutePath?.EndsWith("/api/auth/refresh", StringComparison.OrdinalIgnoreCase) == true) + return await base.SendAsync(request, cancellationToken); + + var existing = LoadForRequest(request); + if (existing is null) + return await base.SendAsync(request, cancellationToken); + + if (IsExpired(existing)) + { + existing = await RevalidateExpiredSessionAsync( + existing.Token, request.RequestUri!, cancellationToken); + if (existing is null) + return ReauthenticationRequired(request); + } + + if (NeedsProactiveRefresh(existing)) + { + existing = await RefreshSingleFlightAsync( + existing.Token, request.RequestUri!, proactive: true, cancellationToken); + if (existing is null) + return ReauthenticationRequired(request); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", existing.Token); var response = await base.SendAsync(request, cancellationToken); - if (response.StatusCode != HttpStatusCode.Unauthorized || _refreshAttempted) + if (response.StatusCode != HttpStatusCode.Unauthorized) return response; - if (request.RequestUri?.AbsolutePath?.EndsWith("/api/auth/refresh", StringComparison.OrdinalIgnoreCase) == true) + var recovered = await RefreshSingleFlightAsync( + existing.Token, request.RequestUri!, proactive: false, cancellationToken); + if (recovered is null || string.Equals(recovered.Token, existing.Token, StringComparison.Ordinal)) return response; - var existing = _tokens.Load(_profile); - if (existing is null - || !SessionContext.HasSameServerOrigin(existing.Server, request.RequestUri?.AbsoluteUri)) + response.Dispose(); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", recovered.Token); + return await base.SendAsync(request, cancellationToken); + } + + private StoredSession? LoadForRequest(HttpRequestMessage request) + { + var session = _tokens.Load(_profile); + return session is not null + && SessionContext.HasSameServerOrigin(session.Server, request.RequestUri?.AbsoluteUri) + ? session + : null; + } + + private bool IsExpired(StoredSession session) + => session.ExpiresAt <= _timeProvider.GetUtcNow(); + + private bool NeedsProactiveRefresh(StoredSession session) + { + var now = _timeProvider.GetUtcNow(); + return session.ExpiresAt - now <= ClientSessionSecurity.ProactiveRefreshLeadTime + && !WasRecentlyRotated(session, now); + } + + private async Task RefreshSingleFlightAsync( + string observedToken, + Uri requestUri, + bool proactive, + CancellationToken cancellationToken) + { + await _refreshGate.WaitAsync(cancellationToken); + try { - // The store may have been changed after this client was created. Never use a - // freshly loaded token unless it is still bound to the request origin. - return response; + using var profileLock = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + _tokens.PathFor(_profile), requestUri.AbsoluteUri, cancellationToken); + var current = _tokens.Load(_profile); + if (current is null || !SessionContext.HasSameServerOrigin(current.Server, requestUri.AbsoluteUri)) + return null; + + // A CLI or another MCP process already rotated the single-use token while this + // request waited for the shared profile lease. Always reuse the winner's generation. + if (!string.Equals(current.Token, observedToken, StringComparison.Ordinal)) + { + if (IsExpired(current)) + return ClearRejectedSession(current.Token, requestUri); + + MarkNearExpiryRotation(current); + return current; + } + if (IsExpired(current)) + { + _tokens.DeleteIfCurrent(_profile, current.Token); + return null; + } + if (proactive) + { + if (WasRecentlyRotated(current, _timeProvider.GetUtcNow()) + || IsInTransientFailureCooldown(current.Token)) + { + return current; + } + } + + using var refreshMsg = new HttpRequestMessage( + HttpMethod.Post, new Uri(requestUri, "/api/auth/refresh")); + refreshMsg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", current.Token); + using var refreshRes = await base.SendAsync(refreshMsg, cancellationToken); + if (refreshRes.StatusCode == HttpStatusCode.Unauthorized) + return ClearRejectedSession(current.Token, requestUri); + if (!refreshRes.IsSuccessStatusCode) + { + if (proactive && IsTransientRefreshFailure(refreshRes.StatusCode)) + StartTransientFailureCooldown(current.Token); + return LoadUsableSession(requestUri); + } + + var rotated = await refreshRes.Content.ReadFromJsonAsync( + NodePilotApiClient.JsonOptions, cancellationToken); + if (rotated is null + || !ClientSessionSecurity.TryResolveExpiration( + rotated.Token, rotated.ExpiresAt, out var rotatedExpiresAt) + || rotatedExpiresAt <= _timeProvider.GetUtcNow()) + { + return ClearRejectedSession(current.Token, requestUri); + } + + var updated = new StoredSession + { + Server = current.Server, + Token = rotated.Token, + Username = rotated.Username, + UserId = rotated.UserId, + Role = rotated.Role, + ExpiresAt = rotatedExpiresAt, + }; + if (!_tokens.TrySaveIfCurrent(_profile, current.Token, updated)) + return LoadUsableSession(requestUri); + + MarkNearExpiryRotation(updated); + return updated; } + finally + { + _refreshGate.Release(); + } + } - _refreshAttempted = true; - response.Dispose(); + private async Task RevalidateExpiredSessionAsync( + string observedToken, + Uri requestUri, + CancellationToken cancellationToken) + { + await _refreshGate.WaitAsync(cancellationToken); + try + { + using var profileLock = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + _tokens.PathFor(_profile), requestUri.AbsoluteUri, cancellationToken); + var current = _tokens.Load(_profile); + if (current is null + || !SessionContext.HasSameServerOrigin(current.Server, requestUri.AbsoluteUri)) + { + return null; + } - using var refreshMsg = new HttpRequestMessage(HttpMethod.Post, new Uri(request.RequestUri!, "/api/auth/refresh")); - refreshMsg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", existing.Token); - // The CLI login opts into a body token via this header; refresh honours it the same way. - refreshMsg.Headers.Add("X-Auth-Token-Response", "true"); - using var refreshRes = await base.SendAsync(refreshMsg, cancellationToken); - if (!refreshRes.IsSuccessStatusCode) return await base.SendAsync(request, cancellationToken); + if (!IsExpired(current)) + { + if (!string.Equals(current.Token, observedToken, StringComparison.Ordinal)) + MarkNearExpiryRotation(current); + return current; + } - var rotated = await refreshRes.Content.ReadFromJsonAsync(NodePilotApiClient.JsonOptions, cancellationToken); - if (rotated is null) return await base.SendAsync(request, cancellationToken); + _tokens.DeleteIfCurrent(_profile, current.Token); + return LoadUsableSession(requestUri); + } + finally + { + _refreshGate.Release(); + } + } - _tokens.Save(_profile, new StoredSession + private StoredSession? ClearRejectedSession(string rejectedToken, Uri requestUri) + { + var latest = _tokens.Load(_profile); + if (latest is null + || !SessionContext.HasSameServerOrigin(latest.Server, requestUri.AbsoluteUri)) { - Server = existing.Server, - Token = rotated.Token, - Username = rotated.Username, - UserId = rotated.UserId, - Role = rotated.Role, - ExpiresAt = DateTime.UtcNow.AddHours(12), - }); - - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rotated.Token); - return await base.SendAsync(request, cancellationToken); + return null; + } + + if (!string.Equals(latest.Token, rejectedToken, StringComparison.Ordinal) + && !IsExpired(latest)) + { + MarkNearExpiryRotation(latest); + return latest; + } + + _tokens.DeleteIfCurrent(_profile, latest.Token); + return LoadUsableSession(requestUri); } + + private StoredSession? LoadUsableSession(Uri requestUri) + { + var latest = _tokens.Load(_profile); + if (latest is null + || !SessionContext.HasSameServerOrigin(latest.Server, requestUri.AbsoluteUri)) + { + return null; + } + + if (!IsExpired(latest)) + return latest; + + _tokens.DeleteIfCurrent(_profile, latest.Token); + return null; + } + + private void MarkNearExpiryRotation(StoredSession session) + { + var now = _timeProvider.GetUtcNow(); + if (session.ExpiresAt - now <= ClientSessionSecurity.ProactiveRefreshLeadTime) + { + Volatile.Write(ref _lastNearExpiryRotationAtUnixMs, now.ToUnixTimeMilliseconds()); + Volatile.Write(ref _lastNearExpiryRotation, session.Token); + } + } + + private bool WasRecentlyRotated(StoredSession session, DateTimeOffset now) + => ClientSessionSecurity.WasIssuedRecently( + session.Token, now, ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow) + || WasMarkedRecently(session.Token, now); + + private bool WasMarkedRecently(string token, DateTimeOffset now) + { + if (!string.Equals( + Volatile.Read(ref _lastNearExpiryRotation), token, StringComparison.Ordinal)) + { + return false; + } + + var ageMilliseconds = now.ToUnixTimeMilliseconds() + - Volatile.Read(ref _lastNearExpiryRotationAtUnixMs); + return ageMilliseconds >= -TimeSpan.FromMinutes(1).TotalMilliseconds + && ageMilliseconds + < ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow.TotalMilliseconds; + } + + private bool IsInTransientFailureCooldown(string token) + { + if (!string.Equals(_transientRefreshFailureToken, token, StringComparison.Ordinal)) + return false; + if (_timeProvider.GetUtcNow() < _transientRefreshRetryAfter) + return true; + + _transientRefreshFailureToken = null; + _transientRefreshRetryAfter = default; + return false; + } + + private void StartTransientFailureCooldown(string token) + { + _transientRefreshFailureToken = token; + _transientRefreshRetryAfter = + _timeProvider.GetUtcNow() + ClientSessionSecurity.TransientRefreshFailureCooldown; + } + + private static bool IsTransientRefreshFailure(HttpStatusCode statusCode) + => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests + || (int)statusCode >= 500; + + private static HttpResponseMessage ReauthenticationRequired(HttpRequestMessage request) + => new(HttpStatusCode.Unauthorized) + { + RequestMessage = request, + ReasonPhrase = "Authentication session expired", + }; } diff --git a/src/NodePilot.Mcp/Auth/TokenStore.cs b/src/NodePilot.Mcp/Auth/TokenStore.cs index cadfd6a0..a7c0f436 100644 --- a/src/NodePilot.Mcp/Auth/TokenStore.cs +++ b/src/NodePilot.Mcp/Auth/TokenStore.cs @@ -32,10 +32,16 @@ public TokenStore(string baseDir) public StoredSession? Load(string profile) { var path = PathFor(profile); - if (!File.Exists(path)) return null; + using var mutation = ClientSessionFileCoordinator.AcquireMutationLock(path); + return LoadPath(path); + } + + private static StoredSession? LoadPath(string path) + { try { - var encrypted = File.ReadAllBytes(path); + var encrypted = ClientSessionFileCoordinator.ReadAllBytesIfExists(path); + if (encrypted is null) return null; var plain = ProtectedData.Unprotect(encrypted, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); return JsonSerializer.Deserialize(plain, JsonOptions); } @@ -50,10 +56,54 @@ public TokenStore(string baseDir) } public void Save(string profile, StoredSession session) + { + var path = PathFor(profile); + using var mutation = ClientSessionFileCoordinator.AcquireMutationLock(path); + Write(path, session); + } + + public void Delete(string profile) + { + var path = PathFor(profile); + using var mutation = ClientSessionFileCoordinator.AcquireMutationLock(path); + ClientSessionFileCoordinator.DeleteIfExists(path); + } + + /// + /// Persists a rotation only while the session generation that was presented to the API is + /// still current. This prevents a refresh response from resurrecting a concurrent logout or + /// overwriting a newer login performed while the HTTP request was in flight. + /// + internal bool TrySaveIfCurrent(string profile, string expectedToken, StoredSession session) + { + var path = PathFor(profile); + using var mutation = ClientSessionFileCoordinator.AcquireMutationLock(path); + var current = LoadPath(path); + if (current is null || !string.Equals(current.Token, expectedToken, StringComparison.Ordinal)) + return false; + + Write(path, session); + return true; + } + + internal bool DeleteIfCurrent(string profile, string expectedToken) + { + var path = PathFor(profile); + using var mutation = ClientSessionFileCoordinator.AcquireMutationLock(path); + var current = LoadPath(path); + if (current is null || !string.Equals(current.Token, expectedToken, StringComparison.Ordinal)) + return false; + + ClientSessionFileCoordinator.DeleteIfExists(path); + return true; + } + + private static void Write(string path, StoredSession session) { var plain = JsonSerializer.SerializeToUtf8Bytes(session, JsonOptions); - var encrypted = ProtectedData.Protect(plain, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); - File.WriteAllBytes(PathFor(profile), encrypted); + var encrypted = ProtectedData.Protect( + plain, optionalEntropy: Entropy, scope: DataProtectionScope.CurrentUser); + ClientSessionFileCoordinator.WriteAllBytesAtomically(path, encrypted); } // Must match the CLI's entropy so a session written by `np auth login` is readable here. @@ -76,5 +126,5 @@ public sealed class StoredSession public string Username { get; set; } = ""; public Guid UserId { get; set; } public string Role { get; set; } = ""; - public DateTime ExpiresAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } } diff --git a/src/NodePilot.Mcp/Mapping/WorkflowDefinitionPatcher.cs b/src/NodePilot.Mcp/Mapping/WorkflowDefinitionPatcher.cs index 3c64172f..e50a012f 100644 --- a/src/NodePilot.Mcp/Mapping/WorkflowDefinitionPatcher.cs +++ b/src/NodePilot.Mcp/Mapping/WorkflowDefinitionPatcher.cs @@ -162,6 +162,12 @@ private static void MergeObject(JsonObject target, JsonObject? source, List notes) + { + if (source is not null && ContainsMask(target)) + { + target.Clear(); + foreach (var item in source) + target.Add(item?.DeepClone()); + return; + } + + for (var index = 0; index < target.Count; index++) + { + var targetItem = target[index]; + var sourceItem = source is not null && index < source.Count ? source[index] : null; + switch (targetItem) + { + case JsonObject targetObject: + MergeObject(targetObject, sourceItem as JsonObject, notes); + break; + case JsonArray targetArray: + MergeNestedArray(targetArray, sourceItem as JsonArray, notes); + break; + case JsonValue targetValue when targetValue.TryGetValue(out string? value) + && value == SecretMask + && sourceItem is not null: + target[index] = sourceItem.DeepClone(); + break; + } + } + } + + private static bool ContainsMask(JsonNode node) + { + if (node is JsonValue value) + return value.TryGetValue(out string? text) && text == SecretMask; + if (node is JsonObject obj) + return obj.Any(property => property.Value is not null && ContainsMask(property.Value)); + return node is JsonArray array + && array.Any(item => item is not null && ContainsMask(item)); + } + private static List ToObjectList(JsonElement def, string arrayName) { var list = new List(); diff --git a/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs b/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs index 28e45ec0..2bbfc17f 100644 --- a/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs +++ b/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol; using ModelContextProtocol.Server; +using NodePilot.Core.Security; using NodePilot.Mcp.Api; using NodePilot.Mcp.Api.Dtos; using NodePilot.Mcp.Mapping; @@ -17,12 +19,15 @@ namespace NodePilot.Mcp.Tools; /// whitelist (SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE), enforces single-statement, rolls back the /// (read-only) transaction, and caps rows + timeout. /// - Hidden secret columns (PasswordHash, EncryptedPassword, byte[]) never appear in list_db_tables; -/// GlobalVariable.Value is masked as "***". +/// GlobalVariable.Value is masked as "***". The shared external-agent policy additionally removes +/// Workflow Definitions, custom-activity scripts and executable parameter defaults. /// - Raw SQL cannot reach them either: /api/dbadmin/query rejects a read statement that names a /// protected column, masks protected result columns of a wildcard select as "***", and rejects a /// whole-row serializer over a table that holds a secret column (to_json/row_to_json/::text/ /// FOR JSON — these carry the row past the two name-based layers). Use list_db_tables for the /// safe schema. +/// - MCP additionally rejects every SQL reference to the four opaque automation tables before the +/// HTTP request and masks matching result-column names in depth. Browser DbAdmin remains forensic. /// [McpServerToolType] public sealed class DbAdminMcpTools @@ -37,7 +42,7 @@ public sealed class DbAdminMcpTools private const int MaxResultChars = 4000; [McpServerTool(Name = "list_db_tables", ReadOnly = true)] - [Description("List the NodePilot App-DB schema (every EF-tracked table with its non-hidden columns, primary keys and row count). Hidden secret columns are excluded; GlobalVariable.Value is masked. Pass `name` to filter to one table (case-insensitive). This is the safe schema source — prefer it over guessing column names for run_readonly_sql. Admin-only.")] + [Description("List the NodePilot App-DB schema (every EF-tracked table with its agent-safe columns, primary keys and row count). Hidden secrets and opaque workflow/custom-activity implementation payloads are excluded; GlobalVariable.Value is masked. Pass `name` to filter one table. Prefer this schema over guessing column names. Admin-only.")] public async Task ListDbTables( [Description("Optional table-name filter (case-insensitive substring). Omit for all tables.")] string? name = null, CancellationToken cancellationToken = default) @@ -51,21 +56,26 @@ public async Task ListDbTables( filtered = tables.Where(t => t.Name.Contains(needle, StringComparison.OrdinalIgnoreCase)); } - var rows = filtered.OrderBy(t => t.Name).Select(t => new + var rows = filtered + .Where(t => ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) + .OrderBy(t => t.Name) + .Select(t => new { name = t.Name, displayName = t.DisplayName, dbTableName = t.DbTableName, pkColumns = t.PkColumns, rowCount = t.RowCount, - columns = t.Columns.Select(c => new - { - name = c.Name, - type = c.ClrType, - isNullable = c.IsNullable, - isPrimaryKey = c.IsPrimaryKey, - isMasked = c.IsMasked, - }), + columns = t.Columns + .Where(c => ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) + .Select(c => new + { + name = c.Name, + type = c.ClrType, + isNullable = c.IsNullable, + isPrimaryKey = c.IsPrimaryKey, + isMasked = c.IsMasked, + }), }); return new { tables = rows }; @@ -87,13 +97,32 @@ public async Task GetDbInfo(CancellationToken cancellationToken = defaul } [McpServerTool(Name = "run_readonly_sql", ReadOnly = true)] - [Description("Run a single read-only SQL statement against the NodePilot App-DB and return columns + rows. Only SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE first-keyword statements are accepted (server-enforced); the transaction is read-only and rolled back. Use list_db_tables first for the schema. Secret columns (PasswordHash, EncryptedPassword, GlobalVariable.Value) are unreachable: naming one rejects the query, a wildcard select returns them as \"***\", and serializing a whole row of such a table (to_json/row_to_json/::text/FOR JSON) is rejected too — select the columns you need explicitly. Results are capped (max 200 rows / 4 KB). Admin-only.")] + [Description("Run one read-only SQL statement against the NodePilot App-DB. The server enforces read-only SQL. MCP additionally rejects opaque Workflow Definition and custom-activity implementation payloads; use their dedicated tools instead. Sensitive result-column names are masked in depth. Results are capped (max 200 rows / 4 KB). Admin-only.")] public async Task RunReadonlySql( [Description("A single read-only SQL statement (SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE).")] string sql, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(sql)) + throw new McpException("SQL statement is required."); + if (ExternalAgentSqlPolicy.ReferencesProtectedProjection(sql)) + throw new McpException(ExternalAgentSqlPolicy.RejectionMessage); + var result = await ApiErrorMapper.Guard(() => _api.ExecuteDbReadQueryAsync(sql, cancellationToken)); + // The API endpoint remains a raw forensic DbAdmin surface. MCP is an external-agent + // boundary, so it reapplies the shared result-name mask before tool output is serialized. + for (var c = 0; c < result.Columns.Count; c++) + { + if (!ExternalAgentSqlPolicy.IsProtectedResultColumn(result.Columns[c].Name)) + continue; + + foreach (var row in result.Rows) + { + if (c < row.Count) + row[c] = ExternalAgentSqlPolicy.Mask; + } + } + var rows = result.Rows; var truncated = result.Truncated; if (rows.Count > MaxResultRows) @@ -128,4 +157,4 @@ public async Task RunReadonlySql( note = "Result too large — rows dropped to stay inside the MCP tool-output cap. Narrow your query (fewer columns / WHERE / LIMIT).", }; } -} \ No newline at end of file +} diff --git a/src/nodepilot-docs-ui/content/api/authentication.md b/src/nodepilot-docs-ui/content/api/authentication.md index 28cf3b1a..30302805 100644 --- a/src/nodepilot-docs-ui/content/api/authentication.md +++ b/src/nodepilot-docs-ui/content/api/authentication.md @@ -71,7 +71,7 @@ curl -s -c cookie.jar -X POST "$NP/api/auth/login" \ -H 'Content-Type: application/json' \ -H 'X-Auth-Token-Response: true' \ -d '{ "username":"admin", "password":"s3cret-pass" }' -# 200 → {"token":"eyJ...","userId":"...","username":"admin","role":"Admin"} +# 200 → {"token":"eyJ...","userId":"...","username":"admin","role":"Admin","expiresAt":"2026-08-15T18:30:00Z"} # Folgende Aufrufe authentifizieren (Cookie oder Bearer) curl -s -b cookie.jar "$NP/api/auth/me" diff --git a/src/nodepilot-docs-ui/content/api/endpoints.md b/src/nodepilot-docs-ui/content/api/endpoints.md index e1efacdd..b0ecd9ba 100644 --- a/src/nodepilot-docs-ui/content/api/endpoints.md +++ b/src/nodepilot-docs-ui/content/api/endpoints.md @@ -320,6 +320,8 @@ Maintenance-Window-Create-Body: ```bash # Script generieren (mit Upstream-Variablen-Kontext) +# Optionaler Editor-Kontext braucht zusätzlich includeCurrentScript:true; ohne das Flag ignoriert +# der Server ein mitgesendetes currentScript, weil es Passwörter oder Tokens enthalten kann. curl -s -b cookie.jar -X POST "$NP/api/ai/generate-script" -H 'Content-Type: application/json' \ -d '{ "prompt":"Write a PowerShell step that checks free disk space", "workflowId":"21f1c0d4-...", "stepId":"runScript_1", diff --git a/src/nodepilot-docs-ui/content/deployment/production.md b/src/nodepilot-docs-ui/content/deployment/production.md index 5db5ca52..d43170d3 100644 --- a/src/nodepilot-docs-ui/content/deployment/production.md +++ b/src/nodepilot-docs-ui/content/deployment/production.md @@ -425,6 +425,17 @@ Der Updater: Ein **erfolgreicher** Update lässt den Dienst immer **laufen**, unabhängig davon, ob er vorher gestoppt war. Nur ein fehlgeschlagener Update stellt den Ausgangszustand wieder her. +Workflow-History wird beim Start absichtlich noch nicht umgeschlüsselt, damit der unmittelbare +Health-Check-Rollback sicher bleibt. Während eines gemischten HA-Upgrades Workflow-Edits und +Rollbacks pausieren (oder nach dem ersten Schreibzugriff eines neuen Knotens keinen Failback auf +alte Knoten mehr zulassen): neue Knoten schreiben neue History-Snapshots sofort als `np:wfv:v1:`, +das alte Binary kann diese Zeilen nicht lesen. Sobald der Health-Check erfolgreich war und **alle +HA-Knoten** die neue Version ausführen, nach dem regulären DB-Backup +`np secrets reencrypt --yes` (oder die Aktion unter Admin-Einstellungen → Security) ausführen. +Danach sind auch alte `WorkflowVersions` als `np:wfv:v1:` geschützt. Nach dem ersten neuen +History-Write oder diesem Cutover darf nicht mehr auf eine Binärversion ohne Format-Unterstützung +zurückgerollt werden. + Das Binärbackup enthält keine secret-haltige `appsettings.Production.json`. Sie wird beim Austausch deshalb als Letztes ersetzt, damit ein Abbruch sie nicht zerstört. ## Deinstallation diff --git a/src/nodepilot-docs-ui/content/enterprise/secrets-providers.md b/src/nodepilot-docs-ui/content/enterprise/secrets-providers.md index dd706075..18eda161 100644 --- a/src/nodepilot-docs-ui/content/enterprise/secrets-providers.md +++ b/src/nodepilot-docs-ui/content/enterprise/secrets-providers.md @@ -82,9 +82,19 @@ curl -X POST -H "Authorization: Bearer " \ - `200 OK` → clean cutover (`partialSuccess: false`). - `207 Multi-Status` → übersprungene Rows in `*SkipDetails`, manuell nachpflegen. +Der Sweep umfasst Credentials, Secret-Globals und die vollständig verschlüsselten Definitionen +in `WorkflowVersions`. Die Response weist dafür zusätzlich `workflowVersionsRewritten`, +`workflowVersionsSkipped` und `workflowVersionSkipDetails` aus. + Derselbe Sweep ist auch in der UI verfügbar — **Admin-Einstellungen → Security → „Secrets neu verschlüsseln“** (Admin-only; Bestätigungsdialog, Ergebnis-Toast mit den Zählern, Partial Success als Fehler-Toast) — sowie per CLI: `np secrets reencrypt`. -**Schritt 3 — Legacy-Config entfernen** (wenn Step 2 `200` + `nodepilot.credential.crypto.legacy_reads`-Counter null): `Secrets:LegacyProvider`/`LegacyDpapiScope`/`LegacyMasterKey` entfernen, Restart. +**Schritt 3 — Legacy-Config entfernen:** erst wenn Step 2 `200` mit +`partialSuccess=false` liefert, **alle** Skip-Counter einschließlich +`workflowVersionsSkipped` null sind und der +`nodepilot.credential.crypto.legacy_reads`-Counter bei den Nachtests null bleibt. +Bei einem History-Skip bleibt `Secrets:LegacyProvider` konfiguriert, bis die genannte Version +wiederhergestellt/repariert und ein erneuter Sweep sauber ist. Danach +`Secrets:LegacyProvider`/`LegacyDpapiScope`/`LegacyMasterKey` entfernen und neu starten. ## AES-GCM Master-Key rotieren @@ -94,7 +104,7 @@ Gleiches Prozedere, aber `LegacyProvider=AesGcm` + `LegacyMasterKey={{old-base64 | Endpoint | Auth | Zweck | |---|---|---| -| `POST /api/secrets/reencrypt` | Admin | Bulk-Sweep aller Credentials + secret-Globals durch decrypt→re-encrypt. `200` (clean) oder `207` (skipped). | +| `POST /api/secrets/reencrypt` | Admin | Bulk-Sweep aller Credentials, Secret-Globals und Workflow-Version-Definitionen durch decrypt→re-encrypt. `200` (clean) oder `207` (skipped, inklusive History-Details). | Audit: `SECRETS_REENCRYPTED`. diff --git a/src/nodepilot-docs-ui/content/import-export.md b/src/nodepilot-docs-ui/content/import-export.md index e117f554..ac48b1b6 100644 --- a/src/nodepilot-docs-ui/content/import-export.md +++ b/src/nodepilot-docs-ui/content/import-export.md @@ -18,7 +18,7 @@ Envelope: `nodepilot-workflow-export/v1`. **Secrets werden hier redigiert** (`** ## System-Configuration Backup (ADR 0001) -Voller DR-Snapshot der Konfiguration: Workflows + Folders/Sharing, Machines, Credentials, Globals + Global-Variable-Ordner, Custom Activities, Alerting, Users, Settings. **Nicht enthalten:** Execution-History, Audit, Stats. Admin-only. Envelope `nodepilot-system-backup/v2` (`.npbackup`) — v2 ergänzt die `alerting`-Sektion; der Reader akzeptiert v1 **und** v2, geschrieben wird ausschließlich v2. +Voller DR-Snapshot der Konfiguration: Workflows + Folders/Sharing, Machines, Credentials, Globals + Global-Variable-Ordner, Custom Activities, Alerting, Users, Settings. **Nicht enthalten:** Execution-History, Audit, Stats. Admin-only. Envelope `nodepilot-system-backup/v3` (`.npbackup`) — v2 ergänzte die `alerting`-Sektion; v3 schützt vollständige Workflowdefinitionen mit `$encDefinition` sowie Custom-Activity-Skripte und Eingabe-Defaults mit `$enc`. Ein Workflow-Export zieht Custom Activities automatisch als harte Abhängigkeit mit. Der Reader akzeptiert v1, v2 und v3 (inklusive alter Plaintext-Custom-Activity-Felder), geschrieben wird ausschließlich v3. Ältere Builds lehnen v3 sichtbar ab. ### Secret-Handling diff --git a/src/nodepilot-ui/package.json b/src/nodepilot-ui/package.json index c8dea7be..9b9816e6 100644 --- a/src/nodepilot-ui/package.json +++ b/src/nodepilot-ui/package.json @@ -10,7 +10,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "lint:ci": "eslint . --max-warnings 13", + "lint:ci": "eslint . --max-warnings 11", "preview": "vite preview", "test": "vitest", "test:run": "vitest run", diff --git a/src/nodepilot-ui/src/__tests__/api/ai.test.ts b/src/nodepilot-ui/src/__tests__/api/ai.test.ts index 16091c33..f02afac7 100644 --- a/src/nodepilot-ui/src/__tests__/api/ai.test.ts +++ b/src/nodepilot-ui/src/__tests__/api/ai.test.ts @@ -140,13 +140,14 @@ describe('generateScriptStream SSE parser', () => { expect(out.join('')).toBe('Get-Service'); }); - it('forwards the current script in the request body (refactor base)', async () => { + it('forwards explicit script-context consent in the request body', async () => { postEventStreamMock.mockResolvedValue(sseResponse(['event: done\ndata: {"model":"m","durationMs":1}\n\n'])); await generateScriptStream( - { prompt: 'refactor', upstreamVariables: [], currentScript: '$now = Get-Date' }, + { prompt: 'refactor', upstreamVariables: [], currentScript: '$now = Get-Date', includeCurrentScript: true }, { onDelta: () => {} }, ); - const body = postEventStreamMock.mock.calls[0][1] as { currentScript?: string }; + const body = postEventStreamMock.mock.calls[0][1] as { currentScript?: string; includeCurrentScript?: boolean }; expect(body.currentScript).toBe('$now = Get-Date'); + expect(body.includeCurrentScript).toBe(true); }); }); diff --git a/src/nodepilot-ui/src/__tests__/components/AiPromptDialog.test.tsx b/src/nodepilot-ui/src/__tests__/components/AiPromptDialog.test.tsx index caabbb2b..b5fad31b 100644 --- a/src/nodepilot-ui/src/__tests__/components/AiPromptDialog.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/AiPromptDialog.test.tsx @@ -44,7 +44,7 @@ describe('AiPromptDialog', () => { fireEvent.change(screen.getByLabelText('AI prompt'), { target: { value: ' do a thing ' } }); fireEvent.click(screen.getByRole('button', { name: /generate/i })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('do a thing', false)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('do a thing', false, false)); }); it('replaceAll toggle flips the flag passed to onSubmit', async () => { @@ -59,10 +59,10 @@ describe('AiPromptDialog', () => { ); fireEvent.change(screen.getByLabelText('AI prompt'), { target: { value: 'hello' } }); - fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('checkbox', { name: /replace entire script/i })); fireEvent.click(screen.getByRole('button', { name: /generate/i })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('hello', true)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('hello', true, false)); }); it('replace toggle is hidden when showReplaceToggle is false', () => { @@ -108,7 +108,7 @@ describe('AiPromptDialog', () => { fireEvent.change(ta, { target: { value: 'go' } }); fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Enter', ctrlKey: true }); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('go', false)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('go', false, false)); }); it('shows loading state while onSubmit is pending', async () => { diff --git a/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.aistream.test.tsx b/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.aistream.test.tsx index 375de5b7..59541cf6 100644 --- a/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.aistream.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.aistream.test.tsx @@ -139,13 +139,13 @@ const tick = () => new Promise((r) => setTimeout(r, 30)); function startGenerate(replaceAll: boolean) { fireEvent.click(screen.getByRole('button', { name: /generate script with ai/i })); fireEvent.change(screen.getByLabelText('AI prompt'), { target: { value: 'go' } }); - if (replaceAll) fireEvent.click(screen.getByRole('checkbox')); + if (replaceAll) fireEvent.click(screen.getByRole('checkbox', { name: /replace entire script/i })); fireEvent.click(screen.getAllByRole('button', { name: /^generate$/i })[0]); } describe('ScriptEditorDialog — AI streaming order into a real (read-only) editor', () => { it('replace-all streams multi-line chunks in order across flushes (no scramble)', async () => { - const onAiGenerate = vi.fn(async (_p: string, _cur: string, onToken: (t: string) => void) => { + const onAiGenerate = vi.fn(async (_p: string, _cur: string | null, onToken: (t: string) => void) => { onToken('$now = Get-Date\n'); await tick(); onToken('Write-Host '); await tick(); onToken('"Zeit: $now"'); @@ -161,7 +161,7 @@ describe('ScriptEditorDialog — AI streaming order into a real (read-only) edit }); it('insert mode streams chunks in order at the cursor (no scramble)', async () => { - const onAiGenerate = vi.fn(async (_p: string, _cur: string, onToken: (t: string) => void) => { + const onAiGenerate = vi.fn(async (_p: string, _cur: string | null, onToken: (t: string) => void) => { onToken('$a = 1\n'); await tick(); onToken('$b = 2\n'); await tick(); onToken('$c = 3'); diff --git a/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.test.tsx b/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.test.tsx index 8a361640..6d772c36 100644 --- a/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/ScriptEditorDialog.test.tsx @@ -113,8 +113,8 @@ describe('ScriptEditorDialog', () => { expect(screen.getByText(/Generate script with AI/i)).toBeInTheDocument(); }); - it('streamed tokens append to the editor buffer (default insert mode)', async () => { - const onAiGenerate = vi.fn((_p: string, _cur: string, onToken: (t: string) => void) => { onToken('Get-Service'); return Promise.resolve(); }); + it('does not expose the current script unless the user explicitly consents', async () => { + const onAiGenerate = vi.fn((_p: string, _cur: string | null, onToken: (t: string) => void) => { onToken('Get-Service'); return Promise.resolve(); }); render( { expect(editor.value).toContain('Get-Service'); }); expect(onAiGenerate.mock.calls[0][0]).toBe('list services'); - expect(onAiGenerate.mock.calls[0][1]).toBe('$existing = 1'); // current editor content, passed in as the basis for refactoring + expect(onAiGenerate.mock.calls[0][1]).toBeNull(); + }); + + it('names the LLM target and sends the current script only after consent', async () => { + const onAiGenerate = vi.fn((_p: string, _cur: string | null, onToken: (t: string) => void) => { onToken('Get-Service'); return Promise.resolve(); }); + render( + {}} + onClose={() => {}} + onAiGenerate={onAiGenerate} + aiTargetHost="llm.example.test" + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /generate script with ai/i })); + expect(screen.getByText(/llm\.example\.test/i)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('AI prompt'), { target: { value: 'refactor' } }); + fireEvent.click(screen.getByRole('checkbox', { name: /send current script/i })); + fireEvent.click(screen.getAllByRole('button', { name: /^generate$/i })[0]); + + await waitFor(() => expect(onAiGenerate).toHaveBeenCalled()); + expect(onAiGenerate.mock.calls[0][1]).toBe("$password = 'possibly-secret'"); + }); + + it('uses an external-LLM fallback warning and forgets consent when reopened', () => { + render( + {}} + onClose={() => {}} + onAiGenerate={async () => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /generate script with ai/i })); + expect(screen.getByText(/configured LLM endpoint.*may be external/i)).toBeInTheDocument(); + const consent = screen.getByRole('checkbox', { name: /send current script/i }); + expect(consent).not.toBeChecked(); + fireEvent.click(consent); + expect(consent).toBeChecked(); + + const dialogs = screen.getAllByRole('dialog'); + fireEvent.keyDown(dialogs[dialogs.length - 1], { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: /generate script with ai/i })); + expect(screen.getByRole('checkbox', { name: /send current script/i })).not.toBeChecked(); }); it('replace-all clears the buffer on first token then streams in', async () => { - const onAiGenerate = vi.fn((_p: string, _cur: string, onToken: (t: string) => void) => { onToken('Get-Service'); return Promise.resolve(); }); + const onAiGenerate = vi.fn((_p: string, _cur: string | null, onToken: (t: string) => void) => { onToken('Get-Service'); return Promise.resolve(); }); render( { fireEvent.click(screen.getByRole('button', { name: /generate script with ai/i })); fireEvent.change(screen.getByLabelText('AI prompt'), { target: { value: 'list services' } }); - fireEvent.click(screen.getByRole('checkbox')); // toggle "replace all" + fireEvent.click(screen.getByRole('checkbox', { name: /replace entire script/i })); fireEvent.click(screen.getAllByRole('button', { name: /^generate$/i })[0]); await waitFor(() => { diff --git a/src/nodepilot-ui/src/__tests__/components/admin-settings/SecuritySection.test.tsx b/src/nodepilot-ui/src/__tests__/components/admin-settings/SecuritySection.test.tsx index dcddaedf..1e29139a 100644 --- a/src/nodepilot-ui/src/__tests__/components/admin-settings/SecuritySection.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/admin-settings/SecuritySection.test.tsx @@ -165,6 +165,7 @@ describe('SecuritySection', () => { return HttpResponse.json({ credentialsRewritten: 3, credentialsSkipped: 0, credentialSkipDetails: [], globalSecretsRewritten: 2, globalSecretsSkipped: 0, globalSecretSkipDetails: [], + workflowVersionsRewritten: 4, workflowVersionsSkipped: 0, workflowVersionSkipDetails: [], partialSuccess: false, }); })); @@ -177,8 +178,17 @@ describe('SecuritySection', () => { await waitFor(() => expect(posted).toBe(true)); await waitFor(() => expect( useToastStore.getState().toasts.some((x) => - x.kind === 'success' && x.message.includes('3') && x.message.includes('2')), + x.kind === 'success' + && /3 credential/i.test(x.message) + && /2 global secret/i.test(x.message) + && /4 workflow history version/i.test(x.message)), ).toBe(true)); + + const card = screen.getByText('Re-encrypt secrets').closest('.np-card') as HTMLElement; + const result = await within(card).findByRole('status'); + expect(result).toHaveTextContent(/Credentials.*3 re-encrypted.*0 skipped/i); + expect(result).toHaveTextContent(/Global secrets.*2 re-encrypted.*0 skipped/i); + expect(result).toHaveTextContent(/Workflow history versions.*4 re-encrypted.*0 skipped/i); }); it('Admin reencrypt: cancelled confirm does not POST', async () => { @@ -202,8 +212,13 @@ describe('SecuritySection', () => { server.use(http.post('/api/secrets/reencrypt', () => HttpResponse.json({ credentialsRewritten: 4, credentialsSkipped: 1, - credentialSkipDetails: [{ name: 'old-cred', reason: 'DecryptFailed' }], + credentialSkipDetails: [{ id: '11111111-1111-1111-1111-111111111111', name: 'old-cred', reason: 'CryptographicException' }], globalSecretsRewritten: 0, globalSecretsSkipped: 0, globalSecretSkipDetails: [], + workflowVersionsRewritten: 8, workflowVersionsSkipped: 2, + workflowVersionSkipDetails: [ + { id: '22222222-2222-2222-2222-222222222222', name: 'Payroll v7', reason: 'CryptographicException' }, + { id: '33333333-3333-3333-3333-333333333333', name: 'Payroll v8', reason: 'FormatException' }, + ], partialSuccess: true, }, { status: 207 }))); renderAll(); @@ -211,7 +226,19 @@ describe('SecuritySection', () => { fireEvent.click(screen.getByRole('button', { name: /re-encrypt now/i })); await waitFor(() => expect( - useToastStore.getState().toasts.some((x) => x.kind === 'error' && /partial/i.test(x.message)), + useToastStore.getState().toasts.some((x) => + x.kind === 'error' + && /partial/i.test(x.message) + && /12 re-encrypted/i.test(x.message) + && /3 skipped/i.test(x.message)), ).toBe(true)); + + const card = screen.getByText('Re-encrypt secrets').closest('.np-card') as HTMLElement; + const details = await within(card).findByRole('alert'); + expect(details).toHaveTextContent(/Credentials.*4 re-encrypted.*1 skipped/i); + expect(details).toHaveTextContent(/Workflow history versions.*8 re-encrypted.*2 skipped/i); + expect(details).toHaveTextContent(/old-cred.*CryptographicException.*11111111-1111-1111-1111-111111111111/i); + expect(details).toHaveTextContent(/Payroll v7.*CryptographicException.*22222222-2222-2222-2222-222222222222/i); + expect(details).toHaveTextContent(/Payroll v8.*FormatException.*33333333-3333-3333-3333-333333333333/i); }); }); diff --git a/src/nodepilot-ui/src/__tests__/hooks/useAiScriptStream.test.tsx b/src/nodepilot-ui/src/__tests__/hooks/useAiScriptStream.test.tsx index 3614b81f..28959fb4 100644 --- a/src/nodepilot-ui/src/__tests__/hooks/useAiScriptStream.test.tsx +++ b/src/nodepilot-ui/src/__tests__/hooks/useAiScriptStream.test.tsx @@ -12,7 +12,7 @@ import { useAuthStore } from '../../stores/authStore'; // propagates to ScriptEditorDialog.onAiGenerate, whose absence hides the button. function caps(llm: boolean): KnowledgeCapabilities { - return { enabled: false, llm, docs: false, operational: false, sourceCode: false, db: false }; + return { enabled: false, llm, docs: false, operational: false, sourceCode: false, db: false, scriptContextTargetHost: 'llm.example.test' }; } // Seeded fresh cache → the query never fetches; no MSW needed. @@ -36,7 +36,8 @@ describe('useAiScriptStream — gating', () => { it('operatorWithUsableLlm_returnsTheCallback', () => { const { result } = renderStreamHook('Operator', true); - expect(typeof result.current).toBe('function'); + expect(typeof result.current?.generate).toBe('function'); + expect(result.current?.targetHost).toBe('llm.example.test'); }); it('llmNotUsable_returnsUndefined', () => { diff --git a/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx b/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx index e3d5080c..a7933d04 100644 --- a/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx +++ b/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Control the react-router `useBlocker` return value per test. The hook owns the in-app @@ -22,6 +22,11 @@ vi.mock('../../stores/confirmStore', async (importOriginal) => { }); import { confirmDialog } from '../../stores/confirmStore'; +vi.mock('../../api/client', () => ({ + api: { put: vi.fn(), post: vi.fn() }, +})); +import { api } from '../../api/client'; + import { useWorkflowPersistence } from '../../hooks/useWorkflowPersistence'; function makeWrapper() { @@ -38,6 +43,146 @@ function renderPersistence() { ); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +function editableNode(label: string) { + return { id: 'step-1', position: { x: 0, y: 0 }, data: { label } }; +} + +describe('useWorkflowPersistence — revision-safe requests', () => { + beforeEach(() => { + routerMock.blocker = { state: 'unblocked', proceed: vi.fn(), reset: vi.fn() }; + vi.mocked(api.put).mockReset(); + vi.mocked(api.post).mockReset(); + }); + + it('saves an immutable snapshot, then follows up when the canvas changed in flight', async () => { + const first = deferred(); + const second = deferred(); + vi.mocked(api.put) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const wrapper = makeWrapper(); + const { result, rerender } = renderHook( + ({ label }) => useWorkflowPersistence({ + workflowId: 'wf-1', workflow: undefined, nodes: [editableNode(label)], edges: [], + }), + { wrapper, initialProps: { label: 'revision-a' } }, + ); + + act(() => { expect(result.current.syncFromServer('Initial name')).toBe(true); }); + act(() => { result.current.markDirty(); result.current.save(); }); + await waitFor(() => expect(api.put).toHaveBeenCalledTimes(1)); + expect(vi.mocked(api.put).mock.calls[0][1]).toMatchObject({ + definitionJson: expect.stringContaining('revision-a'), + }); + + rerender({ label: 'revision-b' }); + act(() => result.current.markDirty()); + await act(async () => first.resolve({})); + + await waitFor(() => expect(api.put).toHaveBeenCalledTimes(2)); + expect(vi.mocked(api.put).mock.calls[1][1]).toMatchObject({ + definitionJson: expect.stringContaining('revision-b'), + }); + // The stale success must neither mark the newer edit clean nor let a refetch replace it. + expect(result.current.isDirty).toBe(true); + expect(result.current.syncFromServer('Stale server name')).toBe(false); + + await act(async () => second.resolve({})); + await waitFor(() => expect(result.current.isDirty).toBe(false)); + }); + + it('does not clear or overwrite a newer revision when publish completes', async () => { + const publish = deferred(); + vi.mocked(api.post).mockImplementationOnce(() => publish.promise); + const wrapper = makeWrapper(); + const { result, rerender } = renderHook( + ({ label }) => useWorkflowPersistence({ + workflowId: 'wf-1', workflow: undefined, nodes: [editableNode(label)], edges: [], + }), + { wrapper, initialProps: { label: 'published-snapshot' } }, + ); + + act(() => { expect(result.current.syncFromServer('Initial name')).toBe(true); }); + act(() => { result.current.rename('Published name'); result.current.publish(); }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + rerender({ label: 'newer-local-revision' }); + act(() => result.current.rename('Newer local name')); + await act(async () => publish.resolve({})); + + await waitFor(() => expect(result.current.isDirty).toBe(true)); + expect(result.current.syncFromServer('Stale server name')).toBe(false); + expect(result.current.name).toBe('Newer local name'); + expect(vi.mocked(api.post).mock.calls[0][1]).toMatchObject({ + name: 'Published name', + definitionJson: expect.stringContaining('published-snapshot'), + }); + }); + + it('applies one async graph token at most once and exposes it to a same-tick Save', async () => { + vi.mocked(api.put).mockResolvedValue({}); + const { result } = renderPersistence(); + act(() => { expect(result.current.syncFromServer('Initial')).toBe(true); }); + const token = result.current.beginAsyncGraphEdit(); + + act(() => { + expect(result.current.applyAsyncGraphEdit(token!, [editableNode('applied-layout')], [])).toBe(true); + expect(result.current.applyAsyncGraphEdit(token!, [editableNode('duplicate-layout')], [])).toBe(false); + result.current.save(); + }); + + await waitFor(() => expect(api.put).toHaveBeenCalledTimes(1)); + expect(vi.mocked(api.put).mock.calls[0][1]).toMatchObject({ + definitionJson: expect.stringContaining('applied-layout'), + }); + expect(vi.mocked(api.put).mock.calls[0][1]).not.toMatchObject({ + definitionJson: expect.stringContaining('duplicate-layout'), + }); + await waitFor(() => expect(result.current.isDirty).toBe(false)); + }); + + it('applies an async graph result atomically and rejects its token after a workflow switch', () => { + const wrapper = makeWrapper(); + const { result, rerender } = renderHook( + ({ workflowId }) => useWorkflowPersistence({ + workflowId, workflow: undefined, nodes: [editableNode('initial')], edges: [], + }), + { wrapper, initialProps: { workflowId: 'wf-1' } }, + ); + act(() => { expect(result.current.syncFromServer('First')).toBe(true); }); + const staleToken = result.current.beginAsyncGraphEdit(); + + rerender({ workflowId: 'wf-2' }); + act(() => { expect(result.current.syncFromServer('Second')).toBe(true); }); + act(() => { + expect(result.current.applyAsyncGraphEdit(staleToken!, [editableNode('stale-layout')], [])).toBe(false); + }); + expect(result.current.isDirty).toBe(false); + }); + + it('invalidates a pending async graph token as soon as Publish is queued', async () => { + const publish = deferred(); + vi.mocked(api.post).mockImplementationOnce(() => publish.promise); + const { result } = renderPersistence(); + act(() => { expect(result.current.syncFromServer('Initial')).toBe(true); }); + const token = result.current.beginAsyncGraphEdit(); + + act(() => result.current.publish()); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + act(() => { + expect(result.current.applyAsyncGraphEdit(token!, [editableNode('late-layout')], [])).toBe(false); + }); + + await act(async () => publish.resolve({})); + }); +}); + describe('useWorkflowPersistence — useBlocker discard guard', () => { beforeEach(() => { // Fresh spies + a blocked navigation for each test; the effect fires on mount. diff --git a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx index b02175f1..4f785e9d 100644 --- a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx @@ -11,6 +11,7 @@ const signalRMock = vi.hoisted(() => ({ connection: null as { stop: ReturnType; invoke: ReturnType } | null, })); const toPngMock = vi.hoisted(() => vi.fn(() => Promise.resolve('data:image/png;base64,'))); +const autoLayoutElkMock = vi.hoisted(() => vi.fn()); // Mock SignalR before the page imports it - the editor opens a hub connection on mount // and we don't want a real WebSocket attempt in the test runner. @@ -42,6 +43,11 @@ vi.mock('@microsoft/signalr', () => { // not blow up at module-load time. vi.mock('html-to-image', () => ({ toPng: toPngMock })); +vi.mock('../../lib/autoLayout', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, autoLayoutELK: autoLayoutElkMock }; +}); + // ELK is loaded as a worker-backed bundle; the module fails to load in jsdom because // of its Web Worker dependency. autoLayoutELK is only invoked behind a button so we // can stub the whole module. @@ -167,12 +173,20 @@ beforeEach(() => { useDesignStore.setState({ designerMode: 'expert' }); useToastStore.setState({ toasts: [] }); toPngMock.mockReset().mockResolvedValue('data:image/png;base64,'); + autoLayoutElkMock.mockReset(); }); function emitSignalR(event: string, payload: unknown) { for (const handler of signalRMock.handlers[event] ?? []) handler(payload); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + function renderPage(role: 'Admin' | 'Operator' | 'Viewer' = 'Admin') { useAuthStore.setState({ userId: TEST_USER_ID, username: 'tester', role, isAuthenticated: true }); patchFetch(); @@ -1350,6 +1364,90 @@ describe('WorkflowEditorPage — Tidy Layout', () => { || savedById.get('step-b')!.position.y !== 100, ).toBe(true); }); + + it('blocks Save and Publish while ELK is pending, then persists only the applied layout', async () => { + useDesignStore.setState({ layoutMode: 'ELK' }); + const elk = deferred>(); + autoLayoutElkMock.mockReturnValueOnce(elk.promise); + const putBodies: Array<{ definitionJson: string }> = []; + const publishBodies: Array<{ definitionJson: string }> = []; + server.use( + http.put(`${BASE}/api/workflows/wf-smoke-1`, async ({ request }) => { + putBodies.push(await request.json() as { definitionJson: string }); + return HttpResponse.json(MOCK_WORKFLOW); + }), + http.post(`${BASE}/api/workflows/wf-smoke-1/publish`, async ({ request }) => { + publishBodies.push(await request.json() as { definitionJson: string }); + return HttpResponse.json(MOCK_WORKFLOW); + }), + ); + + renderPage('Admin'); + await waitForCanvasReady(); + await openToolsMenu(); + fireEvent.click(await screen.findByTitle(/Layout: ELK/)); + await waitFor(() => expect(autoLayoutElkMock).toHaveBeenCalledTimes(1)); + + expect(screen.queryByTitle(/Save in place|Zwischen-Speichern/i)).not.toBeInTheDocument(); + const pendingPublish = screen.getByRole('button', { name: /^Publish$/ }); + expect(pendingPublish).toBeDisabled(); + expect(screen.getByRole('button', { name: /^End/i })).toBeDisabled(); + fireEvent.click(pendingPublish); + expect(putBodies).toHaveLength(0); + expect(publishBodies).toHaveLength(0); + + const inputNodes = autoLayoutElkMock.mock.calls[0][0] as Array<{ id: string; position: { x: number; y: number } }>; + const laidOut = inputNodes.map((node, index) => ({ + ...node, + position: { x: 700 + index * 100, y: 900 + index * 100 }, + })); + await act(async () => elk.resolve(laidOut)); + + const saveButton = await screen.findByTitle(/Save in place|Zwischen-Speichern/i); + await waitFor(() => expect(screen.getByTitle(/Unsaved changes|Ungespeicherte Änderungen/i)).toBeInTheDocument()); + fireEvent.click(saveButton); + await waitFor(() => expect(putBodies).toHaveLength(1)); + const saved = JSON.parse(putBodies[0].definitionJson) as { nodes: typeof laidOut }; + expect(saved.nodes.map((node) => node.position)).toEqual(laidOut.map((node) => node.position)); + + fireEvent.click(screen.getByRole('button', { name: /^Publish$/ })); + await waitFor(() => expect(publishBodies).toHaveLength(1)); + const published = JSON.parse(publishBodies[0].definitionJson) as { nodes: typeof laidOut }; + expect(published.nodes.map((node) => node.position)).toEqual(laidOut.map((node) => node.position)); + }); + + it('releases the editor freeze without dirtying the draft when ELK rejects', async () => { + useDesignStore.setState({ layoutMode: 'ELK' }); + const elk = deferred>(); + autoLayoutElkMock.mockReturnValueOnce(elk.promise); + let putCalls = 0; + server.use( + http.put(`${BASE}/api/workflows/wf-smoke-1`, () => { + putCalls += 1; + return HttpResponse.json(MOCK_WORKFLOW); + }), + ); + + renderPage('Admin'); + await waitForCanvasReady(); + await openToolsMenu(); + fireEvent.click(await screen.findByTitle(/Layout: ELK/)); + await waitFor(() => expect(autoLayoutElkMock).toHaveBeenCalledTimes(1)); + expect(screen.queryByTitle(/Save in place|Zwischen-Speichern/i)).not.toBeInTheDocument(); + + await act(async () => { elk.reject(new Error('ELK worker crashed')); }); + + await waitFor(() => expect(screen.getByTitle(/Save in place|Zwischen-Speichern/i)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /^End/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /^Publish$/ })).not.toBeDisabled(); + expect(screen.queryByTitle(/Unsaved changes|Ungespeicherte Änderungen/i)).not.toBeInTheDocument(); + expect(screen.getByTitle(/Restore layout/)).toBeDisabled(); + expect(putCalls).toBe(0); + expect(useToastStore.getState().toasts).toContainEqual(expect.objectContaining({ + kind: 'error', + message: expect.stringContaining('ELK worker crashed'), + })); + }); }); describe('WorkflowEditorPage — Overlays via Buttons & Keys', () => { diff --git a/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts b/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts index f6c2a153..5cf6e9ad 100644 --- a/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts +++ b/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts @@ -110,13 +110,13 @@ describe('aiChatStore', () => { expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); - it('strips baseDef and streaming/building flags but keeps proposal.definitionJson', () => { + it('strips baseDef, transient flags and every proposal definitionJson', () => { const scope = aiChatScopeKey('u1', 'wf1'); const id = useAiChatStore.getState().newThread(scope, 'Chat 1'); const msg: ChatMessage = { role: 'assistant', content: 'done', streaming: true, building: true, baseDef: { nodes: [], edges: [] }, - proposal: { definitionJson: '{"nodes":[]}', summary: 's', nodeCount: 1, edgeCount: 0, baseDefinitionHash: 'h' }, + proposal: { definitionJson: '{"nodes":[{"secret":"must-not-reach-session-storage"}]}', summary: 's', nodeCount: 1, edgeCount: 0, baseDefinitionHash: 'h' }, }; useAiChatStore.getState().updateMessages(scope, id, () => [msg]); @@ -125,8 +125,9 @@ describe('aiChatStore', () => { expect(stored.streaming).toBeUndefined(); expect(stored.building).toBeUndefined(); expect(stored.content).toBe('done'); // prose survives - expect(stored.proposal?.definitionJson).toBe('{"nodes":[]}'); // survives reload → stays applicable + expect(stored.proposal?.definitionJson).toBe(''); expect(stored.proposal?.summary).toBe('s'); + expect(sessionStorage.getItem(STORAGE_KEY)).not.toContain('must-not-reach-session-storage'); }); it('degrades an oversized proposal.definitionJson to the empty stub', () => { @@ -140,11 +141,11 @@ describe('aiChatStore', () => { useAiChatStore.getState().updateMessages(scope, id, () => [msg]); const stored = persisted().messagesByThread[aiChatFullKey(scope, id)][0]; - expect(stored.proposal?.definitionJson).toBe(''); // over the cap → read-only stub + expect(stored.proposal?.definitionJson).toBe(''); // all persisted proposals are read-only stubs expect(stored.proposal?.summary).toBe('s'); }); - it('keeps definitionJson only for the newest proposal in a thread (aggregate bound)', () => { + it('never persists definitionJson, including for the newest proposal in a thread', () => { const scope = aiChatScopeKey('u1', 'wf1'); const id = useAiChatStore.getState().newThread(scope, 'Chat 1'); const proposalMsg = (json: string): ChatMessage => ({ @@ -160,7 +161,32 @@ describe('aiChatStore', () => { const stored = persisted().messagesByThread[aiChatFullKey(scope, id)]; expect(stored[0].proposal?.definitionJson).toBe(''); // superseded → stub expect(stored[0].proposal?.summary).toBe('s'); // metadata survives - expect(stored[2].proposal?.definitionJson).toBe('{"new":true}'); // newest stays applicable + expect(stored[2].proposal?.definitionJson).toBe(''); + }); + + it('migrates previously persisted proposal JSON out of sessionStorage', async () => { + const key = aiChatFullKey('u1::wf1', 'thread-1'); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + version: 1, + state: { + messagesByThread: { + [key]: [{ + role: 'assistant', content: 'done', + proposal: { + definitionJson: '{"secret":"legacy-session-secret"}', + summary: 's', nodeCount: 1, edgeCount: 0, baseDefinitionHash: 'h', + }, + }], + }, + threadsByScope: {}, + activeThreadByScope: {}, + }, + })); + + await useAiChatStore.persist.rehydrate(); + + expect(sessionStorage.getItem(STORAGE_KEY)).not.toContain('legacy-session-secret'); + expect(useAiChatStore.getState().messagesByThread[key][0].proposal?.definitionJson).toBe(''); }); it('does NOT persist threads of unsaved (__new__) workflows', () => { diff --git a/src/nodepilot-ui/src/api/ai.ts b/src/nodepilot-ui/src/api/ai.ts index 08d1aa5d..c57d6642 100644 --- a/src/nodepilot-ui/src/api/ai.ts +++ b/src/nodepilot-ui/src/api/ai.ts @@ -25,6 +25,8 @@ export interface GenerateScriptRequest { /** Current editor content — the basis for "refactor/fix this script" requests (without * it, the LLM would have to guess/hallucinate the existing script). */ currentScript?: string | null; + /** Explicit, default-false consent. The backend ignores currentScript without it. */ + includeCurrentScript?: boolean; } export interface GenerateWorkflowRequest { @@ -210,6 +212,8 @@ export interface KnowledgeCapabilities { operational: boolean; sourceCode: boolean; db: boolean; + /** Host of the active profile, shown only to Admin/Operator as the script-context target. */ + scriptContextTargetHost?: string | null; } /** Handlers for the read-only knowledge stream — deliberately leaner than {@link ChatStreamHandlers} diff --git a/src/nodepilot-ui/src/components/admin-settings/SecuritySection.tsx b/src/nodepilot-ui/src/components/admin-settings/SecuritySection.tsx index 1550b44b..d94194ca 100644 --- a/src/nodepilot-ui/src/components/admin-settings/SecuritySection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/SecuritySection.tsx @@ -328,11 +328,78 @@ function ExternalTriggerCard() { type ReencryptResult = { credentialsRewritten: number; credentialsSkipped: number; + credentialSkipDetails: ReencryptionSkip[]; globalSecretsRewritten: number; globalSecretsSkipped: number; + globalSecretSkipDetails: ReencryptionSkip[]; + workflowVersionsRewritten: number; + workflowVersionsSkipped: number; + workflowVersionSkipDetails: ReencryptionSkip[]; partialSuccess: boolean; }; +type ReencryptionSkip = { + id: string; + name: string; + reason: string; +}; + +function ReencryptResultSummary({ result }: Readonly<{ result: ReencryptResult }>) { + const { t } = useTranslation('adminSettings'); + const scopes = [ + { + key: 'credentials', label: t('sec.reencryptScopeCredentials'), + rewritten: result.credentialsRewritten, skipped: result.credentialsSkipped, + details: result.credentialSkipDetails, + }, + { + key: 'globals', label: t('sec.reencryptScopeGlobals'), + rewritten: result.globalSecretsRewritten, skipped: result.globalSecretsSkipped, + details: result.globalSecretSkipDetails, + }, + { + key: 'workflowVersions', label: t('sec.reencryptScopeWorkflowVersions'), + rewritten: result.workflowVersionsRewritten, skipped: result.workflowVersionsSkipped, + details: result.workflowVersionSkipDetails, + }, + ]; + + return ( +
+

+ {t(result.partialSuccess ? 'sec.reencryptResultPartial' : 'sec.reencryptResultComplete')} +

+
+ {scopes.map((scope) => ( +
+
{scope.label}
+
{t('sec.reencryptScopeCounts', { rewritten: scope.rewritten, skipped: scope.skipped })}
+
+ ))} +
+ {result.partialSuccess && ( +
+

{t('sec.reencryptSkipDetails')}

+
    + {scopes.flatMap((scope) => scope.details.map((skip) => ( +
  • + {scope.label}: {skip.name} + {' — '}{skip.reason}{' — '} + {skip.id} +
  • + )))} +
+
+ )} +
+ ); +} + function SecretsReencryptCard() { const { t } = useTranslation('adminSettings'); const { canAdmin } = useRole(); @@ -343,13 +410,14 @@ function SecretsReencryptCard() { // partial sweeps as an error toast so skipped rows can't slip by unnoticed. if (r.partialSuccess) { toast.error(t('sec.reencryptPartial', { - rewritten: r.credentialsRewritten + r.globalSecretsRewritten, - skipped: r.credentialsSkipped + r.globalSecretsSkipped, + rewritten: r.credentialsRewritten + r.globalSecretsRewritten + r.workflowVersionsRewritten, + skipped: r.credentialsSkipped + r.globalSecretsSkipped + r.workflowVersionsSkipped, })); } else { toast.success(t('sec.reencryptDone', { credentials: r.credentialsRewritten, globals: r.globalSecretsRewritten, + workflowVersions: r.workflowVersionsRewritten, })); } }, @@ -371,6 +439,7 @@ function SecretsReencryptCard() { {t('sec.reencryptButton')} + {reencrypt.data && } ); } diff --git a/src/nodepilot-ui/src/components/ai/AiPromptDialog.tsx b/src/nodepilot-ui/src/components/ai/AiPromptDialog.tsx index 8dfc176b..d987b9b2 100644 --- a/src/nodepilot-ui/src/components/ai/AiPromptDialog.tsx +++ b/src/nodepilot-ui/src/components/ai/AiPromptDialog.tsx @@ -12,19 +12,22 @@ interface Props { /** Optional "replace the entire editor content" toggle — useful for script generation. */ showReplaceToggle?: boolean; defaultReplaceAll?: boolean; + /** Show a one-shot, default-off consent for sending the current script as LLM context. */ + showScriptContextConsent?: boolean; + /** Sanitized host of the configured LLM target. Null falls back to a generic warning. */ + scriptContextTargetHost?: string | null; /** - * Called with the (trimmed) prompt plus the replace toggle. On success the dialog - * closes itself automatically; if the callback throws, its message is shown as an - * error and the dialog stays open. + * Called with the trimmed prompt, replace toggle, and one-shot script-context consent. + * If the callback throws, its message is shown as an error and the dialog stays open. */ - onSubmit: (prompt: string, replaceAll: boolean) => Promise; + onSubmit: (prompt: string, replaceAll: boolean, includeCurrentScript: boolean) => Promise; onClose: () => void; } /** * Generic input dialog for AI calls (script generation + workflow generation). * Owns its own loading/error state; the caller only gets a thin - * `onSubmit(prompt, replaceAll)` interface. + * `onSubmit(prompt, replaceAll, includeCurrentScript)` interface. */ export function AiPromptDialog({ title, @@ -33,6 +36,8 @@ export function AiPromptDialog({ submitLabel, showReplaceToggle = false, defaultReplaceAll = false, + showScriptContextConsent = false, + scriptContextTargetHost, onSubmit, onClose, }: Readonly) { @@ -40,6 +45,9 @@ export function AiPromptDialog({ const submitLabelResolved = submitLabel ?? t('ai:scriptDialog.generate'); const [prompt, setPrompt] = useState(''); const [replaceAll, setReplaceAll] = useState(defaultReplaceAll); + // Deliberately component-local and default false: closing/reopening the dialog requires a new + // decision and no browser storage ever remembers this data-egress consent. + const [includeCurrentScript, setIncludeCurrentScript] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const textareaRef = useRef(null); @@ -53,7 +61,7 @@ export function AiPromptDialog({ setError(null); setSubmitting(true); try { - await onSubmit(trimmed, replaceAll); + await onSubmit(trimmed, replaceAll, includeCurrentScript); // Closing does NOT happen automatically here — the caller closes the dialog // after inserting the result. That gives it a chance to sync editor state // before the dialog disappears. @@ -63,7 +71,7 @@ export function AiPromptDialog({ } finally { setSubmitting(false); } - }, [prompt, replaceAll, submitting, onSubmit]); + }, [prompt, replaceAll, includeCurrentScript, submitting, onSubmit]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Escape' && !submitting) onClose(); @@ -129,6 +137,23 @@ export function AiPromptDialog({ )} + {showScriptContextConsent && ( + + )} + {error && (
) ?? {}; const upstreamVars = node ? getUpstreamVariables(node.id, nodes, edges) : []; // Hook unconditionally before any early return (Rules of Hooks). - const handleAiGenerate = useAiScriptStream({ workflowId, stepId: node?.id, upstreamVars }); + const aiScript = useAiScriptStream({ workflowId, stepId: node?.id, upstreamVars }); if (!node) return null; @@ -474,7 +474,8 @@ function ScriptDoubleClickEditor({ upstreamRefs={upstreamRefs} outputVariableName={outputVariableName} onRun={workflowId ? runStepTest : undefined} - onAiGenerate={handleAiGenerate} + onAiGenerate={aiScript?.generate} + aiTargetHost={aiScript?.targetHost} /> ); diff --git a/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx b/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx index 1aa16e6d..37f77d21 100644 --- a/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx +++ b/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx @@ -60,7 +60,9 @@ interface Props { * appear as a banner in the editor. Default insert mode is "insert at cursor"; the user can * switch to "replace the whole editor content" in the prompt dialog. */ - onAiGenerate?: (prompt: string, currentScript: string, onToken: (text: string) => void, signal: AbortSignal) => Promise; + onAiGenerate?: (prompt: string, currentScript: string | null, onToken: (text: string) => void, signal: AbortSignal) => Promise; + /** Sanitized active LLM host shown beside the one-shot script-context consent. */ + aiTargetHost?: string | null; } const FONT_SIZE_KEY = 'nodepilot.scriptEditor.fontSize'; @@ -188,7 +190,8 @@ function parseExposedVars(code: string): string[] { } export function ScriptEditorDialog({ - value, onChange, onClose, onRun, availableVars = [], upstreamRefs = [], outputVariableName, title = 'PowerShell Script Editor', onAiGenerate, + value, onChange, onClose, onRun, availableVars = [], upstreamRefs = [], outputVariableName, + title = 'PowerShell Script Editor', onAiGenerate, aiTargetHost, }: Readonly) { const { t } = useTranslation(['ai', 'editor']); const aiDialogTitle = t('ai:scriptDialog.title'); @@ -342,7 +345,7 @@ export function ScriptEditorDialog({ * editor on the first token (so a pre-token error doesn't lose the old content). Cancelled via * `signal` (Stop/Close). */ - const handleAiSubmit = useCallback(async (prompt: string, replaceAll: boolean) => { + const handleAiSubmit = useCallback(async (prompt: string, replaceAll: boolean, includeCurrentScript: boolean) => { if (!onAiGenerate) return; if (aiAbortRef.current) return; // a generation is already running → no second stream const editor = editorRef.current; @@ -431,9 +434,9 @@ export function ScriptEditorDialog({ }; try { - // Send the current editor content along, so "refactor/fix the script" has a starting point. + // Editor contents leave the browser only after the dialog's one-shot consent. const currentScript = editor?.getValue() ?? code; - await onAiGenerate(prompt, currentScript, onToken, ac.signal); + await onAiGenerate(prompt, includeCurrentScript ? currentScript : null, onToken, ac.signal); cleanup(); } catch (err: unknown) { const aborted = (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; @@ -868,6 +871,8 @@ export function ScriptEditorDialog({ submitLabel={aiDialogSubmit} showReplaceToggle defaultReplaceAll={false} + showScriptContextConsent={code.trim().length > 0} + scriptContextTargetHost={aiTargetHost} onSubmit={handleAiSubmit} onClose={() => setAiDialogOpen(false)} /> diff --git a/src/nodepilot-ui/src/components/designer/properties/activities/RunScriptConfig.tsx b/src/nodepilot-ui/src/components/designer/properties/activities/RunScriptConfig.tsx index efec5191..2b2d8020 100644 --- a/src/nodepilot-ui/src/components/designer/properties/activities/RunScriptConfig.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/activities/RunScriptConfig.tsx @@ -40,7 +40,7 @@ export function RunScriptConfig({ config, onUpdate, upstreamVars = [], workflowI }, [workflowId, stepId, config, t]); // AI script generation (streaming): the script types itself out live in the Monaco editor. - const handleAiGenerate = useAiScriptStream({ workflowId, stepId, upstreamVars }); + const aiScript = useAiScriptStream({ workflowId, stepId, upstreamVars }); return ( <> @@ -188,10 +188,11 @@ export function RunScriptConfig({ config, onUpdate, upstreamVars = [], workflowI upstreamRefs={upstreamRefs} outputVariableName={outputVariableName} onRun={canRun ? runStepTest : undefined} - onAiGenerate={handleAiGenerate} + onAiGenerate={aiScript?.generate} + aiTargetHost={aiScript?.targetHost} /> )} ); -} \ No newline at end of file +} diff --git a/src/nodepilot-ui/src/hooks/useAiScriptStream.ts b/src/nodepilot-ui/src/hooks/useAiScriptStream.ts index 28c3ca93..3acffdae 100644 --- a/src/nodepilot-ui/src/hooks/useAiScriptStream.ts +++ b/src/nodepilot-ui/src/hooks/useAiScriptStream.ts @@ -1,30 +1,35 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { aiApi, MAX_UPSTREAM_VARIABLES, type AiUpstreamVariable } from '../api/ai'; import type { UpstreamVariable } from '../lib/upstreamVariables'; import { useAiCapabilities } from './useAiCapabilities'; import { useRole } from '../lib/rbac'; +export interface AiScriptStreamBinding { + generate: ( + prompt: string, + currentScript: string | null, + onToken: (text: string) => void, + signal: AbortSignal, + ) => Promise; + targetHost: string | null; +} + /** - * Builds the streaming `onAiGenerate` callback for the ScriptEditorDialog. Shared between - * the two call sites (RunScriptConfig in the properties panel + the runScript double-click - * editor in EditorOverlays) so the upstream-variable logic and the SSE call aren't duplicated. - * The callback streams the response: `onToken` is called per token, `signal` aborts the stream. - * - * Returns `undefined` when no LLM endpoint is usable or the user is a Viewer - * (`POST /api/ai/generate-script` is Admin/Operator-only — previously Viewers saw the button - * and got a 403 banner). Callers feed the result straight to `ScriptEditorDialog.onAiGenerate`, - * whose absence hides the KI button. + * Builds the streaming binding for both runScript editors. Current editor content is absent by + * default; the dialog only supplies it after an explicit, one-shot consent. The request carries + * the matching server-enforced flag, so older clients cannot opt in by merely sending a value. */ export function useAiScriptStream(opts: { workflowId?: string; stepId?: string; upstreamVars: UpstreamVariable[]; -}): ((prompt: string, currentScript: string, onToken: (text: string) => void, signal: AbortSignal) => Promise) | undefined { +}): AiScriptStreamBinding | undefined { const { workflowId, stepId, upstreamVars } = opts; - const llmUsable = useAiCapabilities().data?.llm === true; + const capabilities = useAiCapabilities().data; + const llmUsable = capabilities?.llm === true; const { isViewer } = useRole(); const callback = useCallback( - async (prompt: string, currentScript: string, onToken: (text: string) => void, signal: AbortSignal) => { + async (prompt: string, currentScript: string | null, onToken: (text: string) => void, signal: AbortSignal) => { const capped: AiUpstreamVariable[] = upstreamVars .slice(0, MAX_UPSTREAM_VARIABLES) .map((v) => ({ @@ -40,12 +45,17 @@ export function useAiScriptStream(opts: { workflowId: workflowId ?? null, stepId: stepId ?? null, upstreamVariables: capped, - currentScript: currentScript || null, // empty editor → don't include a script block in the prompt + currentScript: currentScript || null, + includeCurrentScript: !!currentScript, }, { onDelta: onToken, signal }, ); }, [workflowId, stepId, upstreamVars], ); - return llmUsable && !isViewer ? callback : undefined; + + return useMemo(() => llmUsable && !isViewer + ? { generate: callback, targetHost: capabilities?.scriptContextTargetHost ?? null } + : undefined, + [llmUsable, isViewer, callback, capabilities?.scriptContextTargetHost]); } diff --git a/src/nodepilot-ui/src/hooks/useWorkflowPersistence.ts b/src/nodepilot-ui/src/hooks/useWorkflowPersistence.ts index d5ee2e51..89ca43d7 100644 --- a/src/nodepilot-ui/src/hooks/useWorkflowPersistence.ts +++ b/src/nodepilot-ui/src/hooks/useWorkflowPersistence.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useBlocker } from 'react-router'; import { useTranslation } from 'react-i18next'; @@ -11,114 +11,272 @@ import { toast } from '../stores/toastStore'; interface UseWorkflowPersistenceArgs { workflowId: string | undefined; - /** The loaded workflow — only its description is persisted alongside name + graph. */ + /** The loaded workflow - only its description is persisted alongside name + graph. */ workflow: Workflow | undefined; nodes: Node[]; edges: Edge[]; + /** Pauses the debounce while an async graph producer has not decided which draft to apply. */ + suspendAutoSave?: boolean; +} + +interface WorkflowSnapshot { + workflowId: string; + revision: number; + body: { + name: string; + description: string; + definitionJson: string; + }; +} + +export interface AsyncGraphEditToken { + readonly workflowId: string; + readonly generation: number; + readonly revision: number; } /** - * Single owner of the workflow's save/dirty lifecycle: the editable name, the dirty flag, - * the persistable (runtime-stripped) definition, the save + atomic-publish mutations, the - * 5 s autosave debounce, the `beforeunload` guard, and the `useBlocker` discard-confirm — - * all kept internal. This untangles the former forward-reference (autosave effect → save - * mutation → persistableDefinition) by giving the whole cluster one declaration site. - * - * Exposes a narrow, intent-oriented API only — no `setName`, no `persistableDefinition`, - * no `blocker` leak out. `syncFromServer` is what the page's load effect calls to adopt - * the freshly-fetched name and clear dirty (identical refetch semantics as before). + * Single owner of the workflow's save/dirty lifecycle. Every request gets an immutable, + * revisioned snapshot. A save loops until the newest revision is durable; publish freezes edit + * affordances and a server refetch is adopted only when it cannot replace a local draft. */ -export function useWorkflowPersistence({ workflowId, workflow, nodes, edges }: UseWorkflowPersistenceArgs) { +export function useWorkflowPersistence({ + workflowId, workflow, nodes, edges, suspendAutoSave = false, +}: UseWorkflowPersistenceArgs) { const { t } = useTranslation(['editor', 'common']); const queryClient = useQueryClient(); const [name, setName] = useState(''); const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isPublishQueued, setIsPublishQueued] = useState(false); + const revisionRef = useRef(0); + const dirtyRef = useRef(false); + const savingRef = useRef(false); + const publishRef = useRef(false); + const syncedWorkflowIdRef = useRef(undefined); + const renderedWorkflowIdRef = useRef(workflowId); + const draftGenerationRef = useRef(0); - // Block browser navigation (back/forward) and in-app route changes when there are - // unsaved changes. `useBlocker` is the React Router v7 equivalent of the old `` - // component — it intercepts navigation before it happens so we can ask the user to - // confirm. The `beforeunload` handler below covers tab-close/refresh. const blocker = useBlocker(isDirty); - const persistableDefinition = useMemo(() => stripRuntimeDefinition({ nodes, edges }), [nodes, edges]); + // Always points at the newest rendered draft. Request bodies copy from this ref once; a + // follow-up save deliberately captures it again after the preceding request has completed. + const draftRef = useRef({ workflowId, name, description: workflow?.description ?? '', persistableDefinition }); + useLayoutEffect(() => { + if (renderedWorkflowIdRef.current !== workflowId) { + renderedWorkflowIdRef.current = workflowId; + // Even switching away and back to the same ID invalidates work from the prior visit. + draftGenerationRef.current += 1; + } + draftRef.current = { workflowId, name, description: workflow?.description ?? '', persistableDefinition }; + }, [workflowId, name, workflow?.description, persistableDefinition]); + + useLayoutEffect(() => () => { draftGenerationRef.current += 1; }, []); + + const updateDirty = useCallback((value: boolean) => { + dirtyRef.current = value; + setIsDirty(value); + }, []); + + const captureSnapshot = useCallback((): WorkflowSnapshot | null => { + const draft = draftRef.current; + if (!draft.workflowId) return null; + return { + workflowId: draft.workflowId, + revision: revisionRef.current, + body: { + name: draft.name, + description: draft.description, + definitionJson: JSON.stringify(draft.persistableDefinition), + }, + }; + }, []); + const saveMutation = useMutation({ - mutationFn: () => api.put(`/workflows/${workflowId}`, { name, description: workflow?.description ?? '', definitionJson: JSON.stringify(persistableDefinition) }), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }); setIsDirty(false); }, + mutationFn: (snapshot: WorkflowSnapshot) => api.put(`/workflows/${snapshot.workflowId}`, snapshot.body), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }); }, onError: (err) => toast.error(t('common:saveFailed', { message: (err as Error).message })), }); - // Publish = Save + Enable + Unlock, atomic on the backend so a tab reload between save - // and enable can't leave the workflow half-published. + const saveLoopRef = useRef | null>(null); + const saveLatest = useCallback((): Promise => { + if (saveLoopRef.current) return saveLoopRef.current; + + const run = (async () => { + savingRef.current = true; + setIsSaving(true); + try { + let response: unknown; + for (;;) { + const snapshot = captureSnapshot(); + if (!snapshot) return response; + response = await saveMutation.mutateAsync(snapshot); + + // A route change owns a different draft. Never carry a follow-up across it. + if (draftRef.current.workflowId !== snapshot.workflowId) return response; + if (revisionRef.current === snapshot.revision) { + updateDirty(false); + return response; + } + } + } finally { + savingRef.current = false; + setIsSaving(false); + } + })(); + + saveLoopRef.current = run; + const clear = () => { if (saveLoopRef.current === run) saveLoopRef.current = null; }; + // Use both branches rather than run.finally(), whose returned rejected promise would be + // unobserved when a fire-and-forget save fails. + void run.then(clear, clear); + return run; + }, [captureSnapshot, saveMutation, updateDirty]); + const publishMutation = useMutation({ - mutationFn: () => api.post(`/workflows/${workflowId}/publish`, { - name, - description: workflow?.description ?? '', - definitionJson: JSON.stringify(persistableDefinition), - }), - onSuccess: () => { + mutationFn: (snapshot: WorkflowSnapshot) => api.post(`/workflows/${snapshot.workflowId}/publish`, snapshot.body), + onSuccess: (_response, snapshot) => { queryClient.invalidateQueries({ queryKey: ['workflows'] }); - queryClient.invalidateQueries({ queryKey: ['workflow', workflowId] }); - setIsDirty(false); + queryClient.invalidateQueries({ queryKey: ['workflow', snapshot.workflowId] }); + if (draftRef.current.workflowId === snapshot.workflowId && revisionRef.current === snapshot.revision) + updateDirty(false); }, onError: (err) => toast.error(t('common:saveFailed', { message: (err as Error).message })), + onSettled: () => { + publishRef.current = false; + setIsPublishQueued(false); + }, }); - // Autosave: 5 s after last change, only when dirty and not already saving. + // Autosave five seconds after the newest edit. A running save performs its own follow-up. const autosaveTimer = useRef | null>(null); useEffect(() => { - if (!isDirty || saveMutation.isPending || !workflowId) return; + if (!isDirty || isSaving || isPublishQueued || suspendAutoSave || !workflowId) return; if (autosaveTimer.current) clearTimeout(autosaveTimer.current); - autosaveTimer.current = setTimeout(() => { saveMutation.mutate(); }, 5000); + autosaveTimer.current = setTimeout(() => { void saveLatest().catch(() => undefined); }, 5000); return () => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current); }; - }, [isDirty, nodes, edges, name]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isDirty, isSaving, isPublishQueued, suspendAutoSave, nodes, edges, name, workflowId, saveLatest]); - // Warn on unload when there are unsaved changes. useEffect(() => { - const handler = (e: BeforeUnloadEvent) => { if (isDirty) { e.preventDefault(); } }; + const handler = (e: BeforeUnloadEvent) => { if (isDirty) e.preventDefault(); }; globalThis.addEventListener('beforeunload', handler); return () => globalThis.removeEventListener('beforeunload', handler); }, [isDirty]); - // useBlocker confirmation dialog. When `isDirty` is true, `useBlocker` sets - // `blocker.state === 'blocked'` on any route change. The user can proceed (resetting - // isDirty first so the next navigation isn't blocked again) or cancel. No sync answer - // needed: the blocker stays 'blocked' until proceed()/reset() is called, so the async - // confirmDialog resolves first and the navigation waits (React Router's supported - // pattern for custom confirmation UIs). useEffect(() => { - if (blocker.state === 'blocked') { - void confirmDialog(t('editor:discardChangesConfirm')).then((proceed) => { - if (proceed) { - setIsDirty(false); - blocker.proceed?.(); - } else { - blocker.reset?.(); + if (blocker.state !== 'blocked') return; + void confirmDialog(t('editor:discardChangesConfirm')).then((proceed) => { + if (proceed) { + updateDirty(false); + blocker.proceed?.(); + } else { + blocker.reset?.(); + } + }); + }, [blocker, blocker.state, t, updateDirty]); + + const rename = useCallback((value: string) => { + revisionRef.current += 1; + // Keep same-tick intents (rename followed by Ctrl+S) snapshot-safe before React rerenders. + draftRef.current = { ...draftRef.current, name: value }; + setName(value); + updateDirty(true); + }, [updateDirty]); + + const markDirty = useCallback(() => { + revisionRef.current += 1; + updateDirty(true); + }, [updateDirty]); + + /** Captures ownership of the current draft before an async graph computation starts. */ + const beginAsyncGraphEdit = useCallback((): AsyncGraphEditToken | null => { + const draftWorkflowId = draftRef.current.workflowId; + if (!draftWorkflowId) return null; + return { + workflowId: draftWorkflowId, + generation: draftGenerationRef.current, + revision: revisionRef.current, + }; + }, []); + + /** + * Applies an async result only to the exact draft it was computed from. The request snapshot + * is updated before React renders, so an in-flight save observes exactly one new revision and + * follows up with this graph instead of re-saving the previous canvas. + */ + const applyAsyncGraphEdit = useCallback(( + token: AsyncGraphEditToken, + nextNodes: Node[], + nextEdges: Edge[], + ): boolean => { + if (publishRef.current + || draftRef.current.workflowId !== token.workflowId + || draftGenerationRef.current !== token.generation + || revisionRef.current !== token.revision) return false; + + draftRef.current = { + ...draftRef.current, + persistableDefinition: stripRuntimeDefinition({ nodes: nextNodes, edges: nextEdges }), + }; + revisionRef.current += 1; + updateDirty(true); + return true; + }, [updateDirty]); + + const save = useCallback(() => { void saveLatest().catch(() => undefined); }, [saveLatest]); + const saveAsync = useCallback(() => saveLatest(), [saveLatest]); + + const publish = useCallback(() => { + if (publishRef.current) return; + publishRef.current = true; + // A late async producer must never apply across this lifecycle boundary. + draftGenerationRef.current += 1; + setIsPublishQueued(true); + void (async () => { + try { + // Serialize Save -> Publish. The atomic publish then snapshots the latest durable draft. + if (saveLoopRef.current) await saveLoopRef.current; + const snapshot = captureSnapshot(); + if (!snapshot) { + publishRef.current = false; + setIsPublishQueued(false); + return; } - }); - } - }, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps - - /** Rename the workflow (marks dirty). Wired to the header name input. */ - const rename = useCallback((value: string) => { setName(value); setIsDirty(true); }, []); - /** Flag unsaved changes — called by every graph-mutating handler. */ - const markDirty = useCallback(() => { setIsDirty(true); }, []); - /** Fire-and-forget save (autosave / Ctrl+S). Errors handled by the mutation. */ - const save = useCallback(() => { saveMutation.mutate(); }, [saveMutation]); - /** Awaitable save for the save-before-run path, so a failed save can abort the run. */ - const saveAsync = useCallback(() => saveMutation.mutateAsync(), [saveMutation]); - /** Atomic publish (save + enable + unlock). */ - const publish = useCallback(() => { publishMutation.mutate(); }, [publishMutation]); - /** Adopt the server-loaded name and clear dirty. Called once per workflow load. */ - const syncFromServer = useCallback((serverName: string) => { setName(serverName); setIsDirty(false); }, []); + publishMutation.mutate(snapshot); + } catch { + // The save mutation already surfaced its error. Never publish an older revision. + publishRef.current = false; + setIsPublishQueued(false); + } + })(); + }, [captureSnapshot, publishMutation]); + + /** + * Returns whether the page may atomically adopt the server name and DefinitionJson. Lifecycle + * refetches during a save/publish or while dirty must not replace the locally owned canvas. + */ + const syncFromServer = useCallback((serverName: string): boolean => { + const isNewWorkflow = syncedWorkflowIdRef.current !== workflowId; + if (!isNewWorkflow && (dirtyRef.current || savingRef.current || publishRef.current)) return false; + + syncedWorkflowIdRef.current = workflowId; + if (isNewWorkflow) revisionRef.current = 0; + draftRef.current = { ...draftRef.current, name: serverName }; + setName(serverName); + updateDirty(false); + return true; + }, [workflowId, updateDirty]); return { name, isDirty, - isSaving: saveMutation.isPending, - isPublishing: publishMutation.isPending, + isSaving, + isPublishing: isPublishQueued || publishMutation.isPending, rename, markDirty, + beginAsyncGraphEdit, + applyAsyncGraphEdit, save, saveAsync, publish, diff --git a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json index 14588fda..6635d991 100644 --- a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json @@ -166,11 +166,18 @@ "allowedHostsFieldHint": "Host-Header, die der Host-Filter annimmt — mehrere durch Semikolon getrennt, am besten konkrete FQDNs. 'localhost' mit eintragen: die Health-Probe der Installer geht an https://localhost:/healthz/ready und würde sonst mit 400 abgewiesen.", "remoteNote": "<0>Remote (WinRM): RequireWinRmSsl, WinRM-Timeouts und Session-Pool werden unter <1>Performance → Remote bearbeitet — alle Remote-Schalter liegen dort gebündelt, damit ein Save den ganzen Block atomar überschreibt.", "reencryptCardTitle": "Secrets neu verschlüsseln", - "reencryptHint": "Verschlüsselt alle Credential-Passwörter und secret-markierten globalen Variablen mit dem aktiven Secret-Provider neu. Nach einer Master-Key-Rotation oder einem Wechsel von Secrets:Provider ausführen (Legacy-Provider für das Migrationsfenster konfiguriert lassen, siehe docs/secrets-providers.md).", + "reencryptHint": "Verschlüsselt alle Credential-Passwörter, secret-markierten globalen Variablen und verschlüsselten Workflow-History-Definitionen mit dem aktiven Secret-Provider neu. Nach einer Master-Key-Rotation oder einem Wechsel von Secrets:Provider ausführen (Legacy-Provider für das Migrationsfenster konfiguriert lassen, siehe docs/secrets-providers.md).", "reencryptButton": "Neu verschlüsseln", "reencryptConfirm": "Alle gespeicherten Secrets jetzt mit dem aktiven Provider neu verschlüsseln?", - "reencryptDone": "{{credentials}} Credential(s) und {{globals}} globale(s) Secret(s) neu verschlüsselt.", - "reencryptPartial": "Teilweise erfolgreich: {{rewritten}} neu verschlüsselt, {{skipped}} übersprungen — betroffene Einträge manuell neu erfassen (Details im Support-Log)." + "reencryptDone": "{{credentials}} Credential(s), {{globals}} globale(s) Secret(s) und {{workflowVersions}} Workflow-History-Version(en) neu verschlüsselt.", + "reencryptPartial": "Teilweise erfolgreich: {{rewritten}} neu verschlüsselt, {{skipped}} übersprungen — betroffene Einträge unten prüfen, bevor der Legacy-Provider entfernt wird.", + "reencryptResultComplete": "Rotation ohne übersprungene Einträge abgeschlossen", + "reencryptResultPartial": "Rotation mit noch zu prüfenden Einträgen abgeschlossen", + "reencryptScopeCredentials": "Credentials", + "reencryptScopeGlobals": "Globale Secrets", + "reencryptScopeWorkflowVersions": "Workflow-History-Versionen", + "reencryptScopeCounts": "{{rewritten}} neu verschlüsselt · {{skipped}} übersprungen", + "reencryptSkipDetails": "Übersprungene Einträge (Name · Grund · ID)" }, "logging": { "asyncFileSink": "Asynchroner Datei-Sink", diff --git a/src/nodepilot-ui/src/i18n/locales/de/ai.json b/src/nodepilot-ui/src/i18n/locales/de/ai.json index e32b7066..bfa119fe 100644 --- a/src/nodepilot-ui/src/i18n/locales/de/ai.json +++ b/src/nodepilot-ui/src/i18n/locales/de/ai.json @@ -10,6 +10,8 @@ "streamingBadge": "generiert", "stop": "Stopp", "replaceAll": "Komplettes Skript ersetzen (statt am Cursor einfügen)", + "includeCurrentScriptTarget": "Aktuelles Skript als Kontext an {{target}} senden. Es kann Passwörter, Tokens oder andere Secrets enthalten.", + "includeCurrentScriptFallback": "Aktuelles Skript an den konfigurierten LLM-Endpunkt senden (dieser kann extern sein). Es kann Passwörter, Tokens oder andere Secrets enthalten.", "insert": "Übernehmen", "regenerate": "Neu generieren", "tokenUsed": "Tokens: {{tokens}} · {{ms}} ms", diff --git a/src/nodepilot-ui/src/i18n/locales/de/editor.json b/src/nodepilot-ui/src/i18n/locales/de/editor.json index bdc37fbf..bc419a90 100644 --- a/src/nodepilot-ui/src/i18n/locales/de/editor.json +++ b/src/nodepilot-ui/src/i18n/locales/de/editor.json @@ -49,6 +49,7 @@ "undoTooltip": "Rückgängig (Ctrl+Z)", "redoTooltip": "Wiederherstellen (Ctrl+Y / Ctrl+Shift+Z)", "tidyTooltip": "Layout: {{mode}} — anwenden, dann nächsten Modus aktivieren", + "tidyFailed": "Automatisches Layout fehlgeschlagen: {{message}}", "restoreOrigLayout": "Originallayout wiederherstellen", "restoreOrigLayoutShort": "Orig", "searchTooltip": "Nodes suchen (Ctrl+F)", diff --git a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json index f31fbf01..6c276e77 100644 --- a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json @@ -166,11 +166,18 @@ "allowedHostsFieldHint": "Host headers the host filter accepts — semicolon-separated, ideally concrete FQDNs. Include 'localhost': the installers' own health probe targets https://localhost:/healthz/ready and would otherwise be rejected with a 400.", "remoteNote": "<0>Remote (WinRM): RequireWinRmSsl, WinRM timeouts and the session pool are edited under <1>Performance → Remote — every remote switch lives there together, so one save overwrites the whole block atomically.", "reencryptCardTitle": "Re-encrypt secrets", - "reencryptHint": "Re-encrypts every credential password and secret-flagged global variable with the active secret provider. Run this after rotating the master key or switching Secrets:Provider (keep the legacy provider configured for the migration window, see docs/secrets-providers.md).", + "reencryptHint": "Re-encrypts every credential password, secret-flagged global variable, and encrypted workflow-history definition with the active secret provider. Run this after rotating the master key or switching Secrets:Provider (keep the legacy provider configured for the migration window, see docs/secrets-providers.md).", "reencryptButton": "Re-encrypt now", "reencryptConfirm": "Re-encrypt all stored secrets with the active provider now?", - "reencryptDone": "Re-encrypted {{credentials}} credential(s) and {{globals}} global secret(s).", - "reencryptPartial": "Partial success: {{rewritten}} re-encrypted, {{skipped}} skipped — re-enter the affected entries manually (details in the support log)." + "reencryptDone": "Re-encrypted {{credentials}} credential(s), {{globals}} global secret(s), and {{workflowVersions}} workflow history version(s).", + "reencryptPartial": "Partial success: {{rewritten}} re-encrypted, {{skipped}} skipped — review the affected entries below before removing the legacy provider.", + "reencryptResultComplete": "Rotation completed without skips", + "reencryptResultPartial": "Rotation completed with entries that still need attention", + "reencryptScopeCredentials": "Credentials", + "reencryptScopeGlobals": "Global secrets", + "reencryptScopeWorkflowVersions": "Workflow history versions", + "reencryptScopeCounts": "{{rewritten}} re-encrypted · {{skipped}} skipped", + "reencryptSkipDetails": "Skipped entries (name · reason · ID)" }, "logging": { "asyncFileSink": "Async file sink", diff --git a/src/nodepilot-ui/src/i18n/locales/en/ai.json b/src/nodepilot-ui/src/i18n/locales/en/ai.json index 33ea9f60..549b7393 100644 --- a/src/nodepilot-ui/src/i18n/locales/en/ai.json +++ b/src/nodepilot-ui/src/i18n/locales/en/ai.json @@ -10,6 +10,8 @@ "streamingBadge": "generating", "stop": "Stop", "replaceAll": "Replace entire script (instead of inserting at cursor)", + "includeCurrentScriptTarget": "Send current script to {{target}} as context. It may contain passwords, tokens, or other secrets.", + "includeCurrentScriptFallback": "Send current script to the configured LLM endpoint (which may be external). It may contain passwords, tokens, or other secrets.", "insert": "Apply", "regenerate": "Regenerate", "tokenUsed": "Tokens: {{tokens}} · {{ms}} ms", diff --git a/src/nodepilot-ui/src/i18n/locales/en/editor.json b/src/nodepilot-ui/src/i18n/locales/en/editor.json index b765c45f..5011f700 100644 --- a/src/nodepilot-ui/src/i18n/locales/en/editor.json +++ b/src/nodepilot-ui/src/i18n/locales/en/editor.json @@ -49,6 +49,7 @@ "undoTooltip": "Undo (Ctrl+Z)", "redoTooltip": "Redo (Ctrl+Y / Ctrl+Shift+Z)", "tidyTooltip": "Layout: {{mode}} — click to apply, then advance to next mode", + "tidyFailed": "Auto-layout failed: {{message}}", "restoreOrigLayout": "Restore layout from before first auto-arrange", "restoreOrigLayoutShort": "Orig", "searchTooltip": "Search nodes (Ctrl+F)", diff --git a/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx b/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx index eede992d..245592a0 100644 --- a/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx +++ b/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx @@ -204,6 +204,8 @@ function WorkflowEditorInner() { }, [isAtelier]); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [tidyingWorkflowId, setTidyingWorkflowId] = useState(null); + const isTidying = !!id && tidyingWorkflowId === id; const [selected, setSelected] = useState(null); const [connectionNotice, setConnectionNotice] = useState(''); const [searchQuery, setSearchQuery] = useState(''); @@ -254,14 +256,20 @@ function WorkflowEditorInner() { }); const { - isLockedByMe, isLockedByOther, canWrite, + isLockedByMe, isLockedByOther, canWrite: lockCanWrite, lock, unlock, forceUnlock, disable, enable, isLocking, isUnlocking, isForceUnlocking, isDisabling, isEnabling, } = useWorkflowLock({ workflowId: id, workflow, currentUserId, roleCanWrite }); const { name, isDirty, isSaving, isPublishing, rename, markDirty, save, saveAsync, publish, syncFromServer, - } = useWorkflowPersistence({ workflowId: id, workflow, nodes, edges }); + beginAsyncGraphEdit, applyAsyncGraphEdit, + } = useWorkflowPersistence({ + workflowId: id, workflow, nodes, edges, suspendAutoSave: isTidying, + }); + // Publish saves and releases the lock atomically. Freeze all edit affordances as soon as it + // is queued, and while an async layout still owns a pre-apply graph snapshot. + const canWrite = lockCanWrite && !isPublishing && !isTidying; const { data: allWorkflows } = useQuery({ queryKey: ['workflows'], @@ -324,7 +332,7 @@ function WorkflowEditorInner() { useEffect(() => { if (workflow) { - syncFromServer(workflow.name); + if (!syncFromServer(workflow.name)) return; resetPasteCount(); pushRecentWorkflow(workflow.id); try { @@ -603,41 +611,58 @@ function WorkflowEditorInner() { // ---- Auto-Layout (Tidy) — cycles through LR → TB → Compact → ELK -------- const layoutMode = useDesignStore((s) => s.layoutMode); const setLayoutMode = useDesignStore((s) => s.setLayoutMode); - const [isTidying, setIsTidying] = useState(false); // Snapshot of positions before the first auto-layout in this session. - const origLayoutRef = useRef(null); - const [hasOrigLayout, setHasOrigLayout] = useState(false); + const origLayoutRef = useRef<{ workflowId: string | undefined; nodes: Node[] } | null>(null); + const [origLayoutWorkflowId, setOrigLayoutWorkflowId] = useState(null); + const hasOrigLayout = origLayoutWorkflowId === id; const tidyLayout = useCallback(async () => { if (nodes.length === 0 || isTidying) return; - if (origLayoutRef.current === null) { - origLayoutRef.current = nodes.map((n) => ({ ...n })); - setHasOrigLayout(true); - } - commitHistory('Tidy layout'); - markDirty(); const modeToApply = designerMode === 'standard' ? 'LR' : layoutMode; const next = LAYOUT_MODES[(LAYOUT_MODES.indexOf(layoutMode) + 1) % LAYOUT_MODES.length]; - if (modeToApply === 'LR') { - setNodes(autoLayout(nodes, edges)); - } else if (modeToApply === 'TB') { - setNodes(autoLayoutTB(nodes, edges)); - } else if (modeToApply === 'Compact') { - setNodes(autoLayoutCompact(nodes, edges)); - } else { - setIsTidying(true); - try { setNodes(await autoLayoutELK(nodes, edges)); } - finally { setIsTidying(false); } + const rememberOriginal = () => { + if (origLayoutRef.current?.workflowId !== id) { + origLayoutRef.current = { workflowId: id, nodes: nodes.map((n) => ({ ...n })) }; + setOrigLayoutWorkflowId(id ?? null); + } + }; + + if (modeToApply === 'ELK') { + const token = beginAsyncGraphEdit(); + if (!token || !id) return; + setTidyingWorkflowId(id); + try { + const laidOut = await autoLayoutELK(nodes, edges); + // Reject results after another edit, publish, unmount, or workflow visit. + if (!applyAsyncGraphEdit(token, laidOut, edges)) return; + rememberOriginal(); + commitHistory('Tidy layout'); + setNodes(laidOut); + if (designerMode === 'expert') setLayoutMode(next); + } catch (err) { + toast.error(t('editor:tidyFailed', { message: (err as Error).message })); + } finally { + setTidyingWorkflowId((current) => current === id ? null : current); + } + return; } + + rememberOriginal(); + commitHistory('Tidy layout'); + markDirty(); + if (modeToApply === 'LR') setNodes(autoLayout(nodes, edges)); + else if (modeToApply === 'TB') setNodes(autoLayoutTB(nodes, edges)); + else setNodes(autoLayoutCompact(nodes, edges)); if (designerMode === 'expert') setLayoutMode(next); - }, [nodes, edges, designerMode, layoutMode, isTidying, commitHistory, markDirty, setNodes, setLayoutMode]); + }, [nodes, edges, designerMode, layoutMode, isTidying, id, beginAsyncGraphEdit, + applyAsyncGraphEdit, commitHistory, markDirty, setNodes, setLayoutMode, t]); const restoreOrigLayout = useCallback(() => { - if (!origLayoutRef.current) return; + if (!origLayoutRef.current || origLayoutRef.current.workflowId !== id) return; commitHistory('Restore layout'); markDirty(); - setNodes(origLayoutRef.current); + setNodes(origLayoutRef.current.nodes); origLayoutRef.current = null; - setHasOrigLayout(false); - }, [commitHistory, markDirty, setNodes]); + setOrigLayoutWorkflowId(null); + }, [id, commitHistory, markDirty, setNodes]); // ---- Select All (Ctrl+A) ------------------------------------------------ const selectAll = useCallback(() => { @@ -892,14 +917,14 @@ function WorkflowEditorInner() { // in the toolbar buttons + command palette. Declared here (not earlier) because they // reference saveMutation/lockMutation/handleRunClick/etc., which are declared above. const triggerSave = useCallback(() => { - if (canWrite && isDirty && !isSaving) save(); - }, [canWrite, isDirty, isSaving, save]); + if (canWrite && isDirty && !isSaving && !isTidying) save(); + }, [canWrite, isDirty, isSaving, isTidying, save]); const triggerLock = useCallback(() => { if (roleCanWrite && !isLockedByMe && !isLockedByOther && !isLocking) lock(); }, [roleCanWrite, isLockedByMe, isLockedByOther, isLocking, lock]); const triggerUnlock = useCallback(() => { - if (isLockedByMe && !isUnlocking) unlock(); - }, [isLockedByMe, isUnlocking, unlock]); + if (isLockedByMe && !isUnlocking && !isTidying) unlock(); + }, [isLockedByMe, isUnlocking, isTidying, unlock]); const triggerForceUnlock = useCallback(async () => { if (isAdmin && isLockedByOther && !isForceUnlocking) { if (await confirmDialog(t('editor:banners.forceUnlockConfirm', { user: workflow?.checkedOutByUserName ?? t('common:unknown') }))) { @@ -912,7 +937,7 @@ function WorkflowEditorInner() { // really a Disable — the kill-switch path stays direct (no modal) because stopping // production is not a "is everything ready"-question. const requestPublish = useCallback(async () => { - if (!roleCanWrite || isLockedByOther) return; + if (!roleCanWrite || isLockedByOther || isTidying) return; if (workflow?.isEnabled) { if (await confirmDialog(t('editor:stopWorkflowConfirm'))) { disable(); @@ -929,15 +954,16 @@ function WorkflowEditorInner() { } setPrePublishOpen(true); }, [roleCanWrite, isLockedByOther, workflow?.isEnabled, isLockedByMe, - isPublishing, publish, isEnabling, enable, disable, prePublishLint]); + isPublishing, isTidying, publish, isEnabling, enable, disable, prePublishLint, t]); // Modal "Trotzdem publizieren" / "Publizieren" callback — fires the right mutation. // Errors block the button at render time, so we don't re-check here. const confirmPrePublish = useCallback(() => { setPrePublishOpen(false); + if (isTidying) return; if (isLockedByMe) publish(); else enable(); - }, [isLockedByMe, publish, enable]); + }, [isLockedByMe, isTidying, publish, enable]); // Keyboard shortcut (Ctrl+Shift+S) reuses the same gate so power users see the modal too. const triggerPublish = requestPublish; @@ -1152,12 +1178,12 @@ function WorkflowEditorInner() { lintResult={lintResult} setLintPanelOpen={setLintPanelOpen} setHelpOpen={setHelpOpen} hiddenActivityTypes={hiddenActivityTypes} setHiddenActivityTypes={setHiddenActivityTypes} liveExecution={liveExecution} handleRunClick={run} - exportPng={exportPng} onSave={save} isPublishing={isPublishing} + exportPng={exportPng} onSave={save} isPublishing={isPublishing || isTidying} onRequestPublish={requestPublish} roleCanWrite={roleCanWrite} isLockedByMe={isLockedByMe} isLockedByOther={isLockedByOther} - onLock={lock} isLocking={isLocking} onUnlock={unlock} isUnlocking={isUnlocking} + onLock={lock} isLocking={isLocking} onUnlock={unlock} isUnlocking={isUnlocking || isTidying} onDisable={disable} isDisabling={isDisabling} isEnabling={isEnabling} /> @@ -1737,8 +1763,8 @@ function WorkflowEditorInner() { onLock={lock} isLocking={isLocking} onUnlock={unlock} - isUnlocking={isUnlocking} - isPublishing={isPublishing} + isUnlocking={isUnlocking || isTidying} + isPublishing={isPublishing || isTidying} isEnabling={isEnabling} isDisabling={isDisabling} onForceUnlock={forceUnlock} diff --git a/src/nodepilot-ui/src/stores/aiChatStore.ts b/src/nodepilot-ui/src/stores/aiChatStore.ts index 97edb506..b2ed71ba 100644 --- a/src/nodepilot-ui/src/stores/aiChatStore.ts +++ b/src/nodepilot-ui/src/stores/aiChatStore.ts @@ -41,15 +41,6 @@ export interface ChatThreadMeta { /** Cap on persisted messages per thread (older ones are dropped when saving). */ const MAX_PERSISTED_MESSAGES = 200; -/** - * Cap on the size of a persisted `proposal.definitionJson`. Below the cap, a proposal - * survives a page reload and stays applicable (the diff base is reconstructed from the - * live canvas as long as its hash still matches `baseDefinitionHash`); above the cap it - * degrades to a read-only "expired" notice, so that a pathologically large workflow - * definition can't blow out the sessionStorage quota. - */ -const MAX_PERSISTED_PROPOSAL_CHARS = 100_000; - /** * Holds chat history **per user, workflow, and thread**. Unlike an earlier version, this * store is now `persist`-ed in sessionStorage (survives a page reload, not a closed tab), but @@ -101,28 +92,36 @@ function isPersistableScope(scopeKey: string): boolean { } /** - * Strips heavy/sensitive fields before persisting (the diff-base snapshot, streaming flags). - * Proposal JSON survives a reload only for the thread's **most recent** proposal (and only - * up to a size cap) so it stays applicable: older proposals are superseded anyway — the - * apply path would reject them via the stale-hash guard — so they persist as an empty `''` - * stub instead. Without this, 200 messages at ~90 KB each could blow out the sessionStorage - * quota. The diff base itself needs no snapshot — the panel reconstructs it from the live - * canvas as soon as its hash matches the proposal's baseDefinitionHash. + * Strips heavy/sensitive fields before persisting. Proposal metadata survives so the UI can + * explain that a proposal expired, but its server-restored definition never enters browser + * storage. The in-memory proposal remains applicable until the tab reloads. */ function stripForPersist(messages: ChatMessage[]): ChatMessage[] { const kept = messages.slice(-MAX_PERSISTED_MESSAGES); - const lastProposalIdx = kept.reduce((acc, m, i) => (m.proposal ? i : acc), -1); - return kept.map((m, i) => { + return kept.map((m) => { const { baseDef: _baseDef, streaming: _s, building: _b, proposal, ...rest } = m; const out: ChatMessage = { ...rest }; - if (proposal) { - const keepJson = i === lastProposalIdx && proposal.definitionJson.length <= MAX_PERSISTED_PROPOSAL_CHARS; - out.proposal = keepJson ? { ...proposal } : { ...proposal, definitionJson: '' }; - } + // The server merges proposals back onto the unredacted Workflow Definition. Keep proposal + // metadata for the expired notice, but never persist the potentially secret-bearing JSON. + if (proposal) out.proposal = { ...proposal, definitionJson: '' }; return out; }); } +/** Scrubs v1 state during hydration before Zustand writes the migrated v2 payload back. */ +function migratePersistedState(persistedState: unknown): unknown { + if (!persistedState || typeof persistedState !== 'object') return persistedState; + const state = persistedState as Record; + const rawMessages = state.messagesByThread; + if (!rawMessages || typeof rawMessages !== 'object') return state; + + const messagesByThread: Record = {}; + for (const [key, value] of Object.entries(rawMessages as Record)) { + messagesByThread[key] = Array.isArray(value) ? stripForPersist(value as ChatMessage[]) : []; + } + return { ...state, messagesByThread }; +} + export const useAiChatStore = create()( persist( (set, get) => ({ @@ -202,7 +201,8 @@ export const useAiChatStore = create()( }), { name: AI_CHAT_STORAGE_KEY, - version: 1, + version: 2, + migrate: (persistedState) => migratePersistedState(persistedState) as AiChatStore, storage: createJSONStorage(() => globalThis.sessionStorage), // Only persist saved workflows; strip sensitive/heavy fields. partialize: (state) => { diff --git a/tests/NodePilot.Ai.Tests/ScriptGenerationServiceTests.cs b/tests/NodePilot.Ai.Tests/ScriptGenerationServiceTests.cs index b5eb4df4..bf38fa0d 100644 --- a/tests/NodePilot.Ai.Tests/ScriptGenerationServiceTests.cs +++ b/tests/NodePilot.Ai.Tests/ScriptGenerationServiceTests.cs @@ -13,8 +13,9 @@ private static ScriptGenerationService NewService(FakeLlmClient client) private static GenerateScriptRequest NewRequest( string prompt = "list all stopped services", IReadOnlyList? vars = null, - string? currentScript = null) - => new(prompt, Guid.NewGuid(), "step-x", vars ?? Array.Empty(), currentScript); + string? currentScript = null, + bool includeCurrentScript = false) + => new(prompt, Guid.NewGuid(), "step-x", vars ?? Array.Empty(), currentScript, includeCurrentScript); /// Streams and collects the script text with code-fence markers stripped. private static async Task Collect(ScriptGenerationService svc, GenerateScriptRequest req) @@ -76,7 +77,8 @@ public async Task StreamAsync_WithCurrentScript_FramesItAsRefactorBase() var fake = new FakeLlmClient().EnqueueStream("Get-Date"); await Collect(NewService(fake), NewRequest( prompt: "refactor das skript", - currentScript: "$now = Get-Date\nWrite-Output $now")); + currentScript: "$now = Get-Date\nWrite-Output $now", + includeCurrentScript: true)); var prompt = fake.Calls[0].UserPrompt; prompt.Should().Contain("## Current script"); @@ -84,6 +86,18 @@ await Collect(NewService(fake), NewRequest( prompt.Should().Contain("Write-Output $now"); } + [Fact] + public async Task StreamAsync_LegacyCurrentScriptWithoutConsent_DoesNotForwardIt() + { + var fake = new FakeLlmClient().EnqueueStream("Get-Date"); + await Collect(NewService(fake), NewRequest( + prompt: "write a new script", + currentScript: "$password = 'must-not-leave-nodepilot'")); + + fake.Calls[0].UserPrompt.Should().NotContain("must-not-leave-nodepilot"); + fake.Calls[0].UserPrompt.Should().NotContain("## Current script"); + } + [Fact] public async Task StreamAsync_NoCurrentScript_OmitsTheBlock() { diff --git a/tests/NodePilot.Ai.Tests/WorkflowAssistantServiceTests.cs b/tests/NodePilot.Ai.Tests/WorkflowAssistantServiceTests.cs index 6245281a..958ff109 100644 --- a/tests/NodePilot.Ai.Tests/WorkflowAssistantServiceTests.cs +++ b/tests/NodePilot.Ai.Tests/WorkflowAssistantServiceTests.cs @@ -223,18 +223,22 @@ public async Task StreamChat_DoesNotSendRealSecretsToLlm_AndDisablesJsonMode() } [Fact] - public async Task StreamChat_DoesNotSendUnknownLiteralHttpHeaderToLlm() + public async Task StreamChat_DoesNotSendAnyOpaqueWorkflowFieldToLlm() { const string workflow = - """{"nodes":[{"id":"http","data":{"activityType":"restApi","config":{"headers":{"X-Tenant-Token":"opaque-tenant-credential","Accept":"application/json"}}}}],"edges":[]}"""; + """{"nodes":[{"id":"http","data":{"activityType":"restApi","config":{"headers":{"X-Tenant-Token":"opaque-tenant-credential","Accept":"application/json"},"script":"Write-Output 'unclassified-script-literal'","body":"plain-looking-body","scorchRaw":{"payload":"legacy-raw-literal"},"url":"https://example.test"}}}],"edges":[]}"""; var fake = new FakeLlmClient().EnqueueStream("ok"); await Run(NewService(fake), Req("Erkläre.", workflow)); var userTurn = fake.Calls.Single().Conversation!.Last().Content; userTurn.Should().NotContain("opaque-tenant-credential"); + userTurn.Should().NotContain("unclassified-script-literal"); + userTurn.Should().NotContain("plain-looking-body"); + userTurn.Should().NotContain("legacy-raw-literal"); + userTurn.Should().NotContain("application/json"); userTurn.Should().Contain("***"); - userTurn.Should().Contain("application/json"); + userTurn.Should().NotContain("https://example.test"); } // ---- Empty-canvas design mode ------------------------------------------------- diff --git a/tests/NodePilot.Ai.Tests/WorkflowDefinitionMergeTests.cs b/tests/NodePilot.Ai.Tests/WorkflowDefinitionMergeTests.cs index 109f86e1..07f22bbe 100644 --- a/tests/NodePilot.Ai.Tests/WorkflowDefinitionMergeTests.cs +++ b/tests/NodePilot.Ai.Tests/WorkflowDefinitionMergeTests.cs @@ -190,4 +190,64 @@ public void Merge_ContentMaskedHeadersString_RestoredFromOriginal_ByUniversalMas cfg["headers"]!.GetValue().Should().Be("Authorization: Bearer sk-live-REAL123"); // restored result.Notes.Should().BeEmpty(); } + + [Fact] + public void Merge_OpaqueHeadersObjectMask_RestoresCompleteOriginalShape() + { + var original = Parse(""" + { "nodes": [ { "id": "n1", "type": "activity", "position": {"x":0,"y":0}, + "data": { "activityType": "restApi", "config": { + "url": "https://x", "headers": { "Accept": "application/json", "X-Legacy": "opaque-value" } + } } } ], "edges": [] } + """); + var proposed = Parse(""" + { "nodes": [ { "id": "n1", "type": "activity", "position": {"x":0,"y":0}, + "data": { "activityType": "restApi", "config": { "url": "https://y", "headers": "***" } } } ], "edges": [] } + """); + + var result = WorkflowDefinitionMerge.Merge(original, proposed); + var cfg = result.Definition["nodes"]![0]!["data"]!["config"]!; + + cfg["url"]!.GetValue().Should().Be("https://y"); + cfg["headers"]!["Accept"]!.GetValue().Should().Be("application/json"); + cfg["headers"]!["X-Legacy"]!.GetValue().Should().Be("opaque-value"); + } + + [Fact] + public void Merge_MaskedLiteralsInsideNestedArrays_RestoresOriginalConditionsAndCases() + { + var original = Parse(""" + { "nodes": [{ "id": "decision-1", "type": "decision", "data": { "config": { + "cases": [{ "label": "secret-case", "conditionExpression": { + "type": "comparison", "left": { "kind": "variable", "value": "x" }, + "op": "==", "right": { "kind": "literal", "value": "case-secret" } + }}] + }}}], "edges": [{ "id": "edge-1", "source": "a", "target": "b", "data": { + "conditionExpression": { "type": "group", "op": "AND", "children": [{ + "type": "comparison", "left": { "kind": "literal", "value": "left-secret" }, + "op": "==", "right": { "kind": "variable", "value": "y" } + }]} + }}] } + """); + var proposed = Parse(""" + { "nodes": [{ "id": "decision-1", "type": "decision", "data": { "config": { + "cases": [{ "label": "secret-case", "conditionExpression": { + "type": "comparison", "left": { "kind": "variable", "value": "x" }, + "op": "==", "right": { "kind": "literal", "value": "***" } + }}] + }}}], "edges": [{ "id": "edge-1", "source": "a", "target": "b", "data": { + "conditionExpression": { "type": "group", "op": "AND", "children": [{ + "type": "comparison", "left": { "kind": "literal", "value": "***" }, + "op": "==", "right": { "kind": "variable", "value": "y" } + }]} + }}] } + """); + + var merged = WorkflowDefinitionMerge.Merge(original, proposed).Definition; + + merged["nodes"]![0]!["data"]!["config"]!["cases"]![0]!["conditionExpression"]! + ["right"]!["value"]!.GetValue().Should().Be("case-secret"); + merged["edges"]![0]!["data"]!["conditionExpression"]!["children"]![0]! + ["left"]!["value"]!.GetValue().Should().Be("left-secret"); + } } diff --git a/tests/NodePilot.Ai.Tests/WorkflowSecretRedactorTests.cs b/tests/NodePilot.Ai.Tests/WorkflowSecretRedactorTests.cs index b6ebff4f..cae80b07 100644 --- a/tests/NodePilot.Ai.Tests/WorkflowSecretRedactorTests.cs +++ b/tests/NodePilot.Ai.Tests/WorkflowSecretRedactorTests.cs @@ -85,7 +85,7 @@ public void Redact_MasksExtendedSecretKeys(string key) } [Fact] - public void Redact_RestApiObjectHeaders_MasksCredentialHeaders_PreservesBenignOnes() + public void Redact_RestApiObjectHeaders_MasksCompleteOpaqueValue() { var def = Parse(""" { "config": { "headers": { @@ -94,14 +94,12 @@ public void Redact_RestApiObjectHeaders_MasksCredentialHeaders_PreservesBenignOn "Content-Type": "application/json" } } } """); - var headers = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.AsObject(); - headers["Authorization"]!.GetValue().Should().Be("***"); - headers["X-Api-Key"]!.GetValue().Should().Be("***"); - headers["Content-Type"]!.GetValue().Should().Be("application/json"); // benign preserved + WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.GetValue() + .Should().Be("***"); } [Fact] - public void Redact_RestApiObjectHeaders_MasksUnknownLiteralHeader_PreservesPublicHeaders() + public void Redact_RestApiObjectHeaders_MasksUnknownLiteralAndPublicHeadersTogether() { var def = Parse(""" { "config": { "headers": { @@ -111,15 +109,12 @@ public void Redact_RestApiObjectHeaders_MasksUnknownLiteralHeader_PreservesPubli } } } """); - var headers = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.AsObject(); - - headers["X-Tenant-Token"]!.GetValue().Should().Be("***"); - headers["Accept"]!.GetValue().Should().Be("application/json"); - headers["Content-Type"]!.GetValue().Should().Be("application/json"); + WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.GetValue() + .Should().Be("***"); } [Fact] - public void Redact_RestApiObjectHeaders_PreservesTemplateOnlyCustomHeader() + public void Redact_RestApiObjectHeaders_MasksTemplateOnlyCustomHeaderBecauseFieldIsOpaque() { var def = Parse(""" { "config": { "headers": { @@ -127,13 +122,12 @@ public void Redact_RestApiObjectHeaders_PreservesTemplateOnlyCustomHeader() } } } """); - var headers = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.AsObject(); - - headers["X-Tenant-Token"]!.GetValue().Should().Be("{{globals.TENANT_TOKEN}}"); + WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.GetValue() + .Should().Be("***"); } [Fact] - public void Redact_RestApiObjectHeaders_PreservesTemplateOnlyAuthorizationHeader() + public void Redact_RestApiObjectHeaders_MasksTemplateOnlyAuthorizationHeaderBecauseFieldIsOpaque() { var def = Parse(""" { "config": { "headers": { @@ -141,9 +135,8 @@ public void Redact_RestApiObjectHeaders_PreservesTemplateOnlyAuthorizationHeader } } } """); - var headers = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.AsObject(); - - headers["Authorization"]!.GetValue().Should().Be("Bearer {{globals.API_TOKEN}}"); + WorkflowSecretRedactor.Redact(def).AsObject()["config"]!["headers"]!.GetValue() + .Should().Be("***"); } [Fact] @@ -171,36 +164,194 @@ public void Redact_RestApiStringHeaders_UnknownLiteralHeader_MasksWholeValue() } [Fact] - public void Redact_RestApiStringHeaders_ReferencingGlobals_NotMasked() + public void Redact_RestApiStringHeaders_ReferencingGlobals_StillMaskedAsOpaque() { // The steered pattern references a secret global — no literal secret lives in the definition. var def = Parse(""" { "config": { "headers": "Content-Type: application/json\nAuthorization: Bearer {{globals.API_TOKEN}}" } } """); var cfg = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!.AsObject(); - cfg["headers"]!.GetValue().Should() - .Be("Content-Type: application/json\nAuthorization: Bearer {{globals.API_TOKEN}}"); + cfg["headers"]!.GetValue().Should().Be("***"); } [Fact] - public void Redact_RestApiBody_WithInlineToken_Masked_BenignBodyPreserved() + public void Redact_RestApiBody_AlwaysMaskedWithoutClassifyingContents() { var secretBody = Parse("""{ "config": { "body": "{\"key\":\"sk_live_0123456789abcdef\"}" } }"""); WorkflowSecretRedactor.Redact(secretBody).AsObject()["config"]!["body"]!.GetValue().Should().Be("***"); var benignBody = Parse("""{ "config": { "body": "{\"name\":\"disk-check\",\"count\":3}" } }"""); WorkflowSecretRedactor.Redact(benignBody).AsObject()["config"]!["body"]!.GetValue() - .Should().Be("{\"name\":\"disk-check\",\"count\":3}"); + .Should().Be("***"); } [Fact] - public void Redact_RunScript_WithInlineSecretAssignment_Masked_BenignScriptPreserved() + public void Redact_RunScript_AlwaysMaskedWithoutClassifyingContents() { var secretScript = Parse("""{ "config": { "script": "$apiToken = \"sk-live-9f8e7d6c5b4a\"; Invoke-RestMethod" } }"""); WorkflowSecretRedactor.Redact(secretScript).AsObject()["config"]!["script"]!.GetValue().Should().Be("***"); var benignScript = Parse("""{ "config": { "script": "Get-Service | Where-Object Status -eq Running" } }"""); WorkflowSecretRedactor.Redact(benignScript).AsObject()["config"]!["script"]!.GetValue() - .Should().Be("Get-Service | Where-Object Status -eq Running"); + .Should().Be("***"); + } + + [Fact] + public void Redact_ScorchRaw_AlwaysMasksCompletePayload() + { + var def = Parse("""{ "config": { "scorchRaw": { "payload": "unclassified-legacy-secret" }, "url": "https://example.test" } }"""); + var cfg = WorkflowSecretRedactor.Redact(def).AsObject()["config"]!.AsObject(); + + cfg["scorchRaw"]!.GetValue().Should().Be("***"); + cfg["url"]!.GetValue().Should().Be("https://example.test"); + } + + [Theory] + [InlineData("startProgram", "arguments")] + [InlineData("scheduledTask", "arguments")] + [InlineData("wmiQuery", "filter")] + [InlineData("sql", "query")] + [InlineData("databaseTrigger", "query")] + [InlineData("restApi", "url")] + [InlineData("restApi", "proxyAddress")] + [InlineData("waitForCondition", "url")] + [InlineData("emailNotification", "subject")] + [InlineData("log", "message")] + [InlineData("jsonQuery", "jsonPath")] + [InlineData("xmlQuery", "xpath")] + [InlineData("textFileEdit", "replace")] + [InlineData("textFileEdit", "matchPattern")] + [InlineData("forEach", "items")] + [InlineData("registryOperation", "value")] + [InlineData("llmQuery", "prompt")] + [InlineData("llmQuery", "systemPrompt")] + [InlineData("llmQuery", "baseUrl")] + [InlineData("serviceManagement", "binaryPath")] + [InlineData("powerManagement", "message")] + [InlineData("eventLogTrigger", "messagePattern")] + public void Redact_MasksRuntimeConsumedOpaqueFieldForItsActivity(string activityType, string key) + { + var def = Parse($$""" + { "nodes": [{ "data": { "activityType": "{{activityType}}", "config": { + "{{key}}": "plain-looking-secret" + } } }] } + """); + + var config = WorkflowSecretRedactor.Redact(def)["nodes"]![0]!["data"]!["config"]!; + config[key]!.GetValue().Should().Be("***"); + } + + [Theory] + [InlineData("sql")] + [InlineData("databaseTrigger")] + [InlineData("startWorkflow")] + [InlineData("forEach")] + [InlineData("manualTrigger")] + public void Redact_MasksCompleteParameterPayloadForActivitiesThatForwardValues(string activityType) + { + var def = Parse($$""" + { "nodes": [{ "data": { "activityType": "{{activityType}}", "config": { + "parameters": { "innocentName": "plain-looking-secret" } + } } }] } + """); + + var parameters = WorkflowSecretRedactor.Redact(def)["nodes"]![0]!["data"]!["config"]!["parameters"]!; + parameters.GetValue().Should().Be("***"); + } + + [Fact] + public void Redact_MasksReturnDataObjectAndLiteralEdgeOperand_WithoutMaskingVariableOperand() + { + var def = Parse(""" + { + "nodes": [{ "data": { "activityType": "returnData", "config": { + "data": { "ordinaryName": "plain-looking-secret" } + } } }], + "edges": [{ "data": { "conditionExpression": { + "type": "comparison", + "left": { "kind": "variable", "value": "visible-structural-value" }, + "op": "==", + "right": { "kind": "literal", "value": "plain-looking-secret" } + } } }] + } + """); + + var redacted = WorkflowSecretRedactor.Redact(def); + redacted["nodes"]![0]!["data"]!["config"]!["data"]!.GetValue() + .Should().Be("***"); + var expression = redacted["edges"]![0]!["data"]!["conditionExpression"]!; + expression["left"]!["value"]!.GetValue().Should().Be("visible-structural-value"); + expression["right"]!["value"]!.GetValue().Should().Be("***"); + } + + [Theory] + [InlineData("startProgram", "arguments")] + [InlineData("sql", "query")] + public void Redact_UsesConcreteNodeTypeWhenActivityTypeIsOmitted(string nodeType, string key) + { + var def = Parse($$""" + { "nodes": [{ "type": "{{nodeType}}", "data": { "config": { + "{{key}}": "plain-looking-secret" + } } }] } + """); + + WorkflowSecretRedactor.Redact(def)["nodes"]![0]!["data"]!["config"]![key]! + .GetValue().Should().Be("***"); + } + + [Fact] + public void Redact_NestedActivityTypeCannotOverrideOwningNodePolicy() + { + var def = Parse(""" + { "nodes": [{ "type": "activity", "data": { + "activityType": "startProgram", "config": { + "activityType": "log", "arguments": "--password plain-looking-secret" + } + }}] } + """); + + var config = WorkflowSecretRedactor.Redact(def)["nodes"]![0]!["data"]!["config"]!; + config["arguments"]!.GetValue().Should().Be("***"); + config["activityType"]!.GetValue().Should().Be("log"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Redact_MasksFreeFormCustomActivityStringInputsButKeepsStructuralIdentity(bool explicitActivityType) + { + var nodePrefix = explicitActivityType + ? "\"type\":\"activity\",\"data\":{\"activityType\":\"custom:licensed_task\"," + : "\"type\":\"custom:licensed_task\",\"data\":{"; + var def = Parse("{\"nodes\":[{" + nodePrefix + "\"config\":{" + + "\"__customDefinitionId\":\"11111111-1111-1111-1111-111111111111\"," + + "\"__customKey\":\"licensed_task\",\"license\":\"plain-looking-key\"," + + "\"retries\":3}}}]}" ); + + var config = WorkflowSecretRedactor.Redact(def)["nodes"]![0]!["data"]!["config"]!; + config["license"]!.GetValue().Should().Be("***"); + config["__customKey"]!.GetValue().Should().Be("licensed_task"); + config["__customDefinitionId"]!.GetValue().Should().Be( + "11111111-1111-1111-1111-111111111111"); + config["retries"]!.GetValue().Should().Be(3); + } + + [Fact] + public void Redact_MasksLiteralOperandOnEitherSideAndInsideGroups() + { + var def = Parse(""" + { "edges": [{ "data": { "conditionExpression": { + "type": "group", "op": "AND", "children": [ + { "type": "comparison", + "left": { "kind": "literal", "value": "left-secret" }, + "op": "==", "right": { "kind": "literal", "value": "right-secret" } } + ] + } } }] } + """); + + var comparison = WorkflowSecretRedactor.Redact(def) + ["edges"]![0]!["data"]!["conditionExpression"]!["children"]![0]!; + comparison["left"]!["value"]!.GetValue().Should().Be("***"); + comparison["right"]!["value"]!.GetValue().Should().Be("***"); } } diff --git a/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs b/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs index 546371ca..778fc9b1 100644 --- a/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs +++ b/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NodePilot.Api.Ai; using NodePilot.Api.Services.DbAdmin; @@ -13,8 +14,9 @@ namespace NodePilot.Api.Tests.Ai; /// /// Direct coverage for the text2sql reader's redaction contract: secret columns named in the /// schema (User.PasswordHash, Credential.EncryptedPassword) are masked to "***" -/// by result-column name, the masked-by-name GlobalVariable.Value too, every other cell runs -/// through the redactor, rows are capped, and SQL errors surface as Error instead of throwing. +/// by result-column name, and Workflow Definition payloads are excluded from this generic database +/// knowledge source entirely. Every other cell runs through the redactor, rows are capped, and SQL +/// errors surface as Error instead of throwing. /// Uses the same in-memory SQLite backend as DbAdminQueryExecutorTests. /// public class SqlKnowledgeReaderTests @@ -56,6 +58,14 @@ public async Task ListTables_OmitsHiddenColumns_ListsDbTableName() user.DbTableName.Should().Be("Users"); user.ColumnNames.Should().NotContain("PasswordHash"); // hidden user.ColumnNames.Should().Contain("Username"); + + tables.Select(t => t.Name).Should().NotContain(new[] + { + "Workflow", + "WorkflowVersion", + "CustomActivityDefinition", + "CustomActivityDefinitionVersion", + }); } [Fact] @@ -68,6 +78,72 @@ public async Task GetTable_OmitsHiddenSecretColumns() detail!.Columns.Select(c => c.Name).Should().NotContain("PasswordHash"); } + [Theory] + [InlineData("Workflow")] + [InlineData("WorkflowVersion")] + [InlineData("CustomActivityDefinition")] + [InlineData("CustomActivityDefinitionVersion")] + public async Task GetTable_HidesOpaqueAutomationTablesFromAi(string table) + { + using var db = TestDbFactory.Create(); + + var detail = await NewReader(db).GetTableAsync(table, CancellationToken.None); + + detail.Should().BeNull(); + } + + [Theory] + [InlineData("SELECT DefinitionJson FROM Workflows")] + [InlineData("SELECT w.DefinitionJson AS payload FROM Workflows w")] + [InlineData("SELECT substr(DefinitionJson, 1, 20) AS payload FROM Workflows")] + [InlineData("SELECT [DefinitionJson] FROM [WorkflowVersions]")] + [InlineData("SELECT `DefinitionJson` FROM `WorkflowVersions`")] + [InlineData("SELECT U&\"Definiti\\006FnJson\" AS payload FROM U&\"Workfl\\006Fws\"")] + public async Task ExecuteRead_RejectsWorkflowDefinitionReferencesBeforeExecution(string sql) + { + using var db = TestDbFactory.Create(); + + var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); + + result.Error.Should().Contain("workflow definition"); + result.Rows.Should().BeEmpty(); + } + + [Theory] + [InlineData("SELECT ScriptTemplate FROM CustomActivityDefinitions")] + [InlineData("SELECT d.ScriptTemplate AS payload FROM CustomActivityDefinitions d")] + [InlineData("SELECT substr(InputParametersJson, 1, 10) FROM CustomActivityDefinitions")] + [InlineData("SELECT [InputParametersJson] FROM [CustomActivityDefinitionVersions]")] + public async Task ExecuteRead_RejectsCustomActivityImplementationReferencesBeforeExecution(string sql) + { + using var db = TestDbFactory.Create(); + + var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); + + result.Error.Should().Contain("custom activity implementation"); + result.Rows.Should().BeEmpty(); + } + + [Theory] + [InlineData("SELECT * FROM Workflows")] + [InlineData("SELECT w.* FROM Workflows AS w")] + [InlineData("SELECT * FROM WorkflowVersions")] + [InlineData("WITH current AS (SELECT * FROM Workflows) SELECT Name FROM current")] + [InlineData("TABLE Workflows")] + [InlineData("SELECT Id, Name FROM Workflows")] + [InlineData("SELECT leak FROM Workflows w CROSS JOIN LATERAL regexp_split_to_table(CAST(w AS text), 'NEVER_MATCH') AS leak")] + [InlineData("SELECT * FROM CustomActivityDefinitions")] + [InlineData("SELECT d.* FROM CustomActivityDefinitionVersions d")] + public async Task ExecuteRead_RejectsAnyProtectedAutomationTableReferenceBeforeExecution(string sql) + { + using var db = TestDbFactory.Create(); + + var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); + + result.Error.Should().Contain("workflow definition"); + result.Rows.Should().BeEmpty(); + } + [Fact] public async Task ExecuteRead_RejectsDirectPasswordHashReference() { @@ -140,14 +216,49 @@ public async Task ExecuteRead_RejectsWholeRowProjectionOverProtectedTable(string result.Rows.Should().BeEmpty(); } + [Theory] + [InlineData("SELECT to_json(w) FROM Workflows w")] + [InlineData("SELECT row_to_json(w) FROM \"Workflows\" w")] + [InlineData("SELECT to_jsonb(v) FROM WorkflowVersions v")] + [InlineData("SELECT w::text FROM Workflows w")] + [InlineData("SELECT json_agg(w) FROM Workflows w")] + [InlineData("SELECT CAST(w AS text) AS payload FROM Workflows w")] + [InlineData("SELECT array_to_json(array_agg(w)) AS payload FROM Workflows w")] + [InlineData("SELECT CAST(d AS text) FROM CustomActivityDefinitions d")] + [InlineData("SELECT * FROM Workflows FOR JSON AUTO")] + [InlineData("SELECT * FROM WorkflowVersions FOR XML AUTO")] + public async Task ExecuteRead_RejectsProviderSpecificWorkflowRowSerialization(string sql) + { + using var db = TestDbFactory.Create(); + + var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); + + result.Error.Should().Contain("workflow definition"); + result.Rows.Should().BeEmpty(); + } + + [Theory] + [InlineData("SELECT query_to_xml('SELECT \"DefinitionJson\" FROM \"Workflows\"', false, true, '')")] + [InlineData("SELECT table_to_xml('Workflows', false, true, '')")] + [InlineData("SELECT database_to_xml(false, true, '')")] + public async Task ExecuteRead_RejectsDynamicXmlDataExporters(string sql) + { + using var db = TestDbFactory.Create(); + + var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); + + result.Error.Should().Contain("workflow definition or custom activity implementation"); + result.Rows.Should().BeEmpty(); + } + /// /// The row-projection guard is blunt by design, so it must stay scoped to tables that actually /// hold a masked column — otherwise it would break ordinary analysis on the ~34 tables that /// hold no secret. /// [Theory] - [InlineData("SELECT to_json(w) FROM Workflows w")] - [InlineData("SELECT w::text FROM Workflows w")] + [InlineData("SELECT to_json(e) FROM WorkflowExecutions e")] + [InlineData("SELECT e::text FROM WorkflowExecutions e")] public async Task ExecuteRead_AllowsRowProjectionOverTableWithoutSecrets(string sql) { using var db = TestDbFactory.Create(); @@ -159,6 +270,53 @@ public async Task ExecuteRead_AllowsRowProjectionOverTableWithoutSecrets(string // no secret at all. result.Error.Should().NotBeNull(); result.Error.Should().NotContain("serializes a whole row"); + result.Error.Should().NotContain("workflow definition"); + } + + [Fact] + public async Task ExecuteRead_AllowsCountWildcardAndDefinitionJsonStringLiteral() + { + using var db = TestDbFactory.Create(); + db.GlobalVariables.Add(new GlobalVariable { Name = "safe-name", Value = "opaque" }); + await db.SaveChangesAsync(); + + var count = await NewReader(db).ExecuteReadAsync( + "SELECT COUNT(*) FROM GlobalVariables", CancellationToken.None); + var safeExpression = await NewReader(db).ExecuteReadAsync( + "SELECT CAST(g.Name AS text) FROM GlobalVariables g", CancellationToken.None); + var literal = await NewReader(db).ExecuteReadAsync( + "SELECT 'DefinitionJson' AS Label", CancellationToken.None); + + count.Error.Should().BeNull(); + count.Rows.Should().ContainSingle(); + safeExpression.Error.Should().BeNull(); + safeExpression.Rows.Should().ContainSingle(); + safeExpression.Rows[0][0].Should().Be("safe-name"); + literal.Error.Should().BeNull(); + literal.Rows.Should().ContainSingle(); + literal.Rows[0][0].Should().Be("DefinitionJson"); + } + + [Fact] + public async Task ExecuteRead_MasksWorkflowDefinitionResultColumn_AsDefenseInDepth() + { + using var db = TestDbFactory.Create(); + const string canary = "opaque-workflow-definition-canary-741"; + db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = canary }); + await db.SaveChangesAsync(); + await db.Database.ExecuteSqlRawAsync( + "CREATE VIEW WorkflowDefinitionLeak AS SELECT DefinitionJson FROM Workflows"); + + // The view is intentionally absent from AI schema discovery. This reaches result masking + // without naming the underlying protected table or column in the submitted statement. + var result = await NewReader(db).ExecuteReadAsync( + "SELECT * FROM WorkflowDefinitionLeak", CancellationToken.None); + + result.Error.Should().BeNull(); + result.Columns.Should().ContainSingle().Which.Should().Be("DefinitionJson"); + result.Rows.Should().ContainSingle(); + result.Rows[0][0].Should().Be(DbAdminSecretColumns.Mask); + result.Rows.SelectMany(r => r).Should().NotContain(canary); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Architecture/BackupSectionCoverageTests.cs b/tests/NodePilot.Api.Tests/Architecture/BackupSectionCoverageTests.cs index 313cfaea..7d5fd33c 100644 --- a/tests/NodePilot.Api.Tests/Architecture/BackupSectionCoverageTests.cs +++ b/tests/NodePilot.Api.Tests/Architecture/BackupSectionCoverageTests.cs @@ -23,7 +23,8 @@ public sealed class BackupSectionCoverageTests private static readonly string[] SectionConstants = typeof(BackupSections) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.IsLiteral && f.FieldType == typeof(string)) - .Where(f => f.Name is not ("Schema" or "SchemaV2" or "CurrentSchema")) + .Where(f => !f.Name.StartsWith("Schema", StringComparison.Ordinal) + && f.Name != "CurrentSchema") .Select(f => (string)f.GetRawConstantValue()!) .ToArray(); diff --git a/tests/NodePilot.Api.Tests/Controllers/AiKnowledgeControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/AiKnowledgeControllerTests.cs index 0905b484..dd25358c 100644 --- a/tests/NodePilot.Api.Tests/Controllers/AiKnowledgeControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/AiKnowledgeControllerTests.cs @@ -321,6 +321,7 @@ public void Capabilities_AllEnabledAdmin_AllTrue() caps.Operational.Should().BeTrue(); caps.SourceCode.Should().BeTrue(); caps.Db.Should().BeTrue(); + caps.ScriptContextTargetHost.Should().Be("localhost"); } [Fact] @@ -364,6 +365,7 @@ public void Capabilities_Viewer_SourceCodeAndDbFalse_EvenWhenEnabled() caps.Docs.Should().BeTrue(); caps.SourceCode.Should().BeFalse(); // source-code is Admin/Operator only caps.Db.Should().BeFalse(); // DB (raw SQL) is global-Admin only + caps.ScriptContextTargetHost.Should().BeNull(); // script generation is Admin/Operator only } [Fact] diff --git a/tests/NodePilot.Api.Tests/Controllers/AuthControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/AuthControllerTests.cs index 7889ea8a..5788da9c 100644 --- a/tests/NodePilot.Api.Tests/Controllers/AuthControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/AuthControllerTests.cs @@ -95,6 +95,7 @@ public async Task Login_FirstUser_WithValidBootstrapToken_CreatesAdmin() response.Token.Should().NotBeNullOrEmpty(); response.Username.Should().Be("admin"); response.Role.Should().Be("Admin"); + response.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddHours(7)); // Verify user was created and the token file was consumed var user = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin"); @@ -480,6 +481,7 @@ public async Task Refresh_WithValidUser_ReturnsNewToken() response.Token.Should().NotBeNullOrEmpty(); response.Username.Should().Be("admin"); response.Role.Should().Be("Admin"); + response.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddHours(7)); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Controllers/BackupControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/BackupControllerTests.cs index 9f57f5ed..c867588d 100644 --- a/tests/NodePilot.Api.Tests/Controllers/BackupControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/BackupControllerTests.cs @@ -51,11 +51,14 @@ public BackupControllerTests() new MachineBackupPart(_db), new GlobalVariableFolderBackupPart(_db), new GlobalVariableBackupPart(globals), + new CustomActivityBackupPart(new CustomActivityDefinitionStore(_db)), new WorkflowBackupPart(_db), new SettingsBackupPart(overrides, _atRest), ]); _restore = new BackupRestoreService( - _db, _atRest, overrides, NullLogger.Instance); + _db, _atRest, overrides, NullLogger.Instance, + new NodePilot.Api.Services.WorkflowVersionDefinitionProtector( + _atRest, NullLogger.Instance)); _db.Users.Add(new User { diff --git a/tests/NodePilot.Api.Tests/Controllers/SecretsControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/SecretsControllerTests.cs index 12c7f611..b08beea4 100644 --- a/tests/NodePilot.Api.Tests/Controllers/SecretsControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/SecretsControllerTests.cs @@ -1,11 +1,17 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using NodePilot.Api.Controllers; using NodePilot.Api.Dtos; +using NodePilot.Api.Services; using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Data.Security; using NodePilot.Api.Tests.TestSupport; +using NodePilot.TestCommons; using Xunit; namespace NodePilot.Api.Tests.Controllers; @@ -24,8 +30,10 @@ public class SecretsControllerTests private static SecretsController Build( ReencryptionSummary credResult, ReencryptionSummary globalsResult, + NodePilotDbContext db, out Mock credMock, - out Mock globalsMock) + out Mock globalsMock, + WorkflowVersionDefinitionProtector? versionDefinitions = null) { credMock = new Mock(); credMock.Setup(s => s.ReencryptAllCredentialsAsync(It.IsAny())) @@ -33,15 +41,23 @@ private static SecretsController Build( globalsMock = new Mock(); globalsMock.Setup(s => s.ReencryptAllSecretsAsync(It.IsAny())) .ReturnsAsync(globalsResult); - return new SecretsController(credMock.Object, globalsMock.Object, NoopAuditWriter.Instance); + return new SecretsController( + credMock.Object, globalsMock.Object, db, + versionDefinitions ?? VersionDefinitions(), NoopAuditWriter.Instance); } + private static WorkflowVersionDefinitionProtector VersionDefinitions() => + new(new AesGcmSecretProtector(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()), + NullLogger.Instance); + [Fact] public async Task Reencrypt_AllCleanSuccess_Returns200_WithPartialSuccessFalse() { + using var db = TestDbFactory.Create(); var ctrl = Build( new ReencryptionSummary(Rewritten: 47, Skipped: 0, SkippedDetails: Array.Empty()), new ReencryptionSummary(Rewritten: 12, Skipped: 0, SkippedDetails: Array.Empty()), + db, out _, out _); var result = await ctrl.Reencrypt(CancellationToken.None); @@ -50,6 +66,7 @@ public async Task Reencrypt_AllCleanSuccess_Returns200_WithPartialSuccessFalse() var body = ok.Value.Should().BeOfType().Subject; body.CredentialsRewritten.Should().Be(47); body.GlobalSecretsRewritten.Should().Be(12); + body.WorkflowVersionsRewritten.Should().Be(0); body.PartialSuccess.Should().BeFalse( "every row converted cleanly — operator should see 200 + a clean partialSuccess=false"); } @@ -57,6 +74,7 @@ public async Task Reencrypt_AllCleanSuccess_Returns200_WithPartialSuccessFalse() [Fact] public async Task Reencrypt_SomeRowsSkipped_Returns207_WithDetails() { + using var db = TestDbFactory.Create(); var brokenCredId = Guid.NewGuid(); var brokenGlobalId = Guid.NewGuid(); var ctrl = Build( @@ -68,6 +86,7 @@ public async Task Reencrypt_SomeRowsSkipped_Returns207_WithDetails() Rewritten: 3, Skipped: 1, SkippedDetails: new[] { new ReencryptionSkip(brokenGlobalId, "STRIPE_KEY", "FormatException") }), + db, out _, out _); var result = await ctrl.Reencrypt(CancellationToken.None); @@ -88,11 +107,13 @@ public async Task Reencrypt_SomeRowsSkipped_Returns207_WithDetails() [Fact] public async Task Reencrypt_NothingToDo_Returns200_WithZeros() { + using var db = TestDbFactory.Create(); // Empty deployment (or already-fully-migrated): both sweeps return zeros. // Still a clean success — no skips to flag. var ctrl = Build( new ReencryptionSummary(0, 0, Array.Empty()), new ReencryptionSummary(0, 0, Array.Empty()), + db, out _, out _); var result = await ctrl.Reencrypt(CancellationToken.None); @@ -101,17 +122,20 @@ public async Task Reencrypt_NothingToDo_Returns200_WithZeros() var body = ok.Value.Should().BeOfType().Subject; body.CredentialsRewritten.Should().Be(0); body.GlobalSecretsRewritten.Should().Be(0); + body.WorkflowVersionsRewritten.Should().Be(0); body.PartialSuccess.Should().BeFalse(); } [Fact] public async Task Reencrypt_CallsBothStores_OnceEach() { + using var db = TestDbFactory.Create(); // Pin the contract: the endpoint MUST sweep both surfaces. A regression that // forgot one would silently leave half the rotation incomplete. var ctrl = Build( new ReencryptionSummary(1, 0, Array.Empty()), new ReencryptionSummary(1, 0, Array.Empty()), + db, out var credMock, out var globalsMock); await ctrl.Reencrypt(CancellationToken.None); @@ -119,4 +143,36 @@ public async Task Reencrypt_CallsBothStores_OnceEach() credMock.Verify(s => s.ReencryptAllCredentialsAsync(It.IsAny()), Times.Once); globalsMock.Verify(s => s.ReencryptAllSecretsAsync(It.IsAny()), Times.Once); } + + [Fact] + public async Task Reencrypt_IncludesWorkflowHistoryInAdditiveCounters() + { + using var db = TestDbFactory.Create(); + const string legacyDefinition = + """{"nodes":[{"data":{"config":{"script":"Write-Output 'history-literal'"}}}],"edges":[]}"""; + var workflow = new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = "{}" }; + db.Workflows.Add(workflow); + db.WorkflowVersions.Add(new WorkflowVersion + { + Id = Guid.NewGuid(), WorkflowId = workflow.Id, Version = 1, Name = workflow.Name, + DefinitionJson = legacyDefinition, + }); + await db.SaveChangesAsync(); + var versionDefinitions = VersionDefinitions(); + var ctrl = Build( + new ReencryptionSummary(0, 0, Array.Empty()), + new ReencryptionSummary(0, 0, Array.Empty()), + db, out _, out _, versionDefinitions); + + var result = await ctrl.Reencrypt(CancellationToken.None); + + var body = result.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + body.WorkflowVersionsRewritten.Should().Be(1); + body.WorkflowVersionsSkipped.Should().Be(0); + db.ChangeTracker.Clear(); + var stored = db.WorkflowVersions.Single().DefinitionJson; + stored.Should().NotContain("history-literal"); + versionDefinitions.Unprotect(stored).Should().Be(legacyDefinition); + } } diff --git a/tests/NodePilot.Api.Tests/Controllers/WorkflowControllerHarness.cs b/tests/NodePilot.Api.Tests/Controllers/WorkflowControllerHarness.cs index 54b63df3..e139c906 100644 --- a/tests/NodePilot.Api.Tests/Controllers/WorkflowControllerHarness.cs +++ b/tests/NodePilot.Api.Tests/Controllers/WorkflowControllerHarness.cs @@ -25,6 +25,12 @@ internal sealed record WorkflowControllerHarness( internal static class WorkflowControllerHarnessFactory { + internal static NodePilot.Api.Services.WorkflowVersionDefinitionProtector VersionDefinitions() => + new( + new NodePilot.Data.Security.AesGcmSecretProtector( + Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()), + NullLogger.Instance); + public static WorkflowControllerHarness Build( NodePilotDbContext db, IAuditWriter? audit = null, @@ -55,15 +61,16 @@ public static WorkflowControllerHarness Build( // permissions for its principal. Tests that specifically exercise RBAC denial use // the dedicated RBAC test fixtures instead of this harness. authz ??= new AlwaysAllowAuthorizationService(); + var versionDefinitions = VersionDefinitions(); var workflows = new WorkflowsController( db, NullLogger.Instance, audit, authz, - new NodePilot.Api.Services.WorkflowContractDeriver()) + new NodePilot.Api.Services.WorkflowContractDeriver(), versionDefinitions) { ControllerContext = NewCtx() }; var editing = new WorkflowEditingController( db, NullLogger.Instance, audit, authz, - Mock.Of(), Mock.Of()) + Mock.Of(), Mock.Of(), versionDefinitions) { ControllerContext = NewCtx() }; diff --git a/tests/NodePilot.Api.Tests/Controllers/WorkflowImportExportControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/WorkflowImportExportControllerTests.cs index 73c297c4..ec2f13aa 100644 --- a/tests/NodePilot.Api.Tests/Controllers/WorkflowImportExportControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/WorkflowImportExportControllerTests.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using NodePilot.Api.Controllers; using NodePilot.Api.Dtos; +using NodePilot.Core.Interfaces; using NodePilot.Core.Models; using NodePilot.Data; using NodePilot.Api.Tests.TestSupport; @@ -117,6 +119,31 @@ public async Task ExportOne_RedactsUnknownLiteralHttpHeader() content.Content.Should().Contain("***"); } + [Fact] + public async Task ExportOne_RedactsOpaqueLegacyFields_WithoutSecretHeuristics() + { + var db = CreateContext(); + var wf = new Workflow + { + Id = Guid.NewGuid(), + Name = "Legacy", + DefinitionJson = + """{"nodes":[{"id":"legacy","data":{"activityType":"restApi","config":{"script":"Write-Output 'plain-looking-literal'","body":"opaque-body","headers":{"Accept":"application/json"},"scorchRaw":{"payload":"raw-migration-value"},"url":"https://example.test/?api_key=plain-looking-secret"}}}],"edges":[]}""", + }; + db.Workflows.Add(wf); + await db.SaveChangesAsync(); + + var result = await NewController(db).ImportExport.ExportOne(wf.Id, CancellationToken.None); + var content = result.Should().BeOfType().Subject.Content!; + + content.Should().NotContain("plain-looking-literal"); + content.Should().NotContain("opaque-body"); + content.Should().NotContain("application/json"); + content.Should().NotContain("raw-migration-value"); + content.Should().NotContain("example.test"); + content.Should().NotContain("api_key"); + } + [Fact] public async Task ExportAll_TwoWorkflows_BundleHasBoth() { @@ -383,6 +410,170 @@ public async Task ImportScorch_VariablesOnly_CreatesVariableAndEmitsAudit() call.Details.Should().Contain("\"variables\":1"); } + // Operators migrate Orchestrator runbooks themselves, globals included. This is a deliberate + // product decision, not an oversight: an Operator may already run arbitrary script under the + // service identity, so gating the variable would split every migration into two passes + // without taking away a capability. + [Fact] + public async Task ImportScorch_OperatorImportsWorkflow_CreatesGlobalVariable() + { + var db = CreateContext(); + var h = NewController(db, role: "Operator"); + var workflowId = Guid.NewGuid(); + var variableId = Guid.NewGuid(); + var xml = $$""" + + + + + {{workflowId}} + Operator Migration + Imported by an Operator, globals included. + + + + + + + Variable + {{variableId}} + MissingGlobal + migration-value + + + + + """; + h.ImportExport.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(xml)); + + var result = await h.ImportExport.ImportScorch(null, CancellationToken.None); + + var response = result.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + response.Created.Should().Be(1); + response.Workflows.Should().ContainSingle(w => w.Name == "Operator Migration"); + response.Variables.Should().ContainSingle(v => + v.Name == "MissingGlobal" && v.CreatedNow && !v.Skipped); + response.Warnings.Should().NotContain(w => + w.Contains("Admin approval", StringComparison.OrdinalIgnoreCase)); + db.Workflows.Should().ContainSingle(w => w.Name == "Operator Migration"); + db.GlobalVariables.Should().ContainSingle(g => g.Name == "MissingGlobal"); + } + + [Fact] + public async Task ImportScorch_CombinedWorkflowsAndVariablesOverLimit_IsRejectedBeforeWrites() + { + var db = CreateContext(); + var h = NewController(db); + var variables = string.Join("", Enumerable.Range(0, 501).Select(i => $$""" + + Variable + {{Guid.NewGuid()}} + Var_{{i}} + value + + """)); + var xml = $""" + + {variables} + + """; + h.ImportExport.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(xml)); + + var result = await h.ImportExport.ImportScorch(null, CancellationToken.None); + + result.Result.Should().BeOfType(); + db.Workflows.Should().BeEmpty(); + db.GlobalVariables.Should().BeEmpty(); + } + + [Fact] + public async Task ImportScorch_LaterVariableFailure_RollsBackEarlierVariableAndWorkflow() + { + var db = CreateContext(); + var realStore = new NodePilot.Data.GlobalVariableStore( + db, + new NodePilot.Data.Security.DpapiSecretProtector( + System.Security.Cryptography.DataProtectionScope.CurrentUser)); + var createCalls = 0; + var failingStore = new Mock(MockBehavior.Strict); + failingStore.Setup(s => s.GetAllAsync(It.IsAny())) + .Returns((CancellationToken ct) => realStore.GetAllAsync(ct)); + failingStore.Setup(s => s.CreateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string value, bool isSecret, string? description, + Guid folderId, string? updatedBy, CancellationToken ct) => + { + createCalls++; + if (createCalls == 2) + throw new InvalidOperationException("injected second-variable failure"); + return realStore.CreateAsync( + name, value, isSecret, description, folderId, updatedBy, ct); + }); + + var principal = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "Admin"), + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + Guid.NewGuid().ToString()), + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "test-admin"), + }, + "TestAuth")); + var controller = new WorkflowImportExportController( + db, + NullLogger.Instance, + new CapturingAuditWriter(), + new AlwaysAllowAuthorizationService(), + failingStore.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }, + }; + var xml = $$""" + + + + + {{Guid.NewGuid()}} + Atomic Migration + + + + + + + Variable + {{Guid.NewGuid()}} + FirstGlobal + first-value + + + Variable + {{Guid.NewGuid()}} + SecondGlobal + second-value + + + + + """; + controller.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(xml)); + + var act = () => controller.ImportScorch(null, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("injected second-variable failure"); + db.ChangeTracker.Clear(); + (await db.GlobalVariables.AsNoTracking().ToListAsync()).Should().BeEmpty(); + (await db.Workflows.AsNoTracking().ToListAsync()).Should().BeEmpty(); + } + [Fact] public async Task ExportOne_EmitsWorkflowExportedAudit() { diff --git a/tests/NodePilot.Api.Tests/Controllers/WorkflowsControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/WorkflowsControllerTests.cs index 1de7ba6c..93a29cf6 100644 --- a/tests/NodePilot.Api.Tests/Controllers/WorkflowsControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/WorkflowsControllerTests.cs @@ -828,6 +828,38 @@ await h.Workflows.Update( audit.Calls.Should().ContainSingle(c => c.Action == "WORKFLOW_UPDATED"); } + [Fact] + public async Task Update_StoresHistoricDefinitionEncrypted_AndAuthorizedEditorReadsPlaintext() + { + var db = CreateContext(); + const string legacyDefinition = + """{"nodes":[{"id":"s","data":{"activityType":"runScript","config":{"script":"ConvertTo-SecureString 'history-secret' -AsPlainText -Force"}}}],"edges":[]}"""; + var workflow = new Workflow + { + Id = Guid.NewGuid(), Name = "Legacy", DefinitionJson = legacyDefinition, Version = 1, + CheckedOutByUserId = TestUserId, CheckedOutAt = DateTime.UtcNow, + }; + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + var h = NewController(db); + + await h.Workflows.Update( + workflow.Id, + new UpdateWorkflowRequest("Legacy v2", null, """{"nodes":[],"edges":[]}"""), + CancellationToken.None); + + db.ChangeTracker.Clear(); + var stored = await db.WorkflowVersions.Where(v => v.WorkflowId == workflow.Id) + .Select(v => v.DefinitionJson).SingleAsync(); + stored.Should().NotContain("history-secret"); + stored.Should().StartWith("np:wfv:v1:", + "the database stores an opaque authenticated ciphertext envelope, not readable JSON"); + + var result = await h.Editing.GetVersion(workflow.Id, 1, CancellationToken.None); + var detail = (result.Result as OkObjectResult)!.Value.Should().BeOfType().Subject; + detail.DefinitionJson.Should().Contain("history-secret"); + } + [Fact] public async Task Update_WhenVersionSnapshotAlreadyExists_Returns409() { @@ -925,7 +957,7 @@ public async Task Rollback_AppliesHistoricDefinition_AndWritesNewVersion() WorkflowId = workflow.Id, Version = 1, Name = "Old", - DefinitionJson = OneNodeDef, + DefinitionJson = WorkflowControllerHarnessFactory.VersionDefinitions().Protect(OneNodeDef), }; db.Workflows.Add(workflow); db.WorkflowVersions.Add(target); @@ -944,6 +976,7 @@ public async Task Rollback_AppliesHistoricDefinition_AndWritesNewVersion() var history = await db.WorkflowVersions.Where(v => v.WorkflowId == workflow.Id).OrderBy(v => v.Version).ToListAsync(); history.Select(v => v.Version).Should().Equal(1, 3); history.First(v => v.Version == 3).ChangeNote.Should().Contain("rollback"); + history.First(v => v.Version == 3).DefinitionJson.Should().StartWith("np:wfv:v1:"); audit.Calls.Should().ContainSingle(c => c.Action == "WORKFLOW_ROLLED_BACK"); } diff --git a/tests/NodePilot.Api.Tests/Controllers/WorkflowsEditLockTests.cs b/tests/NodePilot.Api.Tests/Controllers/WorkflowsEditLockTests.cs index 7e2a7e9d..eb17f7b8 100644 --- a/tests/NodePilot.Api.Tests/Controllers/WorkflowsEditLockTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/WorkflowsEditLockTests.cs @@ -602,6 +602,8 @@ public async Task Duplicate_OfEnabledWorkflow_CreatesDisabledCopy() { var db = CreateContext(); var w = NewWorkflow(enabled: true, lockedBy: null); + w.DefinitionJson = + """{"nodes":[{"id":"t","data":{"activityType":"scheduleTrigger","config":{"cron":"0 * * * *"}}},{"id":"a","data":{"activityType":"log","config":{}}}],"edges":[]}"""; db.Workflows.Add(w); await db.SaveChangesAsync(); @@ -613,6 +615,10 @@ public async Task Duplicate_OfEnabledWorkflow_CreatesDisabledCopy() copy.IsEnabled.Should().BeFalse( "L-6: a duplicate is always born disabled so cloning a locked/under-review workflow cannot bypass the edit-lock"); copy.Name.Should().Be("W (Copy)"); + var expectedMetadata = WorkflowMetadata.Compute(w.DefinitionJson); + copy.ActivityCount.Should().Be(expectedMetadata.ActivityCount); + copy.TriggerTypesJson.Should().Be(expectedMetadata.TriggerTypesJson); + copy.Version.Should().Be(1, "a duplicate starts a distinct monotonically-versioned history"); } private sealed class CallbackAuthorizationService( diff --git a/tests/NodePilot.Api.Tests/Rbac/WorkflowResponseCapabilitiesTests.cs b/tests/NodePilot.Api.Tests/Rbac/WorkflowResponseCapabilitiesTests.cs index 9e683b76..d52f4627 100644 --- a/tests/NodePilot.Api.Tests/Rbac/WorkflowResponseCapabilitiesTests.cs +++ b/tests/NodePilot.Api.Tests/Rbac/WorkflowResponseCapabilitiesTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using NodePilot.Api.Controllers; using NodePilot.Api.Dtos; using NodePilot.Api.Security; @@ -10,6 +11,7 @@ using NodePilot.Core.Models; using NodePilot.TestCommons; using NodePilot.Api.Tests.TestSupport; +using NodePilot.Engine; using Xunit; namespace NodePilot.Api.Tests.Rbac; @@ -77,13 +79,29 @@ private WorkflowsController NewCtrl(Guid userId, string role) var ctrl = new WorkflowsController( _db, NullLogger.Instance, NoopAuditWriter.Instance, new ResourceAuthorizationService(_db), - new NodePilot.Api.Services.WorkflowContractDeriver()) + new NodePilot.Api.Services.WorkflowContractDeriver(), + NodePilot.Api.Tests.Controllers.WorkflowControllerHarnessFactory.VersionDefinitions()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = principal } } }; return ctrl; } + private WorkflowEditingController NewEditingCtrl(Guid userId, string role) + { + var principal = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Role, role), + ], "test")); + return new WorkflowEditingController( + _db, NullLogger.Instance, NoopAuditWriter.Instance, + new ResourceAuthorizationService(_db), Mock.Of(), Mock.Of(), + NodePilot.Api.Tests.Controllers.WorkflowControllerHarnessFactory.VersionDefinitions()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = principal } }, + }; + } + [Fact] public async Task GetById_AsEditor_HasFullCapabilitiesExceptAdmin() { @@ -136,8 +154,57 @@ public async Task GetById_AsViewer_RedactsUnknownLiteralHttpHeader() var dto = ok!.Value as WorkflowResponse; dto!.DefinitionJson.Should().NotContain("opaque-tenant-credential"); - dto.DefinitionJson.Should().Contain("\"X-Tenant-Token\":\"***\""); - dto.DefinitionJson.Should().Contain("application/json"); + dto.DefinitionJson.Should().NotContain("application/json"); + dto.DefinitionJson.Should().Contain("\"headers\":\"***\""); + } + + [Fact] + public async Task GetById_AsViewer_RedactsEveryOpaqueDefinitionField_WithoutGuessingSecretSyntax() + { + _financeWorkflow.DefinitionJson = + """ + {"nodes":[{"id":"legacy","data":{"activityType":"restApi","config":{ + "script":"$secure = ConvertTo-SecureString 'hunter2' -AsPlainText -Force", + "body":"arbitrary-legacy-body-literal", + "headers":{"Accept":"application/json","X-Legacy":"unclassified-value"}, + "scorchRaw":{"source":"opaque-migration-payload"}, + "url":"https://example.test" + }}}],"edges":[]} + """; + _db.SaveChanges(); + + var ctrl = NewCtrl(_viewerId, "Viewer"); + var ok = (await ctrl.GetById(_financeWorkflow.Id, CancellationToken.None)).Result as OkObjectResult; + var dto = ok!.Value as WorkflowResponse; + + dto!.DefinitionJson.Should().NotContain("hunter2"); + dto.DefinitionJson.Should().NotContain("arbitrary-legacy-body-literal"); + dto.DefinitionJson.Should().NotContain("application/json"); + dto.DefinitionJson.Should().NotContain("unclassified-value"); + dto.DefinitionJson.Should().NotContain("opaque-migration-payload"); + dto.DefinitionJson.Should().NotContain("https://example.test"); + } + + [Fact] + public async Task GetHistoricVersion_AsViewer_DecryptsInternally_ButReturnsRedactedDefinition() + { + const string historic = + """{"nodes":[{"id":"s","data":{"config":{"script":"Write-Output 'historic-secret'","url":"https://example.test"}}}],"edges":[]}"""; + var protector = NodePilot.Api.Tests.Controllers.WorkflowControllerHarnessFactory.VersionDefinitions(); + _financeWorkflow.Version = 2; + _db.WorkflowVersions.Add(new WorkflowVersion + { + Id = Guid.NewGuid(), WorkflowId = _financeWorkflow.Id, Version = 1, Name = _financeWorkflow.Name, + DefinitionJson = protector.Protect(historic), + }); + _db.SaveChanges(); + + var result = await NewEditingCtrl(_viewerId, "Viewer") + .GetVersion(_financeWorkflow.Id, 1, CancellationToken.None); + var detail = (result.Result as OkObjectResult)!.Value.Should().BeOfType().Subject; + + detail.DefinitionJson.Should().NotContain("historic-secret"); + detail.DefinitionJson.Should().Contain("https://example.test"); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Rbac/WorkflowStepTestRbacTests.cs b/tests/NodePilot.Api.Tests/Rbac/WorkflowStepTestRbacTests.cs index 99e11009..af9eb3b2 100644 --- a/tests/NodePilot.Api.Tests/Rbac/WorkflowStepTestRbacTests.cs +++ b/tests/NodePilot.Api.Tests/Rbac/WorkflowStepTestRbacTests.cs @@ -186,7 +186,8 @@ private WorkflowEditingController NewController() NoopAuditWriter.Instance, new ResourceAuthorizationService(_db), _stepTester.Object, - Mock.Of()) + Mock.Of(), + NodePilot.Api.Tests.Controllers.WorkflowControllerHarnessFactory.VersionDefinitions()) { ControllerContext = new ControllerContext { diff --git a/tests/NodePilot.Api.Tests/Rbac/WorkflowsControllerRbacTests.cs b/tests/NodePilot.Api.Tests/Rbac/WorkflowsControllerRbacTests.cs index e015e33e..a44739cb 100644 --- a/tests/NodePilot.Api.Tests/Rbac/WorkflowsControllerRbacTests.cs +++ b/tests/NodePilot.Api.Tests/Rbac/WorkflowsControllerRbacTests.cs @@ -90,7 +90,8 @@ private WorkflowsController NewController(Guid userId, string globalRole) var ctrl = new WorkflowsController( _db, NullLogger.Instance, NoopAuditWriter.Instance, new ResourceAuthorizationService(_db), - new NodePilot.Api.Services.WorkflowContractDeriver()) + new NodePilot.Api.Services.WorkflowContractDeriver(), + NodePilot.Api.Tests.Controllers.WorkflowControllerHarnessFactory.VersionDefinitions()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = principal } } }; diff --git a/tests/NodePilot.Api.Tests/Security/AuthSessionIssuerSecureCookieTests.cs b/tests/NodePilot.Api.Tests/Security/AuthSessionIssuerSecureCookieTests.cs index f8a96827..cc62a301 100644 --- a/tests/NodePilot.Api.Tests/Security/AuthSessionIssuerSecureCookieTests.cs +++ b/tests/NodePilot.Api.Tests/Security/AuthSessionIssuerSecureCookieTests.cs @@ -1,14 +1,21 @@ +using System.Data.Common; using FluentAssertions; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using NodePilot.Api.Security; using NodePilot.Api.Tests.TestSupport; using NodePilot.Core.Enums; using NodePilot.Core.Models; +using NodePilot.Data; using NodePilot.TestCommons; using Xunit; @@ -25,6 +32,43 @@ namespace NodePilot.Api.Tests.Security; /// public class AuthSessionIssuerSecureCookieTests { + private sealed record RefreshAttempt( + IssuedSession? Session, + Exception? Error, + DefaultHttpContext Context); + + private sealed class CommitAcknowledgementLostException : Exception { } + + private sealed class CommitAmbiguityExecutionStrategy( + ExecutionStrategyDependencies dependencies) + : ExecutionStrategy(dependencies, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero) + { + protected override bool ShouldRetryOn(Exception exception) + => exception is CommitAcknowledgementLostException; + } + + public sealed class CommitAmbiguityExecutionStrategyFactory( + ExecutionStrategyDependencies dependencies) : IExecutionStrategyFactory + { + public IExecutionStrategy Create() => new CommitAmbiguityExecutionStrategy(dependencies); + } + + private sealed class LoseFirstCommitAcknowledgementInterceptor : DbTransactionInterceptor + { + private int _commitCount; + public int CommitCount => Volatile.Read(ref _commitCount); + + public override Task TransactionCommittedAsync( + DbTransaction transaction, + TransactionEndEventData eventData, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _commitCount) == 1) + throw new CommitAcknowledgementLostException(); + return Task.CompletedTask; + } + } + private sealed class FakeEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Development; @@ -197,5 +241,191 @@ public async Task RefreshToken_IsSingleUseWithinServerSideSessionFamily() var persisted = db.AuthSessions.Single(); persisted.CurrentJti.Should().Be(refreshedJwt.Id); persisted.RefreshGeneration.Should().Be(1); + refreshed.TokenRotationCommitted.Should().BeTrue(); + db.RevokedTokens.Should().ContainSingle(r => r.Jti == originalJwt.Id && r.Reason == "rotated"); + } + + [Fact] + public async Task Refresh_WhenRevocationWriteFails_RollsBackSessionAndEmitsNoCookies() + { + using var db = TestDbFactory.Create(); + var user = NewUser(); + db.Users.Add(user); + await db.SaveChangesAsync(); + var issuer = new AuthSessionIssuer( + NewConfig(), new TestJwtKeyProvider(), NoopAuditWriter.Instance, db: db); + var original = await issuer.IssueAsync( + user, AuthSource.Local, NewHttpContext(isHttps: true), default); + var originalJwt = new JwtSecurityTokenHandler().ReadJwtToken(original.Token); + + // Abort exactly the second half of the rotation. SaveChanges has already staged the + // AuthSession update, so this catches regressions that split or fail to transact the + // two writes rather than merely testing a failure before any database work starts. + await db.Database.ExecuteSqlRawAsync( + """ + CREATE TRIGGER fail_refresh_revocation + BEFORE INSERT ON RevokedTokens + WHEN NEW.Reason = 'rotated' + BEGIN + SELECT nodepilot_injected_refresh_failure(); + END; + """); + + var refreshContext = NewHttpContext(isHttps: true); + refreshContext.User = new ClaimsPrincipal( + new ClaimsIdentity(originalJwt.Claims, "jwt")); + + Func refresh = async () => + await issuer.RefreshAsync(user, refreshContext, default); + + await refresh.Should().ThrowAsync(); + ExtractSetCookieHeader(refreshContext).Should().Be((null, null), + "cookies must only be emitted after the complete rotation commits"); + + db.ChangeTracker.Clear(); + var persisted = await db.AuthSessions.AsNoTracking().SingleAsync(); + persisted.CurrentJti.Should().Be(originalJwt.Id, + "a failed revocation insert must roll back the staged CurrentJti change"); + persisted.RefreshGeneration.Should().Be(0); + (await db.RevokedTokens.AsNoTracking().AnyAsync()).Should().BeFalse(); + } + + [Fact] + public async Task Refresh_WhenCommitAcknowledgementIsLost_VerifiesCommitAndReturnsSameToken() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var setupOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + User user; + IssuedSession original; + await using (var setup = new NodePilotDbContext(setupOptions)) + { + await setup.Database.EnsureCreatedAsync(); + user = NewUser(); + setup.Users.Add(user); + await setup.SaveChangesAsync(); + var setupIssuer = new AuthSessionIssuer( + NewConfig(), new TestJwtKeyProvider(), NoopAuditWriter.Instance, db: setup); + original = await setupIssuer.IssueAsync( + user, AuthSource.Local, NewHttpContext(isHttps: true), default); + } + + var lostCommit = new LoseFirstCommitAcknowledgementInterceptor(); + var refreshOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .AddInterceptors(lostCommit) + .ReplaceService() + .Options; + await using var refreshDb = new NodePilotDbContext(refreshOptions); + var issuer = new AuthSessionIssuer( + NewConfig(), new TestJwtKeyProvider(), NoopAuditWriter.Instance, db: refreshDb); + var originalJwt = new JwtSecurityTokenHandler().ReadJwtToken(original.Token); + var refreshContext = NewHttpContext(isHttps: true); + refreshContext.User = new ClaimsPrincipal( + new ClaimsIdentity(originalJwt.Claims, "jwt")); + + var refreshed = await issuer.RefreshAsync(user, refreshContext, default); + + lostCommit.CommitCount.Should().Be(1, + "verifySucceeded must recognize the committed stable JTI instead of replaying the write"); + refreshed.TokenRotationCommitted.Should().BeTrue(); + ExtractSetCookieHeader(refreshContext).Auth.Should().NotBeNull(); + refreshDb.ChangeTracker.Clear(); + var persisted = await refreshDb.AuthSessions.AsNoTracking().SingleAsync(); + var refreshedJwt = new JwtSecurityTokenHandler().ReadJwtToken(refreshed.Token); + persisted.CurrentJti.Should().Be(refreshedJwt.Id); + persisted.RefreshGeneration.Should().Be(1); + (await refreshDb.RevokedTokens.AsNoTracking().SingleAsync()).Jti + .Should().Be(originalJwt.Id); + } + + [Fact] + public async Task ParallelRefreshes_AcrossDbContexts_CommitExactlyOneRotation() + { + var databasePath = Path.Combine( + Path.GetTempPath(), $"nodepilot-refresh-race-{Guid.NewGuid():N}.db"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Pooling = false, + DefaultTimeout = 30, + }.ToString(); + var baseOptions = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + + try + { + IssuedSession original; + await using (var setup = new NodePilotDbContext(baseOptions)) + { + await setup.Database.EnsureCreatedAsync(); + await setup.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + var user = NewUser(); + setup.Users.Add(user); + await setup.SaveChangesAsync(); + var issuer = new AuthSessionIssuer( + NewConfig(), new TestJwtKeyProvider(), NoopAuditWriter.Instance, db: setup); + original = await issuer.IssueAsync( + user, AuthSource.Local, NewHttpContext(isHttps: true), default); + } + + var originalJwt = new JwtSecurityTokenHandler().ReadJwtToken(original.Token); + var racingOptions = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + + await using var firstDb = new NodePilotDbContext(racingOptions); + await using var secondDb = new NodePilotDbContext(racingOptions); + + async Task AttemptAsync(NodePilotDbContext db) + { + var user = await db.Users.AsNoTracking().SingleAsync(); + var context = NewHttpContext(isHttps: true); + context.User = new ClaimsPrincipal( + new ClaimsIdentity(originalJwt.Claims, "jwt")); + var issuer = new AuthSessionIssuer( + NewConfig(), new TestJwtKeyProvider(), NoopAuditWriter.Instance, db: db); + try + { + return new RefreshAttempt( + await issuer.RefreshAsync(user, context, default), null, context); + } + catch (Exception ex) + { + return new RefreshAttempt(null, ex, context); + } + } + + var attempts = await Task.WhenAll( + AttemptAsync(firstDb), + AttemptAsync(secondDb)); + + var winner = attempts.Should().ContainSingle(a => a.Session != null).Subject; + var loser = attempts.Should().ContainSingle(a => a.Error != null).Subject; + loser.Error.Should().BeOfType( + "the stale parallel refresh is a replay, not a second valid rotation"); + winner.Session!.TokenRotationCommitted.Should().BeTrue(); + ExtractSetCookieHeader(winner.Context).Auth.Should().NotBeNull(); + ExtractSetCookieHeader(loser.Context).Should().Be((null, null)); + + await using var verify = new NodePilotDbContext(baseOptions); + var persisted = await verify.AuthSessions.AsNoTracking().SingleAsync(); + var winningJwt = new JwtSecurityTokenHandler().ReadJwtToken(winner.Session.Token); + persisted.CurrentJti.Should().Be(winningJwt.Id); + persisted.RefreshGeneration.Should().Be(1); + var revocation = await verify.RevokedTokens.AsNoTracking().SingleAsync(); + revocation.Jti.Should().Be(originalJwt.Id); + revocation.Reason.Should().Be("rotated"); + } + finally + { + foreach (var path in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" }) + { + try { File.Delete(path); } catch { /* best-effort test cleanup */ } + } + } } } diff --git a/tests/NodePilot.Api.Tests/Security/ProvisioningSeederTests.cs b/tests/NodePilot.Api.Tests/Security/ProvisioningSeederTests.cs index 144f3933..ee1d5697 100644 --- a/tests/NodePilot.Api.Tests/Security/ProvisioningSeederTests.cs +++ b/tests/NodePilot.Api.Tests/Security/ProvisioningSeederTests.cs @@ -44,7 +44,9 @@ private string TempPath(string extension = ".json") private BackupRestoreService Restore(NodePilotDbContext db) => new(db, _atRest, new RuntimeOverridesWriter(TempPath(), NullLogger.Instance), - NullLogger.Instance); + NullLogger.Instance, + new NodePilot.Api.Services.WorkflowVersionDefinitionProtector( + _atRest, NullLogger.Instance)); /// A backup carrying one break-glass Admin — the minimum a seed must contain. private async Task BuildSeedAsync(string username = "seeded-admin") diff --git a/tests/NodePilot.Api.Tests/Services/Backup/BackupAlertingTests.cs b/tests/NodePilot.Api.Tests/Services/Backup/BackupAlertingTests.cs index d202107f..95f4bffa 100644 --- a/tests/NodePilot.Api.Tests/Services/Backup/BackupAlertingTests.cs +++ b/tests/NodePilot.Api.Tests/Services/Backup/BackupAlertingTests.cs @@ -14,8 +14,9 @@ namespace NodePilot.Api.Tests.Services.Backup; /// -/// Backup schema v2 (ADR 0008): alerting rules + system policies round-trip through export/restore with -/// route-secret passphrase-rewrap and scope-target remap; the envelope advertises v2 and both schemas import. +/// Alerting section (introduced in backup schema v2, ADR 0008): rules + system policies round-trip +/// through current export/restore with route-secret rewrap and scope-target remap; legacy schemas +/// remain readable. /// public sealed class BackupAlertingTests : IDisposable { @@ -43,6 +44,7 @@ private IBackupPart[] Parts(NodePilotDbContext db) => [ new FolderBackupPart(db), new UserBackupPart(db), new CredentialBackupPart(db, _atRest), new MachineBackupPart(db), new GlobalVariableFolderBackupPart(db), new GlobalVariableBackupPart(new GlobalVariableStore(db, _atRest)), + new CustomActivityBackupPart(new CustomActivityDefinitionStore(db)), new WorkflowBackupPart(db), new AlertingBackupPart(db, _atRest), ]; @@ -51,19 +53,24 @@ private async Task ExportAsync(NodePilotDbContext db, List secti private BackupRestoreService Restore(NodePilotDbContext db) => new(db, _atRest, new RuntimeOverridesWriter(TempPath(), NullLogger.Instance), - NullLogger.Instance); + NullLogger.Instance, + new NodePilot.Api.Services.WorkflowVersionDefinitionProtector( + _atRest, NullLogger.Instance)); private static Dictionary AllSkip() => new(StringComparer.Ordinal) { [BackupSections.Folders] = RestoreConflictPolicy.Skip, [BackupSections.Workflows] = RestoreConflictPolicy.Skip, [BackupSections.Alerting] = RestoreConflictPolicy.Skip }; [Fact] - public async Task Export_AdvertisesSchemaV2() + public async Task Export_AdvertisesCurrentSchema_AndKeepsOlderSchemasReadable() { await using var db = TestDbFactory.Create(); var bytes = await ExportAsync(db, [BackupSections.Alerting]); var reader = BackupFileReader.Parse(bytes); - reader.Schema.Should().Be(BackupSections.SchemaV2); - BackupSections.SupportedSchemas.Should().Contain(BackupSections.Schema).And.Contain(BackupSections.SchemaV2); + reader.Schema.Should().Be(BackupSections.SchemaV3); + BackupSections.SupportedSchemas.Should() + .Contain(BackupSections.Schema) + .And.Contain(BackupSections.SchemaV2) + .And.Contain(BackupSections.SchemaV3); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Services/Backup/BackupRestoreServiceTests.cs b/tests/NodePilot.Api.Tests/Services/Backup/BackupRestoreServiceTests.cs index 2f57ed40..e323fe04 100644 --- a/tests/NodePilot.Api.Tests/Services/Backup/BackupRestoreServiceTests.cs +++ b/tests/NodePilot.Api.Tests/Services/Backup/BackupRestoreServiceTests.cs @@ -53,12 +53,16 @@ private IBackupPart[] Parts(NodePilotDbContext db) => [ new FolderBackupPart(db), new UserBackupPart(db), new CredentialBackupPart(db, _atRest), new MachineBackupPart(db), new GlobalVariableFolderBackupPart(db), new GlobalVariableBackupPart(new GlobalVariableStore(db, _atRest)), + new CustomActivityBackupPart(new CustomActivityDefinitionStore(db)), new WorkflowBackupPart(db), new SettingsBackupPart(new RuntimeOverridesWriter(TempPath(), NullLogger.Instance), _atRest), ]; private BackupRestoreService Restore(NodePilotDbContext db) => new(db, _atRest, new RuntimeOverridesWriter(TempPath(), NullLogger.Instance), - NullLogger.Instance); + NullLogger.Instance, VersionProtector()); + + private NodePilot.Api.Services.WorkflowVersionDefinitionProtector VersionProtector() => + new(_atRest, NullLogger.Instance); private async Task ExportAsync(NodePilotDbContext db, List sections) => (await new BackupService(Parts(db)).ExportAsync(sections, Passphrase, "admin", CancellationToken.None)).Content; @@ -321,6 +325,50 @@ public async Task Restore_OverwriteLockedWorkflow_AbortsWithoutMutatingEditSessi after.CheckedOutByUserId.Should().Be(owner); } + [Fact] + public async Task Restore_OverwriteWorkflow_SnapshotsEncryptedHistory_BumpsVersion_AndRecomputesMetadata() + { + const string restoredDefinition = + """{"nodes":[{"id":"t","data":{"activityType":"scheduleTrigger","config":{"cron":"0 * * * *"}}},{"id":"a","data":{"activityType":"log","config":{}}}],"edges":[]}"""; + using var src = TestDbFactory.Create(); + src.Workflows.Add(new Workflow + { + Id = Guid.NewGuid(), Name = "restore-wf", DefinitionJson = restoredDefinition, + FolderId = SharedWorkflowFolder.RootFolderId, Version = 2, IsEnabled = true, + }); + await src.SaveChangesAsync(); + var backup = await ExportAsync(src, [BackupSections.Workflows]); + + const string previousDefinition = + """{"nodes":[{"id":"s","data":{"activityType":"runScript","config":{"script":"Write-Output 'historic-secret'"}}}],"edges":[]}"""; + using var dst = TestDbFactory.Create(); + var existing = new Workflow + { + Id = Guid.NewGuid(), Name = "restore-wf", DefinitionJson = previousDefinition, + FolderId = SharedWorkflowFolder.RootFolderId, Version = 7, IsEnabled = false, + }; + dst.Workflows.Add(existing); + await dst.SaveChangesAsync(); + + await Restore(dst).RestoreAsync( + backup, Passphrase, + Policy(BackupSections.Workflows, RestoreConflictPolicy.Overwrite), + CancellationToken.None); + + dst.ChangeTracker.Clear(); + var restored = await dst.Workflows.SingleAsync(w => w.Id == existing.Id); + restored.Version.Should().Be(8, "overwrite restore is a new revision of the target workflow"); + restored.DefinitionJson.Should().Be(restoredDefinition); + var expectedMetadata = WorkflowMetadata.Compute(restoredDefinition); + restored.ActivityCount.Should().Be(expectedMetadata.ActivityCount); + restored.TriggerTypesJson.Should().Be(expectedMetadata.TriggerTypesJson); + + var history = await dst.WorkflowVersions.SingleAsync(v => v.WorkflowId == existing.Id); + history.Version.Should().Be(7); + history.DefinitionJson.Should().NotContain("historic-secret"); + VersionProtector().Unprotect(history.DefinitionJson).Should().Be(previousDefinition); + } + [Fact] public async Task Restore_WouldLeaveNoActiveAdmin_Aborts() { @@ -635,7 +683,8 @@ public async Task Restore_Settings_ReplacesOverrides_RemovingKeysNotInBackup() dstWriter.MutateAndWrite(root => { root["Foo"] = new JsonObject { ["x"] = 1 }; root["Smtp"] = new JsonObject { ["Port"] = 25 }; }); using var dst = TestDbFactory.Create(); - var restore = new BackupRestoreService(dst, _atRest, dstWriter, NullLogger.Instance); + var restore = new BackupRestoreService( + dst, _atRest, dstWriter, NullLogger.Instance, VersionProtector()); await restore.RestoreAsync(backup, Passphrase, Empty(), CancellationToken.None); var after = dstWriter.ReadOrEmpty(); diff --git a/tests/NodePilot.Api.Tests/Services/Backup/BackupServiceExportTests.cs b/tests/NodePilot.Api.Tests/Services/Backup/BackupServiceExportTests.cs index b1a3352a..98682188 100644 --- a/tests/NodePilot.Api.Tests/Services/Backup/BackupServiceExportTests.cs +++ b/tests/NodePilot.Api.Tests/Services/Backup/BackupServiceExportTests.cs @@ -48,6 +48,7 @@ public BackupServiceExportTests() new MachineBackupPart(_db), new GlobalVariableFolderBackupPart(_db), new GlobalVariableBackupPart(globals), + new CustomActivityBackupPart(new CustomActivityDefinitionStore(_db)), new WorkflowBackupPart(_db), new SettingsBackupPart(_overrides, _atRest), }; @@ -116,36 +117,108 @@ public async Task Export_Workflows_AutoIncludesHardDependencies() } [Fact] - public async Task Export_EncryptsInlineWorkflowSecret_ButKeepsMachineGuidVerbatim() + public async Task Export_EncryptsCompleteWorkflowDefinition_IncludingOpaqueFieldsAndReferences() { await SeedAsync(); var result = await _service.ExportAsync([BackupSections.Workflows], Passphrase, "admin", CancellationToken.None); var env = Parse(result.Content); - var config = env["sections"]!["workflows"]!["items"]![0]!["definition"]!["nodes"]![0]!["data"]!; + var definition = env["sections"]!["workflows"]!["items"]![0]!["definition"]!; + var encrypted = definition[WorkflowDefinitionSecretRewriter.DefinitionEncKey]!.GetValue(); + var serializedBackup = Encoding.UTF8.GetString(result.Content); + serializedBackup.Should().NotContain("super-secret-key"); + serializedBackup.Should().NotContain("opaque-tenant-credential"); + definition.ToJsonString().Should().NotContain(_machineId.ToString()); + + var salt = Convert.FromBase64String(env["crypto"]!["salt"]!.GetValue()); + var protector = PassphraseSecretProtector.Derive(Passphrase, salt); + var plaintext = protector.Unprotect(Convert.FromBase64String(encrypted)); + plaintext.Should().Contain("super-secret-key"); + plaintext.Should().Contain("opaque-tenant-credential"); + plaintext.Should().Contain(_machineId.ToString(), "restore remaps references after decrypting the definition"); // apiKey was rewritten to an {"$enc": ...} object. - config["config"]!["apiKey"]!["$enc"].Should().NotBeNull(); - config["config"]!["headers"]!["X-Tenant-Token"]!["$enc"].Should().NotBeNull(); - config["config"]!["headers"]!["Accept"]!.GetValue().Should().Be("application/json"); // targetMachineId is a GUID reference — left verbatim for restore-time remap (K13). - config["targetMachineId"]!.GetValue().Should().Be(_machineId.ToString()); } [Fact] - public void WorkflowBackup_TextFormCustomHeader_IsEncryptedAsOneOpaqueSecret() + public void WorkflowBackup_ArbitraryOpaqueLiterals_AreProtectedWithoutContentDetection() { - const string headerBlock = "Accept: application/json\nX_Tenant.Token: opaque-secret"; using var doc = JsonDocument.Parse(""" - { "config": { "headers": "Accept: application/json\nX_Tenant.Token: opaque-secret" } } + { "config": { + "script": "$secure = ConvertTo-SecureString 'hunter2' -AsPlainText -Force", + "body": "plain-looking-body", + "headers": { "Accept": "application/json" }, + "scorchRaw": { "payload": "legacy-raw-value" } + } } """); var protector = PassphraseSecretProtector.Derive(Passphrase, new byte[16]); var rewritten = WorkflowDefinitionSecretRewriter.Rewrite( doc.RootElement, SecretHandling.EncryptForBackup, protector); - var encrypted = rewritten["config"]!["headers"]![WorkflowDefinitionSecretRewriter.EncKey]! - .GetValue(); - protector.Unprotect(Convert.FromBase64String(encrypted)).Should().Be(headerBlock); + var encrypted = rewritten[WorkflowDefinitionSecretRewriter.DefinitionEncKey]!.GetValue(); + rewritten.ToJsonString().Should().NotContain("hunter2"); + var plaintext = protector.Unprotect(Convert.FromBase64String(encrypted)); + plaintext.Should().Contain("hunter2"); + plaintext.Should().Contain("plain-looking-body"); + plaintext.Should().Contain("application/json"); + plaintext.Should().Contain("legacy-raw-value"); + } + + [Fact] + public void RestoreDefinition_LegacyPerFieldEncryptedBackup_RemainsCompatible() + { + var protector = PassphraseSecretProtector.Derive(Passphrase, new byte[16]); + var sourceMachine = Guid.NewGuid(); + var targetMachine = Guid.NewGuid(); + var legacy = new JsonObject + { + ["nodes"] = new JsonArray + { + new JsonObject + { + ["data"] = new JsonObject + { + ["targetMachineId"] = sourceMachine.ToString(), + ["config"] = new JsonObject + { + ["apiKey"] = new JsonObject + { + [WorkflowDefinitionSecretRewriter.EncKey] = + Convert.ToBase64String(protector.Protect("legacy-secret")), + }, + }, + }, + }, + }, + ["edges"] = new JsonArray(), + }; + + var restored = WorkflowDefinitionSecretRewriter.RestoreDefinition( + legacy, protector, + id => id == sourceMachine ? targetMachine : null, + _ => null, + []); + + restored["nodes"]![0]!["data"]!["config"]!["apiKey"]!.GetValue() + .Should().Be("legacy-secret"); + restored["nodes"]![0]!["data"]!["targetMachineId"]!.GetValue() + .Should().Be(targetMachine.ToString()); + } + + [Fact] + public void RestoreDefinition_CurrentWholeEnvelope_PreservesLiteralLegacyMarkerObjects() + { + using var doc = JsonDocument.Parse( + """{"nodes":[{"data":{"config":{"scorchRaw":{"$enc":"application-owned-literal"}}}}],"edges":[]}"""); + var protector = PassphraseSecretProtector.Derive(Passphrase, new byte[16]); + var sealedDefinition = WorkflowDefinitionSecretRewriter.Rewrite( + doc.RootElement, SecretHandling.EncryptForBackup, protector); + + var restored = WorkflowDefinitionSecretRewriter.RestoreDefinition( + sealedDefinition, protector, _ => null, _ => null, []); + + restored.ToJsonString().Should().Be(doc.RootElement.GetRawText()); } [Fact] @@ -259,6 +332,16 @@ public async Task Export_Credentials_ReportsContainsSecretsTrue() result.ContainsSecrets.Should().BeTrue("the credential password is sealed as a $enc field"); } + [Fact] + public async Task Export_WorkflowsOnly_ReportsContainsSecretsTrueForWholeDefinitionEnvelope() + { + await SeedAsync(); + var result = await _service.ExportAsync([BackupSections.Workflows], Passphrase, "admin", CancellationToken.None); + + result.ContainsSecrets.Should().BeTrue( + "a $encDefinition envelope contains passphrase-protected workflow content even without a legacy $enc field"); + } + [Fact] public async Task Export_ShortPassphrase_Throws() { diff --git a/tests/NodePilot.Api.Tests/Services/Backup/CustomActivityBackupTests.cs b/tests/NodePilot.Api.Tests/Services/Backup/CustomActivityBackupTests.cs index 06d6c29b..a19a2d29 100644 --- a/tests/NodePilot.Api.Tests/Services/Backup/CustomActivityBackupTests.cs +++ b/tests/NodePilot.Api.Tests/Services/Backup/CustomActivityBackupTests.cs @@ -1,4 +1,6 @@ +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using NodePilot.Api.Configuration; @@ -39,7 +41,9 @@ private IBackupPart[] Parts(NodePilotDbContext db) => private BackupRestoreService Restore(NodePilotDbContext db) => new(db, _atRest, new RuntimeOverridesWriter(TempPath(), NullLogger.Instance), - NullLogger.Instance); + NullLogger.Instance, + new NodePilot.Api.Services.WorkflowVersionDefinitionProtector( + _atRest, NullLogger.Instance)); private async Task ExportAsync(NodePilotDbContext db) => (await new BackupService(Parts(db)).ExportAsync(Sections, Passphrase, "admin", CancellationToken.None)).Content; @@ -49,7 +53,9 @@ private static async Task SeedAsync(NodePilotDbContext var store = new CustomActivityDefinitionStore(db); var def = await store.CreateAsync(new CustomActivityDefinitionInput { - Key = "disk_check", Name = "Disk Check", ScriptTemplate = "Get-PSDrive C", + Key = "disk_check", Name = "Disk Check", + ScriptTemplate = "$token = 'inline-custom-script-secret'; Get-PSDrive C", + InputParametersJson = """[{"name":"apiKey","type":"string","default":"inline-custom-default-secret"}]""", OutputParametersJson = "[{\"name\":\"status\",\"type\":\"string\"}]", }, "alice", CancellationToken.None); if (enabled) await store.SetEnabledAsync(def.Id, true, "admin", CancellationToken.None); @@ -93,6 +99,9 @@ public async Task RoundTrip_IntoEmptyDb_PreservesDefinitionAndReference() var def = await SeedAsync(src, enabled: true); SeedWorkflow(src, def.Id); var backup = await ExportAsync(src); + var serialized = Encoding.UTF8.GetString(backup); + serialized.Should().NotContain("inline-custom-script-secret"); + serialized.Should().NotContain("inline-custom-default-secret"); using var dst = TestDbFactory.Create(); await Restore(dst).RestoreAsync(backup, Passphrase, new Dictionary(), CancellationToken.None); @@ -101,7 +110,8 @@ public async Task RoundTrip_IntoEmptyDb_PreservesDefinitionAndReference() restored.Should().NotBeNull(); restored!.Id.Should().Be(def.Id, "source id is preserved on a clean restore"); restored.IsEnabled.Should().BeTrue("system-backup restores the enabled state faithfully (unlike .npca import)"); - restored.ScriptTemplate.Should().Be("Get-PSDrive C"); + restored.ScriptTemplate.Should().Contain("inline-custom-script-secret"); + restored.InputParametersJson.Should().Contain("inline-custom-default-secret"); var wf = dst.Workflows.Single(); CustomDefIdInFirstNode(wf.DefinitionJson).Should().Be(def.Id.ToString(), "the workflow reference still resolves"); @@ -133,6 +143,104 @@ public async Task OverwriteMerge_RemapsWorkflowReferenceToExistingDefinitionId() "the restored workflow's __customDefinitionId is remapped to the destination's existing definition id"); } + [Fact] + public async Task WorkflowOnlyExport_AutoIncludesReferencedCustomActivityDefinitions() + { + using var src = TestDbFactory.Create(); + SeedAdmin(src); + var definition = await SeedAsync(src, enabled: true); + SeedWorkflow(src, definition.Id); + + var result = await new BackupService(Parts(src)).ExportAsync( + [BackupSections.Workflows], Passphrase, "admin", CancellationToken.None); + + result.AutoIncludedSections.Should().Contain(BackupSections.CustomActivities); + var reader = BackupFileReader.Parse(result.Content); + reader.Sections[BackupSections.CustomActivities]!["items"]!.AsArray().Should().ContainSingle(); + } + + [Fact] + public async Task Restore_MissingCustomActivityReference_AbortsBeforeWritingWorkflow() + { + using var src = TestDbFactory.Create(); + SeedAdmin(src); + SeedWorkflow(src, Guid.NewGuid()); + var backup = await ExportAsync(src); + + using var dst = TestDbFactory.Create(); + SeedAdmin(dst); + + var act = () => Restore(dst).RestoreAsync( + backup, Passphrase, new Dictionary(), CancellationToken.None); + await act.Should().ThrowAsync() + .WithMessage("*__customDefinitionId*"); + dst.Workflows.Should().BeEmpty(); + } + + [Fact] + public async Task Restore_LegacyV2PlaintextCustomActivityFields_RemainsCompatible() + { + using var src = TestDbFactory.Create(); + await SeedAsync(src, enabled: true); + var current = await new BackupService(Parts(src)).ExportAsync( + [BackupSections.CustomActivities], Passphrase, "admin", CancellationToken.None); + var reader = BackupFileReader.Parse(current.Content); + var protector = reader.TryUnlock(Passphrase)!; + var envelope = (JsonObject)JsonNode.Parse(Encoding.UTF8.GetString(current.Content))!; + envelope["schema"] = BackupSections.SchemaV2; + var item = envelope["sections"]![BackupSections.CustomActivities]!["items"]![0]!; + item["scriptTemplate"] = "legacy-plaintext-script"; + item["inputParametersJson"] = """[{"name":"token","type":"string","default":"legacy-default"}]"""; + envelope["mac"] = Convert.ToBase64String(protector.ComputeMac( + BackupCanonicalJson.Canonicalize(envelope, excludeKey: "mac"))); + + using var dst = TestDbFactory.Create(); + SeedAdmin(dst); + await Restore(dst).RestoreAsync( + Encoding.UTF8.GetBytes(envelope.ToJsonString()), Passphrase, + new Dictionary(), CancellationToken.None); + + var restored = dst.CustomActivityDefinitions.Single(); + restored.ScriptTemplate.Should().Be("legacy-plaintext-script"); + restored.InputParametersJson.Should().Contain("legacy-default"); + } + + [Fact] + public async Task Restore_DoesNotTreatSameNamedNestedPayloadKeysAsInfrastructureReferences() + { + var payloadCredentialId = Guid.NewGuid(); + var payloadMachineId = Guid.NewGuid(); + using var src = TestDbFactory.Create(); + var adminId = SeedAdmin(src); + src.Workflows.Add(new Workflow + { + Id = Guid.NewGuid(), Name = "payload-ids", FolderId = SharedWorkflowFolder.RootFolderId, + DefinitionJson = """ + {"nodes":[ + {"id":"child","type":"startWorkflow","data":{"config":{"parameters":{ + "credentialId":"__PAYLOAD_CREDENTIAL__"}}}}, + {"id":"return","type":"returnData","data":{"config":{"data":{ + "targetMachineId":"__PAYLOAD_MACHINE__"}}}} + ],"edges":[]} + """ + .Replace("__PAYLOAD_CREDENTIAL__", payloadCredentialId.ToString(), StringComparison.Ordinal) + .Replace("__PAYLOAD_MACHINE__", payloadMachineId.ToString(), StringComparison.Ordinal), + }); + await src.SaveChangesAsync(); + var backup = await ExportAsync(src); + + using var dst = TestDbFactory.Create(); + SeedAdmin(dst, adminId); + await Restore(dst).RestoreAsync( + backup, Passphrase, new Dictionary(), CancellationToken.None); + + var restored = JsonNode.Parse(dst.Workflows.Single().DefinitionJson)!; + restored["nodes"]![0]!["data"]!["config"]!["parameters"]!["credentialId"]! + .GetValue().Should().Be(payloadCredentialId.ToString()); + restored["nodes"]![1]!["data"]!["config"]!["data"]!["targetMachineId"]! + .GetValue().Should().Be(payloadMachineId.ToString()); + } + public void Dispose() { foreach (var f in _tempFiles) { try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } } diff --git a/tests/NodePilot.Api.Tests/Services/DbAdmin/DbAdminReadOnlySqlGuardTests.cs b/tests/NodePilot.Api.Tests/Services/DbAdmin/DbAdminReadOnlySqlGuardTests.cs index 8a0bf8b3..7331de8e 100644 --- a/tests/NodePilot.Api.Tests/Services/DbAdmin/DbAdminReadOnlySqlGuardTests.cs +++ b/tests/NodePilot.Api.Tests/Services/DbAdmin/DbAdminReadOnlySqlGuardTests.cs @@ -52,6 +52,31 @@ public void Validate_RejectsDangerousRoutine() .WithMessage("*not allowed in read mode*"); } + [Theory] + [InlineData("SELECT query_to_xml('SELECT \"PasswordHash\" FROM \"Users\"', false, true, '')")] + [InlineData("SELECT table_to_xml('Users', false, true, '')")] + [InlineData("SELECT schema_to_xml('public', false, true, '')")] + [InlineData("SELECT database_to_xml(false, true, '')")] + [InlineData("SELECT query_to_xmlschema('SELECT * FROM Workflows', false, true, '')")] + [InlineData("SELECT table_to_xml_and_xmlschema('Workflows', false, true, '')")] + public void Validate_RejectsDynamicXmlExportRoutines(string sql) + { + var act = () => DbAdminReadOnlySqlGuard.Validate(sql); + + act.Should().Throw() + .WithMessage("*not allowed in read mode*"); + } + + [Fact] + public void Validate_RejectsUnicodeEscapedIdentifiers() + { + var act = () => DbAdminReadOnlySqlGuard.Validate( + "SELECT U&\"query_to\\005Fxml\"('SELECT 1', false, true, '')"); + + act.Should().Throw() + .WithMessage("*Unicode-escaped identifiers are not allowed*"); + } + [Fact] public void Validate_AllowsWriteKeywordInsideStringLiteral() { diff --git a/tests/NodePilot.Api.Tests/Services/WorkflowVersionDefinitionProtectorTests.cs b/tests/NodePilot.Api.Tests/Services/WorkflowVersionDefinitionProtectorTests.cs new file mode 100644 index 00000000..8e6576e0 --- /dev/null +++ b/tests/NodePilot.Api.Tests/Services/WorkflowVersionDefinitionProtectorTests.cs @@ -0,0 +1,188 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NodePilot.Api.Services; +using NodePilot.Core.Models; +using NodePilot.Data.Security; +using NodePilot.TestCommons; +using Xunit; + +namespace NodePilot.Api.Tests.Services; + +public sealed class WorkflowVersionDefinitionProtectorTests +{ + private static WorkflowVersionDefinitionProtector CreateProtector() => + new(new AesGcmSecretProtector(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()), + NullLogger.Instance); + + [Fact] + public void Protect_StoresNoDefinitionLiteral_AndRoundTripsExactly() + { + const string definition = + """{"nodes":[{"data":{"config":{"script":"ConvertTo-SecureString 'hunter2' -AsPlainText -Force"}}}],"edges":[]}"""; + var sut = CreateProtector(); + + var stored = sut.Protect(definition); + + stored.Should().NotContain("hunter2"); + stored.Should().NotBe(definition); + sut.Unprotect(stored).Should().Be(definition); + } + + [Fact] + public void Unprotect_LegacyPlaintext_ReturnsItDuringUpgrade() + { + const string legacy = """{"nodes":[],"edges":[]}"""; + CreateProtector().Unprotect(legacy).Should().Be(legacy); + } + + [Fact] + public async Task StartupCheck_DetectsLegacyPlaintext_WithoutMutatingIt() + { + await using var db = TestDbFactory.Create(); + var workflow = new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = "{}" }; + db.Workflows.Add(workflow); + db.WorkflowVersions.Add(new WorkflowVersion + { + Id = Guid.NewGuid(), WorkflowId = workflow.Id, Version = 1, Name = "wf", + DefinitionJson = """{"nodes":[{"data":{"config":{"body":"legacy-literal"}}}],"edges":[]}""", + }); + await db.SaveChangesAsync(); + var sut = CreateProtector(); + + (await sut.WarnIfExplicitMigrationRequiredAsync(db, CancellationToken.None)).Should().BeTrue(); + db.ChangeTracker.Clear(); + var stored = await db.WorkflowVersions.Select(v => v.DefinitionJson).SingleAsync(); + stored.Should().Contain("legacy-literal", + "startup must remain read-only so updater rollback and mixed-version HA stay safe"); + } + + [Fact] + public async Task ReencryptAllAsync_ReadsLegacyProvider_AndRewrapsWithActiveProvider() + { + var legacyAtRest = new AesGcmSecretProtector( + Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()); + var activeAtRest = new AesGcmSecretProtector( + Enumerable.Range(33, 32).Select(i => (byte)i).ToArray()); + var legacyDefinitions = new WorkflowVersionDefinitionProtector( + legacyAtRest, NullLogger.Instance); + var migratingDefinitions = new WorkflowVersionDefinitionProtector( + new MigratingSecretProtector(activeAtRest, legacyAtRest), + NullLogger.Instance); + const string definition = + """{"nodes":[{"data":{"config":{"scorchRaw":{"secret":"legacy-key-literal"}}}}],"edges":[]}"""; + + await using var db = TestDbFactory.Create(); + var workflow = new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = "{}" }; + db.Workflows.Add(workflow); + db.WorkflowVersions.Add(new WorkflowVersion + { + Id = Guid.NewGuid(), WorkflowId = workflow.Id, Version = 1, Name = workflow.Name, + DefinitionJson = legacyDefinitions.Protect(definition), + }); + await db.SaveChangesAsync(); + + var result = await migratingDefinitions.ReencryptAllAsync(db, CancellationToken.None); + + result.Rewritten.Should().Be(1); + result.Skipped.Should().Be(0); + db.ChangeTracker.Clear(); + var stored = await db.WorkflowVersions.Select(v => v.DefinitionJson).SingleAsync(); + var activeDefinitions = new WorkflowVersionDefinitionProtector( + activeAtRest, NullLogger.Instance); + activeDefinitions.Unprotect(stored).Should().Be(definition); + var legacyRead = () => legacyDefinitions.Unprotect(stored); + legacyRead.Should().Throw(); + } + + [Fact] + public async Task ReencryptAllAsync_CorruptEnvelope_IsSkippedAndReported() + { + await using var db = TestDbFactory.Create(); + var workflow = new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = "{}" }; + db.Workflows.Add(workflow); + var versionId = Guid.NewGuid(); + db.WorkflowVersions.Add(new WorkflowVersion + { + Id = versionId, WorkflowId = workflow.Id, Version = 4, Name = workflow.Name, + DefinitionJson = "np:wfv:v1:not-base64", + }); + await db.SaveChangesAsync(); + + var result = await CreateProtector().ReencryptAllAsync(db, CancellationToken.None); + + result.Rewritten.Should().Be(0); + result.Skipped.Should().Be(1); + result.SkippedDetails.Should().ContainSingle(s => + s.Id == versionId && s.Name == "wf v4" && s.Reason == nameof(FormatException)); + } + + [Fact] + public async Task ReencryptAllAsync_RetentionDeletesBehindCursor_DoNotSkipLaterRows() + { + var databasePath = Path.Combine( + Path.GetTempPath(), $"nodepilot-version-rotation-{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath};Pooling=False") + .Options; + + try + { + await using var db = new NodePilot.Data.NodePilotDbContext(options); + await db.Database.EnsureCreatedAsync(); + var workflow = new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = "{}" }; + db.Workflows.Add(workflow); + db.WorkflowVersions.AddRange(Enumerable.Range(1, 205).Select(version => new WorkflowVersion + { + Id = Guid.NewGuid(), WorkflowId = workflow.Id, Version = version, Name = workflow.Name, + DefinitionJson = $$"""{"nodes":[],"edges":[],"literal":"legacy-{{version}}"}""", + })); + await db.SaveChangesAsync(); + + var deleted = 0; + var logger = new BatchCallbackLogger(() => + { + if (Interlocked.Exchange(ref deleted, 1) != 0) return; + using var retentionDb = new NodePilot.Data.NodePilotDbContext(options); + retentionDb.WorkflowVersions.Where(v => v.Version <= 50).ExecuteDelete(); + }); + var sut = new WorkflowVersionDefinitionProtector( + new AesGcmSecretProtector(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()), + logger); + + var result = await sut.ReencryptAllAsync(db, CancellationToken.None); + + result.Rewritten.Should().Be(205, + "deleting rows behind a stable keyset cursor cannot shift later legacy rows out of the sweep"); + db.ChangeTracker.Clear(); + var remaining = await db.WorkflowVersions.OrderBy(v => v.Version) + .Select(v => v.DefinitionJson).ToListAsync(); + remaining.Should().HaveCount(155); + remaining.Should().OnlyContain(value => sut.IsProtected(value)); + } + finally + { + if (File.Exists(databasePath)) File.Delete(databasePath); + } + } + + private sealed class BatchCallbackLogger(Action callback) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Debug + && formatter(state, exception).StartsWith( + "Re-encrypted workflow-version batch", StringComparison.Ordinal)) + callback(); + } + } +} diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs index b8e3d7bd..668f34b6 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs @@ -314,12 +314,22 @@ public async Task ReencryptSecretsAsync_AcceptsTwoOhSeven() globalSecretsRewritten = 0, globalSecretsSkipped = 0, globalSecretSkipDetails = Array.Empty(), + workflowVersionsRewritten = 4, + workflowVersionsSkipped = 1, + workflowVersionSkipDetails = new[] + { + new { id = Guid.NewGuid(), name = "Deploy v3", reason = "CryptographicException" }, + }, partialSuccess = true, })); var result = await _client.ReencryptSecretsAsync(CancellationToken.None); result.PartialSuccess.Should().BeTrue(); result.CredentialsRewritten.Should().Be(5); result.CredentialSkipDetails.Should().ContainSingle(); + result.WorkflowVersionsRewritten.Should().Be(4); + result.WorkflowVersionsSkipped.Should().Be(1); + result.WorkflowVersionSkipDetails.Should().ContainSingle() + .Which.Name.Should().Be("Deploy v3"); } [Fact] @@ -330,10 +340,12 @@ public async Task ReencryptSecretsAsync_TwoHundred() { credentialsRewritten = 3, credentialsSkipped = 0, credentialSkipDetails = Array.Empty(), globalSecretsRewritten = 1, globalSecretsSkipped = 0, globalSecretSkipDetails = Array.Empty(), + workflowVersionsRewritten = 7, workflowVersionsSkipped = 0, workflowVersionSkipDetails = Array.Empty(), partialSuccess = false, })); var result = await _client.ReencryptSecretsAsync(CancellationToken.None); result.PartialSuccess.Should().BeFalse(); + result.WorkflowVersionsRewritten.Should().Be(7); } // ---- Shared folders ----------------------------------------------------- diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs index fbee5f34..19972b51 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs @@ -44,6 +44,7 @@ public async Task LoginAsync_PostsCredentialsAndReturnsToken() .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { token = "jwt-abc", userId, username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), })); var resp = await _client.LoginAsync(new LoginRequest("admin", "pw12345678"), null, CancellationToken.None); @@ -51,6 +52,7 @@ public async Task LoginAsync_PostsCredentialsAndReturnsToken() resp.Username.Should().Be("admin"); resp.Role.Should().Be("Admin"); resp.UserId.Should().Be(userId); + resp.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddHours(7)); } [Fact] @@ -61,6 +63,7 @@ public async Task LoginAsync_WithSetupToken_AddsXSetupTokenHeader() .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { token = "jwt", userId = Guid.NewGuid(), username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), })); var resp = await _client.LoginAsync(new LoginRequest("admin", "pw12345678"), "bootstrap-secret", CancellationToken.None); @@ -78,6 +81,7 @@ public async Task LoginAsync_OptsInToTokenInBody_ViaXAuthTokenResponseHeader() .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { token = "jwt", userId = Guid.NewGuid(), username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), })); var resp = await _client.LoginAsync(new LoginRequest("admin", "pw12345678"), null, CancellationToken.None); @@ -112,6 +116,7 @@ public async Task RefreshAsync_ReturnsRotatedToken() .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { token = "rotated", userId = Guid.NewGuid(), username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), })); var resp = await _client.RefreshAsync(CancellationToken.None); diff --git a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs index 53ce9a15..384bf685 100644 --- a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs @@ -48,6 +48,7 @@ public async Task Refreshes_OnUnauthorized_AndReplaysOriginalRequest() userId = Guid.NewGuid(), username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), }; _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost()) @@ -74,6 +75,376 @@ public async Task Refreshes_OnUnauthorized_AndReplaysOriginalRequest() _tokens.Load("default")!.Token.Should().Be("fresh-token"); } + [Fact] + public async Task RefreshesProactivelyBeforeExpiry_AndPersistsServerExpiry() + { + var serverExpiry = DateTimeOffset.UtcNow.AddHours(8); + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = "expiring-token", + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = DateTime.UtcNow.AddMinutes(1), + }); + + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer expiring-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = "proactively-rotated-token", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = serverExpiry, + })); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer proactively-rotated-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + // A reactive-only implementation sends the expiring credential first and fails here. + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer expiring-token")) + .RespondWith(Response.Create().WithStatusCode(500)); + + var handler = new TokenRefreshHandler(_tokens, "default") { InnerHandler = new HttpClientHandler() }; + var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "expiring-token" }; + + var me = await client.MeAsync(CancellationToken.None); + + me.Username.Should().Be("admin"); + var stored = _tokens.Load("default")!; + stored.Token.Should().Be("proactively-rotated-token"); + stored.ExpiresAt.Should().BeCloseTo(serverExpiry.UtcDateTime, TimeSpan.FromSeconds(1)); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + } + + [Fact] + public async Task OlderRefreshResponseWithoutExpiresAt_UsesJwtExpiry_AndDeduplicatesNewProcesses() + { + var now = DateTimeOffset.UtcNow; + var absoluteExpiry = now.AddMinutes(4); + var clock = new ManualTimeProvider(now); + const string initialToken = "rolling-upgrade-expiring-token"; + var firstRotatedToken = Jwt(now, absoluteExpiry); + var secondIssuedAt = now + + ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow + + TimeSpan.FromSeconds(1); + var secondRotatedToken = Jwt(secondIssuedAt, absoluteExpiry); + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = initialToken, + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = absoluteExpiry, + }); + + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", $"Bearer {initialToken}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = firstRotatedToken, + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + // Older API: no expiresAt. The JWT exp is authoritative for local scheduling. + })); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", $"Bearer {firstRotatedToken}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = secondRotatedToken, + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + })); + foreach (var token in new[] { firstRotatedToken, secondRotatedToken }) + { + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", $"Bearer {token}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + } + + async Task CallFromNewProcessAsync() + { + var processStore = new TokenStore(_dir); + var current = processStore.Load("default")!; + var handler = new TokenRefreshHandler( + processStore, "default", timeProvider: clock) + { + InnerHandler = new HttpClientHandler(), + }; + using var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = current.Token }; + (await client.MeAsync(CancellationToken.None)).Username.Should().Be("admin"); + } + + await CallFromNewProcessAsync(); + for (var i = 0; i < 5; i++) + await CallFromNewProcessAsync(); + + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1, + "fresh JWT iat must deduplicate sequential short-lived CLI processes"); + _tokens.Load("default")!.ExpiresAt.Should() + .BeCloseTo(absoluteExpiry, TimeSpan.FromSeconds(1)); + + clock.Advance( + ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow + + TimeSpan.FromSeconds(1)); + await CallFromNewProcessAsync(); + + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(2, + "the cross-process dedupe is a cooldown, not a permanent refresh disable"); + _tokens.Load("default")!.Token.Should().Be(secondRotatedToken); + } + + [Fact] + public async Task ConcurrentExpiringRequests_ShareOneRefresh() + { + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = "shared-expiring-token", + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(1), + }); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer shared-expiring-token")) + .RespondWith(Response.Create() + .WithDelay(TimeSpan.FromMilliseconds(150)) + .WithStatusCode(200) + .WithBodyAsJson(new + { + token = "shared-fresh-token", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer shared-fresh-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + var handler = new TokenRefreshHandler(_tokens, "default") { InnerHandler = new HttpClientHandler() }; + var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "shared-expiring-token" }; + + var callers = await Task.WhenAll( + client.MeAsync(CancellationToken.None), + client.MeAsync(CancellationToken.None)); + + callers.Should().OnlyContain(me => me.Username == "admin"); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/me" + && entry.RequestMessage.Headers!["Authorization"].First() == "Bearer shared-fresh-token") + .Should().Be(2); + } + + [Fact] + public async Task ConcurrentExpiringRequests_TransientRefreshFailure_UseBoundedCooldown() + { + var now = DateTimeOffset.UtcNow; + var clock = new ManualTimeProvider(now); + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = "transient-failure-token", + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = now.AddMinutes(1), + }); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer transient-failure-token")) + .RespondWith(Response.Create() + .WithStatusCode(503)); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer transient-failure-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + var handler = new TokenRefreshHandler( + _tokens, "default", timeProvider: clock) { InnerHandler = new HttpClientHandler() }; + using var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "transient-failure-token" }; + + var callers = await Task.WhenAll(Enumerable.Range(0, 100) + .Select(_ => client.MeAsync(CancellationToken.None))); + + callers.Should().OnlyContain(me => me.Username == "admin"); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/me").Should().Be(100); + _tokens.Load("default")!.Token.Should().Be("transient-failure-token"); + + // The cooldown suppresses a queued herd, but must not disable refresh permanently. + clock.Advance(ClientSessionSecurity.TransientRefreshFailureCooldown + TimeSpan.FromSeconds(1)); + (await client.MeAsync(CancellationToken.None)).Username.Should().Be("admin"); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(2); + } + + [Fact] + public async Task IndependentHandlersSharingProfile_CoordinateOneCrossProcessRefresh() + { + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = "cross-process-expiring-token", + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(1), + }); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer cross-process-expiring-token")) + .RespondWith(Response.Create() + .WithDelay(TimeSpan.FromMilliseconds(250)) + .WithStatusCode(200) + .WithBodyAsJson(new + { + token = "cross-process-fresh-token", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer cross-process-fresh-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + // Separate stores + handlers model the independently hosted CLI and MCP processes. + // A process-local SemaphoreSlim cannot coordinate these two refresh pipelines. + var otherProcessStore = new TokenStore(_dir); + var firstHandler = new TokenRefreshHandler(_tokens, "default") + { + InnerHandler = new HttpClientHandler(), + }; + var secondHandler = new TokenRefreshHandler(otherProcessStore, "default") + { + InnerHandler = new HttpClientHandler(), + }; + using var firstHttp = new HttpClient(firstHandler) { BaseAddress = new Uri(_server.Url + "/") }; + using var secondHttp = new HttpClient(secondHandler) { BaseAddress = new Uri(_server.Url + "/") }; + var firstClient = new NodePilotApiClient(firstHttp) { BearerToken = "cross-process-expiring-token" }; + var secondClient = new NodePilotApiClient(secondHttp) { BearerToken = "cross-process-expiring-token" }; + + var callers = await Task.WhenAll( + firstClient.MeAsync(CancellationToken.None), + secondClient.MeAsync(CancellationToken.None)); + + callers.Should().OnlyContain(me => me.Username == "admin"); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + _tokens.Load("default")!.Token.Should().Be("cross-process-fresh-token"); + } + + [Fact] + public async Task SameHandler_CanRotateMoreThanOnce_AndFutureRequestsUseLatestToken() + { + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer stale-token")) + .RespondWith(Response.Create().WithStatusCode(401)); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer stale-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = "rotation-two", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer rotation-two")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + _server.Given(Request.Create().WithPath("/api/workflows").UsingGet() + .WithHeader("Authorization", "Bearer rotation-two")) + .RespondWith(Response.Create().WithStatusCode(401)); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer rotation-two")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = "rotation-three", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + _server.Given(Request.Create().WithPath("/api/workflows").UsingGet() + .WithHeader("Authorization", "Bearer rotation-three")) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("[]")); + + var handler = new TokenRefreshHandler(_tokens, "default") { InnerHandler = new HttpClientHandler() }; + var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "stale-token" }; + + (await client.MeAsync(CancellationToken.None)).Username.Should().Be("admin"); + (await client.ListWorkflowsAsync(CancellationToken.None)).Should().BeEmpty(); + + _tokens.Load("default")!.Token.Should().Be("rotation-three"); + _server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(2); + } + + [Fact] + public async Task ExpiredSession_DoesNotAttemptRefresh_AndRequiresLogin() + { + _tokens.Save("default", new StoredSession + { + Server = _server.Url!, + Token = "expired-token", + Username = "admin", + Role = "Admin", + UserId = Guid.NewGuid(), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1), + }); + _server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + _server.Given(Request.Create().WithPath("/api/auth/me").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var handler = new TokenRefreshHandler(_tokens, "default") { InnerHandler = new HttpClientHandler() }; + var http = new HttpClient(handler) { BaseAddress = new Uri(_server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "expired-token" }; + + var act = () => client.MeAsync(CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.IsUnauthorized.Should().BeTrue(); + _tokens.Load("default").Should().BeNull(); + _server.LogEntries.Should().BeEmpty(); + } + [Fact] public async Task SecondUnauthorized_SurfacesAsApiException() { @@ -118,4 +489,30 @@ public async Task StoreChangedAfterClientCreation_ToDifferentOrigin_DoesNotRefre _server.LogEntries.Should().NotContain(entry => entry.RequestMessage!.AbsolutePath == "/api/auth/refresh"); } + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow += duration; + } + + private static string Jwt(DateTimeOffset issuedAt, DateTimeOffset expiresAt) + { + static string Encode(string value) => Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + iat = issuedAt.ToUnixTimeSeconds(), + np_iat_ms = issuedAt.ToUnixTimeMilliseconds(), + exp = expiresAt.ToUnixTimeSeconds(), + }); + return $"{Encode("{\"alg\":\"none\"}")}.{Encode(payload)}."; + } } diff --git a/tests/NodePilot.Cli.Tests/Auth/ClientSessionFileCoordinatorTests.cs b/tests/NodePilot.Cli.Tests/Auth/ClientSessionFileCoordinatorTests.cs new file mode 100644 index 00000000..7dfa67e2 --- /dev/null +++ b/tests/NodePilot.Cli.Tests/Auth/ClientSessionFileCoordinatorTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using NodePilot.Core.Clients; +using Xunit; + +namespace NodePilot.Cli.Tests.Auth; + +public sealed class ClientSessionFileCoordinatorTests : IDisposable +{ + private readonly string _dir = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "np-session-lock-" + Guid.NewGuid().ToString("N"))).FullName; + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort cleanup */ } + } + + [Fact] + public async Task EquivalentSessionPathAndServerOrigin_ShareCancellableLock() + { + var canonicalPath = Path.Combine(_dir, "session-default.dat"); + var equivalentPath = Path.Combine(_dir, "nested", "..", "session-default.dat"); + using var owner = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + canonicalPath, "https://NODEPILOT.EXAMPLE:443/api", CancellationToken.None); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + + Func blocked = async () => + { + using var _ = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + equivalentPath, "https://nodepilot.example/another-path", cts.Token); + }; + + await blocked.Should().ThrowAsync(); + owner.Dispose(); + + // The lock file may remain, but releasing/crashing the owner releases the OS handle. + using var successor = await ClientSessionFileCoordinator.AcquireRefreshLockAsync( + equivalentPath, "https://nodepilot.example", CancellationToken.None); + successor.Should().NotBeNull(); + } +} diff --git a/tests/NodePilot.Cli.Tests/Auth/TokenStoreTests.cs b/tests/NodePilot.Cli.Tests/Auth/TokenStoreTests.cs index 88967bba..403509e5 100644 --- a/tests/NodePilot.Cli.Tests/Auth/TokenStoreTests.cs +++ b/tests/NodePilot.Cli.Tests/Auth/TokenStoreTests.cs @@ -61,6 +61,22 @@ public void Load_CorruptFile_ReturnsNull() store.Load("broken").Should().BeNull(); } + [Fact] + public void StoredSession_OldUtcDateTimeJson_RemainsReadableAsDateTimeOffset() + { + const string legacyJson = + """ + {"server":"https://np.example","token":"legacy","username":"admin","userId":"00000000-0000-0000-0000-000000000001","role":"Admin","expiresAt":"2026-08-15T12:34:56Z"} + """; + + var session = System.Text.Json.JsonSerializer.Deserialize( + legacyJson, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + + session.Should().NotBeNull(); + session!.ExpiresAt.Should().Be(new DateTimeOffset(2026, 8, 15, 12, 34, 56, TimeSpan.Zero)); + } + [Fact] public void Delete_RemovesFile() { @@ -70,4 +86,46 @@ public void Delete_RemovesFile() store.Delete("dev"); File.Exists(store.PathFor("dev")).Should().BeFalse(); } + + [Fact] + public async Task ConcurrentStoreInstances_SaveAndLoad_NeverExposePartialEncryptedBlob() + { + var first = new TokenStore(_dir); + var second = new TokenStore(_dir); + var largeTokenA = "a." + new string('A', 64 * 1024) + ".sig"; + var largeTokenB = "b." + new string('B', 64 * 1024) + ".sig"; + StoredSession Session(string token) => new() + { + Server = "https://np.example", + Token = token, + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(8), + }; + first.Save("shared", Session(largeTokenA)); + + using var start = new ManualResetEventSlim(false); + var writers = Enumerable.Range(0, 24).Select(i => Task.Run(() => + { + start.Wait(); + (i % 2 == 0 ? first : second).Save( + "shared", Session(i % 2 == 0 ? largeTokenA : largeTokenB)); + })).ToArray(); + var readers = Enumerable.Range(0, 80).Select(i => Task.Run(() => + { + start.Wait(); + var loaded = (i % 2 == 0 ? first : second).Load("shared"); + loaded.Should().NotBeNull("atomic replacement must expose either complete generation"); + loaded!.Token.Should().BeOneOf(largeTokenA, largeTokenB); + })).ToArray(); + + start.Set(); + await Task.WhenAll(writers.Concat(readers)); + + var final = first.Load("shared"); + final.Should().NotBeNull(); + final!.Token.Should().BeOneOf(largeTokenA, largeTokenB); + Directory.EnumerateFiles(_dir, "*.tmp").Should().BeEmpty(); + } } diff --git a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationNewSurfaceTests.cs b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationNewSurfaceTests.cs index 90fcff9f..d14f8011 100644 --- a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationNewSurfaceTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationNewSurfaceTests.cs @@ -188,11 +188,14 @@ public void SecretsReencrypt_Clean_ReturnsSuccess() { credentialsRewritten = 3, credentialsSkipped = 0, credentialSkipDetails = Array.Empty(), globalSecretsRewritten = 1, globalSecretsSkipped = 0, globalSecretSkipDetails = Array.Empty(), + workflowVersionsRewritten = 7, workflowVersionsSkipped = 0, workflowVersionSkipDetails = Array.Empty(), partialSuccess = false, })); var result = h.Run("secrets", "reencrypt", "--yes"); result.ExitCode.Should().Be(ExitCodes.Success); + result.Output.Should().Contain("\"workflowVersionsRewritten\":7") + .And.Contain("\"workflowVersionsSkipped\":0"); } [Fact] @@ -205,11 +208,46 @@ public void SecretsReencrypt_Partial_ReturnsErrorCode() credentialsRewritten = 2, credentialsSkipped = 1, credentialSkipDetails = new[] { new { id = Guid.NewGuid(), name = "x", reason = "CryptographicException" } }, globalSecretsRewritten = 0, globalSecretsSkipped = 0, globalSecretSkipDetails = Array.Empty(), + workflowVersionsRewritten = 2, workflowVersionsSkipped = 1, + workflowVersionSkipDetails = new[] + { + new { id = Guid.NewGuid(), name = "Deploy v3", reason = "LegacyProviderUnavailable" }, + }, partialSuccess = true, })); var result = h.Run("secrets", "reencrypt", "--yes"); result.ExitCode.Should().Be(ExitCodes.Error); + result.Output.Should().Contain("\"workflowVersionsSkipped\":1") + .And.Contain("Deploy v3") + .And.Contain("LegacyProviderUnavailable"); + } + + [Fact] + public void SecretsReencrypt_Table_ReportsWorkflowHistoryCountersAndSkipDetails() + { + using var h = new CommandTestHarness(); + h.Server.Given(Request.Create().WithPath("/api/secrets/reencrypt").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(207).WithBodyAsJson(new + { + credentialsRewritten = 2, credentialsSkipped = 0, credentialSkipDetails = Array.Empty(), + globalSecretsRewritten = 1, globalSecretsSkipped = 0, globalSecretSkipDetails = Array.Empty(), + workflowVersionsRewritten = 9, workflowVersionsSkipped = 1, + workflowVersionSkipDetails = new[] + { + new { id = Guid.NewGuid(), name = "Deploy v3", reason = "LegacyProviderUnavailable" }, + }, + partialSuccess = true, + })); + + var result = h.Run("secrets", "reencrypt", "--yes", "-o", "table", "--no-color"); + + result.ExitCode.Should().Be(ExitCodes.Error); + result.Output.Should().Contain("Workflow Versions Rewritten").And.Contain("9") + .And.Contain("Workflow Versions Skipped").And.Contain("1") + .And.Contain("Workflow-version skips") + .And.Contain("Deploy v3") + .And.Contain("LegacyProviderUnavailable"); } // ---- settings ----------------------------------------------------------- diff --git a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationTests.cs b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationTests.cs index f29d9df3..67d20dd1 100644 --- a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationTests.cs @@ -37,11 +37,37 @@ public void AuthLogin_Success_StoresTokenAndReturnsZero() .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { token = "fresh", userId = Guid.NewGuid(), username = "admin", role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), })); var result = h.Run("auth", "login", "--username", "admin", "--password", "pw12345678"); result.ExitCode.Should().Be(ExitCodes.Success); h.Tokens.Load("default")!.Token.Should().Be("fresh"); + h.Tokens.Load("default")!.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddHours(7)); + } + + [Fact] + public void AuthLogin_OlderServerWithoutExpiresAt_UsesJwtExpiration() + { + using var h = new CommandTestHarness(authenticated: false); + var expiresAt = DateTimeOffset.UtcNow.AddHours(8); + var token = Jwt(DateTimeOffset.UtcNow, expiresAt); + h.Server.Given(Request.Create().WithPath("/api/auth/login").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token, + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + // Rolling-upgrade fixture: old APIs do not send expiresAt. + })); + + var result = h.Run("auth", "login", "--username", "admin", "--password", "pw12345678"); + + result.ExitCode.Should().Be(ExitCodes.Success); + var stored = h.Tokens.Load("default")!; + stored.Token.Should().Be(token); + stored.ExpiresAt.Should().BeCloseTo(expiresAt, TimeSpan.FromSeconds(1)); } [Fact] @@ -101,6 +127,23 @@ public void AuthWhoami_NoSession_ReturnsAuthRequired() result.ExitCode.Should().Be(ExitCodes.AuthRequired); } + private static string Jwt(DateTimeOffset issuedAt, DateTimeOffset expiresAt) + { + static string Encode(string value) => Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + iat = issuedAt.ToUnixTimeSeconds(), + np_iat_ms = issuedAt.ToUnixTimeMilliseconds(), + exp = expiresAt.ToUnixTimeSeconds(), + }); + return $"{Encode("{\"alg\":\"none\"}")}.{Encode(payload)}."; + } + // ---- Workflow ---------------------------------------------------------- [Fact] diff --git a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs index d5ae952f..b5b980df 100644 --- a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs +++ b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs @@ -230,6 +230,308 @@ public async Task TokenRefreshHandler_StoreChangedAfterClientCreation_DoesNotUse finally { TryDelete(dir); } } + [Fact] + public async Task TokenRefreshHandler_ConcurrentExpiringToolCalls_ShareOneRefresh() + { + var dir = Temp(); + using var server = WireMockServer.Start(); + try + { + var tokens = new TokenStore(dir); + tokens.Save("default", new StoredSession + { + Server = server.Url!, + Token = "mcp-expiring-token", + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(1), + }); + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer mcp-expiring-token")) + .RespondWith(Response.Create() + .WithDelay(TimeSpan.FromMilliseconds(150)) + .WithStatusCode(200) + .WithBodyAsJson(new + { + token = "mcp-fresh-token", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer mcp-fresh-token")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + var handler = new TokenRefreshHandler(tokens, "default") + { + InnerHandler = new HttpClientHandler(), + }; + var http = new HttpClient(handler) { BaseAddress = new Uri(server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "mcp-expiring-token" }; + + var callers = await Task.WhenAll( + client.MeAsync(CancellationToken.None), + client.MeAsync(CancellationToken.None)); + + callers.Should().OnlyContain(me => me.Username == "admin"); + tokens.Load("default")!.Token.Should().Be("mcp-fresh-token"); + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + } + finally { TryDelete(dir); } + } + + [Fact] + public async Task TokenRefreshHandler_ConcurrentTransientRefreshFailures_UseBoundedCooldown() + { + var dir = Temp(); + using var server = WireMockServer.Start(); + try + { + var now = DateTimeOffset.UtcNow; + var clock = new ManualTimeProvider(now); + var tokens = new TokenStore(dir); + tokens.Save("default", new StoredSession + { + Server = server.Url!, + Token = "mcp-transient-failure", + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = now.AddMinutes(1), + }); + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer mcp-transient-failure")) + .RespondWith(Response.Create().WithStatusCode(503)); + server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer mcp-transient-failure")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + var handler = new TokenRefreshHandler(tokens, "default", timeProvider: clock) + { + InnerHandler = new HttpClientHandler(), + }; + using var http = new HttpClient(handler) { BaseAddress = new Uri(server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "mcp-transient-failure" }; + + var callers = await Task.WhenAll(Enumerable.Range(0, 100) + .Select(_ => client.MeAsync(CancellationToken.None))); + + callers.Should().OnlyContain(me => me.Username == "admin"); + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/me").Should().Be(100); + tokens.Load("default")!.Token.Should().Be("mcp-transient-failure"); + + clock.Advance(ClientSessionSecurity.TransientRefreshFailureCooldown + TimeSpan.FromSeconds(1)); + (await client.MeAsync(CancellationToken.None)).Username.Should().Be("admin"); + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(2); + } + finally { TryDelete(dir); } + } + + [Fact] + public async Task TokenRefreshHandler_IndependentProcessesSharingProfile_UseOneRefresh() + { + var dir = Temp(); + using var server = WireMockServer.Start(); + try + { + var firstStore = new TokenStore(dir); + var secondStore = new TokenStore(dir); + firstStore.Save("default", new StoredSession + { + Server = server.Url!, + Token = "mcp-cross-process-expiring", + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(1), + }); + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", "Bearer mcp-cross-process-expiring")) + .RespondWith(Response.Create() + .WithDelay(TimeSpan.FromMilliseconds(250)) + .WithStatusCode(200) + .WithBodyAsJson(new + { + token = "mcp-cross-process-fresh", + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + expiresAt = DateTimeOffset.UtcNow.AddHours(8), + })); + server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", "Bearer mcp-cross-process-fresh")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + + var firstHandler = new TokenRefreshHandler(firstStore, "default") + { + InnerHandler = new HttpClientHandler(), + }; + var secondHandler = new TokenRefreshHandler(secondStore, "default") + { + InnerHandler = new HttpClientHandler(), + }; + using var firstHttp = new HttpClient(firstHandler) { BaseAddress = new Uri(server.Url + "/") }; + using var secondHttp = new HttpClient(secondHandler) { BaseAddress = new Uri(server.Url + "/") }; + var firstClient = new NodePilotApiClient(firstHttp) { BearerToken = "mcp-cross-process-expiring" }; + var secondClient = new NodePilotApiClient(secondHttp) { BearerToken = "mcp-cross-process-expiring" }; + + var callers = await Task.WhenAll( + firstClient.MeAsync(CancellationToken.None), + secondClient.MeAsync(CancellationToken.None)); + + callers.Should().OnlyContain(me => me.Username == "admin"); + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + firstStore.Load("default")!.Token.Should().Be("mcp-cross-process-fresh"); + } + finally { TryDelete(dir); } + } + + [Fact] + public async Task TokenRefreshHandler_OlderResponseUsesJwtExpiry_AndNewProcessesRespectCooldown() + { + var dir = Temp(); + using var server = WireMockServer.Start(); + try + { + var now = DateTimeOffset.UtcNow; + var absoluteExpiry = now.AddMinutes(4); + var clock = new ManualTimeProvider(now); + const string initialToken = "mcp-rolling-upgrade-token"; + var firstRotatedToken = Jwt(now, absoluteExpiry); + var secondIssuedAt = now + + ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow + + TimeSpan.FromSeconds(1); + var secondRotatedToken = Jwt(secondIssuedAt, absoluteExpiry); + var tokens = new TokenStore(dir); + tokens.Save("default", new StoredSession + { + Server = server.Url!, + Token = initialToken, + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = absoluteExpiry, + }); + + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", $"Bearer {initialToken}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = firstRotatedToken, + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + })); + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost() + .WithHeader("Authorization", $"Bearer {firstRotatedToken}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + token = secondRotatedToken, + userId = Guid.NewGuid(), + username = "admin", + role = "Admin", + })); + foreach (var token in new[] { firstRotatedToken, secondRotatedToken }) + { + server.Given(Request.Create().WithPath("/api/auth/me").UsingGet() + .WithHeader("Authorization", $"Bearer {token}")) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + id = Guid.NewGuid(), username = "admin", role = "Admin", + })); + } + + async Task CallFromNewProcessAsync() + { + var processStore = new TokenStore(dir); + var current = processStore.Load("default")!; + var handler = new TokenRefreshHandler( + processStore, "default", timeProvider: clock) + { + InnerHandler = new HttpClientHandler(), + }; + using var http = new HttpClient(handler) { BaseAddress = new Uri(server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = current.Token }; + (await client.MeAsync(CancellationToken.None)).Username.Should().Be("admin"); + } + + await CallFromNewProcessAsync(); + for (var i = 0; i < 5; i++) + await CallFromNewProcessAsync(); + + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(1); + tokens.Load("default")!.ExpiresAt.Should() + .BeCloseTo(absoluteExpiry, TimeSpan.FromSeconds(1)); + + clock.Advance( + ClientSessionSecurity.SuccessfulRefreshDeduplicationWindow + + TimeSpan.FromSeconds(1)); + await CallFromNewProcessAsync(); + + server.LogEntries.Count(entry => + entry.RequestMessage!.AbsolutePath == "/api/auth/refresh").Should().Be(2); + tokens.Load("default")!.Token.Should().Be(secondRotatedToken); + } + finally { TryDelete(dir); } + } + + [Fact] + public async Task TokenRefreshHandler_ExpiredSession_DoesNotCallServerAndRequiresLogin() + { + var dir = Temp(); + using var server = WireMockServer.Start(); + try + { + var tokens = new TokenStore(dir); + tokens.Save("default", new StoredSession + { + Server = server.Url!, + Token = "mcp-expired-token", + Username = "admin", + UserId = Guid.NewGuid(), + Role = "Admin", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1), + }); + server.Given(Request.Create().WithPath("/api/auth/refresh").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + server.Given(Request.Create().WithPath("/api/auth/me").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var handler = new TokenRefreshHandler(tokens, "default") + { + InnerHandler = new HttpClientHandler(), + }; + var http = new HttpClient(handler) { BaseAddress = new Uri(server.Url + "/") }; + var client = new NodePilotApiClient(http) { BearerToken = "mcp-expired-token" }; + + var act = () => client.MeAsync(CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.IsUnauthorized.Should().BeTrue(); + tokens.Load("default").Should().BeNull(); + server.LogEntries.Should().BeEmpty(); + } + finally { TryDelete(dir); } + } + // ---- helpers ------------------------------------------------------------ private static string Temp() => Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "np-mcp-cfg-" + Guid.NewGuid().ToString("N"))).FullName; @@ -244,6 +546,32 @@ public async Task TokenRefreshHandler_StoreChangedAfterClientCreation_DoesNotUse ExpiresAt = DateTime.UtcNow.AddHours(12), }; + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow += duration; + } + + private static string Jwt(DateTimeOffset issuedAt, DateTimeOffset expiresAt) + { + static string Encode(string value) => Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + iat = issuedAt.ToUnixTimeSeconds(), + np_iat_ms = issuedAt.ToUnixTimeMilliseconds(), + exp = expiresAt.ToUnixTimeSeconds(), + }); + return $"{Encode("{\"alg\":\"none\"}")}.{Encode(payload)}."; + } + private static void TryDelete(string dir) { try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } private static readonly string[] ConfigEnvVars = diff --git a/tests/NodePilot.Mcp.Tests/Auth/CliSessionInteropTests.cs b/tests/NodePilot.Mcp.Tests/Auth/CliSessionInteropTests.cs index d09f52e4..0acf3601 100644 --- a/tests/NodePilot.Mcp.Tests/Auth/CliSessionInteropTests.cs +++ b/tests/NodePilot.Mcp.Tests/Auth/CliSessionInteropTests.cs @@ -62,6 +62,22 @@ public void SessionFileName_IsIdenticalInBothStores() "Session, die der MCP-Server nie findet"); } + [Fact] + public void McpStoredSession_ReadsLegacyCliUtcDateTimeJson() + { + const string legacyJson = + """ + {"server":"https://np.example","token":"legacy","username":"admin","userId":"00000000-0000-0000-0000-000000000001","role":"Admin","expiresAt":"2026-08-15T12:34:56Z"} + """; + + var session = System.Text.Json.JsonSerializer.Deserialize( + legacyJson, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + + session.Should().NotBeNull(); + session!.ExpiresAt.Should().Be(new DateTimeOffset(2026, 8, 15, 12, 34, 56, TimeSpan.Zero)); + } + private static string ExtractEntropyExpression(string relativePath) { var source = ReadRepoFile(relativePath); diff --git a/tests/NodePilot.Mcp.Tests/Mapping/WorkflowDefinitionPatcherTests.cs b/tests/NodePilot.Mcp.Tests/Mapping/WorkflowDefinitionPatcherTests.cs index c23ca21e..c5a8122d 100644 --- a/tests/NodePilot.Mcp.Tests/Mapping/WorkflowDefinitionPatcherTests.cs +++ b/tests/NodePilot.Mcp.Tests/Mapping/WorkflowDefinitionPatcherTests.cs @@ -242,4 +242,52 @@ public void MergeFull_ContentMaskedHeadersString_RestoredByUniversalMaskRule() cfg["headers"]!.GetValue().Should().Be("Authorization: Bearer sk-live-REAL"); // restored result.Notes.Should().BeEmpty(); } + + [Fact] + public void MergeFull_OpaqueHeadersObjectMask_RestoresCompleteOriginalShape() + { + var original = E(""" + {"nodes":[{"id":"n1","data":{"config":{"url":"https://x","headers":{"Accept":"application/json","X-Legacy":"opaque-value"}}}}],"edges":[]} + """); + var proposed = E(""" + {"nodes":[{"id":"n1","data":{"config":{"url":"https://y","headers":"***"}}}],"edges":[]} + """); + + var result = WorkflowDefinitionPatcher.MergeFull(original, proposed); + var cfg = result.Definition["nodes"]!.AsArray()[0]!["data"]!["config"]!; + + cfg["url"]!.GetValue().Should().Be("https://y"); + cfg["headers"]!["Accept"]!.GetValue().Should().Be("application/json"); + cfg["headers"]!["X-Legacy"]!.GetValue().Should().Be("opaque-value"); + } + + [Fact] + public void MergeFull_MaskedLiteralsInsideNestedArrays_RestoreOriginalConditionsAndCases() + { + var original = E(""" + {"nodes":[{"id":"decision-1","data":{"config":{"cases":[{ + "conditionExpression":{"type":"comparison","left":{"kind":"variable","value":"x"}, + "op":"==","right":{"kind":"literal","value":"case-secret"}}}]}}}], + "edges":[{"id":"edge-1","source":"a","target":"b","data":{"conditionExpression":{ + "type":"group","op":"AND","children":[{"type":"comparison", + "left":{"kind":"literal","value":"left-secret"},"op":"==", + "right":{"kind":"variable","value":"y"}}]}}}]} + """); + var proposed = E(""" + {"nodes":[{"id":"decision-1","data":{"config":{"cases":[{ + "conditionExpression":{"type":"comparison","left":{"kind":"variable","value":"x"}, + "op":"==","right":{"kind":"literal","value":"***"}}}]}}}], + "edges":[{"id":"edge-1","source":"a","target":"b","data":{"conditionExpression":{ + "type":"group","op":"AND","children":[{"type":"comparison", + "left":{"kind":"literal","value":"***"},"op":"==", + "right":{"kind":"variable","value":"y"}}]}}}]} + """); + + var merged = WorkflowDefinitionPatcher.MergeFull(original, proposed).Definition; + + merged["nodes"]![0]!["data"]!["config"]!["cases"]![0]!["conditionExpression"]! + ["right"]!["value"]!.GetValue().Should().Be("case-secret"); + merged["edges"]![0]!["data"]!["conditionExpression"]!["children"]![0]! + ["left"]!["value"]!.GetValue().Should().Be("left-secret"); + } } diff --git a/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs b/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs index b411f348..cd02304c 100644 --- a/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs +++ b/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs @@ -25,11 +25,29 @@ public sealed class DbAdminMcpToolsTests { new { name = "Id", clrType = "Guid", isNullable = false, maxLength = (int?)null, isPrimaryKey = true, isMasked = false, isReadOnly = true }, new { name = "Name", clrType = "string", isNullable = false, maxLength = (int?)200, isPrimaryKey = false, isMasked = false, isReadOnly = false }, + new { name = "DefinitionJson", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = true }, }, rowCount = 12L, cascadeDeletesTo = Array.Empty(), }, new + { + name = "CustomActivityDefinition", + displayName = "Custom Activity Definition", + dbTableName = "CustomActivityDefinitions", + pkColumns = new[] { "Id" }, + capabilities = new { canUpdate = true, canDelete = false }, + columns = new object[] + { + new { name = "Id", clrType = "Guid", isNullable = false, maxLength = (int?)null, isPrimaryKey = true, isMasked = false, isReadOnly = true }, + new { name = "Name", clrType = "string", isNullable = false, maxLength = (int?)200, isPrimaryKey = false, isMasked = false, isReadOnly = false }, + new { name = "ScriptTemplate", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = false }, + new { name = "InputParametersJson", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = false }, + }, + rowCount = 2L, + cascadeDeletesTo = Array.Empty(), + }, + new { name = "GlobalVariable", displayName = "Global Variable", @@ -56,11 +74,15 @@ public async Task ListDbTables_ReturnsCompactSchema_WithoutSecretColumns() var tools = new DbAdminMcpTools(api.Client()); var json = JsonSerializer.Serialize(await tools.ListDbTables()); - json.Should().Contain("\"name\":\"Workflow\""); + json.Should().NotContain("\"name\":\"Workflow\""); + json.Should().NotContain("CustomActivityDefinition"); json.Should().Contain("\"isMasked\":true"); // GlobalVariable.Value masked flag carried through json.Should().NotContain("capabilities"); // capabilities/cascade dropped for token efficiency json.Should().NotContain("cascadeDeletesTo"); json.Should().NotContain("PasswordHash"); // hidden columns never present from API + json.Should().NotContain("DefinitionJson"); // raw DbAdmin schema is filtered at MCP boundary + json.Should().NotContain("ScriptTemplate"); + json.Should().NotContain("InputParametersJson"); } [Fact] @@ -123,6 +145,64 @@ public async Task RunReadonlySql_SendsReadMode_AndReturnsRows() json.Should().NotContain("\"note\":null"); // success path omits a note } + [Theory] + [InlineData("SELECT DefinitionJson FROM Workflows")] + [InlineData("SELECT w.DefinitionJson AS payload FROM Workflows w")] + [InlineData("SELECT * FROM WorkflowVersions")] + [InlineData("SELECT CAST(w AS text) FROM Workflows w")] + [InlineData("SELECT array_to_json(array_agg(w)) FROM Workflows w")] + [InlineData("SELECT Id, Name FROM Workflows")] + [InlineData("SELECT leak FROM Workflows w CROSS JOIN LATERAL regexp_split_to_table(CAST(w AS text), 'NEVER_MATCH') AS leak")] + [InlineData("SELECT ScriptTemplate FROM CustomActivityDefinitions")] + [InlineData("SELECT substr(InputParametersJson, 1, 10) FROM CustomActivityDefinitionVersions")] + [InlineData("SELECT query_to_xml('SELECT \"DefinitionJson\" FROM \"Workflows\"', false, true, '')")] + [InlineData("SELECT U&\"Definiti\\006FnJson\" AS payload FROM U&\"Workfl\\006Fws\"")] + public async Task RunReadonlySql_RejectsOpaqueAutomationPayloadBeforeApiCall(string sql) + { + using var api = new TestApi(); + var tools = new DbAdminMcpTools(api.Client()); + + var ex = await Assert.ThrowsAsync(() => tools.RunReadonlySql(sql)); + + ex.Message.Should().Contain("workflow definition or custom activity implementation"); + api.Server.LogEntries.Should().BeEmpty("rejected agent SQL must never reach raw DbAdmin"); + } + + [Fact] + public async Task RunReadonlySql_MasksOpaqueResultColumnNames_AsDefenseInDepth() + { + using var api = new TestApi(); + api.Server.Given(Request.Create().WithPath("/api/dbadmin/query").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + columns = new[] + { + new { name = "Name", type = "string" }, + new { name = "DefinitionJson", type = "string" }, + new { name = "ScriptTemplate", type = "string" }, + new { name = "InputParametersJson", type = "string" }, + }, + rows = new object[] + { + new object[] { "safe-name", "definition-canary-741", "script-canary-852", "defaults-canary-963" }, + }, + rowsAffected = (int?)null, + durationMs = 2L, + truncated = false, + mode = "read", + })); + + var tools = new DbAdminMcpTools(api.Client()); + var json = JsonSerializer.Serialize( + await tools.RunReadonlySql("SELECT * FROM AgentSafeProjection")); + + json.Should().Contain("safe-name"); + json.Should().Contain("***"); + json.Should().NotContain("definition-canary-741"); + json.Should().NotContain("script-canary-852"); + json.Should().NotContain("defaults-canary-963"); + } + [Fact] public async Task RunReadonlySql_CapsRowsAt200_AndSetsTruncated() { @@ -193,4 +273,4 @@ public async Task ListDbTables_ApiError_MapsToApiException() var ex = await Assert.ThrowsAsync(() => tools.ListDbTables()); ex.Message.Should().Contain("np auth login"); } -} \ No newline at end of file +} diff --git a/tests/NodePilot.Mcp.Tests/Tools/DefinitionRedactionTests.cs b/tests/NodePilot.Mcp.Tests/Tools/DefinitionRedactionTests.cs index 18802fcf..ebab5d2e 100644 --- a/tests/NodePilot.Mcp.Tests/Tools/DefinitionRedactionTests.cs +++ b/tests/NodePilot.Mcp.Tests/Tools/DefinitionRedactionTests.cs @@ -18,7 +18,7 @@ public sealed class DefinitionRedactionTests public async Task GetWorkflowDefinition_MasksSecretConfigValues() { var id = Guid.NewGuid(); - // A definition with an inline webhook secret + an API key + a harmless script. + // A definition with named secrets and free-form fields whose contents cannot be classified safely. var definition = """ { "nodes": [ @@ -55,10 +55,11 @@ public async Task GetWorkflowDefinition_MasksSecretConfigValues() json.Should().NotContain("sk-live-123"); json.Should().NotContain("hunter2"); json.Should().NotContain("opaque-tenant-credential"); + json.Should().NotContain("Get-PSDrive C"); + json.Should().NotContain("application/json"); json.Should().Contain("***"); - // Non-secret content preserved. - json.Should().Contain("Get-PSDrive C"); - json.Should().Contain("https://example.com"); - json.Should().Contain("application/json"); + // Runtime HTTP destinations are opaque because user-info, path and query components may + // carry unclassified legacy credentials. + json.Should().NotContain("https://example.com"); } } From 82e54ecb3216ca9ef1ecf40cfde3702fadb23db6 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:06:15 +0200 Subject: [PATCH 2/2] Let external agents query workflow tables via SQL again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit introduced ExternalAgentSqlPolicy, which hid Workflows, WorkflowVersions and both custom-activity tables from schema discovery and rejected any SQL mentioning them — for the AI knowledge source and the MCP server alike. That is reverted: the surface is a product decision, and the owner wants the assistant to answer from SQL without exceptions. The restriction bought little. Both surfaces are Admin-only, and a global Admin reads the same rows through the forensic DbAdmin view anyway, so the policy mainly made inventory questions ("which workflows exist") unanswerable while the data stayed one click away. get_workflow_definition remains the more convenient path when secret redaction is wanted. Deliberately kept, because they are unrelated to workflow tables and close real holes in the shared read guard for the genuinely secret columns: - dynamic XML exporters (query_to_xml/table_to_xml/database_to_xml), which take their target as a string and so defeat identifier-based checks; - PostgreSQL U&"..." escaped identifiers, which spell a protected column name without ever writing it. SqlStatementInspector stays for the same reason — DbAdminReadOnlySqlGuard now builds on it. SqlKnowledgeReader and DbAdminMcpTools are back at their previous state; docs/ai-features.md and docs/mcp-server.md record why the tables are deliberately not blocked. Verified: solution builds; Api 2400 and Mcp 180 tests green (the delta to the previous commit is exactly the removed policy tests). --- docs/ai-features.md | 23 +-- docs/mcp-server.md | 14 +- src/NodePilot.Api/Ai/SqlKnowledgeReader.cs | 53 ++---- .../DbAdmin/DbAdminReadOnlySqlGuard.cs | 6 +- .../Security/ExternalAgentSqlPolicy.cs | 106 ----------- src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs | 55 ++---- .../Ai/SqlKnowledgeReaderTests.cs | 166 +----------------- .../Tools/DbAdminMcpToolsTests.cs | 84 +-------- 8 files changed, 56 insertions(+), 451 deletions(-) delete mode 100644 src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs diff --git a/docs/ai-features.md b/docs/ai-features.md index f81831dc..b0f1db37 100644 --- a/docs/ai-features.md +++ b/docs/ai-features.md @@ -477,7 +477,7 @@ Source-Code + DB default aus: |---|---|---|---| | Dokumentation | `DocsEnabled` | `search_docs`, `read_doc` | — | | Workflows & Betrieb | `OperationalEnabled` | `get_workflow_definition`, `analyze_workflow`, `get_next_scheduled_fires` | RBAC-folder-scoped | -| Betrieb (Listen) | via DB-Quelle | "Welche Läufe/Maschinen gibt es" → `list_db_tables` + `execute_readonly_sql` | ausschließlich globaler Admin (text2sql) | +| Workflows & Betrieb (Listen) | via DB-Quelle | "Welche Workflows/Läufe/Maschinen gibt es" → `list_db_tables` + `execute_readonly_sql` | ausschließlich globaler Admin (text2sql) | | Systemkonfiguration | (immer, wenn privilegiert) | `read_settings` | Admin/Operator | | Quellcode | `SourceCodeEnabled` | `search_source`, `read_source` | Admin/Operator | | **DB / text2sql** | `DbEnabled` | `list_db_tables`, `get_db_table`, `execute_readonly_sql` | ausschließlich globaler Admin | @@ -489,15 +489,18 @@ DbAdmin-Services). `execute_readonly_sql` nimmt ein einzelnes Statement bis 64 K Executor (nicht nur am HTTP-Controller), erlaubt als erstes Keyword nur `SELECT`/`WITH`/`EXPLAIN`/`SHOW`/ `VALUES`/`TABLE` und lehnt mutierende Keywords, gefährliche Routinen, Multi-Statements sowie `EXPLAIN ANALYZE` ab. PostgreSQL setzt zusätzlich `SET TRANSACTION READ ONLY`; alle Provider rollen die -Transaktion zurück. **Secret-Schutz mehrlagig**: Schema-Tools verbergen `IsHidden`-Spalten und lassen -`Workflows`, `WorkflowVersions`, `CustomActivityDefinitions` sowie deren Versionstabelle vollständig aus. -Der AI-SQL-Adapter lehnt jede Referenz auf diese vier Tabellen vor Ausführung ab; Workflow-Definitionen -bleiben über das dedizierte, RBAC-geprüfte `get_workflow_definition` erreichbar. Damit muss kein -provider-neutraler Lexer beweisen, dass Composite Rows nicht über Casts, LATERAL-Funktionen oder andere -Wrapper abfließen. PostgreSQL-`U&"…"`-Identifier werden an dieser Grenze ebenfalls abgelehnt. Das ist -absichtlich strenger als die forensische DbAdmin-Ansicht. Result-Spalten werden zusätzlich nach Namen -maskiert und übrige Zellen durch den `IAuditDetailsRedactor` geführt. Row-Cap 200. -Übergroße Tool-Resultate bleiben valides JSON mit explizitem Truncation-Hinweis. +Transaktion zurück. **Secret-Schutz mehrlagig**: Schema-Tools verbergen `IsHidden`-Spalten; jede SQL-Referenz +auf eine geschützte Spalte wird bereits vor Ausführung abgelehnt (auch Alias-/Ausdrucksvarianten); +Whole-Row-Serialisierer über eine Tabelle mit geschützter Spalte (`to_json`/`row_to_json`/`::text`/ +`FOR JSON`) ebenso, weil sie die namensbasierten Schichten umgehen würden. PostgreSQL-`U&"…"`-Identifier +und dynamische XML-Exporter (`query_to_xml` & Co.) sind im Read-Guard generell gesperrt. +Result-Spalten werden zusätzlich nach Namen maskiert und übrige Zellen durch den `IAuditDetailsRedactor` +geführt. Row-Cap 200. Übergroße Tool-Resultate bleiben valides JSON mit explizitem Truncation-Hinweis. + +**Workflow-Definitionen sind hier bewusst nicht ausgenommen.** Der text2sql-Pfad ist Admin-only, und ein +globaler Admin sieht dieselben Zeilen ohnehin über die DbAdmin-Ansicht — eine Sperre auf `Workflows`, +`WorkflowVersions` oder den Custom-Activity-Tabellen hätte nur „welche Workflows gibt es" unbeantwortbar +gemacht, ohne eine Fähigkeit zu entziehen. DB-Tools nutzen Strict Function Schemas; inkompatible lokale Endpoints erhalten automatisch einen Best-Effort-Retry. SQL-Text wird nicht auditiert, stattdessen nur Anzahl und SHA-256-Kurzfingerprints. Text2SQL ist nur als Capability sichtbar, wenn das aktive Profil `EnableToolCalling=true` hat. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index c078fed4..eda73b81 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -130,13 +130,13 @@ gerade“. Gefensterte Gesamtzahlen kommen ohnehin aus `density[]`, nicht aus de `list_db_tables` · `get_db_info` · `run_readonly_sql`. Schema discovery + single read-only SQL statement against the NodePilot App-DB (the agent does the NL→SQL translation). Read keyword whitelist + rollback enforced server-side; no write tool. `list_db_tables` hides secret columns -(`PasswordHash`/`EncryptedPassword`), masks `GlobalVariable.Value` und lässt die vier Tabellen mit -opaquen Automations-Payloads vollständig aus: `Workflows`, `WorkflowVersions`, -`CustomActivityDefinitions`, `CustomActivityDefinitionVersions`. `run_readonly_sql` lehnt jede -Referenz auf diese Tabellen bereits im MCP-Prozess vor dem HTTP-Request ab; passende Ergebnis- -Spaltennamen werden zusätzlich maskiert. So können Composite Rows weder über Casts/LATERAL noch -über provider-spezifische Wrapper zum Agent gelangen. PostgreSQL-`U&"…"`-Identifier sind an dieser -Grenze ebenfalls gesperrt. Dedizierte Workflow-/Custom-Activity-Tools bleiben der Zugriffspfad. +(`PasswordHash`/`EncryptedPassword`) und maskiert `GlobalVariable.Value`. PostgreSQL-`U&"…"`-Identifier +und dynamische XML-Exporter (`query_to_xml` & Co.) sind im serverseitigen Read-Guard generell gesperrt. + +`Workflows`, `WorkflowVersions` und die Custom-Activity-Tabellen sind **bewusst nicht** gesperrt: die +Tools sind Admin-only, und ein Admin liest dieselben Zeilen ohnehin über DbAdmin. Eine Sperre hätte nur +Bestandsfragen („welche Workflows gibt es") unbeantwortbar gemacht. Für Definitionen inklusive +Secret-Redaction bleibt `get_workflow_definition` der bequemere Weg. Für die übrigen Secret-Spalten gelten drei serverseitige Schichten im `DbAdminSecretColumns`-Contract: diff --git a/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs b/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs index 917f6790..2e19f175 100644 --- a/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs +++ b/src/NodePilot.Api/Ai/SqlKnowledgeReader.cs @@ -2,7 +2,6 @@ using NodePilot.Api.Services.DbAdmin; using NodePilot.Core.Audit; using NodePilot.Core.Interfaces; -using NodePilot.Core.Security; namespace NodePilot.Api.Ai; @@ -10,21 +9,19 @@ namespace NodePilot.Api.Ai; /// over the existing DbAdmin services. Reuses /// (singleton — schema is stable) for the catalog and /// (scoped — owns the request DbContext) for read-only execution, -/// then redacts every cell before it leaves the reader. Tables holding Workflow Definitions or -/// custom-activity implementations are excluded from this generic source and remain available only -/// through dedicated, RBAC-aware tools. -/// Scoped, matching . +/// then redacts every cell before it leaves the reader. Scoped, matching +/// . /// -/// Redaction layers: the external-agent policy first removes and rejects opaque -/// automation tables. Then refuses statements that name a protected -/// column, masks protected result columns, and rejects whole-row serializers over protected tables. -/// Finally, every remaining cell is stringified and run through . -/// Result rows are capped (token budget) and cells truncated. Only string? ever leaves this -/// reader. +/// Redaction (three layers): first, refuses statements +/// that name a protected column and replaces protected result columns with "***"; second, it +/// refuses whole-row serializers over those tables, which would otherwise carry the secret past the +/// name-based mask; third, every remaining cell is stringified and run through +/// . Result rows are capped (token budget) and cells truncated. +/// Only string? ever leaves this reader. /// -/// The shared secret-column guard also runs on /api/dbadmin/query. The external-agent -/// table policy intentionally does not: DbAdmin keeps those rows visible to administrators -/// for forensic inspection and never forwards its response to an LLM. +/// This closes the secret-leak gap that raw SQL otherwise opens. The same +/// guard runs on the /api/dbadmin/query endpoint, so the +/// MCP/CLI/UI raw-SQL path enforces the identical contract. /// public sealed class SqlKnowledgeReader : ISqlKnowledgeReader { @@ -53,17 +50,12 @@ public SqlKnowledgeReader( public Task> ListTablesAsync(CancellationToken ct) { var rows = _metadata.GetAllTables() - .Where(t => ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .Select(t => new DbTableKnowledgeSummary( t.Name, t.DbTableName, t.PkColumns, - t.Columns - .Where(c => !c.IsHidden - && ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) - .Select(c => c.Name) - .ToList())) + t.Columns.Where(c => !c.IsHidden).Select(c => c.Name).ToList())) .ToList(); return Task.FromResult>(rows); } @@ -71,11 +63,9 @@ public Task> ListTablesAsync(Cancellation public Task GetTableAsync(string name, CancellationToken ct) { var t = _metadata.GetTable(name); - if (t is null || !ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) - return Task.FromResult(null); + if (t is null) return Task.FromResult(null); var cols = t.Columns - .Where(c => !c.IsHidden - && ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) + .Where(c => !c.IsHidden) .Select(c => new DbColumnKnowledge(c.Name, FriendlyType(c), c.IsNullable, c.IsPrimaryKey)) .ToList(); var visibleNames = cols.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -92,16 +82,6 @@ public async Task ExecuteReadAsync(string sql, Cancella { var sw = Stopwatch.StartNew(); - // DbAdmin keeps opaque automation rows visible for forensic administrators. This reader - // sends results to an external LLM, so it rejects every mention of those tables before a - // database connection opens. Dedicated tools own RBAC and payload redaction. - if (ExternalAgentSqlPolicy.ReferencesProtectedProjection(sql)) - { - return new SqlQueryKnowledgeResult( - Array.Empty(), Array.Empty>(), false, - sw.ElapsedMilliseconds, ExternalAgentSqlPolicy.RejectionMessage); - } - // Result-column masking cannot recover source lineage after aliases/expressions, so a // statement that mentions a protected identifier is refused before it reaches the database. if (_secretColumns.ReferencesProtectedColumn(sql)) @@ -136,11 +116,6 @@ public async Task ExecuteReadAsync(string sql, Cancella var columns = result.Columns.Select(c => c.Name).ToList(); var masked = _secretColumns.BuildColumnMask(columns); - for (var c = 0; c < columns.Count; c++) - { - if (ExternalAgentSqlPolicy.IsProtectedResultColumn(columns[c])) - masked[c] = true; - } var rows = new List>(result.Rows.Count); var truncated = result.Truncated; diff --git a/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs b/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs index 2be6aee1..4e8c9bdb 100644 --- a/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs +++ b/src/NodePilot.Api/Services/DbAdmin/DbAdminReadOnlySqlGuard.cs @@ -13,9 +13,9 @@ internal static class DbAdminReadOnlySqlGuard public const string CastOperator = "::"; /// - /// Constructs that can collapse a complete row into one innocently named result column. This - /// list supports the forensic DbAdmin secret-column guard; external-agent SQL additionally uses - /// bare composite-row detection in . + /// Constructs that can collapse a complete row into one innocently named result column, which + /// would otherwise carry a secret past the two name-based layers. Scoped to the tables that + /// actually hold a protected column — see . /// private static readonly HashSet WholeRowProjectionIdentifiers = new(StringComparer.OrdinalIgnoreCase) { diff --git a/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs b/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs deleted file mode 100644 index daf7439d..00000000 --- a/src/NodePilot.Core/Security/ExternalAgentSqlPolicy.cs +++ /dev/null @@ -1,106 +0,0 @@ -using NodePilot.Core.Models; - -namespace NodePilot.Core.Security; - -/// -/// Shared trust-boundary policy for generic SQL whose schema/results are sent to an external agent -/// (AI Knowledge or MCP). Browser DbAdmin is intentionally outside this policy: administrators may -/// inspect raw automation payloads there for forensics, while agent adapters expose those payloads -/// only through their dedicated, RBAC-aware tools. -/// -public static class ExternalAgentSqlPolicy -{ - public const string Mask = "***"; - - /// - /// Names the protected surface on purpose. The recipient is an LLM deciding what to try next: - /// a generic "protected data" refusal invites it to rephrase the same query, while naming the - /// workflow definition / custom activity implementation and pointing at the dedicated tool - /// routes it somewhere that actually works. - /// - public const string RejectionMessage = - "Query references a workflow definition or custom activity implementation. " - + "Generic SQL cannot expose those to an external agent — " - + "use the dedicated RBAC-aware API or tool for that data instead."; - - private static readonly ProtectedTable[] ProtectedTables = - [ - CreateTable("Workflows", nameof(Workflow.DefinitionJson)), - CreateTable("WorkflowVersions", nameof(WorkflowVersion.DefinitionJson)), - CreateTable( - "CustomActivityDefinitions", - nameof(CustomActivityDefinition.ScriptTemplate), - nameof(CustomActivityDefinition.InputParametersJson)), - CreateTable( - "CustomActivityDefinitionVersions", - nameof(CustomActivityDefinitionVersion.ScriptTemplate), - nameof(CustomActivityDefinitionVersion.InputParametersJson)), - ]; - - private static readonly HashSet AllProtectedTableIdentifiers = ProtectedTables - .SelectMany(table => table.Identifiers) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - private static readonly HashSet AllProtectedColumnIdentifiers = ProtectedTables - .SelectMany(table => table.Columns) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - /// Built-in opaque automation tables; callers may add metadata-derived secret tables. - public static IReadOnlySet BuiltInProtectedTableIdentifiers => AllProtectedTableIdentifiers; - - /// - /// Generic agent SQL has no reliable provider-neutral way to prove projection lineage for these - /// opaque rows. The complete tables are therefore absent from schema discovery; dedicated tools - /// remain the only external-agent access path. - /// - public static bool IsSchemaTableVisible( - string entityOrTableName, - IReadOnlySet? additionalProtectedTableIdentifiers = null) - => !AllProtectedTableIdentifiers.Contains(entityOrTableName) - && (additionalProtectedTableIdentifiers is null - || !additionalProtectedTableIdentifiers.Contains(entityOrTableName)); - - /// Whether a column may appear in generic external-agent schema discovery. - public static bool IsSchemaColumnVisible( - string entityOrTableName, - string columnName, - IReadOnlySet? additionalProtectedTableIdentifiers = null) - => IsSchemaTableVisible(entityOrTableName, additionalProtectedTableIdentifiers) - && !AllProtectedColumnIdentifiers.Contains(columnName); - - /// - /// Rejects every mention of a protected table, rather than attempting a provider-neutral SQL - /// allow-list. Composite rows can flow through SELECT expressions, LATERAL sources, casts, - /// aggregates and extension functions; lexical projection analysis cannot prove those safe. - /// Protected column names and dynamic XML exporters are also rejected in case a view obscures - /// the source table. - /// - public static bool ReferencesProtectedProjection( - string sql, - IReadOnlySet? additionalProtectedTableIdentifiers = null) - { - return SqlStatementInspector.ContainsUnicodeEscapedIdentifier(sql) - || SqlStatementInspector.FindDynamicDataExporter(sql) is not null - || SqlStatementInspector.ReferencesAnyIdentifier(sql, AllProtectedTableIdentifiers) - || (additionalProtectedTableIdentifiers is not null - && SqlStatementInspector.ReferencesAnyIdentifier(sql, additionalProtectedTableIdentifiers)) - || SqlStatementInspector.ReferencesAnyIdentifier(sql, AllProtectedColumnIdentifiers); - } - - /// - /// Result-name defence in depth for views/provider projections whose submitted SQL hides source - /// lineage. A matching column is masked even when it belongs to an unrelated table; ambiguity at - /// this point must resolve toward non-disclosure. - /// - public static bool IsProtectedResultColumn(string columnName) - => AllProtectedColumnIdentifiers.Contains(columnName); - - private static ProtectedTable CreateTable(string dbTableName, params string[] columns) - => new( - new HashSet([typeof(T).Name, dbTableName], StringComparer.OrdinalIgnoreCase), - new HashSet(columns, StringComparer.OrdinalIgnoreCase)); - - private sealed record ProtectedTable( - IReadOnlySet Identifiers, - IReadOnlySet Columns); -} diff --git a/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs b/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs index 2bbfc17f..28e45ec0 100644 --- a/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs +++ b/src/NodePilot.Mcp/Tools/DbAdminMcpTools.cs @@ -1,8 +1,6 @@ using System.ComponentModel; using System.Text.Json; -using ModelContextProtocol; using ModelContextProtocol.Server; -using NodePilot.Core.Security; using NodePilot.Mcp.Api; using NodePilot.Mcp.Api.Dtos; using NodePilot.Mcp.Mapping; @@ -19,15 +17,12 @@ namespace NodePilot.Mcp.Tools; /// whitelist (SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE), enforces single-statement, rolls back the /// (read-only) transaction, and caps rows + timeout. /// - Hidden secret columns (PasswordHash, EncryptedPassword, byte[]) never appear in list_db_tables; -/// GlobalVariable.Value is masked as "***". The shared external-agent policy additionally removes -/// Workflow Definitions, custom-activity scripts and executable parameter defaults. +/// GlobalVariable.Value is masked as "***". /// - Raw SQL cannot reach them either: /api/dbadmin/query rejects a read statement that names a /// protected column, masks protected result columns of a wildcard select as "***", and rejects a /// whole-row serializer over a table that holds a secret column (to_json/row_to_json/::text/ /// FOR JSON — these carry the row past the two name-based layers). Use list_db_tables for the /// safe schema. -/// - MCP additionally rejects every SQL reference to the four opaque automation tables before the -/// HTTP request and masks matching result-column names in depth. Browser DbAdmin remains forensic. /// [McpServerToolType] public sealed class DbAdminMcpTools @@ -42,7 +37,7 @@ public sealed class DbAdminMcpTools private const int MaxResultChars = 4000; [McpServerTool(Name = "list_db_tables", ReadOnly = true)] - [Description("List the NodePilot App-DB schema (every EF-tracked table with its agent-safe columns, primary keys and row count). Hidden secrets and opaque workflow/custom-activity implementation payloads are excluded; GlobalVariable.Value is masked. Pass `name` to filter one table. Prefer this schema over guessing column names. Admin-only.")] + [Description("List the NodePilot App-DB schema (every EF-tracked table with its non-hidden columns, primary keys and row count). Hidden secret columns are excluded; GlobalVariable.Value is masked. Pass `name` to filter to one table (case-insensitive). This is the safe schema source — prefer it over guessing column names for run_readonly_sql. Admin-only.")] public async Task ListDbTables( [Description("Optional table-name filter (case-insensitive substring). Omit for all tables.")] string? name = null, CancellationToken cancellationToken = default) @@ -56,26 +51,21 @@ public async Task ListDbTables( filtered = tables.Where(t => t.Name.Contains(needle, StringComparison.OrdinalIgnoreCase)); } - var rows = filtered - .Where(t => ExternalAgentSqlPolicy.IsSchemaTableVisible(t.Name)) - .OrderBy(t => t.Name) - .Select(t => new + var rows = filtered.OrderBy(t => t.Name).Select(t => new { name = t.Name, displayName = t.DisplayName, dbTableName = t.DbTableName, pkColumns = t.PkColumns, rowCount = t.RowCount, - columns = t.Columns - .Where(c => ExternalAgentSqlPolicy.IsSchemaColumnVisible(t.Name, c.Name)) - .Select(c => new - { - name = c.Name, - type = c.ClrType, - isNullable = c.IsNullable, - isPrimaryKey = c.IsPrimaryKey, - isMasked = c.IsMasked, - }), + columns = t.Columns.Select(c => new + { + name = c.Name, + type = c.ClrType, + isNullable = c.IsNullable, + isPrimaryKey = c.IsPrimaryKey, + isMasked = c.IsMasked, + }), }); return new { tables = rows }; @@ -97,32 +87,13 @@ public async Task GetDbInfo(CancellationToken cancellationToken = defaul } [McpServerTool(Name = "run_readonly_sql", ReadOnly = true)] - [Description("Run one read-only SQL statement against the NodePilot App-DB. The server enforces read-only SQL. MCP additionally rejects opaque Workflow Definition and custom-activity implementation payloads; use their dedicated tools instead. Sensitive result-column names are masked in depth. Results are capped (max 200 rows / 4 KB). Admin-only.")] + [Description("Run a single read-only SQL statement against the NodePilot App-DB and return columns + rows. Only SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE first-keyword statements are accepted (server-enforced); the transaction is read-only and rolled back. Use list_db_tables first for the schema. Secret columns (PasswordHash, EncryptedPassword, GlobalVariable.Value) are unreachable: naming one rejects the query, a wildcard select returns them as \"***\", and serializing a whole row of such a table (to_json/row_to_json/::text/FOR JSON) is rejected too — select the columns you need explicitly. Results are capped (max 200 rows / 4 KB). Admin-only.")] public async Task RunReadonlySql( [Description("A single read-only SQL statement (SELECT/WITH/EXPLAIN/SHOW/VALUES/TABLE).")] string sql, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(sql)) - throw new McpException("SQL statement is required."); - if (ExternalAgentSqlPolicy.ReferencesProtectedProjection(sql)) - throw new McpException(ExternalAgentSqlPolicy.RejectionMessage); - var result = await ApiErrorMapper.Guard(() => _api.ExecuteDbReadQueryAsync(sql, cancellationToken)); - // The API endpoint remains a raw forensic DbAdmin surface. MCP is an external-agent - // boundary, so it reapplies the shared result-name mask before tool output is serialized. - for (var c = 0; c < result.Columns.Count; c++) - { - if (!ExternalAgentSqlPolicy.IsProtectedResultColumn(result.Columns[c].Name)) - continue; - - foreach (var row in result.Rows) - { - if (c < row.Count) - row[c] = ExternalAgentSqlPolicy.Mask; - } - } - var rows = result.Rows; var truncated = result.Truncated; if (rows.Count > MaxResultRows) @@ -157,4 +128,4 @@ public async Task RunReadonlySql( note = "Result too large — rows dropped to stay inside the MCP tool-output cap. Narrow your query (fewer columns / WHERE / LIMIT).", }; } -} +} \ No newline at end of file diff --git a/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs b/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs index 778fc9b1..546371ca 100644 --- a/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs +++ b/tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs @@ -1,5 +1,4 @@ using FluentAssertions; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NodePilot.Api.Ai; using NodePilot.Api.Services.DbAdmin; @@ -14,9 +13,8 @@ namespace NodePilot.Api.Tests.Ai; /// /// Direct coverage for the text2sql reader's redaction contract: secret columns named in the /// schema (User.PasswordHash, Credential.EncryptedPassword) are masked to "***" -/// by result-column name, and Workflow Definition payloads are excluded from this generic database -/// knowledge source entirely. Every other cell runs through the redactor, rows are capped, and SQL -/// errors surface as Error instead of throwing. +/// by result-column name, the masked-by-name GlobalVariable.Value too, every other cell runs +/// through the redactor, rows are capped, and SQL errors surface as Error instead of throwing. /// Uses the same in-memory SQLite backend as DbAdminQueryExecutorTests. /// public class SqlKnowledgeReaderTests @@ -58,14 +56,6 @@ public async Task ListTables_OmitsHiddenColumns_ListsDbTableName() user.DbTableName.Should().Be("Users"); user.ColumnNames.Should().NotContain("PasswordHash"); // hidden user.ColumnNames.Should().Contain("Username"); - - tables.Select(t => t.Name).Should().NotContain(new[] - { - "Workflow", - "WorkflowVersion", - "CustomActivityDefinition", - "CustomActivityDefinitionVersion", - }); } [Fact] @@ -78,72 +68,6 @@ public async Task GetTable_OmitsHiddenSecretColumns() detail!.Columns.Select(c => c.Name).Should().NotContain("PasswordHash"); } - [Theory] - [InlineData("Workflow")] - [InlineData("WorkflowVersion")] - [InlineData("CustomActivityDefinition")] - [InlineData("CustomActivityDefinitionVersion")] - public async Task GetTable_HidesOpaqueAutomationTablesFromAi(string table) - { - using var db = TestDbFactory.Create(); - - var detail = await NewReader(db).GetTableAsync(table, CancellationToken.None); - - detail.Should().BeNull(); - } - - [Theory] - [InlineData("SELECT DefinitionJson FROM Workflows")] - [InlineData("SELECT w.DefinitionJson AS payload FROM Workflows w")] - [InlineData("SELECT substr(DefinitionJson, 1, 20) AS payload FROM Workflows")] - [InlineData("SELECT [DefinitionJson] FROM [WorkflowVersions]")] - [InlineData("SELECT `DefinitionJson` FROM `WorkflowVersions`")] - [InlineData("SELECT U&\"Definiti\\006FnJson\" AS payload FROM U&\"Workfl\\006Fws\"")] - public async Task ExecuteRead_RejectsWorkflowDefinitionReferencesBeforeExecution(string sql) - { - using var db = TestDbFactory.Create(); - - var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); - - result.Error.Should().Contain("workflow definition"); - result.Rows.Should().BeEmpty(); - } - - [Theory] - [InlineData("SELECT ScriptTemplate FROM CustomActivityDefinitions")] - [InlineData("SELECT d.ScriptTemplate AS payload FROM CustomActivityDefinitions d")] - [InlineData("SELECT substr(InputParametersJson, 1, 10) FROM CustomActivityDefinitions")] - [InlineData("SELECT [InputParametersJson] FROM [CustomActivityDefinitionVersions]")] - public async Task ExecuteRead_RejectsCustomActivityImplementationReferencesBeforeExecution(string sql) - { - using var db = TestDbFactory.Create(); - - var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); - - result.Error.Should().Contain("custom activity implementation"); - result.Rows.Should().BeEmpty(); - } - - [Theory] - [InlineData("SELECT * FROM Workflows")] - [InlineData("SELECT w.* FROM Workflows AS w")] - [InlineData("SELECT * FROM WorkflowVersions")] - [InlineData("WITH current AS (SELECT * FROM Workflows) SELECT Name FROM current")] - [InlineData("TABLE Workflows")] - [InlineData("SELECT Id, Name FROM Workflows")] - [InlineData("SELECT leak FROM Workflows w CROSS JOIN LATERAL regexp_split_to_table(CAST(w AS text), 'NEVER_MATCH') AS leak")] - [InlineData("SELECT * FROM CustomActivityDefinitions")] - [InlineData("SELECT d.* FROM CustomActivityDefinitionVersions d")] - public async Task ExecuteRead_RejectsAnyProtectedAutomationTableReferenceBeforeExecution(string sql) - { - using var db = TestDbFactory.Create(); - - var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); - - result.Error.Should().Contain("workflow definition"); - result.Rows.Should().BeEmpty(); - } - [Fact] public async Task ExecuteRead_RejectsDirectPasswordHashReference() { @@ -216,49 +140,14 @@ public async Task ExecuteRead_RejectsWholeRowProjectionOverProtectedTable(string result.Rows.Should().BeEmpty(); } - [Theory] - [InlineData("SELECT to_json(w) FROM Workflows w")] - [InlineData("SELECT row_to_json(w) FROM \"Workflows\" w")] - [InlineData("SELECT to_jsonb(v) FROM WorkflowVersions v")] - [InlineData("SELECT w::text FROM Workflows w")] - [InlineData("SELECT json_agg(w) FROM Workflows w")] - [InlineData("SELECT CAST(w AS text) AS payload FROM Workflows w")] - [InlineData("SELECT array_to_json(array_agg(w)) AS payload FROM Workflows w")] - [InlineData("SELECT CAST(d AS text) FROM CustomActivityDefinitions d")] - [InlineData("SELECT * FROM Workflows FOR JSON AUTO")] - [InlineData("SELECT * FROM WorkflowVersions FOR XML AUTO")] - public async Task ExecuteRead_RejectsProviderSpecificWorkflowRowSerialization(string sql) - { - using var db = TestDbFactory.Create(); - - var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); - - result.Error.Should().Contain("workflow definition"); - result.Rows.Should().BeEmpty(); - } - - [Theory] - [InlineData("SELECT query_to_xml('SELECT \"DefinitionJson\" FROM \"Workflows\"', false, true, '')")] - [InlineData("SELECT table_to_xml('Workflows', false, true, '')")] - [InlineData("SELECT database_to_xml(false, true, '')")] - public async Task ExecuteRead_RejectsDynamicXmlDataExporters(string sql) - { - using var db = TestDbFactory.Create(); - - var result = await NewReader(db).ExecuteReadAsync(sql, CancellationToken.None); - - result.Error.Should().Contain("workflow definition or custom activity implementation"); - result.Rows.Should().BeEmpty(); - } - /// /// The row-projection guard is blunt by design, so it must stay scoped to tables that actually /// hold a masked column — otherwise it would break ordinary analysis on the ~34 tables that /// hold no secret. /// [Theory] - [InlineData("SELECT to_json(e) FROM WorkflowExecutions e")] - [InlineData("SELECT e::text FROM WorkflowExecutions e")] + [InlineData("SELECT to_json(w) FROM Workflows w")] + [InlineData("SELECT w::text FROM Workflows w")] public async Task ExecuteRead_AllowsRowProjectionOverTableWithoutSecrets(string sql) { using var db = TestDbFactory.Create(); @@ -270,53 +159,6 @@ public async Task ExecuteRead_AllowsRowProjectionOverTableWithoutSecrets(string // no secret at all. result.Error.Should().NotBeNull(); result.Error.Should().NotContain("serializes a whole row"); - result.Error.Should().NotContain("workflow definition"); - } - - [Fact] - public async Task ExecuteRead_AllowsCountWildcardAndDefinitionJsonStringLiteral() - { - using var db = TestDbFactory.Create(); - db.GlobalVariables.Add(new GlobalVariable { Name = "safe-name", Value = "opaque" }); - await db.SaveChangesAsync(); - - var count = await NewReader(db).ExecuteReadAsync( - "SELECT COUNT(*) FROM GlobalVariables", CancellationToken.None); - var safeExpression = await NewReader(db).ExecuteReadAsync( - "SELECT CAST(g.Name AS text) FROM GlobalVariables g", CancellationToken.None); - var literal = await NewReader(db).ExecuteReadAsync( - "SELECT 'DefinitionJson' AS Label", CancellationToken.None); - - count.Error.Should().BeNull(); - count.Rows.Should().ContainSingle(); - safeExpression.Error.Should().BeNull(); - safeExpression.Rows.Should().ContainSingle(); - safeExpression.Rows[0][0].Should().Be("safe-name"); - literal.Error.Should().BeNull(); - literal.Rows.Should().ContainSingle(); - literal.Rows[0][0].Should().Be("DefinitionJson"); - } - - [Fact] - public async Task ExecuteRead_MasksWorkflowDefinitionResultColumn_AsDefenseInDepth() - { - using var db = TestDbFactory.Create(); - const string canary = "opaque-workflow-definition-canary-741"; - db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "wf", DefinitionJson = canary }); - await db.SaveChangesAsync(); - await db.Database.ExecuteSqlRawAsync( - "CREATE VIEW WorkflowDefinitionLeak AS SELECT DefinitionJson FROM Workflows"); - - // The view is intentionally absent from AI schema discovery. This reaches result masking - // without naming the underlying protected table or column in the submitted statement. - var result = await NewReader(db).ExecuteReadAsync( - "SELECT * FROM WorkflowDefinitionLeak", CancellationToken.None); - - result.Error.Should().BeNull(); - result.Columns.Should().ContainSingle().Which.Should().Be("DefinitionJson"); - result.Rows.Should().ContainSingle(); - result.Rows[0][0].Should().Be(DbAdminSecretColumns.Mask); - result.Rows.SelectMany(r => r).Should().NotContain(canary); } [Fact] diff --git a/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs b/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs index cd02304c..b411f348 100644 --- a/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs +++ b/tests/NodePilot.Mcp.Tests/Tools/DbAdminMcpToolsTests.cs @@ -25,29 +25,11 @@ public sealed class DbAdminMcpToolsTests { new { name = "Id", clrType = "Guid", isNullable = false, maxLength = (int?)null, isPrimaryKey = true, isMasked = false, isReadOnly = true }, new { name = "Name", clrType = "string", isNullable = false, maxLength = (int?)200, isPrimaryKey = false, isMasked = false, isReadOnly = false }, - new { name = "DefinitionJson", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = true }, }, rowCount = 12L, cascadeDeletesTo = Array.Empty(), }, new - { - name = "CustomActivityDefinition", - displayName = "Custom Activity Definition", - dbTableName = "CustomActivityDefinitions", - pkColumns = new[] { "Id" }, - capabilities = new { canUpdate = true, canDelete = false }, - columns = new object[] - { - new { name = "Id", clrType = "Guid", isNullable = false, maxLength = (int?)null, isPrimaryKey = true, isMasked = false, isReadOnly = true }, - new { name = "Name", clrType = "string", isNullable = false, maxLength = (int?)200, isPrimaryKey = false, isMasked = false, isReadOnly = false }, - new { name = "ScriptTemplate", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = false }, - new { name = "InputParametersJson", clrType = "string", isNullable = false, maxLength = (int?)null, isPrimaryKey = false, isMasked = false, isReadOnly = false }, - }, - rowCount = 2L, - cascadeDeletesTo = Array.Empty(), - }, - new { name = "GlobalVariable", displayName = "Global Variable", @@ -74,15 +56,11 @@ public async Task ListDbTables_ReturnsCompactSchema_WithoutSecretColumns() var tools = new DbAdminMcpTools(api.Client()); var json = JsonSerializer.Serialize(await tools.ListDbTables()); - json.Should().NotContain("\"name\":\"Workflow\""); - json.Should().NotContain("CustomActivityDefinition"); + json.Should().Contain("\"name\":\"Workflow\""); json.Should().Contain("\"isMasked\":true"); // GlobalVariable.Value masked flag carried through json.Should().NotContain("capabilities"); // capabilities/cascade dropped for token efficiency json.Should().NotContain("cascadeDeletesTo"); json.Should().NotContain("PasswordHash"); // hidden columns never present from API - json.Should().NotContain("DefinitionJson"); // raw DbAdmin schema is filtered at MCP boundary - json.Should().NotContain("ScriptTemplate"); - json.Should().NotContain("InputParametersJson"); } [Fact] @@ -145,64 +123,6 @@ public async Task RunReadonlySql_SendsReadMode_AndReturnsRows() json.Should().NotContain("\"note\":null"); // success path omits a note } - [Theory] - [InlineData("SELECT DefinitionJson FROM Workflows")] - [InlineData("SELECT w.DefinitionJson AS payload FROM Workflows w")] - [InlineData("SELECT * FROM WorkflowVersions")] - [InlineData("SELECT CAST(w AS text) FROM Workflows w")] - [InlineData("SELECT array_to_json(array_agg(w)) FROM Workflows w")] - [InlineData("SELECT Id, Name FROM Workflows")] - [InlineData("SELECT leak FROM Workflows w CROSS JOIN LATERAL regexp_split_to_table(CAST(w AS text), 'NEVER_MATCH') AS leak")] - [InlineData("SELECT ScriptTemplate FROM CustomActivityDefinitions")] - [InlineData("SELECT substr(InputParametersJson, 1, 10) FROM CustomActivityDefinitionVersions")] - [InlineData("SELECT query_to_xml('SELECT \"DefinitionJson\" FROM \"Workflows\"', false, true, '')")] - [InlineData("SELECT U&\"Definiti\\006FnJson\" AS payload FROM U&\"Workfl\\006Fws\"")] - public async Task RunReadonlySql_RejectsOpaqueAutomationPayloadBeforeApiCall(string sql) - { - using var api = new TestApi(); - var tools = new DbAdminMcpTools(api.Client()); - - var ex = await Assert.ThrowsAsync(() => tools.RunReadonlySql(sql)); - - ex.Message.Should().Contain("workflow definition or custom activity implementation"); - api.Server.LogEntries.Should().BeEmpty("rejected agent SQL must never reach raw DbAdmin"); - } - - [Fact] - public async Task RunReadonlySql_MasksOpaqueResultColumnNames_AsDefenseInDepth() - { - using var api = new TestApi(); - api.Server.Given(Request.Create().WithPath("/api/dbadmin/query").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new - { - columns = new[] - { - new { name = "Name", type = "string" }, - new { name = "DefinitionJson", type = "string" }, - new { name = "ScriptTemplate", type = "string" }, - new { name = "InputParametersJson", type = "string" }, - }, - rows = new object[] - { - new object[] { "safe-name", "definition-canary-741", "script-canary-852", "defaults-canary-963" }, - }, - rowsAffected = (int?)null, - durationMs = 2L, - truncated = false, - mode = "read", - })); - - var tools = new DbAdminMcpTools(api.Client()); - var json = JsonSerializer.Serialize( - await tools.RunReadonlySql("SELECT * FROM AgentSafeProjection")); - - json.Should().Contain("safe-name"); - json.Should().Contain("***"); - json.Should().NotContain("definition-canary-741"); - json.Should().NotContain("script-canary-852"); - json.Should().NotContain("defaults-canary-963"); - } - [Fact] public async Task RunReadonlySql_CapsRowsAt200_AndSetsTruncated() { @@ -273,4 +193,4 @@ public async Task ListDbTables_ApiError_MapsToApiException() var ex = await Assert.ThrowsAsync(() => tools.ListDbTables()); ex.Message.Should().Contain("np auth login"); } -} +} \ No newline at end of file