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..b0f1db37 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 @@ -487,8 +491,16 @@ Executor (nicht nur am HTTP-Controller), erlaubt als erstes Keyword nur `SELECT` `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); +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/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..eda73b81 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`) 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: 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/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..4e8c9bdb 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, 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) + { + "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/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-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/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/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"); } }