From 49f5420d056a6af1162ea91f80da8033d2bb9d58 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:10:02 +0200 Subject: [PATCH 1/6] Collapse duplicated code across the solution A repo-wide clone scan plus a semantic pass found ~85 verified places where the same logic existed more than once. This removes them, mostly by routing callers through a helper or base type that already existed. Backend: - Five retention services shared a byte-identical ExecuteAsync loop; they now derive from LeaderGatedRetentionService. The broad catch stays in each service's RunIterationAsync, one level below the host-fatal boundary. - fileOperation/folderOperation share FileSystemOperationActivityBase; the marker-envelope PostProcess preamble is now one helper used by seven activities. - The AES-GCM envelope (version byte + nonce + ciphertext + tag) had two independent implementations; both now use SecretEnvelope. Header validation stays outside the metrics scope, as before. - Crypto call/latency instrumentation collapses into DataMetrics.MeasureCrypto. - The ExecRow projection, the long-running/queued-long collectors, the NotificationDispatcher send tail, CsvField, the trigger manual.* extraction and the folder-tree helpers each exist once now. - ScimProvisioningService now forwards audits through AuditEventForwarder instead of hand-rolling the ECS scope. This fixes a real inconsistency: it hard-coded event.category=iam where the shared classifier returns "configuration" for SCIM_GROUP_* codes. Clients: - ApiException, the response plumbing and the config.json read side move to NodePilot.Core.Clients, shared by the CLI and the MCP server. The DTO copies stay duplicated on purpose (ApiDtoParityTests). Frontend: - The AI chat message mutators and the thread picker were duplicated between the designer panel and the global chat page; both now share one module. - LoggingTelemetrySection carried a private fork of useSectionForm. The fork had drifted and was the better version (it re-sends the mapped payload after a 412), so that behaviour moved into the shared hook before the fork was removed. - useDashboardFeed/useOperationsFeed, the type-the-phrase confirm dialog and the node context menu now use shared implementations. Dead code: three unused DbErrorClassifier predicates, a deprecated cloneableConfigKeys alias, four unused frontend exports, two orphaned stress scripts and an unused npm dependency. Test doubles: FakeSmtpServer, CapturingLogger, StubHttpClientFactory, StubGlobalVariableStore and FakeEngine each existed in two places; they now live in TestCommons or a shared Helpers file. ActivityConfigReferenceTests learned to follow an executor's base class and helpers, so config keys that moved into shared types stay verified instead of being exempted. Net effect: 1407 fewer lines of code. Full backend suites (5486 tests) and the frontend suite (2596 tests) pass; the solution builds clean. --- scripts/stress-test-100-executions.py | 274 ---------- scripts/stress-test-master-workflow.py | 276 ---------- src/NodePilot.Ai/ChatToolDispatch.cs | 69 +++ src/NodePilot.Ai/ChatToolRegistry.cs | 44 +- .../Knowledge/DocsKnowledgeReader.cs | 41 +- .../Knowledge/KnowledgeChatToolRegistry.cs | 44 +- .../Knowledge/KnowledgeCorpusReader.cs | 58 ++ .../Knowledge/KnowledgeTimeContext.cs | 21 +- .../Knowledge/SourceCodeKnowledgeReader.cs | 49 +- src/NodePilot.Ai/LlmConfiguredProxy.cs | 7 +- src/NodePilot.Ai/LlmJson.cs | 20 + src/NodePilot.Ai/LlmProfileValidation.cs | 26 +- src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs | 66 +-- src/NodePilot.Ai/OpenAiResponsesLlmClient.cs | 21 +- src/NodePilot.Ai/ToolCallAccumulator.cs | 7 + src/NodePilot.Ai/WorkflowAssistantService.cs | 22 +- .../Controllers/AiChatController.cs | 57 +- src/NodePilot.Api/Controllers/AiController.cs | 54 +- .../Controllers/AiKnowledgeController.cs | 65 +-- .../Controllers/AiStreamSupport.cs | 79 +++ .../Controllers/AlertingController.cs | 81 +-- .../Controllers/AlertingRuleMapping.cs | 165 ++++++ src/NodePilot.Api/Controllers/ApiProblems.cs | 33 -- .../Controllers/AuditController.cs | 29 +- .../Controllers/AuthController.cs | 272 ++++------ .../Controllers/CustomActivitiesController.cs | 96 ++-- .../Controllers/DashboardController.cs | 17 +- .../Controllers/DbAdminController.cs | 27 +- .../Controllers/DiagnosticsController.cs | 31 +- .../Controllers/ExecutionsController.cs | 9 +- .../Controllers/ExternalTriggerController.cs | 34 +- .../Controllers/FolderScopedQueries.cs | 34 ++ .../Controllers/OperationsController.cs | 13 +- .../Controllers/SystemAlertingController.cs | 77 +-- .../Controllers/WorkflowEditingController.cs | 250 ++++----- .../WorkflowImportExportController.cs | 9 +- .../Controllers/WorkflowsController.cs | 26 +- src/NodePilot.Api/Export/CsvWriter.cs | 29 + .../Security/DirectoryGroupPrincipal.cs | 38 ++ .../Security/DirectoryMembershipReconciler.cs | 72 +++ .../Ldap/DirectorySynchronizationService.cs | 25 +- .../Security/Ldap/ExternalUserMapper.cs | 42 +- .../Security/Oidc/OidcIdentityMapper.cs | 66 +-- .../Security/ResourceAuthorizationService.cs | 32 +- .../Security/Scim/ScimProvisioningService.cs | 28 +- .../SubWorkflowAuthorizationResolver.cs | 16 +- .../Services/Backup/BackupRestoreService.cs | 510 +++++++++--------- .../Services/Backup/FolderTreeShape.cs | 84 +++ .../Services/Backup/Parts/FolderBackupPart.cs | 14 +- .../Parts/GlobalVariableFolderBackupPart.cs | 17 +- .../WorkflowDefinitionSecretRewriter.cs | 24 +- src/NodePilot.Cli/Api/ApiException.cs | 45 -- src/NodePilot.Cli/Api/NodePilotApiClient.cs | 34 +- src/NodePilot.Cli/Api/TokenRefreshHandler.cs | 1 + src/NodePilot.Cli/CLAUDE.md | 2 + .../Commands/Auth/AuthCommands.cs | 1 + src/NodePilot.Cli/Commands/BaseCommand.cs | 1 + .../Commands/Config/ConfigCommands.cs | 1 + .../Workflow/WorkflowTriggerCommand.cs | 1 + src/NodePilot.Cli/Settings/ConfigStore.cs | 54 +- .../Clients}/ApiException.cs | 10 +- .../Clients/ApiResponseReader.cs | 43 ++ .../Clients/ClientConfigStore.cs} | 21 +- src/NodePilot.Data/DataMetrics.cs | 34 ++ src/NodePilot.Data/DbErrorClassifier.cs | 9 - .../Security/AesGcmSecretProtector.cs | 98 +--- .../Security/DpapiSecretProtector.cs | 59 +- .../Security/PassphraseSecretProtector.cs | 45 +- src/NodePilot.Data/Security/SecretEnvelope.cs | 83 +++ .../Activities/BaseActivity.cs | 52 +- .../Activities/FileHashActivity.cs | 13 +- .../Activities/FileOperationActivity.cs | 167 +----- .../FileSystemOperationActivityBase.cs | 182 +++++++ .../Activities/FolderOperationActivity.cs | 187 ++----- .../Activities/ForEachActivity.cs | 84 +-- .../Activities/JsonQueryActivity.cs | 46 +- .../Activities/QueryPayloadSource.cs | 58 ++ .../Activities/RegistryActivity.cs | 31 +- .../Activities/ReturnDataActivity.cs | 8 +- .../Activities/ScheduledTaskActivity.cs | 48 +- .../Activities/ServiceManagementActivity.cs | 44 +- .../Activities/StartProgramActivity.cs | 14 +- .../Activities/StartWorkflowActivity.cs | 101 ++-- .../Activities/SubWorkflowInvocation.cs | 123 +++++ .../Activities/TextFileEditActivity.cs | 14 +- .../Activities/XmlQueryActivity.cs | 43 +- .../Activities/ZipOperationActivity.cs | 13 +- .../Conditions/ConditionEvaluator.cs | 18 +- .../Debug/DebugCoordinator.cs | 4 +- src/NodePilot.Engine/Execution/StepRunner.cs | 5 +- .../Execution/VariableResolver.cs | 42 +- .../PowerShell/PowerShellOperation.cs | 23 + .../PowerShell/ProcessExecutionEngine.cs | 29 +- src/NodePilot.Engine/Scorch/ScorchImporter.cs | 25 +- .../Security/FileWatcherPathGuard.cs | 12 +- .../Security/OutputRedactor.cs | 19 + src/NodePilot.Engine/Security/PathGuard.cs | 35 +- .../Security/RestApiHttpClientProvider.cs | 12 +- .../Triggers/DatabaseTrigger.cs | 5 +- .../Triggers/EventLogTrigger.cs | 5 +- .../Triggers/FileWatcherTrigger.cs | 5 +- .../Triggers/ScheduleTrigger.cs | 11 +- .../Triggers/TriggerVariables.cs | 23 + .../Triggers/WebhookTrigger.cs | 5 +- src/NodePilot.Engine/WorkflowEngine.cs | 81 +-- src/NodePilot.Mcp/Api/NodePilotApiClient.cs | 34 +- src/NodePilot.Mcp/Auth/TokenStore.cs | 4 +- src/NodePilot.Mcp/CLAUDE.md | 2 + src/NodePilot.Mcp/Config/McpServerConfig.cs | 5 +- src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs | 1 + src/NodePilot.Mcp/Program.cs | 3 +- .../AuditLogRetentionService.cs | 71 +-- .../ExecutionRetentionService.cs | 79 +-- .../LeaderGatedRetentionService.cs | 135 +++++ .../NotificationDispatcher.cs | 33 +- .../NotificationRetentionService.cs | 74 +-- .../ElapsedExecutionCollector.cs | 123 +++++ .../Notifications/ExecutionEventCollector.cs | 24 +- .../Notifications/ExecutionEventSupport.cs | 28 + .../LongRunningExecutionCollector.cs | 100 +--- .../QueuedLongExecutionCollector.cs | 95 +--- .../SupportEventRetentionService.cs | 74 +-- .../WorkflowVersionsRetentionService.cs | 79 +-- src/nodepilot-ui/package.json | 1 - src/nodepilot-ui/src/api/backup.ts | 11 - .../admin-settings/DbAdminSection.tsx | 77 +-- .../LoggingTelemetrySection.tsx | 118 +--- .../admin-settings/SectionFormHelpers.tsx | 14 +- .../src/components/ai/AiWorkflowChatPanel.tsx | 198 +------ .../src/components/ai/ChatThreadMenu.tsx | 134 +++++ .../components/common/ContextMenuShell.tsx | 6 +- .../common/TypedPhraseConfirmDialog.tsx | 82 +++ .../src/components/dbviewer/QueryPane.tsx | 77 +-- .../components/designer/PropertiesPanel.tsx | 3 - .../designer/overlays/NodeContextMenu.tsx | 50 +- .../components/designer/properties/shared.tsx | 411 +++++++------- .../src/hooks/useDashboardFeed.ts | 87 +-- src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts | 95 ++++ .../src/hooks/useOperationsFeed.ts | 78 +-- src/nodepilot-ui/src/lib/chatMessages.ts | 74 +++ src/nodepilot-ui/src/lib/configClone.ts | 13 - src/nodepilot-ui/src/pages/AiChatPage.tsx | 195 +------ src/nodepilot-ui/src/stores/aiChatStore.ts | 5 - src/nodepilot-ui/src/stores/authStore.ts | 6 - src/nodepilot-ui/src/telemetry/otel.ts | 4 - .../LlmConnectGuardTests.cs | 11 - .../LlmUnreachableDiagnosticsTests.cs | 5 +- .../AdminSettingsControllerSectionTests.cs | 115 ---- .../Controllers/ApiProblemsTests.cs | 34 +- ...dePilotApiClientMaintenanceWindowsTests.cs | 1 + .../Api/NodePilotApiClientNewSurfaceTests.cs | 1 + .../Api/NodePilotApiClientResourcesTests.cs | 1 + .../Api/NodePilotApiClientTests.cs | 1 + .../Api/TokenRefreshHandlerTests.cs | 1 + .../Auth/SessionResolverTests.cs | 1 + .../Commands/BaseCommandTests.cs | 1 + .../Infra/CommandTestHarness.cs | 1 + .../Output/OutputAndRenderingTests.cs | 1 + .../Settings/ConfigStoreTests.cs | 1 + .../ActivityConfigReferenceTests.cs | 27 +- .../Activities/NonRemoteActivityTests.cs | 134 ----- .../RunScriptExecutionTargetTests.cs | 8 - .../Execution/StepTestContextProviderTests.cs | 39 +- .../Execution/StepTesterTests.cs | 22 +- .../Helpers/TestDoubles.cs | 70 +++ .../PowerShellEngineFactoryIsolationTests.cs | 8 +- tests/NodePilot.Mcp.Tests/Api/InfraTests.cs | 13 +- tests/NodePilot.Mcp.Tests/Infra/TestApi.cs | 3 +- tests/NodePilot.TestCommons/FakeSmtpServer.cs | 142 +++++ 169 files changed, 3784 insertions(+), 5189 deletions(-) delete mode 100644 scripts/stress-test-100-executions.py delete mode 100644 scripts/stress-test-master-workflow.py create mode 100644 src/NodePilot.Ai/ChatToolDispatch.cs create mode 100644 src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs create mode 100644 src/NodePilot.Ai/LlmJson.cs create mode 100644 src/NodePilot.Api/Controllers/AiStreamSupport.cs create mode 100644 src/NodePilot.Api/Controllers/AlertingRuleMapping.cs create mode 100644 src/NodePilot.Api/Controllers/FolderScopedQueries.cs create mode 100644 src/NodePilot.Api/Export/CsvWriter.cs create mode 100644 src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs create mode 100644 src/NodePilot.Api/Services/Backup/FolderTreeShape.cs delete mode 100644 src/NodePilot.Cli/Api/ApiException.cs rename src/{NodePilot.Mcp/Api => NodePilot.Core/Clients}/ApiException.cs (79%) create mode 100644 src/NodePilot.Core/Clients/ApiResponseReader.cs rename src/{NodePilot.Mcp/Config/ConfigStore.cs => NodePilot.Core/Clients/ClientConfigStore.cs} (57%) create mode 100644 src/NodePilot.Data/Security/SecretEnvelope.cs create mode 100644 src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs create mode 100644 src/NodePilot.Engine/Activities/QueryPayloadSource.cs create mode 100644 src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs create mode 100644 src/NodePilot.Engine/Triggers/TriggerVariables.cs create mode 100644 src/NodePilot.Scheduler/LeaderGatedRetentionService.cs create mode 100644 src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs create mode 100644 src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx create mode 100644 src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx create mode 100644 src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts create mode 100644 src/nodepilot-ui/src/lib/chatMessages.ts create mode 100644 tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs create mode 100644 tests/NodePilot.TestCommons/FakeSmtpServer.cs diff --git a/scripts/stress-test-100-executions.py b/scripts/stress-test-100-executions.py deleted file mode 100644 index 1825af40..00000000 --- a/scripts/stress-test-100-executions.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Stress test: Launch 100 workflow executions in ~30 seconds and measure performance. -""" - -import requests -import json -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from statistics import mean, stdev, median -from datetime import datetime -import sys -import io - -# Force UTF-8 output -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -BASE_URL = "http://localhost:5000" -WORKFLOW_NAME = "Stress Test Simple" -NUM_EXECUTIONS = 100 -MAX_WORKERS = 10 # Concurrent requests - -# Global token cache -_auth_token = None -_token_lock = __import__('threading').Lock() - -def get_auth_token(): - """Get or refresh Bearer token with caching.""" - global _auth_token - - if _auth_token: - return _auth_token - - with _token_lock: - # Double-check pattern - if _auth_token: - return _auth_token - - login_url = f"{BASE_URL}/api/auth/login" - login_payload = { - "username": "admin", - "password": "admin123" - } - try: - resp = requests.post(login_url, json=login_payload, timeout=5) - if resp.status_code == 200: - data = resp.json() - _auth_token = data.get("token") - if _auth_token: - print(f"[✓] Auth token obtained") - return _auth_token - except Exception as e: - print(f"[WARN] Auth failed: {e}") - return None - -def create_auth_header(): - """Return auth header with cached token.""" - token = get_auth_token() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - -def import_test_workflow(): - """Create a simple test workflow if not already present.""" - print(f"[*] Creating test workflow '{WORKFLOW_NAME}'...") - - # Simple workflow with just a delay - workflow_def = { - "nodes": [ - { - "id": "delay", - "type": "activity", - "position": {"x": 100, "y": 100}, - "data": { - "label": "Delay 1s", - "activityType": "delay", - "config": {"seconds": 1} - } - } - ], - "edges": [] - } - - create_url = f"{BASE_URL}/api/workflows" - payload = { - "name": WORKFLOW_NAME, - "description": "Simple stress test workflow", - "definitionJson": json.dumps(workflow_def) - } - - headers = create_auth_header() - try: - resp = requests.post(create_url, json=payload, headers=headers, timeout=10) - if resp.status_code in [200, 201]: - print(f"[✓] Workflow created successfully") - return True - else: - print(f"[WARN] Create returned {resp.status_code}") - return False - except Exception as e: - print(f"[WARN] Create failed: {e}") - return False - -def get_workflow_id(): - """Get the workflow ID by name.""" - print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...") - list_url = f"{BASE_URL}/api/workflows" - headers = create_auth_header() - try: - resp = requests.get(list_url, headers=headers, timeout=5) - if resp.status_code == 200: - workflows = resp.json() - for wf in workflows: - if wf.get("name") == WORKFLOW_NAME: - wid = wf.get("id") - print(f"[✓] Found workflow ID: {wid}") - return wid - print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list") - except Exception as e: - print(f"[WARN] Lookup failed: {e}") - return None - -def execute_workflow(workflow_id, attempt_num): - """Execute a single workflow and record metrics.""" - execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute" - payload = { - "parameters": {}, - "timeoutSeconds": 300, - "debug": False - } - headers = create_auth_header() - - start = time.time() - try: - resp = requests.post(execute_url, json=payload, headers=headers, timeout=30) - elapsed = time.time() - start - status = resp.status_code - execution_id = None - - if status == 202: - try: - data = resp.json() - execution_id = data.get("executionId") - except: - pass - - return { - "attempt": attempt_num, - "status": status, - "elapsed_ms": elapsed * 1000, - "execution_id": execution_id, - "success": status == 202 - } - except Exception as e: - elapsed = time.time() - start - return { - "attempt": attempt_num, - "status": 0, - "elapsed_ms": elapsed * 1000, - "execution_id": None, - "success": False, - "error": str(e) - } - -def main(): - global _auth_token - - print("=" * 70) - print("NODEPILOT STRESS TEST: 100 Executions in 30 Seconds") - print("=" * 70) - print(f"[*] Start time: {datetime.now().isoformat()}") - print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers") - print() - - # Step 0: Get auth token first - _auth_token = get_auth_token() - if not _auth_token: - print("[!] ERROR: Could not obtain auth token. Aborting.") - sys.exit(1) - print() - - # Step 1: Import workflow - import_test_workflow() - time.sleep(1) - - # Step 2: Get workflow ID - workflow_id = get_workflow_id() - if not workflow_id: - print("[!] ERROR: Could not find or import workflow. Aborting.") - sys.exit(1) - - print() - print(f"[*] Starting execution burst at {datetime.now().isoformat()}...") - print() - - # Step 3: Launch execution burst - results = [] - start_time = time.time() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = [] - for i in range(NUM_EXECUTIONS): - future = executor.submit(execute_workflow, workflow_id, i + 1) - futures.append(future) - - completed = 0 - for future in as_completed(futures): - result = future.result() - results.append(result) - completed += 1 - if completed % 10 == 0: - print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched") - - total_time = time.time() - start_time - - print() - print("=" * 70) - print("PERFORMANCE METRICS") - print("=" * 70) - print() - - # Parse results - successful = [r for r in results if r["success"]] - failed = [r for r in results if not r["success"]] - response_times = [r["elapsed_ms"] for r in successful] - - print(f"Total Time: {total_time:.2f} seconds") - print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)") - print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}") - print() - - if response_times: - print("Response Time (ms):") - print(f" Min: {min(response_times):.1f}") - print(f" Max: {max(response_times):.1f}") - print(f" Mean: {mean(response_times):.1f}") - print(f" Median: {median(response_times):.1f}") - if len(response_times) > 1: - print(f" Stdev: {stdev(response_times):.1f}") - print() - - # Status code distribution - status_counts = {} - for r in results: - status = r["status"] - status_counts[status] = status_counts.get(status, 0) + 1 - - print("Status Code Distribution:") - for status in sorted(status_counts.keys()): - count = status_counts[status] - pct = 100 * count / NUM_EXECUTIONS - print(f" {status:3d}: {count:3d} ({pct:5.1f}%)") - print() - - # Throughput - throughput = NUM_EXECUTIONS / total_time - print(f"Throughput: {throughput:.1f} executions/second") - print() - - # Sample execution IDs - exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]] - if exec_ids: - print("Sample Execution IDs (first 5):") - for eid in exec_ids: - print(f" - {eid}") - print() - - print("=" * 70) - print(f"[*] End time: {datetime.now().isoformat()}") - print("=" * 70) - -if __name__ == "__main__": - main() diff --git a/scripts/stress-test-master-workflow.py b/scripts/stress-test-master-workflow.py deleted file mode 100644 index 2189728b..00000000 --- a/scripts/stress-test-master-workflow.py +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Stress test: Launch 100 executions of "Master Test — All Activities" in ~30 seconds. -This workflow tests all 24 activity types, all edge conditions, retry logic, etc. -""" - -import requests -import json -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from statistics import mean, stdev, median -from datetime import datetime -import sys -import io - -# Force UTF-8 output -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -BASE_URL = "http://localhost:5000" -WORKFLOW_NAME = "Master Test — All Activities" -NUM_EXECUTIONS = 100 -MAX_WORKERS = 10 # Concurrent requests - -# Global token cache -_auth_token = None -_token_lock = __import__('threading').Lock() - -def get_auth_token(): - """Get or refresh Bearer token with caching.""" - global _auth_token - - if _auth_token: - return _auth_token - - with _token_lock: - # Double-check pattern - if _auth_token: - return _auth_token - - login_url = f"{BASE_URL}/api/auth/login" - login_payload = { - "username": "admin", - "password": "admin123" - } - try: - resp = requests.post(login_url, json=login_payload, timeout=5) - if resp.status_code == 200: - data = resp.json() - _auth_token = data.get("token") - if _auth_token: - print(f"[✓] Auth token obtained") - return _auth_token - except Exception as e: - print(f"[WARN] Auth failed: {e}") - return None - -def create_auth_header(): - """Return auth header with cached token.""" - token = get_auth_token() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - -def import_master_workflow(): - """Import the Master Test workflow from file.""" - print(f"[*] Importing master workflow '{WORKFLOW_NAME}'...") - - try: - with open('scripts/test-master-all-activities.json', 'r', encoding='utf-8') as f: - export_data = json.load(f) - except Exception as e: - print(f"[!] Failed to read workflow file: {e}") - return False - - # Extract first workflow from export - if "workflows" not in export_data or not export_data["workflows"]: - print(f"[!] No workflows in export file") - return False - - workflow = export_data["workflows"][0] - - create_url = f"{BASE_URL}/api/workflows" - payload = { - "name": workflow.get("name", WORKFLOW_NAME), - "description": workflow.get("description", ""), - "definitionJson": json.dumps(workflow.get("definition", {})) - } - - headers = create_auth_header() - try: - resp = requests.post(create_url, json=payload, headers=headers, timeout=10) - if resp.status_code in [200, 201]: - print(f"[✓] Workflow imported successfully") - return True - elif resp.status_code == 409: - print(f"[✓] Workflow already exists (conflict, using existing)") - return True - else: - print(f"[WARN] Import returned {resp.status_code}: {resp.text}") - return False - except Exception as e: - print(f"[WARN] Import failed: {e}") - return False - -def get_workflow_id(): - """Get the workflow ID by name.""" - print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...") - list_url = f"{BASE_URL}/api/workflows" - headers = create_auth_header() - try: - resp = requests.get(list_url, headers=headers, timeout=5) - if resp.status_code == 200: - workflows = resp.json() - for wf in workflows: - if wf.get("name") == WORKFLOW_NAME: - wid = wf.get("id") - print(f"[✓] Found workflow ID: {wid}") - return wid - print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list") - except Exception as e: - print(f"[WARN] Lookup failed: {e}") - return None - -def execute_workflow(workflow_id, attempt_num): - """Execute a single workflow and record metrics.""" - execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute" - payload = { - "parameters": {}, - "timeoutSeconds": 300, - "debug": False - } - headers = create_auth_header() - - start = time.time() - try: - resp = requests.post(execute_url, json=payload, headers=headers, timeout=30) - elapsed = time.time() - start - status = resp.status_code - execution_id = None - - if status == 202: - try: - data = resp.json() - execution_id = data.get("executionId") - except: - pass - - return { - "attempt": attempt_num, - "status": status, - "elapsed_ms": elapsed * 1000, - "execution_id": execution_id, - "success": status == 202 - } - except Exception as e: - elapsed = time.time() - start - return { - "attempt": attempt_num, - "status": 0, - "elapsed_ms": elapsed * 1000, - "execution_id": None, - "success": False, - "error": str(e) - } - -def main(): - global _auth_token - - print("=" * 70) - print("NODEPILOT STRESS TEST: Master Workflow — 100 Executions") - print("=" * 70) - print(f"[*] Start time: {datetime.now().isoformat()}") - print(f"[*] Workflow: {WORKFLOW_NAME}") - print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers") - print() - - # Step 0: Get auth token first - _auth_token = get_auth_token() - if not _auth_token: - print("[!] ERROR: Could not obtain auth token. Aborting.") - sys.exit(1) - print() - - # Step 1: Import workflow - import_master_workflow() - time.sleep(1) - - # Step 2: Get workflow ID - workflow_id = get_workflow_id() - if not workflow_id: - print("[!] ERROR: Could not find or import workflow. Aborting.") - sys.exit(1) - - print() - print(f"[*] Starting execution burst at {datetime.now().isoformat()}...") - print() - - # Step 3: Launch execution burst - results = [] - start_time = time.time() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = [] - for i in range(NUM_EXECUTIONS): - future = executor.submit(execute_workflow, workflow_id, i + 1) - futures.append(future) - - completed = 0 - for future in as_completed(futures): - result = future.result() - results.append(result) - completed += 1 - if completed % 10 == 0: - print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched") - - total_time = time.time() - start_time - - print() - print("=" * 70) - print("PERFORMANCE METRICS") - print("=" * 70) - print() - - # Parse results - successful = [r for r in results if r["success"]] - failed = [r for r in results if not r["success"]] - response_times = [r["elapsed_ms"] for r in successful] - - print(f"Total Time: {total_time:.2f} seconds") - print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)") - print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}") - print() - - if response_times: - print("Response Time (ms):") - print(f" Min: {min(response_times):.1f}") - print(f" Max: {max(response_times):.1f}") - print(f" Mean: {mean(response_times):.1f}") - print(f" Median: {median(response_times):.1f}") - if len(response_times) > 1: - print(f" Stdev: {stdev(response_times):.1f}") - print() - - # Status code distribution - status_counts = {} - for r in results: - status = r["status"] - status_counts[status] = status_counts.get(status, 0) + 1 - - print("Status Code Distribution:") - for status in sorted(status_counts.keys()): - count = status_counts[status] - pct = 100 * count / NUM_EXECUTIONS - print(f" {status:3d}: {count:3d} ({pct:5.1f}%)") - print() - - # Throughput - throughput = NUM_EXECUTIONS / total_time - print(f"Throughput: {throughput:.1f} executions/second") - print() - - # Sample execution IDs - exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]] - if exec_ids: - print("Sample Execution IDs (first 5):") - for eid in exec_ids: - print(f" - {eid}") - print() - - print("=" * 70) - print(f"[*] End time: {datetime.now().isoformat()}") - print("=" * 70) - -if __name__ == "__main__": - main() diff --git a/src/NodePilot.Ai/ChatToolDispatch.cs b/src/NodePilot.Ai/ChatToolDispatch.cs new file mode 100644 index 00000000..61758031 --- /dev/null +++ b/src/NodePilot.Ai/ChatToolDispatch.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace NodePilot.Ai; + +/// One tool implementation: raw arguments + the registry's per-request context. +internal delegate Task ChatToolHandler(JsonElement args, TContext context, CancellationToken ct); + +/// +/// The dispatch plumbing both chat tool registries share ( +/// and ): the { "error": … } envelope, the +/// tolerant argument parse — a blank or malformed argument blob is a model artefact and must never +/// abort the tool loop — and the serialized result envelope. Tool sets, gating and serializer +/// options stay with the registries. +/// +internal static class ChatToolDispatch +{ + /// The error envelope a tool failure comes back as instead of aborting the loop. + public static string Error(string message, JsonSerializerOptions json) => + JsonSerializer.Serialize(new { error = message }, json); + + /// Answer for a tool name no registry entry matches (the model can hallucinate names). + public static string UnknownTool(string name, JsonSerializerOptions json) => + Error($"Unbekanntes Tool: {name}", json); + + /// Parses a tool's JSON-schema literal into a detached element. + public static JsonElement ParseParams(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + /// + /// Runs one tool: tolerant argument parse (blank → {}, malformed → ), + /// handler, serialized result — optionally reshaped by (token-budget + /// truncation). Cancellation propagates; every other exception becomes an error envelope. + /// + public static async Task ExecuteAsync( + ChatToolHandler handler, + string argumentsJson, + JsonElement emptyArgs, + TContext context, + JsonSerializerOptions json, + CancellationToken ct, + Func? shape = null) + { + try + { + JsonElement args; + try + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + args = doc.RootElement.Clone(); + } + catch (JsonException) { args = emptyArgs; } + + var result = await handler(args, context, ct); + var serialized = JsonSerializer.Serialize(result, json); + return shape is null ? serialized : shape(serialized); + } + catch (OperationCanceledException) + { + throw; // Cancellation belongs to the caller's loop, not the error-JSON path. + } + catch (Exception ex) + { + return Error(ex.Message, json); + } + } +} diff --git a/src/NodePilot.Ai/ChatToolRegistry.cs b/src/NodePilot.Ai/ChatToolRegistry.cs index cbd35789..b2654969 100644 --- a/src/NodePilot.Ai/ChatToolRegistry.cs +++ b/src/NodePilot.Ai/ChatToolRegistry.cs @@ -41,7 +41,8 @@ public interface IChatToolRegistry /// public sealed class WorkflowChatToolRegistry : IChatToolRegistry { - private static readonly JsonElement NoParams = ParseParams("""{"type":"object","properties":{}}"""); + private static readonly JsonElement NoParams = + ChatToolDispatch.ParseParams("""{"type":"object","properties":{}}"""); private static readonly JsonSerializerOptions Json = new(); // Token-budget caps: redaction happens in the reader on the FULL string; truncation only @@ -59,13 +60,11 @@ public sealed class WorkflowChatToolRegistry : IChatToolRegistry "list_recent_executions", "get_execution_steps", "get_failure_context", }; - private delegate Task ToolHandler(JsonElement args, ChatToolContext context, CancellationToken ct); - - private readonly Dictionary _tools; + private readonly Dictionary Handler)> _tools; public WorkflowChatToolRegistry() { - _tools = new Dictionary(StringComparer.Ordinal) + _tools = new Dictionary)>(StringComparer.Ordinal) { ["analyze_workflow"] = ( new LlmToolDefinition("analyze_workflow", @@ -88,7 +87,7 @@ public WorkflowChatToolRegistry() "Listet die jüngsten Ausführungen (Runs) des aktuell geöffneten Workflows: Status, Zeiten, " + "Fehlermeldung und fehlgeschlagene Steps. Rufe es, wenn der User nach vergangenen Läufen oder " + "Fehlschlägen fragt. Ergebnisse sind redigiert und gekürzt.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{"take":{"type":"integer","minimum":1,"maximum":20, "description":"Anzahl der jüngsten Läufe (Default 10)."}}} """)), @@ -99,7 +98,7 @@ public WorkflowChatToolRegistry() "Liefert die Step-Details EINER Ausführung dieses Workflows: pro Step Status, Versuche " + "(attemptCount), Output und ErrorOutput (redigiert + gekürzt). executionId stammt aus " + "list_recent_executions.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{"executionId":{"type":"string", "description":"GUID der Execution (aus list_recent_executions)."}},"required":["executionId"]} """)), @@ -126,28 +125,8 @@ public IReadOnlyList GetTools(ChatToolContext context) => public async Task ExecuteAsync(string name, string argumentsJson, ChatToolContext context, CancellationToken ct) { if (!_tools.TryGetValue(name, out var tool)) - return Error($"Unbekanntes Tool: {name}"); - try - { - JsonElement args; - try - { - using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); - args = doc.RootElement.Clone(); - } - catch (JsonException) { args = NoParams; } - - var result = await tool.Handler(args, context, ct); - return JsonSerializer.Serialize(result, Json); - } - catch (OperationCanceledException) - { - throw; // Cancellation belongs to the caller's loop, not the error-JSON path. - } - catch (Exception ex) - { - return Error(ex.Message); - } + return ChatToolDispatch.UnknownTool(name, Json); + return await ChatToolDispatch.ExecuteAsync(tool.Handler, argumentsJson, NoParams, context, Json, ct); } private static object AnalyzeWorkflow(ChatToolContext ctx) @@ -305,11 +284,4 @@ private static async Task GetFailureContextAsync(JsonElement args, ChatT return value[..maxChars] + $"…[+{value.Length - maxChars} Zeichen abgeschnitten]"; } - private static string Error(string message) => JsonSerializer.Serialize(new { error = message }, Json); - - private static JsonElement ParseParams(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } } diff --git a/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs b/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs index d8d837cb..6bf47d02 100644 --- a/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs +++ b/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs @@ -19,45 +19,26 @@ public interface IDocsKnowledgeReader /// public sealed class DocsKnowledgeReader(IOptionsMonitor options) : IDocsKnowledgeReader { - private string Root() - { - var configured = options.CurrentValue.DocsRootPath; - return string.IsNullOrWhiteSpace(configured) - ? Path.Combine(AppContext.BaseDirectory, "knowledge", "docs") - : configured; - } + private readonly KnowledgeCorpusReader _corpus = new( + () => options.CurrentValue.DocsRootPath, + "docs", + IsMarkdown, + full => IsMarkdown(full) ? null : "Nur Markdown-Dokumente (.md) sind lesbar.", + notFoundError: "Dokument nicht gefunden.", + unreadableError: "Dokument konnte nicht gelesen werden."); private static bool IsMarkdown(string path) => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase); - public bool IsAvailable() => Directory.Exists(Root()); + public bool IsAvailable() => _corpus.IsAvailable(); public IReadOnlyList Search(string query) { var o = options.CurrentValue; - return KnowledgeFileSearch.Search(Root(), query, o.DocsMaxResults, o.DocsMaxFileBytes, IsMarkdown); + return _corpus.Search(query, o.DocsMaxResults, o.DocsMaxFileBytes); } - public KnowledgeFileResult Read(string relPath) - { - var o = options.CurrentValue; - var root = Root(); - if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) - return KnowledgeFileResult.Fail("Ungültiger oder unerlaubter Pfad."); - if (!IsMarkdown(full)) - return KnowledgeFileResult.Fail("Nur Markdown-Dokumente (.md) sind lesbar."); - if (!File.Exists(full)) - return KnowledgeFileResult.Fail("Dokument nicht gefunden."); - try - { - if (new FileInfo(full).Length > o.DocsMaxFileBytes) - return KnowledgeFileResult.Fail($"Datei zu groß (> {o.DocsMaxFileBytes} Bytes)."); - return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return KnowledgeFileResult.Fail("Dokument konnte nicht gelesen werden."); - } - } + public KnowledgeFileResult Read(string relPath) => + _corpus.Read(relPath, options.CurrentValue.DocsMaxFileBytes); } diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs b/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs index bb5d1142..d8790456 100644 --- a/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs +++ b/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs @@ -46,10 +46,10 @@ public sealed class KnowledgeChatToolRegistry : IKnowledgeToolRegistry { private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static readonly JsonElement NoParams = - ParseParams("""{"type":"object","properties":{},"additionalProperties":false}"""); + ChatToolDispatch.ParseParams("""{"type":"object","properties":{},"additionalProperties":false}"""); - private delegate Task Handler(JsonElement args, KnowledgeToolContext ctx, CancellationToken ct); - private sealed record Tool(LlmToolDefinition Def, Handler Run, Func Gate); + private sealed record Tool( + LlmToolDefinition Def, ChatToolHandler Run, Func Gate); private readonly Dictionary _tools; @@ -98,7 +98,7 @@ public KnowledgeChatToolRegistry(IDocsKnowledgeReader docs, ISourceCodeKnowledge "Berechnet die nächsten geplanten Ausführungszeitpunkte (UTC) aktivierter Workflows mit " + "scheduleTrigger — die verlässliche Quelle für 'wann läuft der/ein Workflow als Nächstes'. " + "Nicht aus vergangenen Läufen raten. Optional per idOrName auf einen Workflow eingrenzen.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{ "idOrName":{"type":"string","description":"Optional: Workflow-Name oder GUID, um nur dessen Fires zu liefern."}, "count":{"type":"integer","minimum":1,"maximum":5,"description":"Fires pro Workflow (Default 3)."}}} @@ -154,7 +154,7 @@ public KnowledgeChatToolRegistry(IDocsKnowledgeReader docs, ISourceCodeKnowledge + "Fragen nach eingestellten Werten oder Defaults ('wie viele Runspaces werden beim Start vorab " + "allokiert', 'welches Log-Format', 'wie lange werden Executions aufbewahrt') — nicht raten. " + "Optional per 'section' auf eine Sektion eingrenzen.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{ "section":{"type":"string","description":"Optionaler Sektions-Teilname (z.B. 'Engine', 'Retention', 'Remote'). Leer = alle Sektionen."}}} """)), @@ -169,31 +169,11 @@ public IReadOnlyList GetTools(KnowledgeToolContext context) = public async Task ExecuteAsync(string name, string argumentsJson, KnowledgeToolContext context, CancellationToken ct) { if (!_tools.TryGetValue(name, out var tool)) - return Error($"Unbekanntes Tool: {name}"); + return ChatToolDispatch.UnknownTool(name, Json); if (!tool.Gate(context)) return Error($"Tool '{name}' ist in dieser Sitzung nicht verfügbar."); - try - { - JsonElement args; - try - { - using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); - args = doc.RootElement.Clone(); - } - catch (JsonException) { args = NoParams; } - - var result = await tool.Run(args, context, ct); - return Truncate(JsonSerializer.Serialize(result, Json)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return Error(ex.Message); - } + return await ChatToolDispatch.ExecuteAsync(tool.Run, argumentsJson, NoParams, context, Json, ct, Truncate); } // ---- docs / source ------------------------------------------------------------------------ @@ -369,19 +349,13 @@ private static string Truncate(string json) }, Json); } - private static string Error(string message) => JsonSerializer.Serialize(new { error = message }, Json); + private static string Error(string message) => ChatToolDispatch.Error(message, Json); private static JsonElement StringParam(string name, string description, bool required = false) { var desc = description.Replace("\\", "\\\\").Replace("\"", "\\\""); var req = required ? $",\"required\":[\"{name}\"]" : ""; - return ParseParams( + return ChatToolDispatch.ParseParams( $"{{\"type\":\"object\",\"properties\":{{\"{name}\":{{\"type\":\"string\",\"description\":\"{desc}\"}}}}{req},\"additionalProperties\":false}}"); } - - private static JsonElement ParseParams(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } } diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs b/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs new file mode 100644 index 00000000..fd134181 --- /dev/null +++ b/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs @@ -0,0 +1,58 @@ +namespace NodePilot.Ai.Knowledge; + +/// +/// The corpus mechanics shared by and +/// : root resolution (configured value, else +/// {AppBaseDir}/knowledge/<folder>), availability, live keyword search, and the guarded +/// read — traversal guard → the corpus' own eligibility gates → existence → size cap → IO. +/// Every corpus supplies its own eligibility rules and its own rejection wording, so the +/// message a caller sees stays corpus-specific; only the order of the gates is shared. +/// +internal sealed class KnowledgeCorpusReader( + Func configuredRoot, + string defaultFolderName, + Func isEligible, + Func readRejection, + string notFoundError, + string unreadableError) +{ + /// Traversal rejection — identical for every corpus, the guard is root-agnostic. + private const string InvalidPathError = "Ungültiger oder unerlaubter Pfad."; + + public string Root + { + get + { + var configured = configuredRoot(); + return string.IsNullOrWhiteSpace(configured) + ? Path.Combine(AppContext.BaseDirectory, "knowledge", defaultFolderName) + : configured; + } + } + + public bool IsAvailable() => Directory.Exists(Root); + + public IReadOnlyList Search(string query, int maxResults, int maxFileBytes) => + KnowledgeFileSearch.Search(Root, query, maxResults, maxFileBytes, isEligible); + + public KnowledgeFileResult Read(string relPath, int maxFileBytes) + { + var root = Root; + if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) + return KnowledgeFileResult.Fail(InvalidPathError); + if (readRejection(full) is { } rejection) + return KnowledgeFileResult.Fail(rejection); + if (!File.Exists(full)) + return KnowledgeFileResult.Fail(notFoundError); + try + { + if (new FileInfo(full).Length > maxFileBytes) + return KnowledgeFileResult.Fail($"Datei zu groß (> {maxFileBytes} Bytes)."); + return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return KnowledgeFileResult.Fail(unreadableError); + } + } +} diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs b/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs index 696362d0..8ad3026d 100644 --- a/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs +++ b/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs @@ -1,4 +1,5 @@ using System.Globalization; +using NodePilot.Core.Time; namespace NodePilot.Ai.Knowledge; @@ -14,10 +15,10 @@ namespace NodePilot.Ai.Knowledge; public static class KnowledgeTimeContext { /// - /// Renders the German context block. is the caller's IANA zone - /// (e.g. Europe/Berlin); is its current UTC offset in - /// minutes (browser fallback). Resolution order: a valid IANA zone (honours DST) → the raw - /// offset → UTC only. + /// Renders the German context block. is the caller's zone — the + /// browser sends an IANA id (e.g. Europe/Berlin), the Windows form resolves too; + /// is its current UTC offset in minutes (browser fallback). + /// Resolution order: a resolvable zone id (honours DST) → the raw offset → UTC only. /// public static string Build(DateTimeOffset nowUtc, string? timeZoneId, int? offsetMinutes) { @@ -25,16 +26,10 @@ public static string Build(DateTimeOffset nowUtc, string? timeZoneId, int? offse var utcLine = utc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); string? localLine = null; - if (!string.IsNullOrWhiteSpace(timeZoneId)) + if (!string.IsNullOrWhiteSpace(timeZoneId) && TimeZoneResolver.TryResolve(timeZoneId, out var tz)) { - try - { - var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId.Trim()); - var local = TimeZoneInfo.ConvertTime(nowUtc, tz); - localLine = $"{Format(local)} ({timeZoneId.Trim()}, {OffsetLabel(local.Offset)})"; - } - catch (TimeZoneNotFoundException) { } - catch (InvalidTimeZoneException) { } + var local = TimeZoneInfo.ConvertTime(nowUtc, tz); + localLine = $"{Format(local)} ({timeZoneId.Trim()}, {OffsetLabel(local.Offset)})"; } if (localLine is null && offsetMinutes is int mins && Math.Abs(mins) <= 14 * 60) diff --git a/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs b/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs index 5a053721..6e3f4b29 100644 --- a/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs +++ b/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs @@ -39,15 +39,15 @@ public sealed class SourceCodeKnowledgeReader(IOptionsMonitor options.CurrentValue.SourceCodeRootPath, + "source", + IsEligible, + RejectionFor, + notFoundError: "Datei nicht gefunden.", + unreadableError: "Datei konnte nicht gelesen werden."); - public bool IsAvailable() => Directory.Exists(Root()); + public bool IsAvailable() => _corpus.IsAvailable(); /// DENY first (belt-and-suspenders on top of the git-tracked-only snapshot), then extension allowlist. internal static bool IsEligible(string path) => !IsDenied(path) && AllowedExtensions.Contains(Path.GetExtension(path)); @@ -65,33 +65,20 @@ internal static bool IsDenied(string path) || normalized.Contains("/.git/", StringComparison.Ordinal); } - public IReadOnlyList Search(string query) + /// Read gates in the same DENY-before-allowlist order as ; null = readable. + private static string? RejectionFor(string path) { - var o = options.CurrentValue; - return KnowledgeFileSearch.Search(Root(), query, o.SourceCodeMaxResults, o.SourceCodeMaxFileBytes, IsEligible); + if (IsDenied(path)) return "Diese Datei ist gesperrt (Secret-/Konfigurationsdatei)."; + if (!AllowedExtensions.Contains(Path.GetExtension(path))) return "Dieser Dateityp ist nicht lesbar."; + return null; } - public KnowledgeFileResult Read(string relPath) + public IReadOnlyList Search(string query) { var o = options.CurrentValue; - var root = Root(); - if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) - return KnowledgeFileResult.Fail("Ungültiger oder unerlaubter Pfad."); - if (IsDenied(full)) - return KnowledgeFileResult.Fail("Diese Datei ist gesperrt (Secret-/Konfigurationsdatei)."); - if (!AllowedExtensions.Contains(Path.GetExtension(full))) - return KnowledgeFileResult.Fail("Dieser Dateityp ist nicht lesbar."); - if (!File.Exists(full)) - return KnowledgeFileResult.Fail("Datei nicht gefunden."); - try - { - if (new FileInfo(full).Length > o.SourceCodeMaxFileBytes) - return KnowledgeFileResult.Fail($"Datei zu groß (> {o.SourceCodeMaxFileBytes} Bytes)."); - return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return KnowledgeFileResult.Fail("Datei konnte nicht gelesen werden."); - } + return _corpus.Search(query, o.SourceCodeMaxResults, o.SourceCodeMaxFileBytes); } + + public KnowledgeFileResult Read(string relPath) => + _corpus.Read(relPath, options.CurrentValue.SourceCodeMaxFileBytes); } diff --git a/src/NodePilot.Ai/LlmConfiguredProxy.cs b/src/NodePilot.Ai/LlmConfiguredProxy.cs index d4894a81..272f62fd 100644 --- a/src/NodePilot.Ai/LlmConfiguredProxy.cs +++ b/src/NodePilot.Ai/LlmConfiguredProxy.cs @@ -116,8 +116,8 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) var cached = _cache; if (cached is not null && cached.Matches(proxy)) return cached.Proxy; - var address = proxy.Address?.Trim(); - if (string.IsNullOrWhiteSpace(address)) + // Same two rules the settings validation applies, from the same place — see LlmProfileValidation. + if (!LlmProfileValidation.HasProxyAddress(proxy.Address, out var address)) { // Rejected by LlmProfileValidation on every save and at boot, so this only fires for a // hand-edited config picked up by hot-reload. Failing loudly beats silently going @@ -127,8 +127,7 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) + "Set a proxy URL (e.g. http://proxy.corp.local:8080) or switch the mode to 'Off' or 'System'."); } - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) + if (!LlmProfileValidation.IsHttpProxyUrl(address, out var proxyUri)) { throw new InvalidOperationException( $"{LlmProxyOptions.SectionName}:Address '{address}' is not a valid http(s) URL."); diff --git a/src/NodePilot.Ai/LlmJson.cs b/src/NodePilot.Ai/LlmJson.cs new file mode 100644 index 00000000..b1965468 --- /dev/null +++ b/src/NodePilot.Ai/LlmJson.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace NodePilot.Ai; + +/// +/// Guarded reads over an LLM response element, shared by both wire dialects +/// (, ): a missing +/// property, a null, or a value of the wrong kind all read as null instead of +/// throwing — upstream payloads are never trusted to have the documented shape. +/// +internal static class LlmJson +{ + public static string? ReadString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + + public static int? ReadInt(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt32() : null; +} diff --git a/src/NodePilot.Ai/LlmProfileValidation.cs b/src/NodePilot.Ai/LlmProfileValidation.cs index 1b381d99..db9bc93e 100644 --- a/src/NodePilot.Ai/LlmProfileValidation.cs +++ b/src/NodePilot.Ai/LlmProfileValidation.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; namespace NodePilot.Ai; @@ -84,8 +85,7 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura if (mode != LlmProxyMode.Custom) return issues; - var address = configuration[addressKey]?.Trim(); - if (string.IsNullOrWhiteSpace(address)) + if (!HasProxyAddress(configuration[addressKey], out var address)) { issues.Add(new ProfileIssue( addressKey, @@ -94,8 +94,7 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura return issues; } - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) + if (!IsHttpProxyUrl(address, out _)) { issues.Add(new ProfileIssue( addressKey, @@ -114,6 +113,25 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura return issues; } + /// + /// First rule for a Custom proxy address: it has to be there. + /// is the trimmed value both callers go on to use. + /// + public static bool HasProxyAddress(string? rawAddress, out string address) + { + address = rawAddress?.Trim() ?? ""; + return !string.IsNullOrWhiteSpace(address); + } + + /// + /// Second rule: an absolute http(s) URL. Shared with , which + /// builds the live proxy from the same value — the two must not disagree on what "valid" means, + /// while each keeps its own wording for the rejection. + /// + public static bool IsHttpProxyUrl(string address, [NotNullWhen(true)] out Uri? url) => + Uri.TryCreate(address, UriKind.Absolute, out url) + && (url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps); + /// /// True when Llm:ActiveProfileId names an existing profile. Read straight from /// configuration so it works on a simulated merged config (settings PUT) as well as at boot. diff --git a/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs b/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs index f832e573..bc9735b8 100644 --- a/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs +++ b/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Extensions.Logging; +using static NodePilot.Ai.LlmJson; namespace NodePilot.Ai; @@ -170,32 +171,23 @@ private async Task SendOnceAsync( } // Tool-call responses often have content: null plus tool_calls — accept both cases. - var contentStr = message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String - ? content.GetString() ?? string.Empty - : string.Empty; + var contentStr = ReadString(message, "content") ?? string.Empty; var toolCalls = ParseToolCalls(message); - var finishReason = first.TryGetProperty("finish_reason", out var fr) && fr.ValueKind == JsonValueKind.String - ? fr.GetString() : null; + var finishReason = ReadString(first, "finish_reason"); if (contentStr.Length == 0 && (toolCalls is null || toolCalls.Count == 0)) { throw new LlmException(LlmErrorKind.MalformedResponse, "LLM-Antwort enthielt weder 'content' (string) noch 'tool_calls'."); } - var modelEcho = doc.RootElement.TryGetProperty("model", out var m) && m.ValueKind == JsonValueKind.String - ? m.GetString() ?? _config.Model - : _config.Model; + var modelEcho = ReadString(doc.RootElement, "model") ?? _config.Model; int? promptTokens = null, completionTokens = null, totalTokens = null; if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { - promptTokens = usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number - ? pt.GetInt32() : null; - // Deliberately not named `ct` locally — that would shadow the outer CancellationToken parameter (CS0136). - completionTokens = usage.TryGetProperty("completion_tokens", out var ctTok) && ctTok.ValueKind == JsonValueKind.Number - ? ctTok.GetInt32() : null; - totalTokens = usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number - ? tt.GetInt32() : null; + promptTokens = ReadInt(usage, "prompt_tokens"); + completionTokens = ReadInt(usage, "completion_tokens"); + totalTokens = ReadInt(usage, "total_tokens"); } return new LlmResponse(contentStr, modelEcho, @@ -265,18 +257,15 @@ public async IAsyncEnumerable StreamAsync( { using var doc = JsonDocument.Parse(data); var root = doc.RootElement; - if (root.TryGetProperty("model", out var m) && m.ValueKind == JsonValueKind.String) - model = m.GetString(); + model = ReadString(root, "model") ?? model; if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0) { var choice0 = choices[0]; - if (choice0.TryGetProperty("finish_reason", out var frEl) && frEl.ValueKind == JsonValueKind.String) - finishReason = frEl.GetString(); + finishReason = ReadString(choice0, "finish_reason") ?? finishReason; if (choice0.TryGetProperty("delta", out var d) && d.ValueKind == JsonValueKind.Object) { - if (d.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String) - delta = c.GetString(); + delta = ReadString(d, "content"); if (d.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array) { AccumulateToolCallDeltas(tcs, toolAcc, ref toolAutoIndex); @@ -286,10 +275,8 @@ public async IAsyncEnumerable StreamAsync( } if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { - if (usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number) - promptTokens = pt.GetInt32(); - if (usage.TryGetProperty("completion_tokens", out var cpt) && cpt.ValueKind == JsonValueKind.Number) - completionTokens = cpt.GetInt32(); + promptTokens = ReadInt(usage, "prompt_tokens") ?? promptTokens; + completionTokens = ReadInt(usage, "completion_tokens") ?? completionTokens; } } catch (JsonException) @@ -429,10 +416,10 @@ private static void AppendTools(Dictionary body, LlmRequest req var list = new List(); foreach (var tc in tcs.EnumerateArray()) { - var id = tc.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? "" : ""; + var id = ReadString(tc, "id") ?? ""; if (!tc.TryGetProperty("function", out var fn) || fn.ValueKind != JsonValueKind.Object) continue; - var name = fn.TryGetProperty("name", out var nm) && nm.ValueKind == JsonValueKind.String ? nm.GetString() ?? "" : ""; - var args = fn.TryGetProperty("arguments", out var ar) && ar.ValueKind == JsonValueKind.String ? ar.GetString() ?? "" : ""; + var name = ReadString(fn, "name") ?? ""; + var args = ReadString(fn, "arguments") ?? ""; if (name.Length > 0) list.Add(new LlmToolCall(id, name, args)); } return list.Count > 0 ? list : null; @@ -452,28 +439,29 @@ private static void AccumulateToolCallDeltas(JsonElement toolCallsArray, Diction { foreach (var tc in toolCallsArray.EnumerateArray()) { - var hasId = tc.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(idEl.GetString()); + var id = ReadString(tc, "id"); + var hasId = !string.IsNullOrEmpty(id); var hasFn = tc.TryGetProperty("function", out var fn) && fn.ValueKind == JsonValueKind.Object; - var startsNewCall = hasId - || (hasFn && fn.TryGetProperty("name", out var nm) && nm.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(nm.GetString())); + var fnName = hasFn ? ReadString(fn, "name") : null; + var startsNewCall = hasId || !string.IsNullOrEmpty(fnName); int index; - if (tc.TryGetProperty("index", out var ix) && ix.ValueKind == JsonValueKind.Number) - index = ix.GetInt32(); // canonical OpenAI incremental stream + if (ReadInt(tc, "index") is { } wireIndex) + index = wireIndex; // canonical OpenAI incremental stream else if (startsNewCall || acc.Count == 0) index = autoIndex++; // index-less runtime: a new call opens a fresh slot else index = Math.Max(0, autoIndex - 1); // index-less arguments continuation → current slot - if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } + var slot = ToolCallAccumulator.Slot(acc, index); if (hasId) - slot.Id = idEl.GetString()!; + slot.Id = id!; if (hasFn) { - if (fn.TryGetProperty("name", out var nm2) && nm2.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(nm2.GetString())) - slot.Name = nm2.GetString()!; - if (fn.TryGetProperty("arguments", out var ar) && ar.ValueKind == JsonValueKind.String) - slot.Arguments.Append(ar.GetString()); + if (!string.IsNullOrEmpty(fnName)) + slot.Name = fnName; + if (ReadString(fn, "arguments") is { } arguments) + slot.Arguments.Append(arguments); } } } diff --git a/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs b/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs index 3bbdbddf..0b42a506 100644 --- a/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs +++ b/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; +using static NodePilot.Ai.LlmJson; namespace NodePilot.Ai; @@ -95,7 +96,7 @@ public async IAsyncEnumerable StreamAsync( if (root.TryGetProperty("item", out var added) && added.ValueKind == JsonValueKind.Object && ReadString(added, "type") == "function_call") { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? toolAcc.Count); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? toolAcc.Count); slot.Id = ReadString(added, "call_id") ?? slot.Id; slot.Name = ReadString(added, "name") ?? slot.Name; sawOutput = true; @@ -104,7 +105,7 @@ public async IAsyncEnumerable StreamAsync( case "response.function_call_arguments.delta": { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); if (root.TryGetProperty("delta", out var ad) && ad.ValueKind == JsonValueKind.String) slot.Arguments.Append(ad.GetString()); sawOutput = true; @@ -118,7 +119,7 @@ public async IAsyncEnumerable StreamAsync( if (root.TryGetProperty("item", out var done) && done.ValueKind == JsonValueKind.Object && ReadString(done, "type") == "function_call") { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); slot.Id = ReadString(done, "call_id") ?? slot.Id; slot.Name = ReadString(done, "name") ?? slot.Name; if (slot.Arguments.Length == 0 && ReadString(done, "arguments") is { } args) @@ -351,18 +352,4 @@ private static (int?, int?) ReadUsage(JsonElement response, int? promptTokens, i return (ReadInt(usage, "input_tokens") ?? promptTokens, ReadInt(usage, "output_tokens") ?? completionTokens); } - private static ToolCallAccumulator Slot(Dictionary acc, int index) - { - if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } - return slot; - } - - private static string? ReadString(JsonElement element, string name) => - element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() : null; - - private static int? ReadInt(JsonElement element, string name) => - element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number - ? value.GetInt32() : null; - } diff --git a/src/NodePilot.Ai/ToolCallAccumulator.cs b/src/NodePilot.Ai/ToolCallAccumulator.cs index bf1a48a7..f7333501 100644 --- a/src/NodePilot.Ai/ToolCallAccumulator.cs +++ b/src/NodePilot.Ai/ToolCallAccumulator.cs @@ -14,6 +14,13 @@ internal sealed class ToolCallAccumulator public string Name = ""; public StringBuilder Arguments { get; } = new(); + /// The slot for , created on first use. + public static ToolCallAccumulator Slot(Dictionary acc, int index) + { + if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } + return slot; + } + /// /// Orders by wire index and drops nameless slots (argument fragments whose header /// chunk never arrived). Returns null when nothing usable accumulated, so callers can diff --git a/src/NodePilot.Ai/WorkflowAssistantService.cs b/src/NodePilot.Ai/WorkflowAssistantService.cs index c01df2e1..9ae58b33 100644 --- a/src/NodePilot.Ai/WorkflowAssistantService.cs +++ b/src/NodePilot.Ai/WorkflowAssistantService.cs @@ -292,14 +292,9 @@ private static bool IsEmptyCanvas(JsonElement original) return true; foreach (var node in nodes.EnumerateArray()) { - if (node.ValueKind != JsonValueKind.Object) continue; - if (node.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object - && data.TryGetProperty("activityType", out var at) && at.ValueKind == JsonValueKind.String) - { - var t = at.GetString(); - if (!string.IsNullOrEmpty(t) && !t.EndsWith("Trigger", StringComparison.Ordinal)) - return false; // a real activity exists → not an empty canvas - } + var t = ActivityTypeOf(node); + if (!string.IsNullOrEmpty(t) && !t.EndsWith("Trigger", StringComparison.Ordinal)) + return false; // a real activity exists → not an empty canvas } return true; } @@ -316,15 +311,8 @@ private static string BuildActivityMetadata(JsonElement original, IReadOnlyDicti { foreach (var node in nodes.EnumerateArray()) { - if (node.ValueKind != JsonValueKind.Object) continue; - if (node.TryGetProperty("data", out var data) - && data.ValueKind == JsonValueKind.Object - && data.TryGetProperty("activityType", out var at) - && at.ValueKind == JsonValueKind.String) - { - var t = at.GetString(); - if (!string.IsNullOrEmpty(t)) types.Add(t); - } + var t = ActivityTypeOf(node); + if (!string.IsNullOrEmpty(t)) types.Add(t); } } diff --git a/src/NodePilot.Api/Controllers/AiChatController.cs b/src/NodePilot.Api/Controllers/AiChatController.cs index 64923421..c88a45cf 100644 --- a/src/NodePilot.Api/Controllers/AiChatController.cs +++ b/src/NodePilot.Api/Controllers/AiChatController.cs @@ -11,10 +11,8 @@ using NodePilot.Core.Audit; using NodePilot.Api.Dtos; using NodePilot.Api.Security; -using NodePilot.Api.Telemetry; using NodePilot.Core.Interfaces; using NodePilot.Data; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -173,11 +171,21 @@ public async Task Chat(WorkflowChatRequest request, CancellationT async Task Write(ChatStreamEvent e) { + // The closing totals must be captured before the write, so a client that + // disconnects mid-flush still lands in the audit trail with what we knew. + if (e is ChatStreamEvent.DoneEvent done) + { + model = done.Model; + durationMs = done.DurationMs; + promptTokens = done.PromptTokens; + completionTokens = done.CompletionTokens; + } + + if (await AiStreamSupport.TryWriteSharedEventAsync(sse, e, onToolCall: null, ct)) + return; + switch (e) { - case ChatStreamEvent.DeltaEvent d: - await sse.WriteAsync("delta", new { text = d.Text }, ct); - break; case ChatStreamEvent.BuildingEvent: await sse.WriteAsync("building", new { }, ct); break; @@ -185,19 +193,6 @@ async Task Write(ChatStreamEvent e) proposed = true; await sse.WriteAsync("proposal", p.Dto, ct); break; - case ChatStreamEvent.ToolCallEvent tc: - await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); - break; - case ChatStreamEvent.ToolResultEvent tr: - await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); - break; - case ChatStreamEvent.DoneEvent done: - model = done.Model; - durationMs = done.DurationMs; - promptTokens = done.PromptTokens; - completionTokens = done.CompletionTokens; - await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); - break; } } @@ -273,33 +268,19 @@ public async Task>> ChatActivity( return Ok(rows); } + private const string LlmKind = "chat"; + private static void RecordResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "chat"), new("result", result)); + AiStreamSupport.RecordResult(LlmKind, result); private void RecordError(LlmException ex) { - RecordResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(LlmKind, ex); _logger.LogWarning(ex, "LLM chat stream failed: {Kind}", ex.Kind); } - private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(LlmKind, model, durationMs, promptTokens, completionTokens); private Task AuditAsync(string model, int durationMs, bool proposed, bool cancelled, int turnCount, Guid? workflowId, CancellationToken ct = default) => diff --git a/src/NodePilot.Api/Controllers/AiController.cs b/src/NodePilot.Api/Controllers/AiController.cs index aa755e36..0d262936 100644 --- a/src/NodePilot.Api/Controllers/AiController.cs +++ b/src/NodePilot.Api/Controllers/AiController.cs @@ -6,8 +6,6 @@ using NodePilot.Ai; using NodePilot.Api.Configuration; using NodePilot.Core.Audit; -using NodePilot.Api.Telemetry; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -128,33 +126,20 @@ async Task Write(ScriptStreamEvent e) return new EmptyResult(); } + private const string ScriptKind = "script"; + private const string WorkflowKind = "workflow"; + private static void RecordScriptResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "script"), new("result", result)); + AiStreamSupport.RecordResult(ScriptKind, result); private void RecordScriptError(LlmException ex) { - RecordScriptResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(ScriptKind, ex); _logger.LogWarning(ex, "LLM script stream failed: {Kind}", ex.Kind); } - private static void RecordScriptSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordScriptResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordScriptSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(ScriptKind, model, durationMs, promptTokens, completionTokens); private Task ScriptAuditAsync(string model, int durationMs, int responseChars, GenerateScriptRequest request, bool cancelled, CancellationToken ct = default) => @@ -193,22 +178,8 @@ public async Task> GenerateWorkflow( { var resp = await _workflowGen.GenerateAsync(request, ct); - ApiMetrics.LlmCalls.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new("result", "success")); - ApiMetrics.LlmCallDuration.Record(resp.DurationMs, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model)); - if (resp.PromptTokens.HasValue) - ApiMetrics.LlmTokens.Add(resp.PromptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model), - new("token_type", "prompt")); - if (resp.CompletionTokens.HasValue) - ApiMetrics.LlmTokens.Add(resp.CompletionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model), - new("token_type", "completion")); + AiStreamSupport.RecordSuccess(WorkflowKind, resp.Model, resp.DurationMs, + resp.PromptTokens, resp.CompletionTokens); await _audit.LogAsync(AuditActions.AiWorkflowGenerated, "Workflow", null, AuditDetails.Json( @@ -224,12 +195,7 @@ await _audit.LogAsync(AuditActions.AiWorkflowGenerated, "Workflow", null, } catch (LlmException ex) { - ApiMetrics.LlmCalls.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new("result", "error")); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(WorkflowKind, ex); return this.MapLlmException(_logger, ex, "LLM call"); } } diff --git a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs index 3efc603f..a5c63527 100644 --- a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs +++ b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs @@ -11,10 +11,8 @@ using NodePilot.Api.Ai; using NodePilot.Api.Dtos; using NodePilot.Api.Security; -using NodePilot.Api.Telemetry; using NodePilot.Core.Audit; using NodePilot.Core.Interfaces; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -144,32 +142,25 @@ public async Task Ask(KnowledgeAskRequest request, CancellationTo var dbQueryFingerprints = new List(); int? promptTokens = null, completionTokens = null; + // BuildingEvent / ProposalEvent never occur on the knowledge stream, so the shared + // writer covers every event this endpoint can see. async Task Write(ChatStreamEvent e) { - switch (e) + if (e is ChatStreamEvent.DoneEvent done) { - case ChatStreamEvent.DeltaEvent d: - await sse.WriteAsync("delta", new { text = d.Text }, ct); - break; - case ChatStreamEvent.ToolCallEvent tc: - toolCalls++; - if (string.Equals(tc.ToolName, "execute_readonly_sql", StringComparison.Ordinal) - && TryFingerprintSqlToolCall(tc.ArgumentsJson) is { } fingerprint) - dbQueryFingerprints.Add(fingerprint); - await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); - break; - case ChatStreamEvent.ToolResultEvent tr: - await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); - break; - case ChatStreamEvent.DoneEvent done: - model = done.Model; - durationMs = done.DurationMs; - promptTokens = done.PromptTokens; - completionTokens = done.CompletionTokens; - await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); - break; - // BuildingEvent / ProposalEvent never occur on the knowledge stream. + model = done.Model; + durationMs = done.DurationMs; + promptTokens = done.PromptTokens; + completionTokens = done.CompletionTokens; } + + await AiStreamSupport.TryWriteSharedEventAsync(sse, e, tc => + { + toolCalls++; + if (string.Equals(tc.ToolName, "execute_readonly_sql", StringComparison.Ordinal) + && TryFingerprintSqlToolCall(tc.ArgumentsJson) is { } fingerprint) + dbQueryFingerprints.Add(fingerprint); + }, ct); } try @@ -248,32 +239,18 @@ private static List NormalizeHistory(IReadOnlyList .ToList(); } + private const string LlmKind = "knowledge"; + private static void RecordResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "knowledge"), new("result", result)); + AiStreamSupport.RecordResult(LlmKind, result); private void RecordError(LlmException ex) { - RecordResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(LlmKind, ex); _logger.LogWarning(ex, "LLM knowledge stream failed: {Kind}", ex.Kind); } - private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(LlmKind, model, durationMs, promptTokens, completionTokens); } diff --git a/src/NodePilot.Api/Controllers/AiStreamSupport.cs b/src/NodePilot.Api/Controllers/AiStreamSupport.cs new file mode 100644 index 00000000..8d86ca0e --- /dev/null +++ b/src/NodePilot.Api/Controllers/AiStreamSupport.cs @@ -0,0 +1,79 @@ +using NodePilot.Ai; +using NodePilot.Api.Ai; +using NodePilot.Api.Telemetry; +using NodePilot.Core.Telemetry; + +namespace NodePilot.Api.Controllers; + +/// +/// Shared plumbing for the AI endpoints (, , +/// ): the LLM telemetry trio and the SSE events their streams +/// have in common. Everything that differs stays with the caller — the metric kind tag is a +/// parameter, the per-endpoint log line stays at the call site, and the chat-only +/// building/proposal events are handled there too. +/// +internal static class AiStreamSupport +{ + /// Counts one LLM call outcome. Metric name, tag names and tag order are dashboard contract. + public static void RecordResult(string kind, string result) => + ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, kind), new("result", result)); + + /// Counts a failed call: the error result first, then the error-kind breakdown. + public static void RecordError(string kind, LlmException ex) + { + RecordResult(kind, "error"); + ApiMetrics.LlmErrors.Add(1, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + } + + /// Counts a successful call plus its latency and (when reported) its token usage. + public static void RecordSuccess(string kind, string model, int durationMs, int? promptTokens, int? completionTokens) + { + RecordResult(kind, "success"); + ApiMetrics.LlmCallDuration.Record(durationMs, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model)); + if (promptTokens.HasValue) + ApiMetrics.LlmTokens.Add(promptTokens.Value, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); + if (completionTokens.HasValue) + ApiMetrics.LlmTokens.Add(completionTokens.Value, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); + } + + /// + /// Writes the chat-stream events that the workflow assistant and the knowledge assistant emit + /// identically (delta/tool_call/tool_result/done) and reports + /// false for everything else, so a caller with extra events (building/proposal) can + /// handle those itself. runs before the tool_call event is + /// written — the knowledge stream counts calls and fingerprints SQL there. + /// + public static async Task TryWriteSharedEventAsync( + SseResponseWriter sse, + ChatStreamEvent e, + Action? onToolCall, + CancellationToken ct) + { + switch (e) + { + case ChatStreamEvent.DeltaEvent d: + await sse.WriteAsync("delta", new { text = d.Text }, ct); + return true; + case ChatStreamEvent.ToolCallEvent tc: + onToolCall?.Invoke(tc); + await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); + return true; + case ChatStreamEvent.ToolResultEvent tr: + await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); + return true; + case ChatStreamEvent.DoneEvent done: + await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); + return true; + default: + return false; + } + } +} diff --git a/src/NodePilot.Api/Controllers/AlertingController.cs b/src/NodePilot.Api/Controllers/AlertingController.cs index 6347efdb..6e98df38 100644 --- a/src/NodePilot.Api/Controllers/AlertingController.cs +++ b/src/NodePilot.Api/Controllers/AlertingController.cs @@ -232,41 +232,7 @@ public async Task> TestFire(Guid id, Cancellation var user = this.GetCurrentUsername(); var ctx = BuildSampleContext(rule.Name, user); - var now = DateTime.UtcNow; - var results = new List(); - - foreach (var route in rule.Routes) - { - var attempt = new NotificationDeliveryAttempt - { - Id = Guid.NewGuid(), - NotificationRuleId = rule.Id, - NotificationRouteId = route.Id, - EventKey = $"test:{Guid.NewGuid():N}", - DedupKey = $"test:{rule.Id}", - IsTest = true, - Attempt = 1, - CreatedAt = now, - SentAt = now, - }; - - NotificationSendResult result; - if (!_sinks.TryGetValue(route.Channel, out var sink)) - { - result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); - } - else - { - var secret = string.IsNullOrEmpty(route.Secret) ? null : await _store.GetRouteSecretAsync(route.Id, ct); - result = await sink.SendAsync(ctx, route.Target, secret, ct); - } - - attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; - attempt.Error = result.Error; - attempt.Summary = $"[test] {route.Channel}:{route.Target}"; - _db.NotificationDeliveryAttempts.Add(attempt); - results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); - } + var results = await AlertingRuleMapping.DeliverTestFireAsync(_db, _store, _sinks, rule, ctx, ct); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditActions.AlertRuleTestFired, "NotificationRule", rule.Id, @@ -384,49 +350,12 @@ private bool TryBuildDraft( // Routes if (routes is null || routes.Count == 0) return Fail("At least one route is required", out error); - var mappedRoutes = new List(); - var order = 0; - foreach (var rt in routes) - { - if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) - return Fail($"Invalid channel '{rt.Channel}'", out error); - if (!_sinks.ContainsKey(channel)) - return Fail($"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", _sinks.Keys)})", out error); - if (string.IsNullOrWhiteSpace(rt.Target)) - return Fail("Each route requires a target", out error); - if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) - return Fail($"route conditionExpressionJson {routeConditionError}", out error); - mappedRoutes.Add(new NotificationRoute - { - Id = rt.Id ?? Guid.Empty, - Channel = channel, - Target = rt.Target.Trim(), - Secret = rt.Secret, // store resolves unchanged-sentinel / encrypts plaintext - ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, - Order = order++, - }); - } + if (!AlertingRuleMapping.TryMapRoutes(routes, _sinks, out var mappedRoutes, out var routeError)) + return Fail(routeError!, out error); // Scope targets - var mappedTargets = new List(); - if (scope != NotificationScopeKind.Global) - { - var expectedKind = scope == NotificationScopeKind.Folders - ? NotificationTargetKind.Folder - : NotificationTargetKind.Workflow; - if (targets is null || targets.Count == 0) - return Fail($"{scope} rules require at least one target", out error); - foreach (var t in targets) - { - if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) - return Fail($"Invalid target kind '{t.TargetKind}'", out error); - if (kind != expectedKind) - return Fail($"{scope} rules may only contain {expectedKind} targets", out error); - if (t.TargetId == Guid.Empty) - return Fail("Target id must not be empty", out error); - mappedTargets.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); - } - } + if (!AlertingRuleMapping.TryMapScopeTargets(scope, targets, "rules", out var mappedTargets, out var targetError)) + return Fail(targetError!, out error); draft = new NotificationRule { diff --git a/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs b/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs new file mode 100644 index 00000000..5b83dc58 --- /dev/null +++ b/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs @@ -0,0 +1,165 @@ +using NodePilot.Api.Dtos; +using NodePilot.Core.Enums; +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.Notifications; + +namespace NodePilot.Api.Controllers; + +/// +/// The parts (custom rules) and +/// (system policies) run identically over the same graph: route mapping, +/// scope-target mapping and the test-fire delivery loop. Only the noun inside two target-validation +/// messages differs between the surfaces ("rules" vs "policies"), so it is a parameter; every other +/// message, the route order and the ledger rows stay byte-identical. +/// +internal static class AlertingRuleMapping +{ + /// + /// Validates and maps request routes onto entities. Returns false + /// with set on the first problem; the caller wraps that in its own 400 + /// shape. An absent/empty route list maps to an empty result — whether that is legal is the + /// caller's rule (custom rules always need one, a disabled system policy does not). + /// + public static bool TryMapRoutes( + IReadOnlyList? routes, + IReadOnlyDictionary sinks, + out List mapped, + out string? error) + { + mapped = new List(); + error = null; + var order = 0; + foreach (var rt in routes ?? []) + { + if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) + { + error = $"Invalid channel '{rt.Channel}'"; + return false; + } + if (!sinks.ContainsKey(channel)) + { + error = $"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", sinks.Keys)})"; + return false; + } + if (string.IsNullOrWhiteSpace(rt.Target)) + { + error = "Each route requires a target"; + return false; + } + if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) + { + error = $"route conditionExpressionJson {routeConditionError}"; + return false; + } + mapped.Add(new NotificationRoute + { + Id = rt.Id ?? Guid.Empty, + Channel = channel, + Target = rt.Target.Trim(), + Secret = rt.Secret, // store resolves unchanged-sentinel / encrypts plaintext + ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, + Order = order++, + }); + } + return true; + } + + /// + /// Maps the scope targets. Global scope carries none. is the plural the + /// caller's surface uses in its validation messages ("rules" / "policies") — the wording is part + /// of the API contract, so it must not converge. + /// + public static bool TryMapScopeTargets( + NotificationScopeKind scope, + IReadOnlyList? targets, + string noun, + out List mapped, + out string? error) + { + mapped = new List(); + error = null; + if (scope == NotificationScopeKind.Global) return true; + + var expectedKind = scope == NotificationScopeKind.Folders + ? NotificationTargetKind.Folder + : NotificationTargetKind.Workflow; + if (targets is null || targets.Count == 0) + { + error = $"{scope} {noun} require at least one target"; + return false; + } + foreach (var t in targets) + { + if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) + { + error = $"Invalid target kind '{t.TargetKind}'"; + return false; + } + if (kind != expectedKind) + { + error = $"{scope} {noun} may only contain {expectedKind} targets"; + return false; + } + if (t.TargetId == Guid.Empty) + { + error = "Target id must not be empty"; + return false; + } + mapped.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); + } + return true; + } + + /// + /// Sends the synthetic notification through every route of and stages one + /// IsTest delivery-ledger row per route. The caller keeps SaveChanges and the audit entry — + /// those are the only parts that differ between the two test-fire endpoints. + /// + public static async Task> DeliverTestFireAsync( + NodePilotDbContext db, + INotificationRuleStore store, + IReadOnlyDictionary sinks, + NotificationRule rule, + NotificationContext ctx, + CancellationToken ct) + { + var now = DateTime.UtcNow; + var results = new List(); + + foreach (var route in rule.Routes) + { + var attempt = new NotificationDeliveryAttempt + { + Id = Guid.NewGuid(), + NotificationRuleId = rule.Id, + NotificationRouteId = route.Id, + EventKey = $"test:{Guid.NewGuid():N}", + DedupKey = $"test:{rule.Id}", + IsTest = true, + Attempt = 1, + CreatedAt = now, + SentAt = now, + }; + + NotificationSendResult result; + if (!sinks.TryGetValue(route.Channel, out var sink)) + { + result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); + } + else + { + var secret = string.IsNullOrEmpty(route.Secret) ? null : await store.GetRouteSecretAsync(route.Id, ct); + result = await sink.SendAsync(ctx, route.Target, secret, ct); + } + + attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; + attempt.Error = result.Error; + attempt.Summary = $"[test] {route.Channel}:{route.Target}"; + db.NotificationDeliveryAttempts.Add(attempt); + results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); + } + return results; + } +} diff --git a/src/NodePilot.Api/Controllers/ApiProblems.cs b/src/NodePilot.Api/Controllers/ApiProblems.cs index cf8e8524..1941818f 100644 --- a/src/NodePilot.Api/Controllers/ApiProblems.cs +++ b/src/NodePilot.Api/Controllers/ApiProblems.cs @@ -17,39 +17,6 @@ public static BadRequestObjectResult BadRequest( return result; } - public static NotFoundObjectResult NotFound( - ControllerBase controller, - string code, - string detail, - string title = "Not found") - { - var result = new NotFoundObjectResult(BuildProblem(controller, StatusCodes.Status404NotFound, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - - public static ConflictObjectResult Conflict( - ControllerBase controller, - string code, - string detail, - string title = "Conflict") - { - var result = new ConflictObjectResult(BuildProblem(controller, StatusCodes.Status409Conflict, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - - public static UnauthorizedObjectResult Unauthorized( - ControllerBase controller, - string code, - string detail, - string title = "Unauthorized") - { - var result = new UnauthorizedObjectResult(BuildProblem(controller, StatusCodes.Status401Unauthorized, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - public static ProblemDetails BuildProblem( ControllerBase controller, int status, diff --git a/src/NodePilot.Api/Controllers/AuditController.cs b/src/NodePilot.Api/Controllers/AuditController.cs index 5c168638..2fb34d3e 100644 --- a/src/NodePilot.Api/Controllers/AuditController.cs +++ b/src/NodePilot.Api/Controllers/AuditController.cs @@ -7,6 +7,7 @@ using NodePilot.Api.Dtos; using NodePilot.Core.Audit; using NodePilot.Data; +using NodePilot.Api.Export; namespace NodePilot.Api.Controllers; @@ -199,12 +200,12 @@ await writer.WriteLineAsync(JsonSerializer.Serialize(new sb.Append(row.Id).Append(',') .Append(row.Timestamp.ToString("O")).Append(',') .Append(row.UserId?.ToString() ?? "").Append(','); - CsvField(sb, row.Username); sb.Append(','); - CsvField(sb, row.Action); sb.Append(','); - CsvField(sb, row.ResourceType); sb.Append(','); + CsvWriter.Field(sb, row.Username); sb.Append(','); + CsvWriter.Field(sb, row.Action); sb.Append(','); + CsvWriter.Field(sb, row.ResourceType); sb.Append(','); sb.Append(row.ResourceId?.ToString() ?? "").Append(','); - CsvField(sb, row.IpAddress); sb.Append(','); - CsvField(sb, row.Details); + CsvWriter.Field(sb, row.IpAddress); sb.Append(','); + CsvWriter.Field(sb, row.Details); await writer.WriteLineAsync(sb); } @@ -231,22 +232,4 @@ await _audit.LogAsync( ("exported", batch)), ct); } - - /// - /// RFC 4180 minimal CSV escaping: only quote when the value contains a comma, quote, - /// or newline; double internal quotes. NULL and empty render as empty (no quotes). - /// - private static void CsvField(StringBuilder sb, string? value) - { - if (string.IsNullOrEmpty(value)) return; - var needsQuoting = value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needsQuoting) { sb.Append(value); return; } - sb.Append('"'); - foreach (var c in value) - { - if (c == '"') sb.Append("\"\""); - else sb.Append(c); - } - sb.Append('"'); - } } diff --git a/src/NodePilot.Api/Controllers/AuthController.cs b/src/NodePilot.Api/Controllers/AuthController.cs index c89b23c4..896b7817 100644 --- a/src/NodePilot.Api/Controllers/AuthController.cs +++ b/src/NodePilot.Api/Controllers/AuthController.cs @@ -118,6 +118,42 @@ public AuthController(NodePilotDbContext db, IConfiguration config, IAuditWriter internal const int LockoutFailureThreshold = 10; internal static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15); + /// Single write point for the login-outcome metric. Tag names, order and values are + /// dashboard contract — every login path funnels through here instead of restating them. + private static void RecordLoginAttempt(string result, string reason) => + ApiMetrics.AuthLoginAttempts.Add(1, + new KeyValuePair("result", result), + new KeyValuePair("reason", 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. + /// + private async Task TryStageTokenRevocationAsync( + string jti, Guid userId, string? expClaim, string reason, CancellationToken ct) + { + long.TryParse(expClaim, out var expSec); + var expiresAt = expSec > 0 + ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime + : DateTime.UtcNow.Add(TokenLifetime); + + var existing = await _db.RevokedTokens.FindAsync([jti], ct); + if (existing is not null) return false; + + _db.RevokedTokens.Add(new RevokedToken + { + Jti = jti, + UserId = userId, + RevokedAt = DateTime.UtcNow, + ExpiresAt = expiresAt, + Reason = reason, + }); + return true; + } + // H-5: cookie names for the httpOnly auth cookie and the (JS-readable) CSRF token. // The SPA no longer persists the JWT in localStorage where a single XSS bug would // exfiltrate an admin token; the browser holds it in an httpOnly cookie and the @@ -468,9 +504,7 @@ await _audit.LogAsync(AuditActions.LoginLocked, "User", null, _ = BCrypt.Net.BCrypt.Verify(request.Password, DummyHash); await _audit.LogAsync(AuditActions.LoginFailed, "User", null, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", "unknown_username")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "unknown_user")); + RecordLoginAttempt("failure", "unknown_user"); return Unauthorized(new { message = "Invalid credentials" }); } } @@ -487,9 +521,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, ("username", user.Username), ("reason", "external_user_local_login_attempt"), ("provider", user.Provider.ToString())), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "external_user_local_login")); + RecordLoginAttempt("failure", "external_user_local_login"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -503,9 +535,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await _audit.LogAsync(AuditActions.LoginLocked, "User", user.Id, AuditDetails.Json(("username", user.Username), ("lockedUntil", localAttempt.LockedUntil?.ToString("o") ?? "unknown")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "locked")); + RecordLoginAttempt("failure", "locked"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -517,9 +547,7 @@ await _audit.LogAsync(AuditActions.LoginLocked, "User", user.Id, await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", auditReason), ("failedCount", localAttempt.FailureCount)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "bad_password")); + RecordLoginAttempt("failure", "bad_password"); if (localAttempt.TriggeredLockout) ApiMetrics.AuthLockouts.Add(1); return Unauthorized(new { message = "Invalid credentials" }); } @@ -534,9 +562,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await ReleaseUserAttemptAsync(user, localAttempt, ct); await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -546,9 +572,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, // IAuthSessionIssuer so LDAP / Windows-Auth flows can reuse them. AuthSource.Local // for the BCrypt path; LDAP/Windows pass their own value. var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Local, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "ok")); + RecordLoginAttempt("success", "ok"); return SessionResult(session.Token, user, TokenInBodyRequested()); } @@ -680,9 +704,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, await _audit.LogAsync(AuditActions.LoginLocked, "User", existing.Id, AuditDetails.Json(("username", existing.Username), ("lockedUntil", ldapLockedUntil.ToString("o")), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "locked")); + RecordLoginAttempt("failure", "locked"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -740,45 +762,14 @@ await _externalLoginThrottle.RecordSuccessAsync( if (existing is { Provider: AuthProvider.Ldap } && ldapUserAttempt is not null) await ResetUserAttemptsAsync(existing, CancellationToken.None); var mapping = await _externalUserMapper.MapAsync(ldap.Result!, ct); - if (mapping.Result == ExternalUserMapResult.RefusedUsernameCollision) + if (MapExternalIdentityRefusal( + mapping.Result, + "ldap", + collisionMessage: "Invalid credentials", + identityRefusedMessage: "Invalid credentials", + lastActiveAdminMessage: "Invalid credentials") is { } refused) { - // Audit was already written by the mapper; surface a generic 401 so the - // outsider can't tell collision from wrong-password. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_username_collision")); - return Unauthorized(new { message = "Invalid credentials" }); - } - if (mapping.Result is ExternalUserMapResult.RefusedIdentityConflict - or ExternalUserMapResult.RefusedTombstoned - or ExternalUserMapResult.RefusedDirectoryAccess) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_identity_refused")); - return Unauthorized(new { message = "Invalid credentials" }); - } - if (mapping.Result == ExternalUserMapResult.RefusedBootstrapNotAdmin) - { - // External identities never bootstrap the recovery administrator. The - // one-shot local bootstrap must establish a break-glass account first. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_bootstrap_refused")); - return Unauthorized(new - { - message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", - }); - } - if (mapping.Result == ExternalUserMapResult.RefusedLastActiveAdmin) - { - // The mapper preserved the database Admin invariant, invalidated stale - // sessions, and wrote the refusal audit. Do not mint a new token from - // that deliberately-preserved role. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_last_admin_demotion_refused")); - return Unauthorized(new { message = "Invalid credentials" }); + return refused; } var user = mapping.User!; @@ -786,9 +777,7 @@ or ExternalUserMapResult.RefusedTombstoned { await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -798,9 +787,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await ResetUserAttemptsAsync(user, ct); var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Ldap, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "ldap_ok")); + RecordLoginAttempt("success", "ldap_ok"); return SessionResult(session.Token, user, TokenInBodyRequested()); } case LdapAuthOutcome.InvalidCredentials: @@ -825,9 +812,7 @@ await _audit.LogAsync( ("failedCount", (ldapUserAttempt?.FailureCount ?? 0).ToString()), ("lockoutTriggered", ldapTriggeredLockout.ToString()), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_invalid_credentials")); + RecordLoginAttempt("failure", "ldap_invalid_credentials"); return Unauthorized(new { message = "Invalid credentials" }); case LdapAuthOutcome.DirectoryObjectMissing: // Password verified, but the directory holds no user object for this UPN — @@ -839,9 +824,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", existing?.Id, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", "ldap_user_object_not_found"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_user_object_not_found")); + RecordLoginAttempt("failure", "ldap_user_object_not_found"); return Unauthorized(new { message = "Invalid credentials" }); default: // Local password users were already short-circuited before LDAP. Reaching @@ -854,9 +837,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", existing?.Id, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", ldap.UnavailableReason ?? "directory_unavailable"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", ldap.UnavailableReason ?? "unavailable")); + RecordLoginAttempt("failure", ldap.UnavailableReason ?? "unavailable"); return StatusCode(StatusCodes.Status503ServiceUnavailable, new { message = "Directory authentication cannot verify current authorization.", @@ -888,6 +869,54 @@ await ReleaseUserAttemptAsync( } } + /// + /// Shared refusal ladder for the two external-identity login paths (LDAP and Windows SSO). + /// yields the same verdicts for both; only the metric-reason + /// prefix and the client-facing messages differ, so they are parameters. Returns null + /// when the mapping was not refused and the caller may continue with mapping.User. + /// + private ActionResult? MapExternalIdentityRefusal( + ExternalUserMapResult result, + string reasonPrefix, + string collisionMessage, + string identityRefusedMessage, + string lastActiveAdminMessage) + { + if (result == ExternalUserMapResult.RefusedUsernameCollision) + { + // Audit was already written by the mapper; surface a generic 401 so the + // outsider can't tell collision from wrong-password. + RecordLoginAttempt("failure", reasonPrefix + "_username_collision"); + return Unauthorized(new { message = collisionMessage }); + } + if (result is ExternalUserMapResult.RefusedIdentityConflict + or ExternalUserMapResult.RefusedTombstoned + or ExternalUserMapResult.RefusedDirectoryAccess) + { + RecordLoginAttempt("failure", reasonPrefix + "_identity_refused"); + return Unauthorized(new { message = identityRefusedMessage }); + } + if (result == ExternalUserMapResult.RefusedBootstrapNotAdmin) + { + // External identities never bootstrap the recovery administrator. The + // one-shot local bootstrap must establish a break-glass account first. + RecordLoginAttempt("failure", reasonPrefix + "_bootstrap_refused"); + return Unauthorized(new + { + message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", + }); + } + if (result == ExternalUserMapResult.RefusedLastActiveAdmin) + { + // The mapper preserved the database Admin invariant, invalidated stale + // sessions, and wrote the refusal audit. Do not mint a new token from + // that deliberately-preserved role. + RecordLoginAttempt("failure", reasonPrefix + "_last_admin_demotion_refused"); + return Unauthorized(new { message = lastActiveAdminMessage }); + } + return null; + } + private const string AuthSourceLdap = "Ldap"; /// @@ -941,9 +970,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, ("reason", "windows_ntlm_disabled"), ("source", "Windows"), ("mechanism", authMechanism)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_ntlm_disabled")); + RecordLoginAttempt("failure", "windows_ntlm_disabled"); return Unauthorized(new { message = "Kerberos required — NTLM fallback is disabled. Verify your client has a Kerberos ticket and the SPN is registered." @@ -1014,41 +1041,14 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, GroupSids: snapshot.GroupSids); var mapping = await _externalUserMapper.MapAsync(ldapResult, AuthProvider.Windows, ct); - if (mapping.Result == ExternalUserMapResult.RefusedUsernameCollision) + if (MapExternalIdentityRefusal( + mapping.Result, + "windows", + collisionMessage: "Windows authentication refused — username collision.", + identityRefusedMessage: "Windows authentication refused.", + lastActiveAdminMessage: "Windows authentication refused because the directory mapping would remove the last active Admin.") is { } refused) { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_username_collision")); - return Unauthorized(new { message = "Windows authentication refused — username collision." }); - } - if (mapping.Result is ExternalUserMapResult.RefusedIdentityConflict - or ExternalUserMapResult.RefusedTombstoned - or ExternalUserMapResult.RefusedDirectoryAccess) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_identity_refused")); - return Unauthorized(new { message = "Windows authentication refused." }); - } - if (mapping.Result == ExternalUserMapResult.RefusedBootstrapNotAdmin) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_bootstrap_refused")); - return Unauthorized(new - { - message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", - }); - } - if (mapping.Result == ExternalUserMapResult.RefusedLastActiveAdmin) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_last_admin_demotion_refused")); - return Unauthorized(new - { - message = "Windows authentication refused because the directory mapping would remove the last active Admin.", - }); + return refused; } var user = mapping.User!; @@ -1056,9 +1056,7 @@ or ExternalUserMapResult.RefusedTombstoned { await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled"), ("source", "Windows")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Account is disabled" }); } @@ -1070,9 +1068,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, } var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Windows, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "windows_ok")); + RecordLoginAttempt("success", "windows_ok"); // Windows SSO is browser-only and is driven by ambient OS credentials via the Negotiate // 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 @@ -1129,23 +1125,9 @@ public async Task Logout(CancellationToken ct) return NoContent(); } - long.TryParse(expClaim, out var expSec); - var expiresAt = expSec > 0 - ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime - : DateTime.UtcNow.Add(TokenLifetime); - // Idempotent: if the jti is already revoked we leave it as is. - var existing = await _db.RevokedTokens.FindAsync([jti], ct); - if (existing is null) + if (await TryStageTokenRevocationAsync(jti, userId, expClaim, "user-logout", ct)) { - _db.RevokedTokens.Add(new RevokedToken - { - Jti = jti, - UserId = userId, - RevokedAt = DateTime.UtcNow, - ExpiresAt = expiresAt, - Reason = "user-logout", - }); ApiMetrics.AuthTokenRevocations.Add(1, new KeyValuePair("reason", "user-logout")); } @@ -1219,28 +1201,12 @@ public async Task> Refresh(CancellationToken ct) // that's the intended behavior. var presentedJti = User.FindFirstValue(JwtRegisteredClaimNames.Jti); var expClaim = User.FindFirstValue("exp"); - if (!string.IsNullOrEmpty(presentedJti)) + if (!string.IsNullOrEmpty(presentedJti) + && await TryStageTokenRevocationAsync(presentedJti, id, expClaim, "rotated", ct)) { - long.TryParse(expClaim, out var expSec); - var expiresAt = expSec > 0 - ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime - : DateTime.UtcNow.Add(TokenLifetime); - - var existing = await _db.RevokedTokens.FindAsync([presentedJti], ct); - if (existing is null) - { - _db.RevokedTokens.Add(new RevokedToken - { - Jti = presentedJti, - UserId = id, - RevokedAt = DateTime.UtcNow, - ExpiresAt = expiresAt, - Reason = "rotated", - }); - await _db.SaveChangesAsync(ct); - ApiMetrics.AuthTokenRevocations.Add(1, - new KeyValuePair("reason", "rotated")); - } + 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 diff --git a/src/NodePilot.Api/Controllers/CustomActivitiesController.cs b/src/NodePilot.Api/Controllers/CustomActivitiesController.cs index b294e353..898b202a 100644 --- a/src/NodePilot.Api/Controllers/CustomActivitiesController.cs +++ b/src/NodePilot.Api/Controllers/CustomActivitiesController.cs @@ -63,25 +63,18 @@ public async Task>> Ge [Authorize(Roles = "Admin,Operator")] public async Task> Create(CreateCustomActivityRequest request, CancellationToken ct) { - var inputs = request.Inputs ?? []; - var outputs = request.Outputs ?? []; - var icon = string.IsNullOrWhiteSpace(request.Icon) ? "extension" : request.Icon!; - var engine = string.IsNullOrWhiteSpace(request.Engine) ? "auto" : request.Engine!; + var (icon, engine, inputs, outputs) = NormalizePayload(request.Icon, request.Engine, request.Inputs, request.Outputs); var error = CustomActivityValidation.Validate(request.Key, request.Name, icon, engine, inputs, outputs, requireKey: true); if (error is not null) return BadRequest(new { message = error }); if (string.IsNullOrWhiteSpace(request.ScriptTemplate)) return BadRequest(new { message = "ScriptTemplate is required." }); - var newInput = new CustomActivityDefinitionInput - { - Key = request.Key, Name = request.Name, Description = request.Description, Icon = icon, Color = request.Color, - ScriptTemplate = request.ScriptTemplate, Engine = engine, RunsRemote = request.RunsRemote, Isolated = request.Isolated, - MemoryLimitMb = request.MemoryLimitMb, MaxProcesses = request.MaxProcesses, - DefaultTimeoutSeconds = request.DefaultTimeoutSeconds, SuccessExitCodes = request.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - }; + var newInput = BuildDefinitionInput( + request.Key, request.Name, request.Description, icon, request.Color, + request.ScriptTemplate, engine, request.RunsRemote, request.Isolated, + request.MemoryLimitMb, request.MaxProcesses, request.DefaultTimeoutSeconds, request.SuccessExitCodes, + inputs, outputs); CustomActivityDefinition def; try { def = await store.CreateAsync(newInput, this.GetCurrentUsername(), ct); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } @@ -101,26 +94,18 @@ public async Task> Update(Guid id, Upda if (existing is null) return NotFound(); if (MutationForbidden(existing, out var forbid)) return forbid!; - var inputs = request.Inputs ?? []; - var outputs = request.Outputs ?? []; - var icon = string.IsNullOrWhiteSpace(request.Icon) ? "extension" : request.Icon!; - var engine = string.IsNullOrWhiteSpace(request.Engine) ? "auto" : request.Engine!; + var (icon, engine, inputs, outputs) = NormalizePayload(request.Icon, request.Engine, request.Inputs, request.Outputs); var error = CustomActivityValidation.Validate(null, request.Name, icon, engine, inputs, outputs, requireKey: false); if (error is not null) return BadRequest(new { message = error }); if (string.IsNullOrWhiteSpace(request.ScriptTemplate)) return BadRequest(new { message = "ScriptTemplate is required." }); - var updInput = new CustomActivityDefinitionInput - { - Key = existing.Key, Name = request.Name, Description = request.Description, Icon = icon, Color = request.Color, - ScriptTemplate = request.ScriptTemplate, Engine = engine, RunsRemote = request.RunsRemote, Isolated = request.Isolated, - MemoryLimitMb = request.MemoryLimitMb, MaxProcesses = request.MaxProcesses, - DefaultTimeoutSeconds = request.DefaultTimeoutSeconds, SuccessExitCodes = request.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - ChangeNote = request.ChangeNote, - }; + var updInput = BuildDefinitionInput( + existing.Key, request.Name, request.Description, icon, request.Color, + request.ScriptTemplate, engine, request.RunsRemote, request.Isolated, + request.MemoryLimitMb, request.MaxProcesses, request.DefaultTimeoutSeconds, request.SuccessExitCodes, + inputs, outputs, request.ChangeNote); CustomActivityDefinition def; try { def = await store.UpdateAsync(id, updInput, request.ConcurrencyToken, this.GetCurrentUsername(), ct); } catch (KeyNotFoundException) { return NotFound(); } @@ -220,24 +205,17 @@ public async Task>> Import(Cu var imported = new List(); foreach (var item in envelope.Items) { - var icon = string.IsNullOrWhiteSpace(item.Icon) ? "extension" : item.Icon; - var engine = string.IsNullOrWhiteSpace(item.Engine) ? "auto" : item.Engine; - var inputs = item.Inputs ?? []; - var outputs = item.Outputs ?? []; + var (icon, engine, inputs, outputs) = NormalizePayload(item.Icon, item.Engine, item.Inputs, item.Outputs); if (CustomActivityValidation.Validate(item.Key, item.Name, icon, engine, inputs, outputs, requireKey: true) is not null) continue; // skip malformed entries if (await store.GetByKeyAsync(item.Key, ct) is not null) continue; // skip key collisions - var input = new CustomActivityDefinitionInput - { - Key = item.Key, Name = item.Name, Description = item.Description, Icon = icon, Color = item.Color, - ScriptTemplate = item.ScriptTemplate, Engine = engine, RunsRemote = item.RunsRemote, Isolated = item.Isolated, - MemoryLimitMb = item.MemoryLimitMb, MaxProcesses = item.MaxProcesses, - DefaultTimeoutSeconds = item.DefaultTimeoutSeconds, SuccessExitCodes = item.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - }; + var input = BuildDefinitionInput( + item.Key, item.Name, item.Description, icon, item.Color, + item.ScriptTemplate, engine, item.RunsRemote, item.Isolated, + item.MemoryLimitMb, item.MaxProcesses, item.DefaultTimeoutSeconds, item.SuccessExitCodes, + inputs, outputs); var def = await store.CreateAsync(input, this.GetCurrentUsername(), ct); // created disabled await audit.LogAsync(AuditActions.CustomActivityImported, "CustomActivity", def.Id, AuditDetails.Json(("key", def.Key)), ct); @@ -263,6 +241,44 @@ private bool MutationForbidden(CustomActivityDefinition def, out ActionResult? r return false; } + /// + /// Applies the defaults every write payload shares: icon falls back to "extension", engine to + /// "auto", absent parameter lists to empty. Validation and persistence must see the same + /// normalised values, so this runs ahead of both. + /// + private static (string Icon, string Engine, + IReadOnlyList Inputs, + IReadOnlyList Outputs) NormalizePayload( + string? icon, string? engine, + IReadOnlyList? inputs, + IReadOnlyList? outputs) + => (string.IsNullOrWhiteSpace(icon) ? "extension" : icon!, + string.IsNullOrWhiteSpace(engine) ? "auto" : engine!, + inputs ?? [], + outputs ?? []); + + /// + /// Single mapper onto the store's input record. Create, Update and Import persist the exact same + /// shape from three different payload types; only the key source (request vs. existing row) and + /// the change note differ, which is why those are parameters. + /// + private static CustomActivityDefinitionInput BuildDefinitionInput( + string key, string name, string? description, string icon, string? color, + string scriptTemplate, string engine, bool runsRemote, bool isolated, + int? memoryLimitMb, int? maxProcesses, int? defaultTimeoutSeconds, string? successExitCodes, + IReadOnlyList inputs, + IReadOnlyList outputs, + string? changeNote = null) => new() + { + Key = key, Name = name, Description = description, Icon = icon, Color = color, + ScriptTemplate = scriptTemplate, Engine = engine, RunsRemote = runsRemote, Isolated = isolated, + MemoryLimitMb = memoryLimitMb, MaxProcesses = maxProcesses, + DefaultTimeoutSeconds = defaultTimeoutSeconds, SuccessExitCodes = successExitCodes, + InputParametersJson = CustomActivityParameters.Serialize(inputs), + OutputParametersJson = CustomActivityParameters.Serialize(outputs), + ChangeNote = changeNote, + }; + private static IReadOnlyList Lint(string script) => WorkflowScriptLinter.LintScript(script).Select(w => new CustomActivityLintWarning(w.Rule, w.Message)).ToList(); diff --git a/src/NodePilot.Api/Controllers/DashboardController.cs b/src/NodePilot.Api/Controllers/DashboardController.cs index dd227cab..d0eedbba 100644 --- a/src/NodePilot.Api/Controllers/DashboardController.cs +++ b/src/NodePilot.Api/Controllers/DashboardController.cs @@ -53,18 +53,13 @@ public async Task> Get(CancellationToken ct, [FromQ // accessible-folder set once and reuse for every workflow + execution query // below. Global Admin gets the unrestricted set and skips filtering. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var workflowQuery = _db.Workflows.AsNoTracking().AsQueryable(); - var execQuery = _db.WorkflowExecutions.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) + var workflowQuery = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + var execQuery = _db.WorkflowExecutions.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (workflowQuery is null || execQuery is null) { - if (accessible.FolderIds.Count == 0) - { - // User has zero folder access — return an empty dashboard rather than a - // potentially confusing partial one. - return Ok(EmptyStats(sinceWindow, windowHours, NormalizeProvider(_db.Database.ProviderName), GetClusterRole(), GetLlmEnabled())); - } - workflowQuery = workflowQuery.Where(w => accessible.FolderIds.Contains(w.FolderId)); - execQuery = execQuery.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + // User has zero folder access — return an empty dashboard rather than a + // potentially confusing partial one. + return Ok(EmptyStats(sinceWindow, windowHours, NormalizeProvider(_db.Database.ProviderName), GetClusterRole(), GetLlmEnabled())); } // TriggerTypesJson, not DefinitionJson. The definition is unbounded text holding the whole diff --git a/src/NodePilot.Api/Controllers/DbAdminController.cs b/src/NodePilot.Api/Controllers/DbAdminController.cs index 4845ac9a..1172b641 100644 --- a/src/NodePilot.Api/Controllers/DbAdminController.cs +++ b/src/NodePilot.Api/Controllers/DbAdminController.cs @@ -1,4 +1,3 @@ -using System.Security.Claims; using System.Security.Cryptography; using System.Data; using System.Text; @@ -6,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Security; using NodePilot.Api.Services.DbAdmin; @@ -227,7 +227,7 @@ private async Task PatchRowCore( // transaction-scoped invariant gate established by PatchRow. if (entity is User user) { - var callerId = GetCallerId(); + var callerId = this.GetCurrentUserId(); if (callerId is null) return Unauthorized(); var guard = await DbAdminPolicy.PreUpdateUserGuardAsync(user, req.Column, coercedValue, _db, callerId.Value, ct); if (guard.IsBlocked) return BadRequest(new { code = guard.Code, message = guard.Message }); @@ -267,8 +267,7 @@ private async Task PatchRowCore( // so the row mutation and the audit row are atomic. Routes through IAuditStager // (same as every other audit-write path) so redaction + 4 KiB cap apply uniformly. var pkDisplay = string.Join(";", pk); - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); var auditEntry = _stager.Build( action: AuditActions.DbAdminRowUpdated, actor: auditActor, @@ -382,7 +381,7 @@ private async Task DeleteRowCore( // transaction-scoped invariant gate established by DeleteRow. if (entity is User user) { - var callerId = GetCallerId(); + var callerId = this.GetCurrentUserId(); if (callerId is null) return Unauthorized(); var guard = await DbAdminPolicy.PreDeleteUserGuardAsync(user, _db, callerId.Value, ct); if (guard.IsBlocked) return BadRequest(new { code = guard.Code, message = guard.Message }); @@ -392,8 +391,7 @@ private async Task DeleteRowCore( _db.Remove(entity); var pkDisplay = string.Join(";", pk); - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); var auditEntry = _stager.Build( action: AuditActions.DbAdminRowDeleted, actor: auditActor, @@ -610,8 +608,7 @@ private async Task WriteQueryAuditAsync( string? reason, CancellationToken ct) { - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); // The stager already caps details at 4 KiB, so even pathological SQL pastes are bounded // in audit storage. The full statement is represented by a stable hash + byte length + @@ -656,8 +653,7 @@ private async Task WriteRowsViewedAuditAsync( int returned, CancellationToken ct) { - var actor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var actor = CurrentAuditActor(); var entry = _stager.Build( AuditActions.DbAdminRowsViewed, actor, @@ -720,11 +716,10 @@ private async Task CountRowsAsync(Type clrType, CancellationToken ct) } } - private Guid? GetCallerId() - { - var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); - return Guid.TryParse(raw, out var id) ? id : null; - } + /// Actor for every DB-admin audit row: caller identity plus the remote address. + private AuditActor CurrentAuditActor() => new( + this.GetCurrentUserId(), this.GetCurrentUsername(), + HttpContext?.Connection?.RemoteIpAddress?.ToString()); private static Guid? TryParseGuid(string? s) => s is not null && Guid.TryParse(s, out var g) ? g : null; diff --git a/src/NodePilot.Api/Controllers/DiagnosticsController.cs b/src/NodePilot.Api/Controllers/DiagnosticsController.cs index d3c89d11..c222fe40 100644 --- a/src/NodePilot.Api/Controllers/DiagnosticsController.cs +++ b/src/NodePilot.Api/Controllers/DiagnosticsController.cs @@ -8,6 +8,7 @@ using NodePilot.Api.Dtos; using NodePilot.Core.Audit; using NodePilot.Data; +using NodePilot.Api.Export; namespace NodePilot.Api.Controllers; @@ -406,14 +407,14 @@ await writer.WriteLineAsync(JsonSerializer.Serialize(new sb.Append(row.Id).Append(',') .Append(row.Timestamp.ToString("O")).Append(',') .Append(row.Level).Append(','); - CsvField(sb, row.EventType); sb.Append(','); - CsvField(sb, row.WorkflowName); sb.Append(','); - CsvField(sb, row.ExecutionShort); sb.Append(','); - CsvField(sb, row.StepLabel); sb.Append(','); - CsvField(sb, row.ActivityType); sb.Append(','); - CsvField(sb, row.UserName); sb.Append(','); - CsvField(sb, row.Message); sb.Append(','); - CsvField(sb, row.PropertiesJson); + CsvWriter.Field(sb, row.EventType); sb.Append(','); + CsvWriter.Field(sb, row.WorkflowName); sb.Append(','); + CsvWriter.Field(sb, row.ExecutionShort); sb.Append(','); + CsvWriter.Field(sb, row.StepLabel); sb.Append(','); + CsvWriter.Field(sb, row.ActivityType); sb.Append(','); + CsvWriter.Field(sb, row.UserName); sb.Append(','); + CsvWriter.Field(sb, row.Message); sb.Append(','); + CsvWriter.Field(sb, row.PropertiesJson); await writer.WriteLineAsync(sb); } @@ -437,19 +438,5 @@ await _audit.LogAsync( ("exported", batch)), ct); } - - private static void CsvField(StringBuilder sb, string? value) - { - if (string.IsNullOrEmpty(value)) return; - var needsQuoting = value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needsQuoting) { sb.Append(value); return; } - sb.Append('"'); - foreach (var c in value) - { - if (c == '"') sb.Append("\"\""); - else sb.Append(c); - } - sb.Append('"'); - } } diff --git a/src/NodePilot.Api/Controllers/ExecutionsController.cs b/src/NodePilot.Api/Controllers/ExecutionsController.cs index 74d50d43..9e173866 100644 --- a/src/NodePilot.Api/Controllers/ExecutionsController.cs +++ b/src/NodePilot.Api/Controllers/ExecutionsController.cs @@ -55,12 +55,9 @@ public ExecutionsController( IQueryable query, CancellationToken ct) { var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - if (accessible.IsUnrestricted) return query; - if (accessible.FolderIds.Count == 0) - return query.Where(_ => false); - // Inner-join semantics: pulls each execution's workflow folder via the navigation - // property. Translates to a single JOIN on Postgres + SQL Server. - return query.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + // Zero folder access has no dedicated response on the list endpoints — an always-false + // query keeps the rest of the pipeline (paging, projections) intact. + return query.ScopeToAccessibleFolders(accessible) ?? query.Where(_ => false); } /// diff --git a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs index 5d7a1b89..15020207 100644 --- a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs +++ b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs @@ -76,6 +76,19 @@ public ExternalTriggerController( _redactor.Redact(execution.ReturnData), _redactor.Redact(execution.InputParametersJson)); + /// + /// Answers an idempotency replay: flag the response, count the cached hit, hand back the + /// original execution. Three call sites reach it (pre-check, unique-violation race, and the + /// replay decided inside the transaction) and they must answer identically. + /// + private OkObjectResult IdempotentReplay(WorkflowExecution replay) + { + Response.Headers["Idempotent-Replayed"] = "true"; + NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, + new KeyValuePair("result", "cached")); + return Ok(ToResponse(replay)); + } + private static async Task FindIdempotencyReplayAsync( NodePilotDbContext db, string idempotencyKey, @@ -204,12 +217,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); if (replay is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(replay)); - } + return IdempotentReplay(replay); } @@ -314,12 +322,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" _db.ChangeTracker.Clear(); var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); if (replay is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(replay)); - } + return IdempotentReplay(replay); return Conflict(new { message = "Idempotency-Key is currently being processed; retry with the same key." }); } @@ -327,12 +330,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" // Replay is decided inside the transaction but answered out here: an early return // from within strategy.ExecuteAsync would escape the retry unit. if (outcome.Replayed is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(outcome.Replayed)); - } + return IdempotentReplay(outcome.Replayed); pending = outcome.Fresh!; NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, diff --git a/src/NodePilot.Api/Controllers/FolderScopedQueries.cs b/src/NodePilot.Api/Controllers/FolderScopedQueries.cs new file mode 100644 index 00000000..4165e1b8 --- /dev/null +++ b/src/NodePilot.Api/Controllers/FolderScopedQueries.cs @@ -0,0 +1,34 @@ +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; + +namespace NodePilot.Api.Controllers; + +/// +/// Folder-RBAC scoping for the list/aggregate endpoints. The three-step shape — global Admin +/// passes through unrestricted, a caller with zero accessible folders is a dead end, everyone +/// else gets the folder IN-filter — was restated at every call site. Only the dead-end answer +/// differs per endpoint (404, empty list, empty dashboard, always-false query), so it stays with +/// the caller: a null return means "this caller can read no folder at all". +/// +internal static class FolderScopedQueries +{ + public static IQueryable? ScopeToAccessibleFolders( + this IQueryable query, AccessibleFolderSet accessible) + { + if (accessible.IsUnrestricted) return query; + if (accessible.FolderIds.Count == 0) return null; + return query.Where(w => accessible.FolderIds.Contains(w.FolderId)); + } + + /// + /// Execution variant. Inner-join semantics: pulls each execution's workflow folder via the + /// navigation property. Translates to a single JOIN on Postgres + SQL Server. + /// + public static IQueryable? ScopeToAccessibleFolders( + this IQueryable query, AccessibleFolderSet accessible) + { + if (accessible.IsUnrestricted) return query; + if (accessible.FolderIds.Count == 0) return null; + return query.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + } +} diff --git a/src/NodePilot.Api/Controllers/OperationsController.cs b/src/NodePilot.Api/Controllers/OperationsController.cs index bbfe426b..5372a079 100644 --- a/src/NodePilot.Api/Controllers/OperationsController.cs +++ b/src/NodePilot.Api/Controllers/OperationsController.cs @@ -119,15 +119,10 @@ public async Task> GetGraph( // RBAC: resolve the accessible-folder set once and scope every query to it. Global Admin // is unrestricted and skips the filter; a user with zero folder access gets an empty graph. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var workflowQuery = _db.Workflows.AsNoTracking().AsQueryable(); - var execQuery = _db.WorkflowExecutions.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) - return Ok(new OperationsGraphDto([], [], [], [], [], emptyMeta)); - workflowQuery = workflowQuery.Where(w => accessible.FolderIds.Contains(w.FolderId)); - execQuery = execQuery.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); - } + var workflowQuery = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + var execQuery = _db.WorkflowExecutions.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (workflowQuery is null || execQuery is null) + return Ok(new OperationsGraphDto([], [], [], [], [], emptyMeta)); // Deliberately WITHOUT DefinitionJson. Definitions are unbounded text including every // inline script (21-42 KB apiece in the repo's example set) and the only thing this endpoint diff --git a/src/NodePilot.Api/Controllers/SystemAlertingController.cs b/src/NodePilot.Api/Controllers/SystemAlertingController.cs index b6e92761..3b7048da 100644 --- a/src/NodePilot.Api/Controllers/SystemAlertingController.cs +++ b/src/NodePilot.Api/Controllers/SystemAlertingController.cs @@ -204,39 +204,7 @@ public async Task> TestFire(Guid id, Cancellation if (policy is null) return NotFound(); var ctx = BuildSampleContext(policy); - var now = DateTime.UtcNow; - var results = new List(); - - foreach (var route in policy.Routes) - { - var attempt = new NotificationDeliveryAttempt - { - Id = Guid.NewGuid(), - NotificationRuleId = policy.Id, - NotificationRouteId = route.Id, - EventKey = $"test:{Guid.NewGuid():N}", - DedupKey = $"test:{policy.Id}", - IsTest = true, - Attempt = 1, - CreatedAt = now, - SentAt = now, - }; - - NotificationSendResult result; - if (!_sinks.TryGetValue(route.Channel, out var sink)) - result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); - else - { - var secret = string.IsNullOrEmpty(route.Secret) ? null : await _store.GetRouteSecretAsync(route.Id, ct); - result = await sink.SendAsync(ctx, route.Target, secret, ct); - } - - attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; - attempt.Error = result.Error; - attempt.Summary = $"[test] {route.Channel}:{route.Target}"; - _db.NotificationDeliveryAttempts.Add(attempt); - results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); - } + var results = await AlertingRuleMapping.DeliverTestFireAsync(_db, _store, _sinks, policy, ctx, ct); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditActions.SystemAlertPolicyTestFired, "SystemAlertPolicy", policy.Id, @@ -290,48 +258,13 @@ private bool TryBuildDraft(SaveSystemAlertPolicyRequest request, out Notificatio return Bad("minOccurrences must be at least 1", out problem); // Routes: required only to enable (a draft policy may be saved disabled with no route). - var mappedRoutes = new List(); if (request.IsEnabled && (request.Routes is null || request.Routes.Count == 0)) return Bad("An enabled policy requires at least one route.", out problem); - var order = 0; - foreach (var rt in request.Routes ?? []) - { - if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) - return Bad($"Invalid channel '{rt.Channel}'", out problem); - if (!_sinks.ContainsKey(channel)) - return Bad($"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", _sinks.Keys)})", out problem); - if (string.IsNullOrWhiteSpace(rt.Target)) - return Bad("Each route requires a target", out problem); - if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) - return Bad($"route conditionExpressionJson {routeConditionError}", out problem); - mappedRoutes.Add(new NotificationRoute - { - Id = rt.Id ?? Guid.Empty, - Channel = channel, - Target = rt.Target.Trim(), - Secret = rt.Secret, - ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, - Order = order++, - }); - } + if (!AlertingRuleMapping.TryMapRoutes(request.Routes, _sinks, out var mappedRoutes, out var routeError)) + return Bad(routeError!, out problem); - var mappedTargets = new List(); - if (scope != NotificationScopeKind.Global) - { - var expectedKind = scope == NotificationScopeKind.Folders ? NotificationTargetKind.Folder : NotificationTargetKind.Workflow; - if (request.Targets is null || request.Targets.Count == 0) - return Bad($"{scope} policies require at least one target", out problem); - foreach (var t in request.Targets) - { - if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) - return Bad($"Invalid target kind '{t.TargetKind}'", out problem); - if (kind != expectedKind) - return Bad($"{scope} policies may only contain {expectedKind} targets", out problem); - if (t.TargetId == Guid.Empty) - return Bad("Target id must not be empty", out problem); - mappedTargets.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); - } - } + if (!AlertingRuleMapping.TryMapScopeTargets(scope, request.Targets, "policies", out var mappedTargets, out var targetError)) + return Bad(targetError!, out problem); draft = new NotificationRule { diff --git a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs index 6ebcd638..2df676c9 100644 --- a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs @@ -1,7 +1,9 @@ +using System.Linq.Expressions; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Dtos; @@ -43,6 +45,87 @@ public WorkflowEditingController( _testContextProvider = testContextProvider; } + // --- Optimistic-concurrency core (shared by Publish + Rollback) ------------------------ + + /// + /// M-3 (security audit 2026-05-15): field mutation + history snapshot inside one + /// execution-strategy transaction, with the workflow UPDATE an atomic compare-and-swap on + /// (folder, lock owner, lock timestamp, version). This closes two TOCTOU windows the old + /// load→check→SaveChanges left open: (a) a force-unlock + re-lock by another user landing + /// between the in-memory EnsureWriteLockAsync check and the write (lock-theft), and + /// (b) a concurrent publish/update bumping the version (lost-update). A return value of 0 + /// means one of those raced us — the caller re-reads via + /// to return the right 423/409 verdict. + /// + /// Publish and Rollback differ only in the setter chain and the snapshot row, so both + /// come in as parameters. The snapshot is a factory, not an instance: every retry of + /// the execution strategy clears the change tracker and must stage a fresh row. + /// + private Task CompareAndSwapWorkflowAsync( + Guid id, + Workflow workflow, + Guid? meId, + int oldVersion, + Action> setters, + Func snapshot, + CancellationToken ct) + { + var strategy = _db.Database.CreateExecutionStrategy(); + return strategy.ExecuteAsync(async () => + { + _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt + await using var tx = await _db.Database.BeginTransactionAsync(ct); + var rows = await _db.Workflows + .Where(w => w.Id == id + && w.FolderId == workflow.FolderId + && w.CheckedOutByUserId == meId + && w.CheckedOutAt == workflow.CheckedOutAt + && w.Version == oldVersion) + .ExecuteUpdateAsync(setters, ct); + if (rows == 0) + { + await tx.RollbackAsync(ct); + return 0; + } + _db.WorkflowVersions.Add(snapshot()); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return rows; + }); + } + + /// + /// The compare-and-swap matched no row: re-read and answer with whichever verdict actually + /// applies now — 404 if the workflow vanished, the access gate's verdict if permissions + /// changed, 423 if the lock moved, otherwise the version conflict. + /// + private async Task LostCompareAndSwapAsync(Guid id, string conflictMessage, CancellationToken ct) + { + var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); + if (current is null) return NotFound(); + if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) + return deniedNow; + if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; + return Conflict(new + { + code = "workflow_version_conflict", + message = conflictMessage, + currentVersion = current.Version, + }); + } + + /// Same conflict body, but for the unique-index violation raised by a racing insert. + private async Task VersionUniqueConflictAsync(Guid id, string conflictMessage, CancellationToken ct) + => Conflict(new + { + code = "workflow_version_conflict", + message = conflictMessage, + currentVersion = await _db.Workflows.AsNoTracking() + .Where(w => w.Id == id) + .Select(w => w.Version) + .FirstOrDefaultAsync(ct), + }); + // --- Versions / Rollback -------------------------------------------------------------- [HttpGet("{id:guid}/versions")] @@ -134,40 +217,26 @@ public async Task> Rollback( var computed = new Workflow { DefinitionJson = target.DefinitionJson }; PopulateComputedColumns(computed); - // M-3 (security audit 2026-05-15): roll-forward (snapshot live row, apply target as a new - // version) inside one execution-strategy transaction, with the workflow UPDATE an atomic - // compare-and-swap on (lock-owner == me, version == oldVersion). The lock is intentionally - // retained (rollback ≠ publish). The CAS closes the lock-theft / lost-update TOCTOU the old - // load→check→SaveChanges left open. + // Roll-forward: snapshot the live row, apply the target as a new version — through the + // shared compare-and-swap (see CompareAndSwapWorkflowAsync). The lock is intentionally + // retained here (rollback ≠ publish), which is why the setter chain clears no lock fields. + const string rollbackConflictMessage = + "Workflow was updated concurrently. Reload the workflow and retry publish."; int updated; try { - var strategy = _db.Database.CreateExecutionStrategy(); - updated = await strategy.ExecuteAsync(async () => - { - _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt - await using var tx = await _db.Database.BeginTransactionAsync(ct); - var rows = await _db.Workflows - .Where(w => w.Id == id - && w.FolderId == workflow.FolderId - && w.CheckedOutByUserId == meId - && w.CheckedOutAt == workflow.CheckedOutAt - && w.Version == oldVersion) - .ExecuteUpdateAsync(s => s - .SetProperty(w => w.Name, target.Name) - .SetProperty(w => w.Description, target.Description) - .SetProperty(w => w.DefinitionJson, target.DefinitionJson) - .SetProperty(w => w.Version, oldVersion + 1) - .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) - .SetProperty(w => w.ActivityCount, computed.ActivityCount) - .SetProperty(w => w.UpdatedAt, now) - .SetProperty(w => w.UpdatedBy, updatedBy), ct); - if (rows == 0) - { - await tx.RollbackAsync(ct); - return 0; - } - _db.WorkflowVersions.Add(new WorkflowVersion + updated = await CompareAndSwapWorkflowAsync( + id, workflow, meId, oldVersion, + s => s + .SetProperty(w => w.Name, target.Name) + .SetProperty(w => w.Description, target.Description) + .SetProperty(w => w.DefinitionJson, target.DefinitionJson) + .SetProperty(w => w.Version, oldVersion + 1) + .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) + .SetProperty(w => w.ActivityCount, computed.ActivityCount) + .SetProperty(w => w.UpdatedAt, now) + .SetProperty(w => w.UpdatedBy, updatedBy), + () => new WorkflowVersion { Id = Guid.NewGuid(), WorkflowId = id, @@ -178,39 +247,16 @@ public async Task> Rollback( CreatedAt = now, CreatedBy = updatedBy, ChangeNote = $"Superseded by rollback to v{version}", - }); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return rows; - }); + }, + ct); } catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) == true) { - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was updated concurrently. Reload the workflow and retry publish.", - currentVersion = await _db.Workflows.AsNoTracking() - .Where(w => w.Id == id) - .Select(w => w.Version) - .FirstOrDefaultAsync(ct), - }); + return await VersionUniqueConflictAsync(id, rollbackConflictMessage, ct); } if (updated == 0) - { - var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); - if (current is null) return NotFound(); - if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) - return deniedNow; - if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was updated concurrently. Reload the workflow and retry publish.", - currentVersion = current.Version, - }); - } + return await LostCompareAndSwapAsync(id, rollbackConflictMessage, ct); var rolled = await _db.Workflows.AsNoTracking().FirstAsync(w => w.Id == id, ct); var reason = string.IsNullOrWhiteSpace(body?.Reason) ? $"Rolled back to v{version}" : body!.Reason!; @@ -437,46 +483,29 @@ public async Task> Publish( var computed = new Workflow { DefinitionJson = request.DefinitionJson }; PopulateComputedColumns(computed); - // M-3 (security audit 2026-05-15): the field mutation + history snapshot run inside one - // execution-strategy transaction, and the workflow UPDATE is an atomic compare-and-swap - // on (lock-owner == me, version == oldVersion). This closes two TOCTOU windows the old - // load→check→SaveChanges left open: (a) a force-unlock + re-lock by another user landing - // between the in-memory EnsureWriteLockAsync check and the write (lock-theft), and (b) a - // concurrent publish/update bumping the version (lost-update). updated==0 ⇒ one of those - // raced us; we re-read to return the right 423/409 verdict. + // Publish through the shared compare-and-swap (see CompareAndSwapWorkflowAsync); the + // setter chain additionally enables the workflow and releases the edit lock. + const string publishConflictMessage = + "Workflow was published concurrently. Reload the workflow and retry publish."; int updated; try { - var strategy = _db.Database.CreateExecutionStrategy(); - updated = await strategy.ExecuteAsync(async () => - { - _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt - await using var tx = await _db.Database.BeginTransactionAsync(ct); - var rows = await _db.Workflows - .Where(w => w.Id == id - && w.FolderId == workflow.FolderId - && w.CheckedOutByUserId == meId - && w.CheckedOutAt == workflow.CheckedOutAt - && w.Version == oldVersion) - .ExecuteUpdateAsync(s => s - .SetProperty(w => w.Name, request.Name) - .SetProperty(w => w.Description, request.Description) - .SetProperty(w => w.DefinitionJson, request.DefinitionJson) - .SetProperty(w => w.Version, oldVersion + 1) - .SetProperty(w => w.IsEnabled, true) - .SetProperty(w => w.PublishedByUserId, meId) - .SetProperty(w => w.CheckedOutByUserId, (Guid?)null) - .SetProperty(w => w.CheckedOutAt, (DateTime?)null) - .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) - .SetProperty(w => w.ActivityCount, computed.ActivityCount) - .SetProperty(w => w.UpdatedAt, now) - .SetProperty(w => w.UpdatedBy, updatedBy), ct); - if (rows == 0) - { - await tx.RollbackAsync(ct); - return 0; - } - _db.WorkflowVersions.Add(new WorkflowVersion + updated = await CompareAndSwapWorkflowAsync( + id, workflow, meId, oldVersion, + s => s + .SetProperty(w => w.Name, request.Name) + .SetProperty(w => w.Description, request.Description) + .SetProperty(w => w.DefinitionJson, request.DefinitionJson) + .SetProperty(w => w.Version, oldVersion + 1) + .SetProperty(w => w.IsEnabled, true) + .SetProperty(w => w.PublishedByUserId, meId) + .SetProperty(w => w.CheckedOutByUserId, (Guid?)null) + .SetProperty(w => w.CheckedOutAt, (DateTime?)null) + .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) + .SetProperty(w => w.ActivityCount, computed.ActivityCount) + .SetProperty(w => w.UpdatedAt, now) + .SetProperty(w => w.UpdatedBy, updatedBy), + () => new WorkflowVersion { Id = Guid.NewGuid(), WorkflowId = id, @@ -486,39 +515,16 @@ public async Task> Publish( DefinitionJson = oldDefinitionJson, CreatedAt = now, CreatedBy = oldCreatedBy ?? updatedBy, - }); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return rows; - }); + }, + ct); } catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) == true) { - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was published concurrently. Reload the workflow and retry publish.", - currentVersion = await _db.Workflows.AsNoTracking() - .Where(w => w.Id == id) - .Select(w => w.Version) - .FirstOrDefaultAsync(ct), - }); + return await VersionUniqueConflictAsync(id, publishConflictMessage, ct); } if (updated == 0) - { - var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); - if (current is null) return NotFound(); - if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) - return deniedNow; - if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was published concurrently. Reload the workflow and retry publish.", - currentVersion = current.Version, - }); - } + return await LostCompareAndSwapAsync(id, publishConflictMessage, ct); var published = await _db.Workflows.AsNoTracking().FirstAsync(w => w.Id == id, ct); await _audit.LogAsync(AuditActions.WorkflowPublished, "Workflow", published.Id, diff --git a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs index 8a4507c7..9c17ad06 100644 --- a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs @@ -44,17 +44,12 @@ public async Task ExportAll(CancellationToken ct) var sw = Stopwatch.StartNew(); // RBAC: export only what the caller may read. Global Admin gets everything. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); + var all = _db.Workflows.AsNoTracking(); // A restricted user with zero accessible folders still gets a (valid, empty) // envelope rather than an early-return — the audit emission below must run for // the empty case too. An attempted catalogue-pull from a viewer who lost their // last grant is exactly the SIEM signal "WORKFLOW_EXPORTED_BULK count=0" is for. - if (!accessible.IsUnrestricted) - { - query = accessible.FolderIds.Count == 0 - ? query.Where(_ => false) - : query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = all.ScopeToAccessibleFolders(accessible) ?? all.Where(_ => false); var workflows = await query .OrderBy(w => w.Name) .ToListAsync(ct); diff --git a/src/NodePilot.Api/Controllers/WorkflowsController.cs b/src/NodePilot.Api/Controllers/WorkflowsController.cs index 82d42849..91ec60ad 100644 --- a/src/NodePilot.Api/Controllers/WorkflowsController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowsController.cs @@ -69,12 +69,8 @@ public async Task> GetContractByName(stri { if (string.IsNullOrWhiteSpace(name)) return NotFound(); var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) return NotFound(); - query = query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (query is null) return NotFound(); var result = await WorkflowNameResolver.ResolveByNameAsync(query, name, ct); if (result.Outcome == WorkflowNameResolver.Outcome.Ambiguous) return Conflict(new { message = $"Multiple workflows named '{name.Trim()}' — disambiguate with the GUID." }); @@ -99,13 +95,9 @@ public async Task>> GetAll(CancellationToken // RBAC list-filter: collapse to "every workflow whose folder I can read". Global // Admin gets the unrestricted set and skips the IN-clause. var accessibleFolders = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessibleFolders.IsUnrestricted) - { - if (accessibleFolders.FolderIds.Count == 0) - return Ok(new List()); - query = query.Where(w => accessibleFolders.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessibleFolders); + if (query is null) + return Ok(new List()); var workflows = await query .OrderByDescending(w => w.UpdatedAt) .Take(HardLimitWorkflows) @@ -319,12 +311,8 @@ public async Task> GetByName(string name, Cancell { if (string.IsNullOrWhiteSpace(name)) return NotFound(); var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) return NotFound(); - query = query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (query is null) return NotFound(); var result = await WorkflowNameResolver.ResolveByNameAsync(query, name, ct); if (result.Outcome == WorkflowNameResolver.Outcome.Ambiguous) return Conflict(new { message = $"Multiple workflows named '{name.Trim()}' — disambiguate with the GUID." }); diff --git a/src/NodePilot.Api/Export/CsvWriter.cs b/src/NodePilot.Api/Export/CsvWriter.cs new file mode 100644 index 00000000..1a0f82ae --- /dev/null +++ b/src/NodePilot.Api/Export/CsvWriter.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace NodePilot.Api.Export; + +/// +/// CSV escaping shared by every controller that streams a table export (audit log, support +/// events). Kept in one place so the two exports cannot drift into different quoting rules — +/// a consumer that parses both would otherwise see the same value escaped two ways. +/// +public static class CsvWriter +{ + /// + /// RFC 4180 minimal CSV escaping: only quote when the value contains a comma, quote, + /// or newline; double internal quotes. NULL and empty render as empty (no quotes). + /// + public static void Field(StringBuilder sb, string? value) + { + if (string.IsNullOrEmpty(value)) return; + var needsQuoting = value.IndexOfAny([',', '"', '\n', '\r']) >= 0; + if (!needsQuoting) { sb.Append(value); return; } + sb.Append('"'); + foreach (var c in value) + { + if (c == '"') sb.Append("\"\""); + else sb.Append(c); + } + sb.Append('"'); + } +} diff --git a/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs b/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs index d3571da0..859f30e0 100644 --- a/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs +++ b/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Enums; using NodePilot.Core.Models; @@ -26,6 +27,43 @@ public bool Matches(SharedFolderPermission permission) : StringComparison.Ordinal); } + /// + /// SQL-translatable grant predicate shared by every folder-permission lookup: the user's own + /// grants plus any group grant whose key and authority both appear in . + /// Coarse on purpose — the authority-exact, per-authority string comparison lives in + /// and has to run in memory over the candidates (see + /// ). A grant written before authority scoping carries an empty + /// PrincipalAuthority and means Active Directory, so the empty string joins the authority list + /// whenever the user holds an AD group. + /// + public static Expression> GrantPredicate( + Guid userId, + IReadOnlyCollection groups) + { + var userKey = userId.ToString("D"); + var groupKeys = groups.Select(group => group.GroupKey).Distinct().ToList(); + var groupAuthorities = groups.Select(group => group.Authority).Distinct().ToList(); + if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) + groupAuthorities.Add(string.Empty); + return permission => + (permission.PrincipalType == FolderPrincipalType.User && permission.PrincipalKey == userKey) + || (permission.PrincipalType == FolderPrincipalType.Group + && groupKeys.Contains(permission.PrincipalKey) + && groupAuthorities.Contains(permission.PrincipalAuthority)); + } + + /// + /// Narrows the candidates returned by to the grants that really + /// apply: user grants pass through, group grants must match one principal exactly. + /// + public static List ExactMatches( + IEnumerable candidates, + IReadOnlyCollection groups) => + candidates + .Where(permission => permission.PrincipalType == FolderPrincipalType.User + || groups.Any(group => group.Matches(permission))) + .ToList(); + public static async Task> LoadAsync( NodePilotDbContext db, User user, diff --git a/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs b/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs new file mode 100644 index 00000000..4a6767e9 --- /dev/null +++ b/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using NodePilot.Core.Models; +using NodePilot.Data; + +namespace NodePilot.Api.Security; + +/// +/// Reconciles the persisted rows of one (user, authority) +/// pair against a desired group set — the single implementation behind the AD login mapper, +/// the directory-sync background pass and the OIDC/SCIM identity mapper. +/// +/// The key comparer is a caller decision and part of the authority's semantics: AD SIDs are +/// compared case-insensitively, OIDC/SCIM group ids are opaque and compared ordinally. Removals +/// use the desired set's own comparer, so the caller controls both halves. +/// +/// +internal static class DirectoryMembershipReconciler +{ + /// + /// Applies onto an already-loaded membership list. Rows of a + /// different authority are left untouched, so a caller may pass every membership of the user. + /// + public static void Apply( + NodePilotDbContext db, + Guid userId, + string authority, + IReadOnlyCollection existing, + IReadOnlySet desired, + DateTime timestamp, + StringComparer keyComparer) + { + var scoped = existing + .Where(membership => string.Equals(membership.Authority, authority, StringComparison.Ordinal)) + .ToList(); + + foreach (var membership in scoped) + { + if (!desired.Contains(membership.GroupKey)) + db.DirectoryMemberships.Remove(membership); + else + membership.LastSeenAt = timestamp; + } + + var existingKeys = scoped.Select(membership => membership.GroupKey).ToHashSet(keyComparer); + foreach (var group in desired.Where(group => !existingKeys.Contains(group))) + { + db.DirectoryMemberships.Add(new DirectoryMembership + { + UserId = userId, + Authority = authority, + GroupKey = group, + LastSeenAt = timestamp, + }); + } + } + + /// Loads the (user, authority) memberships and applies to them. + public static async Task ApplyAsync( + NodePilotDbContext db, + Guid userId, + string authority, + IReadOnlySet desired, + DateTime timestamp, + StringComparer keyComparer, + CancellationToken ct) + { + var existing = await db.DirectoryMemberships + .Where(membership => membership.UserId == userId && membership.Authority == authority) + .ToListAsync(ct); + Apply(db, userId, authority, existing, desired, timestamp, keyComparer); + } +} diff --git a/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs b/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs index 417fae04..8f4d830d 100644 --- a/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs +++ b/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs @@ -227,23 +227,14 @@ private async Task ApplyAttemptAsync( user.KnownGroupSidsJson = JsonSerializer.Serialize(desiredGroups.OrderBy(group => group)); identity.LastSeenAt = snapshot is null ? identity.LastSeenAt : state.AttemptTime; - foreach (var membership in oldGroups) - { - if (!desiredGroups.Contains(membership.GroupKey)) - db.DirectoryMemberships.Remove(membership); - else - membership.LastSeenAt = state.AttemptTime; - } - foreach (var group in desiredGroups.Where(group => !oldGroupKeys.Contains(group))) - { - db.DirectoryMemberships.Add(new DirectoryMembership - { - UserId = user.Id, - Authority = ExternalIdentity.ActiveDirectoryAuthority, - GroupKey = group, - LastSeenAt = state.AttemptTime, - }); - } + DirectoryMembershipReconciler.Apply( + db, + user.Id, + ExternalIdentity.ActiveDirectoryAuthority, + oldGroups, + desiredGroups, + state.AttemptTime, + StringComparer.OrdinalIgnoreCase); IReadOnlyList executionIds = []; if (securityChanged) diff --git a/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs b/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs index cf07d0a8..593a9d7c 100644 --- a/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs +++ b/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs @@ -847,39 +847,19 @@ private async Task SaveMutationWithAuditAsync( await _db.SaveChangesAsync(ct); } - private async Task ReplaceDirectoryMembershipsAsync( + // AD SIDs are compared case-insensitively; the authority is always the AD constant here. + private Task ReplaceDirectoryMembershipsAsync( Guid userId, IReadOnlyCollection groupKeys, - CancellationToken ct) - { - var now = DateTime.UtcNow; - var desired = groupKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); - var existing = await _db.DirectoryMemberships - .Where(m => m.UserId == userId - && m.Authority == ExternalIdentity.ActiveDirectoryAuthority) - .ToListAsync(ct); - - foreach (var membership in existing) - { - if (!desired.Contains(membership.GroupKey)) - _db.DirectoryMemberships.Remove(membership); - else - membership.LastSeenAt = now; - } - - var existingKeys = existing.Select(m => m.GroupKey) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var groupKey in desired.Where(key => !existingKeys.Contains(key))) - { - _db.DirectoryMemberships.Add(new DirectoryMembership - { - UserId = userId, - Authority = ExternalIdentity.ActiveDirectoryAuthority, - GroupKey = groupKey, - LastSeenAt = now, - }); - } - } + CancellationToken ct) => + DirectoryMembershipReconciler.ApplyAsync( + _db, + userId, + ExternalIdentity.ActiveDirectoryAuthority, + groupKeys.ToHashSet(StringComparer.OrdinalIgnoreCase), + DateTime.UtcNow, + StringComparer.OrdinalIgnoreCase, + ct); private static IReadOnlyList NormalizeGroupSids(IEnumerable groupSids) => groupSids diff --git a/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs b/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs index 02c00096..8a1f8b18 100644 --- a/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs +++ b/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs @@ -185,7 +185,9 @@ internal async Task MapAsync(ClaimsPrincipal principal, Cancellat LastSeenAt = now, }); db.Users.Add(user); - await SyncMembershipsAsync(user.Id, issuer!, groups, initialAuthorizationObservedAt, ct); + await DirectoryMembershipReconciler.ApplyAsync( + db, user.Id, issuer!, groups, initialAuthorizationObservedAt, + StringComparer.Ordinal, ct); try { @@ -358,9 +360,9 @@ private async Task ReconcileExistingAsync( { existing.LastDirectorySyncAt = authorizationObservedAt; existing.DirectorySyncStatus = targetAuthorizationStatus; - ApplyMembershipSnapshot( - existing.Id, issuer, groups, authorizationObservedAt!.Value, - oldMemberships); + DirectoryMembershipReconciler.Apply( + db, existing.Id, issuer, oldMemberships, groups, + authorizationObservedAt!.Value, StringComparer.Ordinal); } else { @@ -477,62 +479,6 @@ await ExternalExecutionCancellation.SignalAfterCommitAsync( return result; } - private void ApplyMembershipSnapshot( - Guid userId, - string authority, - IReadOnlySet groups, - DateTime timestamp, - IReadOnlyCollection existing) - { - db.DirectoryMemberships.RemoveRange(existing.Where(membership => - string.Equals(membership.Authority, authority, StringComparison.Ordinal) - && !groups.Contains(membership.GroupKey))); - foreach (var retained in existing.Where(membership => - string.Equals(membership.Authority, authority, StringComparison.Ordinal) - && groups.Contains(membership.GroupKey))) - { - retained.LastSeenAt = timestamp; - } - - var existingKeys = existing - .Where(membership => string.Equals( - membership.Authority, authority, StringComparison.Ordinal)) - .Select(membership => membership.GroupKey) - .ToHashSet(StringComparer.Ordinal); - db.DirectoryMemberships.AddRange(groups - .Where(group => !existingKeys.Contains(group)) - .Select(group => new DirectoryMembership - { - UserId = userId, - Authority = authority, - GroupKey = group, - LastSeenAt = timestamp, - })); - } - - private async Task SyncMembershipsAsync( - Guid userId, - string authority, - IReadOnlySet groups, - DateTime timestamp, - CancellationToken ct) - { - var existing = await db.DirectoryMemberships - .Where(x => x.UserId == userId && x.Authority == authority) - .ToListAsync(ct); - db.DirectoryMemberships.RemoveRange(existing.Where(x => !groups.Contains(x.GroupKey))); - foreach (var retained in existing.Where(x => groups.Contains(x.GroupKey))) - retained.LastSeenAt = timestamp; - var existingKeys = existing.Select(x => x.GroupKey).ToHashSet(StringComparer.Ordinal); - db.DirectoryMemberships.AddRange(groups.Where(x => !existingKeys.Contains(x)).Select(group => new DirectoryMembership - { - UserId = userId, - Authority = authority, - GroupKey = group, - LastSeenAt = timestamp, - })); - } - private static GroupSnapshot ReadGroupSnapshot( ClaimsPrincipal principal, string? claimType) diff --git a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs index b8daba1e..9b784618 100644 --- a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs +++ b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs @@ -91,26 +91,15 @@ public async Task GetAccessibleFolderIdsAsync(ClaimsPrincip // bounded (depth 5 means thousands max even at flat-org-of-thousands scale); the // cost of the in-memory walk is negligible compared to per-row DB hits. var allFolders = await GetAllFoldersAsync(ct); - var userKey = userId.Value.ToString("D"); var directoryGroups = await GetDirectoryGroupsAsync(userId.Value, ct); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); // Group-aware grant lookup: include any (PrincipalType=Group, PrincipalKey IN // userGroupSids) grant alongside the per-user grants. Local users have an empty // groupSids list so this branch is a no-op for them. var candidateGrants = await _db.SharedFolderPermissions .AsNoTracking() - .Where(p => - (p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority))) + .Where(DirectoryGroupPrincipal.GrantPredicate(userId.Value, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups) .Select(permission => new { permission.FolderId, permission.Role }) .ToList(); @@ -250,23 +239,12 @@ private async Task> GetAncestryGrantsAsync( return _ancestryGrantsCache[cacheKey]; } - var userKey = userId.ToString("D"); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); var candidateGrants = await _db.SharedFolderPermissions .AsNoTracking() - .Where(p => chain.Contains(p.FolderId) - && ((p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority)))) + .Where(p => chain.Contains(p.FolderId)) + .Where(DirectoryGroupPrincipal.GrantPredicate(userId, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) - .ToList(); + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups); _ancestryGrantsCache[cacheKey] = grants; return grants; } diff --git a/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs b/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs index 0da370d8..03bf5a42 100644 --- a/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs +++ b/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs @@ -876,30 +876,10 @@ private void ForwardCommittedAudit(AuditLogEntry entry) try { - using (logger.BeginScope(new Dictionary - { - ["support.event_type"] = "AUDIT", - ["support.message"] = $"{entry.Action} user={entry.Username ?? "-"} resource={entry.ResourceType ?? "-"}/{entry.ResourceId?.ToString() ?? "-"} ip={entry.IpAddress ?? "-"}", - ["event.action"] = entry.Action, - ["event.category"] = "iam", - ["event.kind"] = "event", - ["event.outcome"] = "success", - ["event.dataset"] = "nodepilot.audit", - ["event.id"] = entry.Id.ToString(), - ["event.original"] = entry.Details, - ["user.id"] = entry.UserId?.ToString(), - ["user.name"] = entry.Username, - ["source.ip"] = entry.IpAddress, - ["AuditResourceType"] = entry.ResourceType, - ["AuditResourceId"] = entry.ResourceId?.ToString(), - ["SupportLog"] = true, - })) - { - logger.LogInformation( - "AUDIT {Action} user={UserName} resource={ResourceType}/{ResourceId} ip={RemoteIp}", - entry.Action, entry.Username ?? "-", entry.ResourceType ?? "-", - entry.ResourceId?.ToString() ?? "-", entry.IpAddress ?? "-"); - } + // Shared ECS shape (category/outcome/support-log allowlist) — a hand-rolled copy here + // had SCIM_GROUP_* events landing in a different event.category than the same action + // forwarded by any other writer. + AuditEventForwarder.ForwardCommitted(logger, entry); } catch (Exception ex) { diff --git a/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs b/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs index 7d2987cc..860a0e70 100644 --- a/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs +++ b/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs @@ -97,25 +97,15 @@ public SubWorkflowAuthorizationResolver( } if (chain.Count == 0) return $"sub-workflow folder chain unresolvable for '{childWorkflow.Name}'"; - var userKey = effectiveUserId.Value.ToString("D"); // Group-aware grant lookup uses the same normalized, server-side snapshot as HTTP // authorization. Scheduled/triggered work must never depend on group claims from an // old browser token or the legacy JSON cache on User. var directoryGroups = await DirectoryGroupPrincipal.LoadAsync(_db, user, ct); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); var candidateGrants = await _db.SharedFolderPermissions.AsNoTracking() - .Where(p => chain.Contains(p.FolderId) - && ((p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority)))) + .Where(p => chain.Contains(p.FolderId)) + .Where(DirectoryGroupPrincipal.GrantPredicate(effectiveUserId.Value, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups) .Select(permission => permission.Role) .ToList(); if (grants.Count == 0) diff --git a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs index 2fa7abe9..29c052f2 100644 --- a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs +++ b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs @@ -444,34 +444,77 @@ private async Task RestoreDirectoryMembershipsAsync( db.DirectoryMemberships.AddRange(restored); } - private async Task RestoreFoldersAsync(RestoreState s, CancellationToken ct) + private Task RestoreFoldersAsync(RestoreState s, CancellationToken ct) => + RestoreFolderStructureAsync( + s, + BackupSections.Folders, + SharedWorkflowFolder.RootFolderId, + s.Folders, + s.ExistingFolderIds, + s.FolderMap, + // Root is represented by a null ParentFolderId here, and an unresolvable parent stays null. + parentSource => parentSource is null ? null : s.ResolveFolder(parentSource.Value), + FolderTrees.Shared, + folder => db.SharedWorkflowFolders.Add(folder), + ct); + + private Task RestoreGlobalFoldersAsync(RestoreState s, CancellationToken ct) => + RestoreFolderStructureAsync( + s, + BackupSections.GlobalVariableFolders, + GlobalVariableFolder.RootFolderId, + s.GlobalFolders, + s.ExistingGlobalFolderIds, + s.GlobalFolderMap, + // Unlike shared folders, a missing/unresolvable parent lands under the singleton Root. + parentSource => parentSource is null + ? GlobalVariableFolder.RootFolderId + : s.ResolveGlobalFolder(parentSource.Value) ?? GlobalVariableFolder.RootFolderId, + FolderTrees.Global, + folder => db.GlobalVariableFolders.Add(folder), + ct); + + /// + /// Restores one folder tree's structure array. Identical for both folder types + /// (see ); only the root id, the parent-resolution rule + /// and the target DbSet differ. + /// + private async Task RestoreFolderStructureAsync( + RestoreState s, + string section, + Guid rootId, + IDictionary byPath, + HashSet existingIds, + IDictionary folderMap, + Func resolveParent, + FolderTreeShape shape, + Action add, + CancellationToken ct) { - var policy = s.Policy(BackupSections.Folders); + var policy = s.Policy(section); int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var structure = (s.Reader.Sections[BackupSections.Folders] as JsonObject)?["structure"] as JsonArray ?? []; + var structure = (s.Reader.Sections[section] as JsonObject)?["structure"] as JsonArray ?? []; // Id -> restored Path, so a child derives its Path from the *restored* parent Path instead of // the stale backup path. The export orders folders by Depth (parents first), so every parent - // is already in this map when its children are processed. Seeded with the target DB's - // pre-existing folders — an existing folder reused as a parent (Skip policy) must expose its - // current Path to its restored children. Root is represented by a null ParentFolderId, so a - // null parentTarget maps to the "" prefix. Without this, a parent renamed on conflict left its - // children with the old backup Path while their ParentFolderId pointed at the renamed parent - // → inconsistent materialized Path for the whole subtree. - var pathById = new Dictionary(); - foreach (var f in s.Folders.Values) - pathById[f.Id] = f.Path == "/" ? "" : f.Path; - - s.FolderMap[SharedWorkflowFolder.RootFolderId] = SharedWorkflowFolder.RootFolderId; + // is already in this map when its children are processed. Seeded with Root (path prefix "") and + // the target DB's pre-existing folders — an existing folder reused as a parent (Skip policy) + // must expose its current Path to its restored children. Without this, a parent renamed on + // conflict left its children with the old backup Path while their ParentFolderId pointed at the + // renamed parent → inconsistent materialized Path for the whole subtree. + var pathById = new Dictionary { [rootId] = "" }; + foreach (var f in byPath.Values) + pathById[shape.Id(f)] = shape.Path(f) == "/" ? "" : shape.Path(f); + + folderMap[rootId] = rootId; foreach (var item in structure) { var sourceId = Gid(item!["sourceId"]); - if (sourceId == SharedWorkflowFolder.RootFolderId) { skipped++; continue; } // Root is fixed; never recreated. + if (sourceId == rootId) { skipped++; continue; } // Root is fixed; never recreated. var name = item["name"]!.GetValue(); var depth = item["depth"]?.GetValue() ?? 1; - var parentSource = GidN(item["parentFolderId"]); - var parentTarget = parentSource is null ? (Guid?)null : s.ResolveFolder(parentSource.Value); + var parentTarget = resolveParent(GidN(item["parentFolderId"])); var createdBy = ResolveUserOrNull(s, GidN(item["createdByUserId"])); // remaps the folder-creator's user id; null if it can't be resolved (K17) // Recompute the Path from the parent's restored Path + this folder's name. The backup path @@ -481,190 +524,174 @@ private async Task RestoreFoldersAsync(RestoreState s, Can var parentPath = parentTarget is null ? "" : pathById.GetValueOrDefault(parentTarget.Value, ""); var path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - if (s.Folders.TryGetValue(path, out var existing)) + if (byPath.TryGetValue(path, out var existing)) { - if (policy == RestoreConflictPolicy.Skip) { s.FolderMap[sourceId] = existing.Id; skipped++; continue; } + if (policy == RestoreConflictPolicy.Skip) { folderMap[sourceId] = shape.Id(existing); skipped++; continue; } if (policy == RestoreConflictPolicy.Overwrite) { - existing.Name = name; existing.ParentFolderId = parentTarget; existing.Depth = depth; existing.Path = path; existing.CreatedByUserId = createdBy; - pathById[existing.Id] = path; - s.FolderMap[sourceId] = existing.Id; overwritten++; continue; + shape.Apply(existing, name, path, depth, parentTarget, createdBy); + pathById[shape.Id(existing)] = path; + folderMap[sourceId] = shape.Id(existing); overwritten++; continue; } // Rename: the DB enforces unique(ParentFolderId, Name), so we MUST give the new // folder a sibling-unique name and recompute the Path from it so the in-memory lookup // key tracks the actual stored Path. var siblingNames = new HashSet( - s.Folders.Values.Where(f => f.ParentFolderId == parentTarget).Select(f => f.Name), StringComparer.Ordinal); + byPath.Values.Where(f => shape.ParentId(f) == parentTarget).Select(shape.Name), StringComparer.Ordinal); name = UniqueName(name, siblingNames); path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; renamed++; } else created++; - var id = s.ExistingFolderIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var folder = new SharedWorkflowFolder - { - Id = id, Name = name, Path = path, Depth = depth, ParentFolderId = parentTarget, CreatedByUserId = createdBy, - }; - db.SharedWorkflowFolders.Add(folder); - s.Folders[path] = folder; s.ExistingFolderIds.Add(id); s.FolderMap[sourceId] = id; + var id = existingIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; + var folder = shape.New(id); + shape.Apply(folder, name, path, depth, parentTarget, createdBy); + add(folder); + byPath[path] = folder; existingIds.Add(id); folderMap[sourceId] = id; pathById[id] = path; } await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Folders, created, overwritten, skipped, renamed); + return new SectionRestoreResult(section, created, overwritten, skipped, renamed); } - private async Task RestoreCredentialsAsync(RestoreState s, CancellationToken ct) + /// + /// One parsed item of a by-name section: its conflict key, the backup's source id, and the two + /// section-specific writes — apply onto the existing row, or materialize a new one under the + /// (possibly renamed) name. + /// + private sealed record NamedRestoreItem( + string Name, + Guid SourceId, + Action Overwrite, + Func Create); + + /// + /// The by-name conflict algorithm every "named row" section shares: counters, the taken-name + /// set, the Skip/Overwrite/Rename branch, the source-id collision remap (K3) and the section + /// result. parses an item BEFORE the conflict check — a section may + /// reject an item outright (decryption/validation) whatever the policy says, and it must see + /// the item's original name. + /// + private async Task RestoreNamedSectionAsync( + RestoreState s, + string section, + IDictionary byName, + Func idOf, + HashSet existingIds, + IDictionary? idMap, + Action add, + Func> read, + CancellationToken ct, + bool preserveSourceIds = true) { - var policy = s.Policy(BackupSections.Credentials); + var policy = s.Policy(section); int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Credentials.Keys, StringComparer.Ordinal); + var takenNames = new HashSet(byName.Keys, StringComparer.Ordinal); - foreach (var item in Items(s.Reader, BackupSections.Credentials)) + foreach (var node in Items(s.Reader, section)) { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var username = item["username"]?.GetValue() ?? ""; - var domain = item["domain"]?.GetValue(); - var expiresAt = DateTime.TryParse( - item["expiresAt"]?.GetValue(), null, - System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, - out var exp) ? exp : (DateTime?)null; - byte[] encrypted = EncryptedPasswordFor(item, s, name); - - if (s.Credentials.TryGetValue(name, out var existing)) + var item = read(node!); + var name = item.Name; + + if (byName.TryGetValue(name, out var existing)) { - if (policy == RestoreConflictPolicy.Skip) { s.CredentialMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) + if (policy == RestoreConflictPolicy.Skip) { - existing.Username = username; existing.Domain = domain; existing.EncryptedPassword = encrypted; - existing.ExpiresAt = expiresAt; - s.CredentialMap[sourceId] = existing.Id; overwritten++; continue; + if (idMap is not null) idMap[item.SourceId] = idOf(existing); + skipped++; continue; } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = s.ExistingCredentialIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var cred = new Credential { Id = id, Name = name, Username = username, Domain = domain, EncryptedPassword = encrypted, ExpiresAt = expiresAt }; - db.Credentials.Add(cred); - s.Credentials[name] = cred; s.ExistingCredentialIds.Add(id); s.CredentialMap[sourceId] = id; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Credentials, created, overwritten, skipped, renamed); - } - - private async Task RestoreMachinesAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Machines); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Machines.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Machines)) - { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var hostname = item["hostname"]?.GetValue() ?? ""; - var winRmPort = item["winRmPort"]?.GetValue() ?? 5985; - var useSsl = item["useSsl"]?.GetValue() ?? false; - var tags = item["tags"]?.GetValue(); - var credSource = GidN(item["defaultCredentialId"]); - var credTarget = credSource is null ? (Guid?)null : s.ResolveCredential(credSource.Value); - - if (s.Machines.TryGetValue(name, out var existing)) - { - if (policy == RestoreConflictPolicy.Skip) { s.MachineMap[sourceId] = existing.Id; skipped++; continue; } if (policy == RestoreConflictPolicy.Overwrite) { - existing.Hostname = hostname; existing.WinRmPort = winRmPort; existing.UseSsl = useSsl; - existing.Tags = tags; existing.DefaultCredentialId = credTarget; - s.MachineMap[sourceId] = existing.Id; overwritten++; continue; + item.Overwrite(existing); + if (idMap is not null) idMap[item.SourceId] = idOf(existing); + overwritten++; continue; } name = UniqueName(name, takenNames); renamed++; } else created++; takenNames.Add(name); - var id = s.ExistingMachineIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var machine = new ManagedMachine - { - Id = id, Name = name, Hostname = hostname, WinRmPort = winRmPort, UseSsl = useSsl, - Tags = tags, DefaultCredentialId = credTarget, - }; - db.ManagedMachines.Add(machine); - s.Machines[name] = machine; s.ExistingMachineIds.Add(id); s.MachineMap[sourceId] = id; + var id = preserveSourceIds && !existingIds.Contains(item.SourceId) ? item.SourceId : Guid.NewGuid(); + var entity = item.Create(id, name); + add(entity); + byName[name] = entity; existingIds.Add(id); + if (idMap is not null) idMap[item.SourceId] = id; } await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Machines, created, overwritten, skipped, renamed); + return new SectionRestoreResult(section, created, overwritten, skipped, renamed); } - private async Task RestoreGlobalFoldersAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.GlobalVariableFolders); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var structure = (s.Reader.Sections[BackupSections.GlobalVariableFolders] as JsonObject)?["structure"] as JsonArray ?? []; - - // Id -> restored Path, so a child derives its Path from the *restored* parent Path instead of - // the stale backup path. The export orders folders by Depth (parents first), so every parent - // is already in this map when its children are processed. Seeded with Root (path prefix "") and - // the target DB's pre-existing folders — an existing folder reused as a parent (Skip policy) - // must expose its current Path to its restored children. Without this, a parent renamed on - // conflict left its children with the old backup Path while their ParentFolderId pointed at the - // renamed parent → inconsistent materialized Path for the whole subtree. - var pathById = new Dictionary { [GlobalVariableFolder.RootFolderId] = "" }; - foreach (var f in s.GlobalFolders.Values) - pathById[f.Id] = f.Path == "/" ? "" : f.Path; - - s.GlobalFolderMap[GlobalVariableFolder.RootFolderId] = GlobalVariableFolder.RootFolderId; - foreach (var item in structure) - { - var sourceId = Gid(item!["sourceId"]); - if (sourceId == GlobalVariableFolder.RootFolderId) { skipped++; continue; } // Root is fixed; never recreated. - - var name = item["name"]!.GetValue(); - var depth = item["depth"]?.GetValue() ?? 1; - var parentSource = GidN(item["parentFolderId"]); - var parentTarget = parentSource is null ? GlobalVariableFolder.RootFolderId : (s.ResolveGlobalFolder(parentSource.Value) ?? GlobalVariableFolder.RootFolderId); - var createdBy = ResolveUserOrNull(s, GidN(item["createdByUserId"])); - - // Recompute the Path from the parent's restored Path + this folder's name. The backup path - // is only a serialization hint; the stored Path must follow the actual parent chain (which - // may have been renamed above). Conflict detection runs on this recomputed path so a folder - // clashes with whatever already lives at its true target position. - var parentPath = pathById.GetValueOrDefault(parentTarget, ""); - var path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - - if (s.GlobalFolders.TryGetValue(path, out var existing)) + private Task RestoreCredentialsAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Credentials, + s.Credentials, + credential => credential.Id, + s.ExistingCredentialIds, + s.CredentialMap, + credential => db.Credentials.Add(credential), + item => { - if (policy == RestoreConflictPolicy.Skip) { s.GlobalFolderMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - existing.Name = name; existing.ParentFolderId = parentTarget; existing.Depth = depth; existing.Path = path; existing.CreatedByUserId = createdBy; - pathById[existing.Id] = path; - s.GlobalFolderMap[sourceId] = existing.Id; overwritten++; continue; - } - // Rename: unique(ParentFolderId, Name) forces a sibling-unique name; recompute the Path from it. - var siblingNames = new HashSet( - s.GlobalFolders.Values.Where(f => f.ParentFolderId == parentTarget).Select(f => f.Name), StringComparer.Ordinal); - name = UniqueName(name, siblingNames); - path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - renamed++; - } - else created++; - - var id = s.ExistingGlobalFolderIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var folder = new GlobalVariableFolder + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var username = item["username"]?.GetValue() ?? ""; + var domain = item["domain"]?.GetValue(); + var expiresAt = DateTime.TryParse( + item["expiresAt"]?.GetValue(), null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var exp) ? exp : (DateTime?)null; + byte[] encrypted = EncryptedPasswordFor(item, s, name); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + existing.Username = username; existing.Domain = domain; existing.EncryptedPassword = encrypted; + existing.ExpiresAt = expiresAt; + }, + (id, finalName) => new Credential + { + Id = id, Name = finalName, Username = username, Domain = domain, + EncryptedPassword = encrypted, ExpiresAt = expiresAt, + }); + }, + ct); + + private Task RestoreMachinesAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Machines, + s.Machines, + machine => machine.Id, + s.ExistingMachineIds, + s.MachineMap, + machine => db.ManagedMachines.Add(machine), + item => { - Id = id, Name = name, Path = path, Depth = depth, ParentFolderId = parentTarget, CreatedByUserId = createdBy, - }; - db.GlobalVariableFolders.Add(folder); - s.GlobalFolders[path] = folder; s.ExistingGlobalFolderIds.Add(id); s.GlobalFolderMap[sourceId] = id; - pathById[id] = path; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.GlobalVariableFolders, created, overwritten, skipped, renamed); - } + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var hostname = item["hostname"]?.GetValue() ?? ""; + var winRmPort = item["winRmPort"]?.GetValue() ?? 5985; + var useSsl = item["useSsl"]?.GetValue() ?? false; + var tags = item["tags"]?.GetValue(); + var credSource = GidN(item["defaultCredentialId"]); + var credTarget = credSource is null ? (Guid?)null : s.ResolveCredential(credSource.Value); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + existing.Hostname = hostname; existing.WinRmPort = winRmPort; existing.UseSsl = useSsl; + existing.Tags = tags; existing.DefaultCredentialId = credTarget; + }, + (id, finalName) => new ManagedMachine + { + Id = id, Name = finalName, Hostname = hostname, WinRmPort = winRmPort, UseSsl = useSsl, + Tags = tags, DefaultCredentialId = credTarget, + }); + }, + ct); private async Task RestoreGlobalsAsync(RestoreState s, CancellationToken ct) { @@ -770,94 +797,83 @@ private static void ApplyCustomActivityFields(CustomActivityDefinition def, Json def.Version = item["version"]?.GetValue() ?? 1; } - private async Task RestoreWorkflowsAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Workflows); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Workflows.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Workflows)) - { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var description = item["description"]?.GetValue(); - var isEnabled = item["isEnabled"]?.GetValue() ?? false; - var version = item["version"]?.GetValue() ?? 1; - var folderTarget = s.ResolveFolder(GidN(item["folderId"]) ?? SharedWorkflowFolder.RootFolderId) - ?? SharedWorkflowFolder.RootFolderId; - var definitionJson = RestoreDefinitionJson(item["definition"], s); - - if (s.Workflows.TryGetValue(name, out var existing)) + private Task RestoreWorkflowsAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Workflows, + s.Workflows, + workflow => workflow.Id, + s.ExistingWorkflowIds, + s.WorkflowMap, + workflow => db.Workflows.Add(workflow), + item => { - if (policy == RestoreConflictPolicy.Skip) { s.WorkflowMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - 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; - s.WorkflowMap[sourceId] = existing.Id; overwritten++; continue; - } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = s.ExistingWorkflowIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var wf = new Workflow - { - Id = id, Name = name, Description = description, DefinitionJson = definitionJson, - Version = version, IsEnabled = isEnabled, FolderId = folderTarget, - CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, - }; - db.Workflows.Add(wf); - s.Workflows[name] = wf; s.ExistingWorkflowIds.Add(id); s.WorkflowMap[sourceId] = id; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Workflows, created, overwritten, skipped, renamed); - } - - private async Task RestoreAlertingAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Alerting); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.NotificationRules.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Alerting)) - { - var name = item!["name"]!.GetValue(); - var kind = Enum.TryParse(item["kind"]?.GetValue(), out var k) ? k : NotificationRuleKind.Custom; - var isEnabled = item["isEnabled"]?.GetValue() ?? false; - - if (s.NotificationRules.TryGetValue(name, out var existing)) + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var description = item["description"]?.GetValue(); + var isEnabled = item["isEnabled"]?.GetValue() ?? false; + var version = item["version"]?.GetValue() ?? 1; + var folderTarget = s.ResolveFolder(GidN(item["folderId"]) ?? SharedWorkflowFolder.RootFolderId) + ?? SharedWorkflowFolder.RootFolderId; + var definitionJson = RestoreDefinitionJson(item["definition"], s); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + 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; + }, + (id, finalName) => new Workflow + { + Id = id, Name = finalName, Description = description, DefinitionJson = definitionJson, + Version = version, IsEnabled = isEnabled, FolderId = folderTarget, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + }, + ct); + + // Alerting rules carry no source-id map: a restored rule always gets a fresh id, and nothing + // in the envelope references a rule by id. + private Task RestoreAlertingAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Alerting, + s.NotificationRules, + rule => rule.Id, + s.ExistingNotificationRuleIds, + idMap: null, + rule => db.NotificationRules.Add(rule), + item => { - if (policy == RestoreConflictPolicy.Skip) { skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - ApplyRuleScalars(existing, item, kind, isEnabled); - db.NotificationRoutes.RemoveRange(existing.Routes); - db.NotificationRuleTargets.RemoveRange(existing.Targets); - foreach (var r in RestoredRoutes(item, s, existing.Id)) db.NotificationRoutes.Add(r); - foreach (var tg in RestoredTargets(item, s, existing.Id)) db.NotificationRuleTargets.Add(tg); - overwritten++; continue; - } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = Guid.NewGuid(); - var rule = new NotificationRule { Id = id, Name = name }; - ApplyRuleScalars(rule, item, kind, isEnabled); - rule.Routes = RestoredRoutes(item, s, id); - rule.Targets = RestoredTargets(item, s, id); - db.NotificationRules.Add(rule); - s.NotificationRules[name] = rule; s.ExistingNotificationRuleIds.Add(id); - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Alerting, created, overwritten, skipped, renamed); - } + var name = item["name"]!.GetValue(); + var kind = Enum.TryParse(item["kind"]?.GetValue(), out var k) ? k : NotificationRuleKind.Custom; + var isEnabled = item["isEnabled"]?.GetValue() ?? false; + return new NamedRestoreItem( + name, + Guid.Empty, + existing => + { + ApplyRuleScalars(existing, item, kind, isEnabled); + db.NotificationRoutes.RemoveRange(existing.Routes); + db.NotificationRuleTargets.RemoveRange(existing.Targets); + foreach (var r in RestoredRoutes(item, s, existing.Id)) db.NotificationRoutes.Add(r); + foreach (var tg in RestoredTargets(item, s, existing.Id)) db.NotificationRuleTargets.Add(tg); + }, + (id, finalName) => + { + var rule = new NotificationRule { Id = id, Name = finalName }; + ApplyRuleScalars(rule, item, kind, isEnabled); + rule.Routes = RestoredRoutes(item, s, id); + rule.Targets = RestoredTargets(item, s, id); + return rule; + }); + }, + ct, + preserveSourceIds: false); private static void ApplyRuleScalars(NotificationRule rule, JsonNode item, NotificationRuleKind kind, bool isEnabled) { diff --git a/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs b/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs new file mode 100644 index 00000000..388cd723 --- /dev/null +++ b/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; +using NodePilot.Core.Models; + +namespace NodePilot.Api.Services.Backup; + +/// +/// Accessors over the two structurally identical folder trees — +/// and . Both carry Id / ParentFolderId / Name / Path / Depth / +/// CreatedByUserId with the same semantics (singleton Root, materialized Path, sibling-unique Name), +/// but they share no interface in NodePilot.Core, so backup export and restore reach the fields +/// through delegates. Core follow-up: give both entities one folder-node interface and delete this +/// shim. Apply writes the mutable columns in one go: name, path, depth, parent id, creator. +/// +internal sealed record FolderTreeShape( + Func Id, + Func ParentId, + Func Name, + Func Path, + Func Depth, + Func CreatedBy, + Func New, + Action Apply); + +/// The concrete shapes plus the export projection both folder sections share. +internal static class FolderTrees +{ + public static readonly FolderTreeShape Shared = new( + folder => folder.Id, + folder => folder.ParentFolderId, + folder => folder.Name, + folder => folder.Path, + folder => folder.Depth, + folder => folder.CreatedByUserId, + id => new SharedWorkflowFolder { Id = id }, + (folder, name, path, depth, parentId, createdBy) => + { + folder.Name = name; + folder.Path = path; + folder.Depth = depth; + folder.ParentFolderId = parentId; + folder.CreatedByUserId = createdBy; + }); + + public static readonly FolderTreeShape Global = new( + folder => folder.Id, + folder => folder.ParentFolderId, + folder => folder.Name, + folder => folder.Path, + folder => folder.Depth, + folder => folder.CreatedByUserId, + id => new GlobalVariableFolder { Id = id }, + (folder, name, path, depth, parentId, createdBy) => + { + folder.Name = name; + folder.Path = path; + folder.Depth = depth; + folder.ParentFolderId = parentId; + folder.CreatedByUserId = createdBy; + }); + + /// + /// The structure array of a folder section. The caller supplies the folders already + /// ordered by Depth then Name — restore relies on every parent preceding its children. + /// + public static JsonArray Structure( + IEnumerable folders, + FolderTreeShape shape) + { + var structure = new JsonArray(); + foreach (var f in folders) + { + structure.Add(new JsonObject + { + ["sourceId"] = shape.Id(f).ToString(), + ["parentFolderId"] = shape.ParentId(f)?.ToString(), + ["name"] = shape.Name(f), + ["path"] = shape.Path(f), + ["depth"] = shape.Depth(f), + ["createdByUserId"] = shape.CreatedBy(f)?.ToString(), + }); + } + return structure; + } +} diff --git a/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs index 9090e753..3c075d60 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs @@ -26,19 +26,7 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok var folders = await db.SharedWorkflowFolders.AsNoTracking().OrderBy(f => f.Depth).ThenBy(f => f.Name).ToListAsync(ct); var grants = await db.SharedFolderPermissions.AsNoTracking().ToListAsync(ct); - var structure = new JsonArray(); - foreach (var f in folders) - { - structure.Add(new JsonObject - { - ["sourceId"] = f.Id.ToString(), - ["parentFolderId"] = f.ParentFolderId?.ToString(), - ["name"] = f.Name, - ["path"] = f.Path, - ["depth"] = f.Depth, - ["createdByUserId"] = f.CreatedByUserId?.ToString(), - }); - } + var structure = FolderTrees.Structure(folders, FolderTrees.Shared); var grantArr = new JsonArray(); foreach (var g in grants) diff --git a/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs index 63151e42..e9025cc1 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs @@ -22,20 +22,9 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok var folders = await db.GlobalVariableFolders.AsNoTracking() .OrderBy(f => f.Depth).ThenBy(f => f.Name).ToListAsync(ct); - var structure = new JsonArray(); - foreach (var f in folders) + return new JsonObject { - structure.Add(new JsonObject - { - ["sourceId"] = f.Id.ToString(), - ["parentFolderId"] = f.ParentFolderId?.ToString(), - ["name"] = f.Name, - ["path"] = f.Path, - ["depth"] = f.Depth, - ["createdByUserId"] = f.CreatedByUserId?.ToString(), - }); - } - - return new JsonObject { ["structure"] = structure }; + ["structure"] = FolderTrees.Structure(folders, FolderTrees.Global), + }; } } diff --git a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs index 21566e25..19a4d830 100644 --- a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs +++ b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs @@ -7,7 +7,7 @@ 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 all three modes so the +/// () drives both modes so the /// contextual "share one workflow" export and the system backup never disagree about what's a secret. /// public enum SecretHandling @@ -18,9 +18,6 @@ public enum SecretHandling /// Replace secret values with an {"$enc":"<base64>"} object encrypted under /// the backup passphrase — the DR backup path. EncryptForBackup, - - /// Leave values untouched. Internal/test only — never sent over the wire. - PlainInternal, } /// @@ -58,7 +55,7 @@ public static JsonNode Rewrite(JsonElement root, SecretHandling handling, Passph var node = JsonNode.Parse(root.GetRawText()) ?? throw new InvalidOperationException("Workflow definition is not valid JSON."); - return Walk(node, parentName: null, isHttpHeaderValue: false, handling, protector); + return Walk(node, parentName: null, isHttpHeaderValue: false, protector); } /// @@ -130,8 +127,10 @@ private static JsonNode RestoreWalk( } } + // 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, - SecretHandling handling, PassphraseSecretProtector? protector) + PassphraseSecretProtector? protector) { switch (node) { @@ -140,14 +139,14 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade 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, handling, protector); + 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, handling, protector)); + 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: @@ -155,14 +154,9 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade if (!NodePilot.Core.WorkflowDefinitions.WorkflowSecretKeys.IsSecretValue(parentName, s, isHttpHeaderValue)) return JsonValue.Create(s); - return handling switch + return new JsonObject { - SecretHandling.Redact => JsonValue.Create("***"), - SecretHandling.EncryptForBackup => new JsonObject - { - [EncKey] = Convert.ToBase64String(protector!.Protect(s)), - }, - _ => JsonValue.Create(s), // PlainInternal + [EncKey] = Convert.ToBase64String(protector!.Protect(s)), }; } default: diff --git a/src/NodePilot.Cli/Api/ApiException.cs b/src/NodePilot.Cli/Api/ApiException.cs deleted file mode 100644 index 43acbe23..00000000 --- a/src/NodePilot.Cli/Api/ApiException.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Net; - -namespace NodePilot.Cli.Api; - -/// -/// Thrown by on every non-2xx response. Captures both -/// the HTTP status (so commands can branch on 401/403/423) and the parsed -/// ProblemDetails payload when the server returned one. -/// -public sealed class ApiException : Exception -{ - public HttpStatusCode StatusCode { get; } - public string? Title { get; } - public string? Detail { get; } - public string? RawBody { get; } - - public ApiException(HttpStatusCode statusCode, string? title, string? detail, string? rawBody) - : base(BuildMessage(statusCode, title, detail, rawBody)) - { - StatusCode = statusCode; - Title = title; - Detail = detail; - RawBody = rawBody; - } - - private static string BuildMessage(HttpStatusCode status, string? title, string? detail, string? body) - { - var label = title ?? status.ToString(); - if (!string.IsNullOrWhiteSpace(detail)) return $"{(int)status} {label}: {detail}"; - if (!string.IsNullOrWhiteSpace(body) && body.Length < 400) return $"{(int)status} {label}: {body}"; - return $"{(int)status} {label}"; - } - - /// True for 401 — caller must re-authenticate. - public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized; - - /// True for 403 — caller authenticated but lacks the required role. - public bool IsForbidden => StatusCode == HttpStatusCode.Forbidden; - - /// True for 423 — workflow is checked out by another user. - public bool IsLocked => (int)StatusCode == 423; - - /// True for 409 — conflicting state (e.g. lock contention, idempotency). - public bool IsConflict => StatusCode == HttpStatusCode.Conflict; -} diff --git a/src/NodePilot.Cli/Api/NodePilotApiClient.cs b/src/NodePilot.Cli/Api/NodePilotApiClient.cs index b93ea452..bffc234e 100644 --- a/src/NodePilot.Cli/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Cli/Api/NodePilotApiClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using NodePilot.Cli.Api.Dtos; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Api; @@ -1008,33 +1009,10 @@ public async Task ExecuteDbAdminQueryAsync(string sql, // ---- Plumbing ----------------------------------------------------------- - private static async Task ParseAsync(HttpResponseMessage res, CancellationToken ct) - { - await EnsureSuccessAsync(res, ct); - if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) - return default!; - var stream = await res.Content.ReadAsStreamAsync(ct); - var value = await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); - if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); - return value; - } + // Response plumbing is shared with the MCP client — see ApiResponseReader in Core. + private static Task ParseAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.ParseAsync(res, JsonOptions, ct); - private static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) - { - if (res.IsSuccessStatusCode) return; - var body = await res.Content.ReadAsStringAsync(ct); - string? title = null, detail = null; - if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); - if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); - if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); - } - catch (JsonException) { /* leave body as raw */ } - } - throw new ApiException(res.StatusCode, title, detail, body); - } + private static Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.EnsureSuccessAsync(res, ct); } diff --git a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs index dce5af0e..12e6ab9d 100644 --- a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs +++ b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs @@ -4,6 +4,7 @@ using System.Runtime.Versioning; using NodePilot.Cli.Api.Dtos; using NodePilot.Cli.Auth; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Api; diff --git a/src/NodePilot.Cli/CLAUDE.md b/src/NodePilot.Cli/CLAUDE.md index 1f78d896..8fec47d5 100644 --- a/src/NodePilot.Cli/CLAUDE.md +++ b/src/NodePilot.Cli/CLAUDE.md @@ -5,3 +5,5 @@ Gilt für `src/NodePilot.Cli/`. Projektweite Regeln stehen in der Root-`CLAUDE.m Reiner HTTP-Client gegen die REST-Endpoints (Spectre.Console.Cli), ausgeliefert per `dotnet publish` — **kein** `dotnet global tool` (`PackAsTool` × `net10.0-windows` = NETSDK1146). Befehlsbereiche: `auth`, `workflow`, `exec`, `machine`, `credential`, `globals`, `user`, `shared-folder`, `maintenance`, `alerting`, `system-alert`, `audit`, `backup`, `db`, `cron`, `health`, `dashboard`, `operations`, `observability`, `settings`, `secrets`, `config`. Details: `docs/claude-reference.md`. **Architektur-Konvention:** Neuer API-Endpoint → parallel Methode in `NodePilotApiClient.cs` + Command anlegen. DTOs in `Cli/Api/Dtos/` duplizieren (kein ProjectReference). + +**Geteilte Client-Infrastruktur:** `ApiException`, das Response-Plumbing (`ApiResponseReader`) und die Lese-Seite der `config.json` (`ClientConfigStore` + `CliConfig`) liegen in `NodePilot.Core.Clients` — gemeinsam mit dem MCP-Server. **Nur die DTOs bleiben bewusst dupliziert** (siehe oben, `ApiDtoParityTests`); neue Infrastruktur nicht erneut kopieren. diff --git a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs index de6de210..041add5e 100644 --- a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs +++ b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs @@ -5,6 +5,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console; using Spectre.Console.Cli; diff --git a/src/NodePilot.Cli/Commands/BaseCommand.cs b/src/NodePilot.Cli/Commands/BaseCommand.cs index 3c0d840a..e79c9c76 100644 --- a/src/NodePilot.Cli/Commands/BaseCommand.cs +++ b/src/NodePilot.Cli/Commands/BaseCommand.cs @@ -3,6 +3,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console.Cli; namespace NodePilot.Cli.Commands; diff --git a/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs b/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs index aab7335c..caba26a2 100644 --- a/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs +++ b/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs @@ -2,6 +2,7 @@ using System.Runtime.Versioning; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console; using Spectre.Console.Cli; diff --git a/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs b/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs index 1cf32035..a9f91eee 100644 --- a/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs +++ b/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs @@ -4,6 +4,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console.Cli; namespace NodePilot.Cli.Commands.Workflow; diff --git a/src/NodePilot.Cli/Settings/ConfigStore.cs b/src/NodePilot.Cli/Settings/ConfigStore.cs index e3309137..9c2d5572 100644 --- a/src/NodePilot.Cli/Settings/ConfigStore.cs +++ b/src/NodePilot.Cli/Settings/ConfigStore.cs @@ -1,54 +1,29 @@ using System.Text.Json; using System.Text.Json.Serialization; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Settings; /// /// Plain-JSON config under %APPDATA%\NodePilot\config.json. Holds non-secret connection /// settings only — tokens live in Auth/TokenStore (DPAPI-encrypted) so a config -/// backup never carries a usable session. +/// backup never carries a usable session. The CLI is the only writer; the read side +/// (path, Load, CliConfig) lives in so the +/// MCP server reads exactly the same file the same way. /// -public sealed class ConfigStore +public sealed class ConfigStore : ClientConfigStore { + // Write-side only: indentation and null-skipping shape the file we emit. Reading goes + // through the base store, which needs neither. private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - public string ConfigDir { get; } - public string ConfigPath => Path.Combine(ConfigDir, "config.json"); + public ConfigStore() : base(DefaultConfigDir()) { } - public ConfigStore() : this(DefaultConfigDir()) { } - - public ConfigStore(string configDir) - { - ConfigDir = configDir; - Directory.CreateDirectory(ConfigDir); - } - - public static string DefaultConfigDir() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - return Path.Combine(appData, "NodePilot"); - } - - public CliConfig Load() - { - if (!File.Exists(ConfigPath)) - return new CliConfig(); - try - { - using var stream = File.OpenRead(ConfigPath); - return JsonSerializer.Deserialize(stream, JsonOptions) ?? new CliConfig(); - } - catch (JsonException) - { - // Corrupt config → treat as empty rather than blocking the user. They can - // always `np config set server …` to repair, and `np auth login` re-creates. - return new CliConfig(); - } - } + public ConfigStore(string configDir) : base(configDir) { } public void Save(CliConfig config) { @@ -78,14 +53,3 @@ public string ResolveProfileName(string? requested, CliConfig? config = null) return null; } } - -public sealed class CliConfig -{ - public string DefaultProfile { get; set; } = "default"; - public Dictionary Profiles { get; set; } = new(StringComparer.OrdinalIgnoreCase); -} - -public sealed class ProfileEntry -{ - public string? Server { get; set; } -} diff --git a/src/NodePilot.Mcp/Api/ApiException.cs b/src/NodePilot.Core/Clients/ApiException.cs similarity index 79% rename from src/NodePilot.Mcp/Api/ApiException.cs rename to src/NodePilot.Core/Clients/ApiException.cs index d6422b39..b1f40581 100644 --- a/src/NodePilot.Mcp/Api/ApiException.cs +++ b/src/NodePilot.Core/Clients/ApiException.cs @@ -1,11 +1,13 @@ using System.Net; -namespace NodePilot.Mcp.Api; +namespace NodePilot.Core.Clients; /// -/// Thrown by on every non-2xx response. Captures both -/// the HTTP status (so tools can branch on 401/403/409/423) and the parsed -/// ProblemDetails payload when the server returned one. Copied from the CLI client. +/// Thrown by the HTTP-only clients (the np CLI and the nodepilot-mcp server) +/// on every non-2xx response. Captures both the HTTP status (so commands and tools can +/// branch on 401/403/404/409/423) and the parsed ProblemDetails payload when the +/// server returned one. Shared here for the same reason as +/// : the two executables must not drift apart. /// public sealed class ApiException : Exception { diff --git a/src/NodePilot.Core/Clients/ApiResponseReader.cs b/src/NodePilot.Core/Clients/ApiResponseReader.cs new file mode 100644 index 00000000..e144f64f --- /dev/null +++ b/src/NodePilot.Core/Clients/ApiResponseReader.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Text.Json; + +namespace NodePilot.Core.Clients; + +/// +/// Response plumbing shared by the two HTTP-only clients: every non-2xx becomes an +/// carrying the ProblemDetails title/detail, so callers +/// branch on a single exception type. Each client passes its own +/// — the deserializer settings stay client-owned. +/// +public static class ApiResponseReader +{ + public static async Task ParseAsync(HttpResponseMessage res, JsonSerializerOptions jsonOptions, CancellationToken ct) + { + await EnsureSuccessAsync(res, ct); + if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) + return default!; + var stream = await res.Content.ReadAsStreamAsync(ct); + var value = await JsonSerializer.DeserializeAsync(stream, jsonOptions, ct); + if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); + return value; + } + + public static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + { + if (res.IsSuccessStatusCode) return; + var body = await res.Content.ReadAsStringAsync(ct); + string? title = null, detail = null; + if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); + if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); + if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); + } + catch (JsonException) { /* leave body as raw */ } + } + throw new ApiException(res.StatusCode, title, detail, body); + } +} diff --git a/src/NodePilot.Mcp/Config/ConfigStore.cs b/src/NodePilot.Core/Clients/ClientConfigStore.cs similarity index 57% rename from src/NodePilot.Mcp/Config/ConfigStore.cs rename to src/NodePilot.Core/Clients/ClientConfigStore.cs index c8589f58..b684046b 100644 --- a/src/NodePilot.Mcp/Config/ConfigStore.cs +++ b/src/NodePilot.Core/Clients/ClientConfigStore.cs @@ -1,22 +1,24 @@ using System.Text.Json; -namespace NodePilot.Mcp.Config; +namespace NodePilot.Core.Clients; /// -/// Reads the same %APPDATA%\NodePilot\config.json the np CLI writes, so the -/// MCP server can fall back to a CLI-configured profile server URL. Read-only here — the -/// MCP server never writes config; the operator manages it via np config. +/// Reads the plain-JSON config under %APPDATA%\NodePilot\config.json that both +/// HTTP-only clients share: the np CLI owns it (its ConfigStore adds the write +/// side), the nodepilot-mcp server only reads it to fall back to a CLI-configured +/// profile server. Non-secret connection settings only — tokens live in the DPAPI session +/// store so a config backup never carries a usable session. /// -public sealed class ConfigStore +public class ClientConfigStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public string ConfigDir { get; } public string ConfigPath => Path.Combine(ConfigDir, "config.json"); - public ConfigStore() : this(DefaultConfigDir()) { } + public ClientConfigStore() : this(DefaultConfigDir()) { } - public ConfigStore(string configDir) + public ClientConfigStore(string configDir) { ConfigDir = configDir; Directory.CreateDirectory(ConfigDir); @@ -30,7 +32,8 @@ public static string DefaultConfigDir() public CliConfig Load() { - if (!File.Exists(ConfigPath)) return new CliConfig(); + if (!File.Exists(ConfigPath)) + return new CliConfig(); try { using var stream = File.OpenRead(ConfigPath); @@ -38,6 +41,8 @@ public CliConfig Load() } catch (JsonException) { + // Corrupt config → treat as empty rather than blocking the user. They can + // always `np config set server …` to repair, and `np auth login` re-creates. return new CliConfig(); } } diff --git a/src/NodePilot.Data/DataMetrics.cs b/src/NodePilot.Data/DataMetrics.cs index d1c8c446..4628f150 100644 --- a/src/NodePilot.Data/DataMetrics.cs +++ b/src/NodePilot.Data/DataMetrics.cs @@ -20,6 +20,40 @@ public static class DataMetrics "nodepilot.credential.crypto.duration", unit: "ms", description: "DPAPI encrypt/decrypt latency, tagged by operation."); + /// + /// Runs one crypto operation and records call-count + latency for it, tagged + /// success/failure. Every routes through + /// here so the two instrumented providers cannot drift into different tag sets — a + /// dashboard splitting on provider would otherwise silently lose a series. + /// Exceptions are recorded as a failure and rethrown unchanged. + /// + public static T MeasureCrypto(string operation, string provider, Func body) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + var result = body(); + Record(operation, provider, "success", sw); + return result; + } + catch + { + Record(operation, provider, "failure", sw); + throw; + } + } + + private static void Record(string operation, string provider, string result, System.Diagnostics.Stopwatch sw) + { + sw.Stop(); + CredentialCryptoCalls.Add(1, + new("operation", operation), + new("provider", provider), + new("result", result)); + var tags = new System.Diagnostics.TagList { new("operation", operation), new("provider", provider) }; + CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); + } + /// /// Counts decrypts served by the *legacy* protector when MigratingSecretProtector is /// wired. Operators watch this drop to zero after a re-encrypt sweep — at that point diff --git a/src/NodePilot.Data/DbErrorClassifier.cs b/src/NodePilot.Data/DbErrorClassifier.cs index 2ebf809e..f7f6c48c 100644 --- a/src/NodePilot.Data/DbErrorClassifier.cs +++ b/src/NodePilot.Data/DbErrorClassifier.cs @@ -186,15 +186,6 @@ public static DbFailureKind Classify(Exception? exception) /// public static bool IsCommandTimeout(Exception? exception) => Classify(exception) is DbFailureKind.CommandTimeout; - /// True when nothing is listening or the transport died. See . - public static bool IsConnectionFailure(Exception? exception) => Classify(exception) is DbFailureKind.ConnectionFailure; - - /// True when the server answered and refused. See . - public static bool IsConnectionRejectedByServer(Exception? exception) => Classify(exception) is DbFailureKind.ConnectionRejected; - - /// True when the server is alive but out of a resource. See . - public static bool IsCapacityBackpressure(Exception? exception) => Classify(exception) is DbFailureKind.CapacityBackpressure; - private static DbFailureKind ClassifyOne(Exception exception) { // Pool exhaustion is checked before anything else about the exception, because both providers diff --git a/src/NodePilot.Data/Security/AesGcmSecretProtector.cs b/src/NodePilot.Data/Security/AesGcmSecretProtector.cs index 08df1f41..e2b045e6 100644 --- a/src/NodePilot.Data/Security/AesGcmSecretProtector.cs +++ b/src/NodePilot.Data/Security/AesGcmSecretProtector.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Security.Cryptography; using System.Text; using NodePilot.Core.Interfaces; @@ -25,9 +24,6 @@ namespace NodePilot.Data.Security; public sealed class AesGcmSecretProtector : ISecretProtector { private readonly byte[] _key; - private const byte Version = 0x01; - private const int NonceSize = 12; // GCM standard - private const int TagSize = 16; // 128-bit GCM tag public string ProviderName => "AesGcm"; @@ -41,95 +37,23 @@ public AesGcmSecretProtector(byte[] masterKey) _key = masterKey; } - public byte[] Protect(string plaintext) - { - var sw = Stopwatch.StartNew(); - try - { - var plain = Encoding.UTF8.GetBytes(plaintext); - var nonce = new byte[NonceSize]; - RandomNumberGenerator.Fill(nonce); - var ciphertext = new byte[plain.Length]; - var tag = new byte[TagSize]; - - // .NET 10's AesGcm constructor takes the key + tag size — the latter is required - // since 9.0 (was inferable before; explicit prevents later breaking-change pain). - using var gcm = new AesGcm(_key, TagSize); - gcm.Encrypt(nonce, plain, ciphertext, tag); + public byte[] Protect(string plaintext) => DataMetrics.MeasureCrypto("encrypt", ProviderName, () => + SecretEnvelope.Seal(Encoding.UTF8.GetBytes(plaintext), _key)); - var blob = new byte[1 + NonceSize + ciphertext.Length + TagSize]; - blob[0] = Version; - Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); - Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); - Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); + private const string TooShortMessage = "AES-GCM blob is shorter than the minimum envelope (header + nonce + tag)."; - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return blob; - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + private static string UnknownVersionMessage(byte actual) => + $"Unknown AES-GCM envelope version 0x{actual:X2}. Expected {SecretEnvelope.ExpectedVersionHex}. " + + "Was the row written by a different ISecretProtector?"; public string Unprotect(byte[] blob) { - ArgumentNullException.ThrowIfNull(blob); - if (blob.Length < 1 + NonceSize + TagSize) - throw new CryptographicException("AES-GCM blob is shorter than the minimum envelope (header + nonce + tag)."); - if (blob[0] != Version) - throw new CryptographicException( - $"Unknown AES-GCM envelope version 0x{blob[0]:X2}. Expected 0x{Version:X2}. " + - "Was the row written by a different ISecretProtector?"); + // Header validation runs OUTSIDE the measured region, as it always has: a malformed blob + // is a caller error, not a crypto failure, and must not land in the failed-decrypt series. + SecretEnvelope.ValidateHeader(blob, TooShortMessage, UnknownVersionMessage); - var sw = Stopwatch.StartNew(); - try - { - var ciphertextLength = blob.Length - 1 - NonceSize - TagSize; - var nonce = new byte[NonceSize]; - var ciphertext = new byte[ciphertextLength]; - var tag = new byte[TagSize]; - Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); - Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); - Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); - - var plain = new byte[ciphertextLength]; - using var gcm = new AesGcm(_key, TagSize); - gcm.Decrypt(nonce, ciphertext, tag, plain); - - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return Encoding.UTF8.GetString(plain); - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } + return DataMetrics.MeasureCrypto("decrypt", ProviderName, () => + Encoding.UTF8.GetString(SecretEnvelope.Open(blob, _key, TooShortMessage, UnknownVersionMessage))); } /// diff --git a/src/NodePilot.Data/Security/DpapiSecretProtector.cs b/src/NodePilot.Data/Security/DpapiSecretProtector.cs index 1cce6c9c..04fc2c1d 100644 --- a/src/NodePilot.Data/Security/DpapiSecretProtector.cs +++ b/src/NodePilot.Data/Security/DpapiSecretProtector.cs @@ -27,60 +27,9 @@ public DpapiSecretProtector(DataProtectionScope scope) _scope = scope; } - public byte[] Protect(string plaintext) - { - var sw = Stopwatch.StartNew(); - try - { - var bytes = Encoding.UTF8.GetBytes(plaintext); - var result = ProtectedData.Protect(bytes, null, _scope); - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return result; - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + public byte[] Protect(string plaintext) => DataMetrics.MeasureCrypto("encrypt", ProviderName, () => + ProtectedData.Protect(Encoding.UTF8.GetBytes(plaintext), null, _scope)); - public string Unprotect(byte[] blob) - { - var sw = Stopwatch.StartNew(); - try - { - var bytes = ProtectedData.Unprotect(blob, null, _scope); - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return Encoding.UTF8.GetString(bytes); - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + public string Unprotect(byte[] blob) => DataMetrics.MeasureCrypto("decrypt", ProviderName, () => + Encoding.UTF8.GetString(ProtectedData.Unprotect(blob, null, _scope))); } diff --git a/src/NodePilot.Data/Security/PassphraseSecretProtector.cs b/src/NodePilot.Data/Security/PassphraseSecretProtector.cs index e49becea..da2615b8 100644 --- a/src/NodePilot.Data/Security/PassphraseSecretProtector.cs +++ b/src/NodePilot.Data/Security/PassphraseSecretProtector.cs @@ -35,9 +35,6 @@ public sealed class PassphraseSecretProtector : ISecretProtector public const int DefaultIterations = 600_000; public const int SaltSize = 16; - private const byte Version = 0x01; - private const int NonceSize = 12; // GCM standard - private const int TagSize = 16; // 128-bit GCM tag private const int KeySize = 32; // 256-bit subkeys // Distinct HKDF info labels keep the three derived subkeys cryptographically independent. @@ -143,42 +140,10 @@ public bool VerifyPassphrase(byte[] verifierBlob) } } - private static byte[] EncryptGcm(byte[] key, byte[] plain) - { - var nonce = new byte[NonceSize]; - RandomNumberGenerator.Fill(nonce); - var ciphertext = new byte[plain.Length]; - var tag = new byte[TagSize]; - using (var gcm = new AesGcm(key, TagSize)) - gcm.Encrypt(nonce, plain, ciphertext, tag); - - var blob = new byte[1 + NonceSize + ciphertext.Length + TagSize]; - blob[0] = Version; - Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); - Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); - Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); - return blob; - } + private static byte[] EncryptGcm(byte[] key, byte[] plain) => SecretEnvelope.Seal(plain, key); - private static byte[] DecryptGcm(byte[] key, byte[] blob) - { - ArgumentNullException.ThrowIfNull(blob); - if (blob.Length < 1 + NonceSize + TagSize) - throw new CryptographicException("Backup secret blob is shorter than the minimum envelope."); - if (blob[0] != Version) - throw new CryptographicException($"Unknown backup secret envelope version 0x{blob[0]:X2}."); - - var ciphertextLength = blob.Length - 1 - NonceSize - TagSize; - var nonce = new byte[NonceSize]; - var ciphertext = new byte[ciphertextLength]; - var tag = new byte[TagSize]; - Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); - Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); - Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); - - var plain = new byte[ciphertextLength]; - using var gcm = new AesGcm(key, TagSize); - gcm.Decrypt(nonce, ciphertext, tag, plain); - return plain; - } + private static byte[] DecryptGcm(byte[] key, byte[] blob) => SecretEnvelope.Open( + blob, key, + "Backup secret blob is shorter than the minimum envelope.", + actual => $"Unknown backup secret envelope version 0x{actual:X2}."); } diff --git a/src/NodePilot.Data/Security/SecretEnvelope.cs b/src/NodePilot.Data/Security/SecretEnvelope.cs new file mode 100644 index 00000000..12f25890 --- /dev/null +++ b/src/NodePilot.Data/Security/SecretEnvelope.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; + +namespace NodePilot.Data.Security; + +/// +/// The on-disk/in-column AES-GCM envelope every NodePilot secret is wrapped in: +/// [version:1][nonce:12][ciphertext:n][tag:16]. +/// +/// This is a persisted wire format — a stored credential written today must still open years +/// later. It therefore lives in exactly one place: (credential +/// column) and (backup rewrap) previously carried +/// byte-compatible copies of the layout, where any edit to one would have silently produced blobs +/// the other could not read. +/// +/// +internal static class SecretEnvelope +{ + private const byte Version = 0x01; + private const int NonceSize = 12; // GCM standard + private const int TagSize = 16; // 128-bit GCM tag + + /// Smallest possible envelope: header + nonce + tag, with empty ciphertext. + public const int MinLength = 1 + NonceSize + TagSize; + + public static byte[] Seal(byte[] plain, byte[] key) + { + var nonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(nonce); + var ciphertext = new byte[plain.Length]; + var tag = new byte[TagSize]; + + // .NET 10's AesGcm constructor takes the key + tag size — the latter is required + // since 9.0 (was inferable before; explicit prevents later breaking-change pain). + using (var gcm = new AesGcm(key, TagSize)) + gcm.Encrypt(nonce, plain, ciphertext, tag); + + var blob = new byte[MinLength + ciphertext.Length]; + blob[0] = Version; + Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); + Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); + Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); + return blob; + } + + /// + /// Rejects a null, truncated or foreign-versioned blob. Separate from so + /// a caller can run it outside its metrics scope — a malformed blob is a caller error, not a + /// crypto failure, and must not land in the failed-decrypt series. + /// and keep each + /// caller's diagnostic wording: the credential path points at a mismatched ISecretProtector, + /// the backup path at a foreign archive, and an operator needs to know which they are holding. + /// + public static void ValidateHeader(byte[] blob, string tooShortMessage, Func unknownVersionMessage) + { + ArgumentNullException.ThrowIfNull(blob); + if (blob.Length < MinLength) + throw new CryptographicException(tooShortMessage); + if (blob[0] != Version) + throw new CryptographicException(unknownVersionMessage(blob[0])); + } + + /// Validates the header (see ) and decrypts. + public static byte[] Open(byte[] blob, byte[] key, string tooShortMessage, Func unknownVersionMessage) + { + ValidateHeader(blob, tooShortMessage, unknownVersionMessage); + + var ciphertextLength = blob.Length - MinLength; + var nonce = new byte[NonceSize]; + var ciphertext = new byte[ciphertextLength]; + var tag = new byte[TagSize]; + Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); + Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); + Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); + + var plain = new byte[ciphertextLength]; + using var gcm = new AesGcm(key, TagSize); + gcm.Decrypt(nonce, ciphertext, tag, plain); + return plain; + } + + /// Renders the expected version byte for a caller's diagnostic message. + public static string ExpectedVersionHex => $"0x{Version:X2}"; +} diff --git a/src/NodePilot.Engine/Activities/BaseActivity.cs b/src/NodePilot.Engine/Activities/BaseActivity.cs index d6630c7c..e94b85fe 100644 --- a/src/NodePilot.Engine/Activities/BaseActivity.cs +++ b/src/NodePilot.Engine/Activities/BaseActivity.cs @@ -191,10 +191,52 @@ internal static bool IsLoopbackHostname(string? hostname) protected virtual ActivityResult PostProcess(ActivityResult raw, JsonElement config) => raw; /// - /// Helper for PostProcess overrides: copies a string field from the JSON object into the - /// target dictionary. A ValueKind.String is copied via GetString(), Null/Undefined - /// is stored as an empty string, and every other kind is serialized via GetRawText(). + /// Shared preamble of every marker-envelope PostProcess override: parses the JSON block the + /// script wrote between the result markers. Returns false when the caller must return + /// instead of projecting — either the untouched raw result (no + /// envelope in the output, e.g. the script died before reaching it) or a failure carrying + /// ": could not parse result JSON: …" for a malformed envelope. /// - protected static void CopyStringField(JsonElement obj, string sourceKey, IDictionary dest, string destKey) - => PowerShellOperation.CopyStringField(obj, sourceKey, dest, destKey); + private protected static bool TryParseResultEnvelope( + ActivityResult raw, + PowerShellOperationMarkers markers, + string label, + out JsonDocument? document, + out ActivityResult? passthrough) + { + if (PowerShellOperation.TryParseJsonBlock(raw.Output, markers, out document, out var parseError)) + { + passthrough = null; + return true; + } + + passthrough = parseError is null + ? raw + : new ActivityResult + { + Success = false, + Output = raw.Output, + ErrorOutput = $"{label}: could not parse result JSON: {parseError}", + Duration = raw.Duration, + }; + return false; + } + + /// + /// Returns unchanged when nothing was projected, otherwise a copy that + /// carries the parameters. Success/Output/ErrorOutput/Duration are passed through verbatim. + /// + protected static ActivityResult WithOutputParameters(ActivityResult raw, Dictionary parameters) + { + if (parameters.Count == 0) return raw; + + return new ActivityResult + { + Success = raw.Success, + Output = raw.Output, + ErrorOutput = raw.ErrorOutput, + Duration = raw.Duration, + OutputParameters = parameters, + }; + } } diff --git a/src/NodePilot.Engine/Activities/FileHashActivity.cs b/src/NodePilot.Engine/Activities/FileHashActivity.cs index 3511e605..5706965b 100644 --- a/src/NodePilot.Engine/Activities/FileHashActivity.cs +++ b/src/NodePilot.Engine/Activities/FileHashActivity.cs @@ -76,17 +76,8 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co { if (!raw.Success) return raw; - if (!PowerShellOperation.TryParseJsonBlock(raw.Output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"File Hash: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "File Hash", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/FileOperationActivity.cs b/src/NodePilot.Engine/Activities/FileOperationActivity.cs index 5a2f4d3a..38af6f9d 100644 --- a/src/NodePilot.Engine/Activities/FileOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FileOperationActivity.cs @@ -1,8 +1,6 @@ -using System.Text.Json; using Microsoft.Extensions.Configuration; using NodePilot.Core.Interfaces; using NodePilot.Engine.PowerShell; -using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -16,15 +14,18 @@ namespace NodePilot.Engine.Activities; /// PostProcess projects into OutputParameters (param.operation, param.path, param.destination, /// param.newPath, param.exists, param.fullName — depending on the operation). This guarantees /// that {{step.param.exists}} is always "true"/"false" and downstream steps can rely on -/// a consistent set of keys. +/// a consistent set of keys. Validation, envelope and projection live in +/// . /// -public class FileOperationActivity : BaseRemoteActivity +public class FileOperationActivity : FileSystemOperationActivityBase { public override string ActivityType => "fileOperation"; - private static readonly PowerShellOperationMarkers ResultMarkers = PowerShellOperation.Markers("FILEOP"); + protected override string OperationLabel => "File Operation"; - private readonly IConfiguration _config; + protected override string SupportedOperations => "copy, move, delete, exists, create, rename"; + + protected override int ResultJsonDepth => 4; public FileOperationActivity( IRemoteSessionFactory sessionFactory, @@ -32,66 +33,20 @@ public FileOperationActivity( NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, IConfiguration config) - : base(sessionFactory, credentialStore, db, engineFactory, config) + : base(sessionFactory, credentialStore, db, engineFactory, config, "FILEOP") { - _config = config; } - protected override string BuildScript(JsonElement config, StepExecutionContext context) + protected override string BuildOperationBody(string operation) => operation switch { - var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(operation)) - throw new InvalidOperationException("File Operation: 'operation' is required (copy, move, delete, exists, create, rename)"); - - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - throw new InvalidOperationException("File Operation: 'path' is required"); - - var destination = config.GetStringOrNull("destination"); - var newName = config.GetStringOrNull("newName"); - - PathGuard.Validate(_config, path, allowWildcards: false); - if (!string.IsNullOrWhiteSpace(destination)) - PathGuard.Validate(_config, destination, allowWildcards: false); - if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) - PathGuard.ValidateSiblingRenameTarget(_config, path, newName); - - if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) - throw new InvalidOperationException($"File Operation '{operation}' requires 'destination'"); - if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("File Operation 'rename' requires 'newName'"); - - var qPath = PowerShellOperation.Literal(path); - var qDest = PowerShellOperation.Literal(destination); - var qNewName = PowerShellOperation.Literal(newName); - - var opBody = operation switch - { - "copy" => BuildCopy(), - "move" => BuildMove(), - "delete" => BuildDelete(), - "exists" => BuildExists(), - "create" => BuildCreate(), - "rename" => BuildRename(), - _ => throw new InvalidOperationException($"Unknown file operation: {operation}") - }; - - // operation is whitelisted (any other variant throws above), so direct interpolation is safe. - return $$""" - $ErrorActionPreference = 'Stop' - $__path = {{qPath}} - $__destination = {{qDest}} - $__newName = {{qNewName}} - $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } - try { - {{opBody}} - } catch { - $__result.ok = $false - $__result.error = $_.Exception.Message - } - {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 4)}} - """; - } + "copy" => BuildCopy(), + "move" => BuildMove(), + "delete" => BuildDelete(), + "exists" => BuildExists(), + "create" => BuildCreate(), + "rename" => BuildRename(), + _ => throw new InvalidOperationException($"Unknown file operation: {operation}") + }; // Leaf-Assertion: ensures the path is a file before mutation, so a folder typed here // by mistake throws cleanly instead of being copied/moved/deleted as if it were a file. @@ -142,92 +97,4 @@ private static string BuildRename() => $$""" $__result.newPath = $__target $__result.newName = $__newName """; - - protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) - { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"File Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } - - using (doc!) - { - var root = doc!.RootElement; - var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); - var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; - - if (!ok) - { - var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; - return new ActivityResult - { - Success = false, - Output = null, - ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, - Duration = raw.Duration, - }; - } - - var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["operation"] = operation, - }; - if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) - parameters["path"] = pathEl.GetString() ?? ""; - - string display; - switch (operation) - { - case "copy": - case "move": - if (root.TryGetProperty("destination", out var destEl)) - parameters["destination"] = destEl.GetString() ?? ""; - display = $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; - break; - - case "exists": - var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); - parameters["exists"] = exists ? "true" : "false"; - display = exists ? "True" : "False"; - break; - - case "create": - if (root.TryGetProperty("fullName", out var fnEl)) - parameters["fullName"] = fnEl.GetString() ?? ""; - if (root.TryGetProperty("creationTime", out var ctEl)) - parameters["creationTime"] = ctEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("fullName") ?? ""; - break; - - case "rename": - if (root.TryGetProperty("newPath", out var npEl)) - parameters["newPath"] = npEl.GetString() ?? ""; - if (root.TryGetProperty("newName", out var nnEl)) - parameters["newName"] = nnEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("newPath") ?? ""; - break; - - default: - display = "OK"; - break; - } - - return new ActivityResult - { - Success = true, - Output = display, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = parameters, - }; - } - } } diff --git a/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs new file mode 100644 index 00000000..969c33b0 --- /dev/null +++ b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Interfaces; +using NodePilot.Engine.PowerShell; +using NodePilot.Engine.Security; + +namespace NodePilot.Engine.Activities; + +/// +/// Shared skeleton of the two path-scoped activities — and +/// . Both validate the same config surface (operation, path, +/// destination, newName), emit the same JSON result envelope and project it into the same +/// OutputParameters; only the PowerShell bodies, the wording of the user-facing strings and the +/// extra list operation of the folder variant differ. Everything user-visible is therefore +/// supplied by the subclass — label, operation list, envelope depth, marker token. +/// +public abstract class FileSystemOperationActivityBase : BaseRemoteActivity +{ + private readonly IConfiguration _config; + private readonly PowerShellOperationMarkers _resultMarkers; + + protected FileSystemOperationActivityBase( + IRemoteSessionFactory sessionFactory, + ICredentialStore credentialStore, + NodePilot.Data.NodePilotDbContext db, + PowerShellEngineFactory engineFactory, + IConfiguration config, + string markerToken) + : base(sessionFactory, credentialStore, db, engineFactory, config) + { + _config = config; + _resultMarkers = PowerShellOperation.Markers(markerToken); + } + + /// Prefix of every user-facing message — "File Operation" / "Folder Operation". + protected abstract string OperationLabel { get; } + + /// Operations named in the "'operation' is required" message, in UI order. + protected abstract string SupportedOperations { get; } + + /// ConvertTo-Json -Depth for the result envelope. + protected abstract int ResultJsonDepth { get; } + + /// + /// PowerShell body for the (already lower-cased and validated-as-present) operation. The + /// subclass owns the unknown-operation throw, because its wording names the scope. + /// + protected abstract string BuildOperationBody(string operation); + + protected override string BuildScript(JsonElement config, StepExecutionContext context) + { + var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(operation)) + throw new InvalidOperationException($"{OperationLabel}: 'operation' is required ({SupportedOperations})"); + + var path = config.GetStringOrNull("path"); + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException($"{OperationLabel}: 'path' is required"); + + var destination = config.GetStringOrNull("destination"); + var newName = config.GetStringOrNull("newName"); + + PathGuard.Validate(_config, path, allowWildcards: false); + if (!string.IsNullOrWhiteSpace(destination)) + PathGuard.Validate(_config, destination, allowWildcards: false); + if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) + PathGuard.ValidateSiblingRenameTarget(_config, path, newName); + + if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) + throw new InvalidOperationException($"{OperationLabel} '{operation}' requires 'destination'"); + if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) + throw new InvalidOperationException($"{OperationLabel} 'rename' requires 'newName'"); + + var qPath = PowerShellOperation.Literal(path); + var qDest = PowerShellOperation.Literal(destination); + var qNewName = PowerShellOperation.Literal(newName); + + var opBody = BuildOperationBody(operation); + + // operation is whitelisted (any other variant throws above), so direct interpolation is safe. + return $$""" + $ErrorActionPreference = 'Stop' + $__path = {{qPath}} + $__destination = {{qDest}} + $__newName = {{qNewName}} + $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } + try { + {{opBody}} + } catch { + $__result.ok = $false + $__result.error = $_.Exception.Message + } + {{_resultMarkers.RenderJsonEnvelope("$__result", depth: ResultJsonDepth)}} + """; + } + + protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) + { + if (!TryParseResultEnvelope(raw, _resultMarkers, OperationLabel, out var doc, out var passthrough)) + return passthrough!; + + using (doc!) + { + var root = doc!.RootElement; + var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); + var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; + + if (!ok) + { + var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; + return new ActivityResult + { + Success = false, + Output = null, + ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, + Duration = raw.Duration, + }; + } + + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["operation"] = operation, + }; + if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) + parameters["path"] = pathEl.GetString() ?? ""; + + var display = ProjectOperationOutputs(operation, root, parameters); + + return new ActivityResult + { + Success = true, + Output = display, + ErrorOutput = raw.ErrorOutput, + Duration = raw.Duration, + OutputParameters = parameters, + }; + } + } + + private string ProjectOperationOutputs(string operation, JsonElement root, Dictionary parameters) + { + switch (operation) + { + case "copy": + case "move": + if (root.TryGetProperty("destination", out var destEl)) + parameters["destination"] = destEl.GetString() ?? ""; + return $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; + + case "exists": + var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); + parameters["exists"] = exists ? "true" : "false"; + return exists ? "True" : "False"; + + case "create": + if (root.TryGetProperty("fullName", out var fnEl)) + parameters["fullName"] = fnEl.GetString() ?? ""; + if (root.TryGetProperty("creationTime", out var ctEl)) + parameters["creationTime"] = ctEl.GetString() ?? ""; + return parameters.GetValueOrDefault("fullName") ?? ""; + + case "rename": + if (root.TryGetProperty("newPath", out var npEl)) + parameters["newPath"] = npEl.GetString() ?? ""; + if (root.TryGetProperty("newName", out var nnEl)) + parameters["newName"] = nnEl.GetString() ?? ""; + return parameters.GetValueOrDefault("newPath") ?? ""; + + default: + return ProjectExtraOperation(operation, root, parameters) ?? "OK"; + } + } + + /// + /// Hook for an operation only one of the two activities offers (folder: list). Returns + /// the display string, or null to fall back to the shared default. + /// + protected virtual string? ProjectExtraOperation( + string operation, + JsonElement root, + Dictionary parameters) => null; +} diff --git a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs index 15b01d3d..e9d8e5b0 100644 --- a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration; using NodePilot.Core.Interfaces; using NodePilot.Engine.PowerShell; -using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -16,15 +15,18 @@ namespace NodePilot.Engine.Activities; /// PostProcess projects into OutputParameters (param.operation, param.path, param.destination, /// param.newPath, param.exists, param.fullName, param.items, param.count — depending on the /// operation). This guarantees that {{step.param.exists}} is always "true"/"false" and -/// {{step.param.items}} is always a JSON array. +/// {{step.param.items}} is always a JSON array. Validation, envelope and projection live +/// in ; only list is folder-specific. /// -public class FolderOperationActivity : BaseRemoteActivity +public class FolderOperationActivity : FileSystemOperationActivityBase { public override string ActivityType => "folderOperation"; - private static readonly PowerShellOperationMarkers ResultMarkers = PowerShellOperation.Markers("FOLDEROP"); + protected override string OperationLabel => "Folder Operation"; - private readonly IConfiguration _config; + protected override string SupportedOperations => "copy, move, delete, exists, list, create, rename"; + + protected override int ResultJsonDepth => 6; public FolderOperationActivity( IRemoteSessionFactory sessionFactory, @@ -32,66 +34,21 @@ public FolderOperationActivity( NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, IConfiguration config) - : base(sessionFactory, credentialStore, db, engineFactory, config) + : base(sessionFactory, credentialStore, db, engineFactory, config, "FOLDEROP") { - _config = config; } - protected override string BuildScript(JsonElement config, StepExecutionContext context) + protected override string BuildOperationBody(string operation) => operation switch { - var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(operation)) - throw new InvalidOperationException("Folder Operation: 'operation' is required (copy, move, delete, exists, list, create, rename)"); - - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - throw new InvalidOperationException("Folder Operation: 'path' is required"); - - var destination = config.GetStringOrNull("destination"); - var newName = config.GetStringOrNull("newName"); - - PathGuard.Validate(_config, path, allowWildcards: false); - if (!string.IsNullOrWhiteSpace(destination)) - PathGuard.Validate(_config, destination, allowWildcards: false); - if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) - PathGuard.ValidateSiblingRenameTarget(_config, path, newName); - - if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) - throw new InvalidOperationException($"Folder Operation '{operation}' requires 'destination'"); - if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("Folder Operation 'rename' requires 'newName'"); - - var qPath = PowerShellOperation.Literal(path); - var qDest = PowerShellOperation.Literal(destination); - var qNewName = PowerShellOperation.Literal(newName); - - var opBody = operation switch - { - "copy" => BuildCopy(), - "move" => BuildMove(), - "delete" => BuildDelete(), - "exists" => BuildExists(), - "list" => BuildList(), - "create" => BuildCreate(), - "rename" => BuildRename(), - _ => throw new InvalidOperationException($"Unknown folder operation: {operation}") - }; - - return $$""" - $ErrorActionPreference = 'Stop' - $__path = {{qPath}} - $__destination = {{qDest}} - $__newName = {{qNewName}} - $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } - try { - {{opBody}} - } catch { - $__result.ok = $false - $__result.error = $_.Exception.Message - } - {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 6)}} - """; - } + "copy" => BuildCopy(), + "move" => BuildMove(), + "delete" => BuildDelete(), + "exists" => BuildExists(), + "list" => BuildList(), + "create" => BuildCreate(), + "rename" => BuildRename(), + _ => throw new InvalidOperationException($"Unknown folder operation: {operation}") + }; // Container-Assertion: ensures the path is a folder before mutation, so a file typed // here by mistake throws cleanly instead of being copied/moved/deleted as if it were @@ -163,101 +120,19 @@ private static string BuildRename() => $$""" $__result.newName = $__newName """; - protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) + protected override string? ProjectExtraOperation( + string operation, + JsonElement root, + Dictionary parameters) { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Folder Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } - - using (doc!) - { - var root = doc!.RootElement; - var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); - var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; - - if (!ok) - { - var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; - return new ActivityResult - { - Success = false, - Output = null, - ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, - Duration = raw.Duration, - }; - } - - var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["operation"] = operation, - }; - if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) - parameters["path"] = pathEl.GetString() ?? ""; - - string display; - switch (operation) - { - case "copy": - case "move": - if (root.TryGetProperty("destination", out var destEl)) - parameters["destination"] = destEl.GetString() ?? ""; - display = $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; - break; - - case "exists": - var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); - parameters["exists"] = exists ? "true" : "false"; - display = exists ? "True" : "False"; - break; - - case "list": - if (root.TryGetProperty("items", out var itemsEl)) - parameters["items"] = itemsEl.GetRawText(); - var count = root.TryGetProperty("count", out var cEl) ? cEl.GetInt32() : 0; - parameters["count"] = count.ToString(); - if (root.TryGetProperty("truncated", out var truncEl) && truncEl.ValueKind != JsonValueKind.Null) - parameters["truncated"] = truncEl.GetBoolean() ? "true" : "false"; - display = root.TryGetProperty("items", out var itemsEl2) ? itemsEl2.GetRawText() : "[]"; - break; - - case "create": - if (root.TryGetProperty("fullName", out var fnEl)) - parameters["fullName"] = fnEl.GetString() ?? ""; - if (root.TryGetProperty("creationTime", out var ctEl)) - parameters["creationTime"] = ctEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("fullName") ?? ""; - break; - - case "rename": - if (root.TryGetProperty("newPath", out var npEl)) - parameters["newPath"] = npEl.GetString() ?? ""; - if (root.TryGetProperty("newName", out var nnEl)) - parameters["newName"] = nnEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("newPath") ?? ""; - break; - - default: - display = "OK"; - break; - } - - return new ActivityResult - { - Success = true, - Output = display, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = parameters, - }; - } + if (operation != "list") return null; + + if (root.TryGetProperty("items", out var itemsEl)) + parameters["items"] = itemsEl.GetRawText(); + var count = root.TryGetProperty("count", out var cEl) ? cEl.GetInt32() : 0; + parameters["count"] = count.ToString(); + if (root.TryGetProperty("truncated", out var truncEl) && truncEl.ValueKind != JsonValueKind.Null) + parameters["truncated"] = truncEl.GetBoolean() ? "true" : "false"; + return root.TryGetProperty("items", out var itemsEl2) ? itemsEl2.GetRawText() : "[]"; } } diff --git a/src/NodePilot.Engine/Activities/ForEachActivity.cs b/src/NodePilot.Engine/Activities/ForEachActivity.cs index fe834e95..4773320c 100644 --- a/src/NodePilot.Engine/Activities/ForEachActivity.cs +++ b/src/NodePilot.Engine/Activities/ForEachActivity.cs @@ -1,12 +1,12 @@ using System.Diagnostics; using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NodePilot.Core.Enums; using NodePilot.Core.Interfaces; using NodePilot.Core.Models; using NodePilot.Data; using NodePilot.Engine.Execution; +using NodePilot.Engine.PowerShell; namespace NodePilot.Engine.Activities; @@ -207,34 +207,25 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, private async Task<(Workflow? Workflow, string? Error)> ResolveChildWorkflowAsync(string nameOrId, CancellationToken ct) { - Workflow? workflow; - if (Guid.TryParse(nameOrId, out var id)) + // Exact-case wins, then case-insensitive; ambiguous names fail the step. + var (outcome, workflow) = await SubWorkflowInvocation.ResolveChildWorkflowAsync(_db, nameOrId, ct); + return outcome switch { - workflow = await _db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); - } - else - { - // Exact-case wins, then case-insensitive; ambiguous names fail the step. - var resolved = await WorkflowNameResolver.ResolveByNameAsync(_db.Workflows, nameOrId, ct); - if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) - return (null, $"forEach: multiple workflows named '{nameOrId}' — disambiguate with the GUID"); - workflow = resolved.Workflow; - } - - if (workflow is null) - return (null, $"forEach: child workflow '{nameOrId}' not found"); - if (!workflow.IsEnabled) - return (null, $"forEach: child workflow '{workflow.Name}' is disabled"); - return (workflow, null); + SubWorkflowInvocation.ChildOutcome.Ambiguous => + (null, $"forEach: multiple workflows named '{nameOrId}' — disambiguate with the GUID"), + SubWorkflowInvocation.ChildOutcome.NotFound => + (null, $"forEach: child workflow '{nameOrId}' not found"), + SubWorkflowInvocation.ChildOutcome.Disabled => + (null, $"forEach: child workflow '{workflow!.Name}' is disabled"), + _ => (workflow, null), + }; } private async Task<(int CurrentDepth, string? Error)> ValidateCallContextAsync(StepExecutionContext context, Workflow childWorkflow, CancellationToken ct) { // Self-invocation guard — identical to startWorkflow. - var parentExec = await _db.WorkflowExecutions - .AsNoTracking() - .FirstOrDefaultAsync(e => e.Id == context.WorkflowExecutionId, ct); - if (parentExec is not null && parentExec.WorkflowId == childWorkflow.Id) + var parentExec = await SubWorkflowInvocation.LoadParentExecutionAsync(_db, context.WorkflowExecutionId, ct); + if (SubWorkflowInvocation.IsSelfInvocation(parentExec, childWorkflow)) return (0, "forEach: self-invocation is not allowed (direct recursion)"); // RBAC sub-workflow runtime check — identical model to StartWorkflowActivity. @@ -242,20 +233,13 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, // still iterate a forEach over a child workflow they no longer have permission to // start. Defense in depth: PrePublishChecklist enforces the same check at save // time, but folder permissions can be revoked or rotated mid-flight. - if (_subWorkflowAuthz is not null && parentExec is not null) - { - var blocked = await _subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); - if (blocked is not null) - return (0, $"forEach: {blocked}"); - } + var blocked = await SubWorkflowInvocation.GetAuthorizationBlockAsync( + _subWorkflowAuthz, parentExec, childWorkflow, ct); + if (blocked is not null) + return (0, $"forEach: {blocked}"); // Call-depth guard. - var currentDepth = 0; - if (context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) - && int.TryParse(depthStr, out var parsed)) - { - currentDepth = parsed; - } + var currentDepth = SubWorkflowInvocation.CurrentCallDepth(context); if (currentDepth >= WorkflowRecursion.MaxCallDepth) return (currentDepth, $"forEach: call depth limit ({WorkflowRecursion.MaxCallDepth}) exceeded"); @@ -264,25 +248,10 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, private static Dictionary CollectStaticParams(JsonElement config, out string? error) { - error = null; - var staticParams = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!config.TryGetProperty("parameters", out var paramsEl) || paramsEl.ValueKind != JsonValueKind.Object) - return staticParams; - - foreach (var prop in paramsEl.EnumerateObject()) - { - if (prop.Name.StartsWith("__", StringComparison.OrdinalIgnoreCase)) - { - error = $"forEach: parameter name '{prop.Name}' is reserved ('__'-prefix)"; - return staticParams; - } - staticParams[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; - } + var staticParams = SubWorkflowInvocation.CollectParameters(config, out var reservedKey); + error = reservedKey is null + ? null + : $"forEach: parameter name '{reservedKey}' is reserved ('__'-prefix)"; return staticParams; } @@ -485,12 +454,7 @@ private static List ParseItems(string raw, string format) var list = new List(); foreach (var el in doc.RootElement.EnumerateArray()) { - list.Add(el.ValueKind switch - { - JsonValueKind.String => el.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => el.GetRawText(), - }); + list.Add(PowerShellOperation.JsonElementToScalarString(el)); } return list; } diff --git a/src/NodePilot.Engine/Activities/JsonQueryActivity.cs b/src/NodePilot.Engine/Activities/JsonQueryActivity.cs index a58113fa..bdf7a388 100644 --- a/src/NodePilot.Engine/Activities/JsonQueryActivity.cs +++ b/src/NodePilot.Engine/Activities/JsonQueryActivity.cs @@ -60,7 +60,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var loaded = await LoadJsonAsync(source, config, ct); if (loaded.Error is not null) return loaded.Error; - var json = loaded.Json!; + var json = loaded.Content!; if (json.Length > MaxJsonBytes) return Fail($"input is {json.Length} chars; exceeds limit of {MaxJsonBytes}."); @@ -83,41 +83,15 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme }; }, ex => $"JsonQuery error: {ex.Message}"); - private async Task<(string? Json, ActivityResult? Error)> LoadJsonAsync(string source, JsonElement config, CancellationToken ct) - { - if (source == "file") - { - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - return (null, Fail("'path' is required when source=file")); - - // M-8: apply the same PathGuard config the FileSystemOperation activity uses, so ops - // can restrict file-mode JsonQuery to allow-listed roots / block traversal. - if (_config is not null) - { - try { PathGuard.Validate(_config, path); } - catch (InvalidOperationException ex) - { - return (null, Fail($"file access denied: {ex.Message}")); - } - } - - if (!File.Exists(path)) - return (null, Fail($"file not found: {path}")); - - // M-7: check size before reading so a 10 GiB file doesn't pin the managed heap. - var fi = new FileInfo(path); - if (fi.Length > MaxJsonBytes) - return (null, Fail($"file '{path}' is {fi.Length} bytes; exceeds limit of {MaxJsonBytes}.")); - - return (await File.ReadAllTextAsync(path, ct), null); - } - - var inline = config.GetString("content", ""); - if (string.IsNullOrWhiteSpace(inline)) - return (null, Fail("'content' is required when source=inline")); - return (inline, null); - } + private Task<(string? Content, ActivityResult? Error)> LoadJsonAsync(string source, JsonElement config, CancellationToken ct) + => QueryPayloadSource.LoadAsync( + source, + config, + _config, + MaxJsonBytes, + Fail, + (path, length) => $"file '{path}' is {length} bytes; exceeds limit of {MaxJsonBytes}.", + ct); private static (JToken? Root, ActivityResult? Error) ParseJson(string json) { diff --git a/src/NodePilot.Engine/Activities/QueryPayloadSource.cs b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs new file mode 100644 index 00000000..6f14df32 --- /dev/null +++ b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Interfaces; +using NodePilot.Engine.Security; + +namespace NodePilot.Engine.Activities; + +/// +/// Resolves the source = file | inline payload that and +/// both accept. The two differ only in their size cap and in the +/// wording of the oversize message, so both are supplied by the caller — as is the Fail +/// projection that carries the activity prefix. +/// +internal static class QueryPayloadSource +{ + public static async Task<(string? Content, ActivityResult? Error)> LoadAsync( + string source, + JsonElement config, + IConfiguration? pathGuardConfig, + long maxBytes, + Func fail, + Func oversizeMessage, + CancellationToken ct) + { + if (source == "file") + { + var path = config.GetStringOrNull("path"); + if (string.IsNullOrWhiteSpace(path)) + return (null, fail("'path' is required when source=file")); + + // M-8: apply the same PathGuard config the FileSystemOperation activity uses, so ops + // can restrict file-mode queries to allow-listed roots / block traversal. + if (pathGuardConfig is not null) + { + try { PathGuard.Validate(pathGuardConfig, path); } + catch (InvalidOperationException ex) + { + return (null, fail($"file access denied: {ex.Message}")); + } + } + + if (!File.Exists(path)) + return (null, fail($"file not found: {path}")); + + // M-7: check size before reading so a 10 GiB file doesn't pin the managed heap. + var fileInfo = new FileInfo(path); + if (fileInfo.Length > maxBytes) + return (null, fail(oversizeMessage(path, fileInfo.Length))); + + return (await File.ReadAllTextAsync(path, ct), null); + } + + var inline = config.GetString("content", ""); + if (string.IsNullOrWhiteSpace(inline)) + return (null, fail("'content' is required when source=inline")); + return (inline, null); + } +} diff --git a/src/NodePilot.Engine/Activities/RegistryActivity.cs b/src/NodePilot.Engine/Activities/RegistryActivity.cs index bed6c04c..fedcf49b 100644 --- a/src/NodePilot.Engine/Activities/RegistryActivity.cs +++ b/src/NodePilot.Engine/Activities/RegistryActivity.cs @@ -236,35 +236,14 @@ private static string BuildListSubKeys() => """ $__result.count = $names.Count """; - private static string BuildListValues() => """ - $key = Get-Item -LiteralPath $__keyPath - $items = New-Object System.Collections.ArrayList - foreach ($n in $key.Property) { - [void]$items.Add([ordered]@{ - name = $n - type = $key.GetValueKind($n).ToString() - value = $key.GetValue($n) - }) - } - $__result.values = @($items) - $__result.count = $items.Count - """; + // listValues is a valueName-less read under another name: same inventory, same JSON shape + // (values/count), so it emits the exact same snippet instead of a second copy of it. + private static string BuildListValues() => BuildReadAllValues(); protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) { - var output = raw.Output ?? ""; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Registry: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Registry", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs index e4571a08..031f6626 100644 --- a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs +++ b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using NodePilot.Core.Interfaces; using NodePilot.Data; +using NodePilot.Engine.PowerShell; using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -77,12 +78,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso var outputParams = new Dictionary(); foreach (var prop in dataEl.EnumerateObject()) { - var raw = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; + var raw = PowerShellOperation.JsonElementToScalarString(prop.Value); outputParams[prop.Name] = raw.Length > MaxPerValueChars ? raw[..MaxPerValueChars] + PerValueTruncationMarker : raw; diff --git a/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs b/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs index bf95e4fe..d2bcfed4 100644 --- a/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs +++ b/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs @@ -344,42 +344,18 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co if (action != "get" || !raw.Success || string.IsNullOrWhiteSpace(raw.Output)) return raw; - var op = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (PowerShellOperation.TryParseJson(raw.Output!, out var parsedDoc, out _)) - { - using var doc = parsedDoc!; - var root = doc.RootElement; - // ConvertTo-Json wraps single-result objects in {} but multi-result in []. Get-ScheduledTask - // for a single name is single-result, but we still defensively unwrap a 1-array. - if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) - root = root[0]; - - if (root.ValueKind == JsonValueKind.Object) - { - PowerShellOperation.CopyStringField(root, "TaskName", op, "taskName"); - PowerShellOperation.CopyStringField(root, "State", op, "state"); - PowerShellOperation.CopyStringField(root, "LastRunTime", op, "lastRunTime"); - PowerShellOperation.CopyStringField(root, "LastTaskResult", op, "lastTaskResult"); - PowerShellOperation.CopyStringField(root, "NextRunTime", op, "nextRunTime"); - } - } - else - { - // Output wasn't JSON (e.g. because the task was missing and PowerShell printed an - // error instead) — we leave Success/Output unchanged and return empty - // OutputParameters; the caller sees ErrorOutput anyway. - } - - if (op.Count == 0) return raw; - - return new ActivityResult - { - Success = raw.Success, - Output = raw.Output, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = op, - }; + // Non-JSON output (e.g. because the task was missing and PowerShell printed an error + // instead) projects to nothing — Success/Output stay unchanged and the caller sees + // ErrorOutput anyway. + var op = PowerShellOperation.MapStatusJsonFields( + raw.Output, + ("TaskName", "taskName"), + ("State", "state"), + ("LastRunTime", "lastRunTime"), + ("LastTaskResult", "lastTaskResult"), + ("NextRunTime", "nextRunTime")); + + return WithOutputParameters(raw, op); } } diff --git a/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs b/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs index 3cfaf0af..49793a83 100644 --- a/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs +++ b/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs @@ -165,39 +165,15 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co if (action != "status" || !raw.Success || string.IsNullOrWhiteSpace(raw.Output)) return raw; - var op = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - using var doc = JsonDocument.Parse(raw.Output!); - var root = doc.RootElement; - // ConvertTo-Json wraps single-result objects in {} but multi-result in []. Get-Service - // for a single service is single-result, but we defensively unwrap a 1-element array too. - if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) - root = root[0]; - - if (root.ValueKind == JsonValueKind.Object) - { - CopyStringField(root, "Name", op, "name"); - CopyStringField(root, "Status", op, "status"); - CopyStringField(root, "StartType", op, "startType"); - } - } - catch (JsonException) - { - // Output wasn't JSON (e.g. because the service was missing and PowerShell printed an - // error instead) — we leave Success/Output unchanged and return empty - // OutputParameters; the caller sees ErrorOutput anyway. - } - - if (op.Count == 0) return raw; - - return new ActivityResult - { - Success = raw.Success, - Output = raw.Output, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = op, - }; + // Non-JSON output (e.g. because the service was missing and PowerShell printed an error + // instead) projects to nothing — Success/Output stay unchanged and the caller sees + // ErrorOutput anyway. + var op = PowerShellOperation.MapStatusJsonFields( + raw.Output, + ("Name", "name"), + ("Status", "status"), + ("StartType", "startType")); + + return WithOutputParameters(raw, op); } } diff --git a/src/NodePilot.Engine/Activities/StartProgramActivity.cs b/src/NodePilot.Engine/Activities/StartProgramActivity.cs index 992a0e8d..25ef9fd8 100644 --- a/src/NodePilot.Engine/Activities/StartProgramActivity.cs +++ b/src/NodePilot.Engine/Activities/StartProgramActivity.cs @@ -312,17 +312,11 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co }; } + // Same comma-separated allow-list runScript parses, except that "unset" means {0} here + // (a program's exit code is always gated) instead of "no gate at all". private static HashSet ParseSuccessExitCodes(JsonElement config) - { - var raw = config.GetStringOrNull("successExitCodes"); - if (string.IsNullOrWhiteSpace(raw)) return new HashSet { 0 }; - var set = new HashSet(); - foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (int.TryParse(part, out var n)) set.Add(n); - } - return set.Count == 0 ? new HashSet { 0 } : set; - } + => PowerShellActivitySupport.ParseSuccessExitCodes(config.GetStringOrNull("successExitCodes")) + ?? new HashSet { 0 }; private void ValidateLocalAbsolutePath(string fieldName, string path) { diff --git a/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs b/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs index 780184f6..26c1fc93 100644 --- a/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs +++ b/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs @@ -8,6 +8,7 @@ using NodePilot.Core.Models; using NodePilot.Data; using NodePilot.Engine.Execution; +using NodePilot.Engine.PowerShell; namespace NodePilot.Engine.Activities; @@ -111,27 +112,17 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso var waitForCompletion = config.GetBool("waitForCompletion", true); // Locate child workflow — GUID first; by name exact-case wins, then case-insensitive. - Workflow? childWorkflow; - if (Guid.TryParse(workflowNameOrId, out var id)) + var (outcome, resolvedWorkflow) = await SubWorkflowInvocation.ResolveChildWorkflowAsync(_db, workflowNameOrId, ct); + if (outcome == SubWorkflowInvocation.ChildOutcome.Ambiguous) { - childWorkflow = await _db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); - } - else - { - var resolved = await WorkflowNameResolver.ResolveByNameAsync(_db.Workflows, workflowNameOrId, ct); - if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) + return new ActivityResult { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: multiple workflows named '{workflowNameOrId}' — disambiguate with the GUID", - Duration = sw.Elapsed, - }; - } - childWorkflow = resolved.Workflow; + Success = false, + ErrorOutput = $"startWorkflow: multiple workflows named '{workflowNameOrId}' — disambiguate with the GUID", + Duration = sw.Elapsed, + }; } - - if (childWorkflow is null) + if (outcome == SubWorkflowInvocation.ChildOutcome.NotFound) { return new ActivityResult { @@ -140,7 +131,8 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso Duration = sw.Elapsed, }; } - if (!childWorkflow.IsEnabled) + var childWorkflow = resolvedWorkflow!; + if (outcome == SubWorkflowInvocation.ChildOutcome.Disabled) { return new ActivityResult { @@ -152,10 +144,8 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // Self-call guard - requires the parent workflow id. It is not on the context directly, // so we derive it from the current execution row. - var parentExec = await _db.WorkflowExecutions - .AsNoTracking() - .FirstOrDefaultAsync(e => e.Id == context.WorkflowExecutionId, ct); - if (parentExec is not null && parentExec.WorkflowId == childWorkflow.Id) + var parentExec = await SubWorkflowInvocation.LoadParentExecutionAsync(_db, context.WorkflowExecutionId, ct); + if (SubWorkflowInvocation.IsSelfInvocation(parentExec, childWorkflow)) { return new ActivityResult { @@ -173,28 +163,21 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // - manual run: parentExec.StartedByUserId // - trigger-driven run: parent workflow's LastModifiedByUserId (best proxy V1) // If neither resolves, the run lacks a principal — refuse the cross-folder call. - if (_subWorkflowAuthz is not null && parentExec is not null) + var blocked = await SubWorkflowInvocation.GetAuthorizationBlockAsync( + _subWorkflowAuthz, parentExec, childWorkflow, ct); + if (blocked is not null) { - var blocked = await _subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); - if (blocked is not null) + return new ActivityResult { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: {blocked}", - Duration = sw.Elapsed, - }; - } + Success = false, + ErrorOutput = $"startWorkflow: {blocked}", + Duration = sw.Elapsed, + }; } // Call-depth guard - read from the reserved variable the engine places into context.Variables // ("manual.__callDepth" when passed via inputParameters). - var currentDepth = 0; - if (context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) - && int.TryParse(depthStr, out var parsed)) - { - currentDepth = parsed; - } + var currentDepth = SubWorkflowInvocation.CurrentCallDepth(context); if (currentDepth >= WorkflowRecursion.MaxCallDepth) { _subWorkflowDepthExceeded.Add(1); @@ -226,31 +209,18 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // who supplies "__callDepth", "__CALLDEPTH", "__CallDepth", etc. as a user parameter // and resets the counter. Any key starting with "__" is reserved for engine bookkeeping // and rejected on ingest, case-insensitively. - var childParams = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (config.TryGetProperty("parameters", out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) + // Reserved-prefix guard (H5): case-insensitive. Previously the reserved key was + // seeded first and then overwritten by user params because the user-loop came + // second. Reject ALL "__"-prefixed keys from user input - they're ours. + var childParams = SubWorkflowInvocation.CollectParameters(config, out var reservedKey); + if (reservedKey is not null) { - foreach (var prop in paramsEl.EnumerateObject()) + return new ActivityResult { - // Reserved-prefix guard (H5): case-insensitive. Previously the reserved key was - // seeded first and then overwritten by user params because the user-loop came - // second. Reject ALL "__"-prefixed keys from user input - they're ours. - if (prop.Name.StartsWith("__", StringComparison.OrdinalIgnoreCase)) - { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: parameter name '{prop.Name}' is reserved (keys starting with '__' are used by the engine). Rename the parameter.", - Duration = sw.Elapsed, - }; - } - - childParams[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; - } + Success = false, + ErrorOutput = $"startWorkflow: parameter name '{reservedKey}' is reserved (keys starting with '__' are used by the engine). Rename the parameter.", + Duration = sw.Elapsed, + }; } // Seed the reserved depth counter AFTER the user loop so even if the reject above were // bypassed, the engine's value always wins. Belt-and-suspenders on H5. @@ -387,12 +357,7 @@ async Task ExecuteSynchronousChildAsync() { foreach (var prop in doc.RootElement.EnumerateObject()) { - returned[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; + returned[prop.Name] = PowerShellOperation.JsonElementToScalarString(prop.Value); } } } diff --git a/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs b/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs new file mode 100644 index 00000000..73ea7287 --- /dev/null +++ b/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.PowerShell; + +namespace NodePilot.Engine.Activities; + +/// +/// The guards every child-workflow-spawning activity runs before it hands work to the engine — +/// shared by and : child +/// resolution, self-invocation, runtime RBAC, call depth and the user-parameter ingest. +/// +/// Only the mechanics live here. Each activity formats its OWN error strings (their wording is +/// part of the step's observable output) and keeps its own instrumentation — startWorkflow, for +/// instance, counts and tags a depth violation before returning. +/// +internal static class SubWorkflowInvocation +{ + internal enum ChildOutcome + { + Found, + Ambiguous, + NotFound, + Disabled, + } + + /// + /// Locates the child workflow: GUID first, then by name (exact-case wins, then + /// case-insensitive). Ambiguous names, a missing workflow and a disabled workflow are + /// reported as outcomes — the caller turns them into its own error message. + /// + public static async Task<(ChildOutcome Outcome, Workflow? Workflow)> ResolveChildWorkflowAsync( + NodePilotDbContext db, + string nameOrId, + CancellationToken ct) + { + Workflow? workflow; + if (Guid.TryParse(nameOrId, out var id)) + { + workflow = await db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); + } + else + { + var resolved = await WorkflowNameResolver.ResolveByNameAsync(db.Workflows, nameOrId, ct); + if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) + return (ChildOutcome.Ambiguous, null); + workflow = resolved.Workflow; + } + + if (workflow is null) return (ChildOutcome.NotFound, null); + if (!workflow.IsEnabled) return (ChildOutcome.Disabled, workflow); + return (ChildOutcome.Found, workflow); + } + + /// + /// The parent execution row behind the current step. Needed for the self-invocation guard and + /// the RBAC re-check; the parent workflow id is not on the step context directly. + /// + public static Task LoadParentExecutionAsync( + NodePilotDbContext db, + Guid workflowExecutionId, + CancellationToken ct) + => db.WorkflowExecutions + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == workflowExecutionId, ct); + + /// True when the step would start the workflow it is running in (direct recursion). + public static bool IsSelfInvocation(WorkflowExecution? parentExec, Workflow childWorkflow) + => parentExec is not null && parentExec.WorkflowId == childWorkflow.Id; + + /// + /// Runtime RBAC re-check (Defense-in-Depth — folder permissions can be revoked between Publish + /// and Run). Returns the block reason WITHOUT an activity prefix, or null when allowed. + /// + public static async Task GetAuthorizationBlockAsync( + ISubWorkflowAuthorizationResolver? subWorkflowAuthz, + WorkflowExecution? parentExec, + Workflow childWorkflow, + CancellationToken ct) + { + if (subWorkflowAuthz is null || parentExec is null) return null; + return await subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); + } + + /// + /// Current call depth, read from the reserved variable the engine places into + /// context.Variables ("manual.__callDepth" when passed via inputParameters). Missing or + /// unparsable means depth 0. + /// + public static int CurrentCallDepth(StepExecutionContext context) + => context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) + && int.TryParse(depthStr, out var parsed) + ? parsed + : 0; + + /// + /// Reads the optional parameters object into a case-insensitive dictionary — the same + /// comparer the template resolver and PowerShell use, so Foo and foo collide. + /// Stops at the first "__"-prefixed key and reports it via : + /// that namespace belongs to engine bookkeeping (see __callDepth) and letting a user + /// steer it would bypass the recursion guard. + /// + public static Dictionary CollectParameters(JsonElement config, out string? reservedKey) + { + reservedKey = null; + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!config.TryGetProperty("parameters", out var paramsEl) || paramsEl.ValueKind != JsonValueKind.Object) + return parameters; + + foreach (var prop in paramsEl.EnumerateObject()) + { + if (WorkflowRecursion.IsReservedParameterName(prop.Name)) + { + reservedKey = prop.Name; + return parameters; + } + parameters[prop.Name] = PowerShellOperation.JsonElementToScalarString(prop.Value); + } + return parameters; + } +} diff --git a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs index d8b7fa12..1d64f804 100644 --- a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs +++ b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs @@ -274,18 +274,8 @@ private static string SyntheticLeafName(string path, string backupSuffix) protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Text File Edit: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Text File Edit", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/XmlQueryActivity.cs b/src/NodePilot.Engine/Activities/XmlQueryActivity.cs index a881ebf6..51d4c2ca 100644 --- a/src/NodePilot.Engine/Activities/XmlQueryActivity.cs +++ b/src/NodePilot.Engine/Activities/XmlQueryActivity.cs @@ -53,7 +53,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var loaded = await LoadXmlAsync(source, config, ct); if (loaded.Error is not null) return loaded.Error; - var xml = loaded.Xml!; + var xml = loaded.Content!; // Hard caps to stop a malicious inline XML from DoS-ing the engine: reject // payloads larger than 8 MiB outright, and load via XmlReader with DTD @@ -80,38 +80,15 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme }; }, ex => $"XmlQuery error: {ex.Message}"); - private async Task<(string? Xml, ActivityResult? Error)> LoadXmlAsync(string source, JsonElement config, CancellationToken ct) - { - if (source == "file") - { - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - return (null, Fail("'path' is required when source=file")); - - // M-8: apply the same PathGuard config FileOperationActivity uses — ops can - // restrict file-mode XmlQuery to allow-listed roots / reject `..` traversal. - if (_config is not null) - { - try { PathGuard.Validate(_config, path); } - catch (InvalidOperationException ex) - { - return (null, Fail($"file access denied: {ex.Message}")); - } - } - - if (!File.Exists(path)) - return (null, Fail($"file not found: {path}")); - var fileInfo = new FileInfo(path); - if (fileInfo.Length > MaxXmlBytes) - return (null, Fail($"file exceeds {MaxXmlBytes} bytes")); - return (await File.ReadAllTextAsync(path, ct), null); - } - - var inline = config.GetString("content", ""); - if (string.IsNullOrWhiteSpace(inline)) - return (null, Fail("'content' is required when source=inline")); - return (inline, null); - } + private Task<(string? Content, ActivityResult? Error)> LoadXmlAsync(string source, JsonElement config, CancellationToken ct) + => QueryPayloadSource.LoadAsync( + source, + config, + _config, + MaxXmlBytes, + Fail, + (_, _) => $"file exceeds {MaxXmlBytes} bytes", + ct); private static XmlDocument LoadXmlDocument(string xml) { diff --git a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs index 6ef56da3..cac733be 100644 --- a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs @@ -131,17 +131,8 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co { if (!raw.Success) return raw; - if (!PowerShellOperation.TryParseJsonBlock(raw.Output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Zip Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Zip Operation", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs b/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs index db138a83..c8dc5cc4 100644 --- a/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs +++ b/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs @@ -111,12 +111,10 @@ private static bool EvaluateComparison(JsonElement cmp, ConditionContext ctx) }; } - // Step-output template: matches the regex used by VariableResolver.StepPattern (kept - // independent so this evaluator can be reused without dragging the Engine.Execution - // namespace in). globals/manual run as a separate pre-pass because they don't carry a - // step-shaped tail. - private static readonly Regex TemplateRegex = new(@"\{\{([\w-]+)\.(output|error|success|param\.([\w-]+))\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - private static readonly Regex GlobalsTemplateRegex = new(@"\{\{globals\.([A-Za-z0-9_\-]+)\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + // Step-output and globals templates reuse VariableResolver's compiled patterns — this + // evaluator already depends on Engine.Execution (see EvaluateEdge below), so keeping a + // second copy of the same two patterns only risked them drifting apart. `manual.` has no + // counterpart there and runs as its own pre-pass; like globals it carries no step-shaped tail. private static readonly Regex ManualTemplateRegex = new(@"\{\{manual\.([A-Za-z0-9_\-]+)\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); private static string ResolveOperand(JsonElement operand, ConditionContext ctx) @@ -262,7 +260,7 @@ private static string ResolveTemplates(string raw, ConditionContext ctx) // mis-classified as an unresolved step (and silently return ""). if (ctx.GlobalVariables is not null && ctx.GlobalVariables.Count > 0) { - raw = GlobalsTemplateRegex.Replace(raw, m => + raw = VariableResolver.GlobalsPattern.Replace(raw, m => ctx.GlobalVariables.TryGetValue(m.Groups[1].Value, out var gv) ? gv : m.Value); } @@ -274,7 +272,11 @@ private static string ResolveTemplates(string raw, ConditionContext ctx) ctx.InputParameters.TryGetValue(m.Groups[1].Value, out var mv) ? mv : m.Value); } - return TemplateRegex.Replace(raw, m => + // The substitution body stays local: it resolves against ctx.Results with THAT dict's + // own comparer (ordinal for the engine's result map), whereas VariableResolver merges + // results + aliases into an OrdinalIgnoreCase map. Routing through it would newly + // resolve differently-cased step ids in conditions. + return VariableResolver.StepPattern.Replace(raw, m => { var name = m.Groups[1].Value; if (!ctx.Results.ContainsKey(name) && ctx.OutputVariableToStepId is not null diff --git a/src/NodePilot.Engine/Debug/DebugCoordinator.cs b/src/NodePilot.Engine/Debug/DebugCoordinator.cs index f8b943f6..4bdb5177 100644 --- a/src/NodePilot.Engine/Debug/DebugCoordinator.cs +++ b/src/NodePilot.Engine/Debug/DebugCoordinator.cs @@ -53,9 +53,7 @@ internal async Task HandlePauseAsync( var redactedVariables = new Dictionary(variables.Count, StringComparer.OrdinalIgnoreCase); foreach (var (k, v) in variables) redactedVariables[k] = _redactor.RedactNamedValue(k, v) ?? v; - var snapshotJson = JsonSerializer.Serialize(redactedVariables); - if (snapshotJson.Length > MaxSnapshotChars) - snapshotJson = snapshotJson.Substring(0, MaxSnapshotChars) + "... [truncated]"; + var snapshotJson = OutputRedactor.Cap(JsonSerializer.Serialize(redactedVariables), MaxSnapshotChars); stepExecution.Status = ExecutionStatus.Paused; stepExecution.PausedAt = DateTime.UtcNow; diff --git a/src/NodePilot.Engine/Execution/StepRunner.cs b/src/NodePilot.Engine/Execution/StepRunner.cs index a21169f6..a7b6aac4 100644 --- a/src/NodePilot.Engine/Execution/StepRunner.cs +++ b/src/NodePilot.Engine/Execution/StepRunner.cs @@ -618,10 +618,7 @@ private void LogStepDetail(WorkflowExecution execution, WorkflowNode node, Activ } private static string Truncate(string? s, int max) - { - if (string.IsNullOrEmpty(s)) return ""; - return s.Length > max ? s.Substring(0, max) + "... [truncated]" : s; - } + => string.IsNullOrEmpty(s) ? "" : OutputRedactor.Cap(s, max); /// /// Emits a compact line to the support log (a second Serilog sink) when a step diff --git a/src/NodePilot.Engine/Execution/VariableResolver.cs b/src/NodePilot.Engine/Execution/VariableResolver.cs index 7e63b572..a4a79913 100644 --- a/src/NodePilot.Engine/Execution/VariableResolver.cs +++ b/src/NodePilot.Engine/Execution/VariableResolver.cs @@ -50,18 +50,9 @@ internal static class VariableResolver /// 3. previous-step OutputParameters: fully-qualified {stepVar}.param.{key} /// always wins; the short-name alias is only added if not already present and /// not in . - /// - internal static Dictionary BuildStepVariables( - Dictionary? inputParameters, - IReadOnlyDictionary globalVariables, - IReadOnlyDictionary previousResults, - List allNodes) - => BuildStepVariables(inputParameters, globalVariables, previousResults, BuildOutputNameByStepId(allNodes)); - - /// - /// Hot-path overload: callers who already built an output-name index once per execution - /// (see ) pass it through directly to skip per-call - /// node scans. + /// + /// Callers pass the output-name index they already built once per execution + /// (see ) so nothing rescans the node list per call. /// internal static Dictionary BuildStepVariables( Dictionary? inputParameters, @@ -316,17 +307,6 @@ private static IReadOnlyDictionary BuildVariableMap( return map; } - /// - /// Materialises an id → node lookup for one call-site's batch of resolves. Called from - /// the legacy List<WorkflowNode> overloads; the hot path in - /// constructs the dict once per execution and reuses it. - /// - internal static Dictionary BuildNodesById(List allNodes) - => WorkflowDefinitionDocument.BuildNodesById(allNodes); - - internal static Dictionary BuildOutputNameByStepId(IReadOnlyList allNodes) - => WorkflowDefinitionDocument.BuildOutputNameByStepId(allNodes); - internal static Dictionary BuildOutputVariableAliasMap(IReadOnlyList allNodes) => WorkflowDefinitionDocument.BuildOutputVariableAliasMap(allNodes); @@ -358,22 +338,20 @@ internal static Dictionary BuildOutputVariableAliasMap(IReadOnly return StepPattern.Replace(raw!, match => { - var varNameStr = match.Groups[1].Value; - var propertyStr = match.Groups[2].Value; + var varName = match.Groups[1].Value; + var property = match.Groups[2].Value; + + if (!variableMap.TryGetValue(varName, out var result)) + return match.Value; - if (variableMap.TryGetValue(varNameStr, out var res) && propertyStr.StartsWith("param.") && match.Groups[3].Success) + if (property.StartsWith("param.") && match.Groups[3].Success) { // No Trim — keeps the value byte-identical to ResolveVariables' JSON-config // pass. Mismatched trim semantics caused subtle bugs where the same template // resolved one way in restApi.url (string-path) and another way in restApi.body // (JSON-path). - return res.OutputParameters.TryGetValue(match.Groups[3].Value, out var pv) ? pv : match.Value; + return result.OutputParameters.TryGetValue(match.Groups[3].Value, out var pv) ? pv : match.Value; } - var varName = match.Groups[1].Value; - var property = match.Groups[2].Value; - - if (!variableMap.TryGetValue(varName, out var result)) - return match.Value; return property.ToLowerInvariant() switch { diff --git a/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs b/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs index 735249a1..e25845fc 100644 --- a/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs +++ b/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs @@ -128,6 +128,29 @@ public static Dictionary MapObjectFields( return result; } + /// + /// Projects a bare ConvertTo-Json status object (no marker envelope) into the + /// requested fields. ConvertTo-Json wraps a single result in {} but multiple results + /// in [], so a 1-element array is defensively unwrapped. Non-JSON output — e.g. the + /// cmdlet printed an error instead — yields an empty dictionary rather than throwing. + /// + public static Dictionary MapStatusJsonFields( + string? json, + params (string SourceKey, string DestKey)[] fields) + { + if (json is null || !TryParseJson(json, out var parsedDoc, out _)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var doc = parsedDoc!; + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) + root = root[0]; + + return root.ValueKind == JsonValueKind.Object + ? MapObjectFields(root, fields) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + public static string JsonElementToScalarString(JsonElement value) => value.ValueKind switch { JsonValueKind.String => value.GetString() ?? string.Empty, diff --git a/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs b/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs index 5a7ce00b..f2c453d3 100644 --- a/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs +++ b/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs @@ -159,13 +159,7 @@ public async Task ExecuteAsync(PowerShellExecutionReq catch (Exception ex) { sw.Stop(); - return new PowerShellExecutionResult - { - Success = false, - ExitCode = -1, - Error = $"Failed to start {_executable}: {ex.Message}", - Duration = sw.Elapsed, - }; + return EngineFailure($"Failed to start {_executable}: {ex.Message}", sw.Elapsed); } finally { @@ -300,13 +294,7 @@ private async Task ExecuteIsolatedWindowsAsync(PowerS catch (Exception ex) { sw.Stop(); - return new PowerShellExecutionResult - { - Success = false, - ExitCode = -1, - Error = $"Isolated execution failed: {ex.Message}", - Duration = sw.Elapsed, - }; + return EngineFailure($"Isolated execution failed: {ex.Message}", sw.Elapsed); } finally { @@ -339,6 +327,19 @@ private async Task ExecuteIsolatedWindowsAsync(PowerS } } + /// + /// The engine never got far enough to have a script result: no exit code, no output, just + /// the reason. Callers stop the stopwatch first so the reported duration excludes the + /// message formatting. + /// + private static PowerShellExecutionResult EngineFailure(string error, TimeSpan duration) => new() + { + Success = false, + ExitCode = -1, + Error = error, + Duration = duration, + }; + /// /// Returns a completed read's text, or empty for a read still blocked by a leaked inherited pipe /// handle. A still-pending read is observed via a continuation so its eventual fault diff --git a/src/NodePilot.Engine/Scorch/ScorchImporter.cs b/src/NodePilot.Engine/Scorch/ScorchImporter.cs index 55a5631c..481d8803 100644 --- a/src/NodePilot.Engine/Scorch/ScorchImporter.cs +++ b/src/NodePilot.Engine/Scorch/ScorchImporter.cs @@ -70,23 +70,7 @@ public sealed class ScorchImporter }; public ScorchImportResult Parse(string xml) - { - var result = new ScorchImportResult(); - - XDocument doc; - try - { - using var reader = XmlReader.Create(new StringReader(xml), HardenedReaderSettings); - doc = XDocument.Load(reader); - } - catch (Exception ex) - { - result.Errors.Add($"Failed to parse XML: {ex.Message}"); - return result; - } - - return ParseFromDocument(doc, result); - } + => ParseFromReader(() => XmlReader.Create(new StringReader(xml), HardenedReaderSettings)); /// /// M-14: Stream-based overload. Preferred over for large @@ -95,13 +79,18 @@ public ScorchImportResult Parse(string xml) /// tree). Streaming straight into the XmlReader lets the XML parser hold only one copy. /// public ScorchImportResult Parse(Stream xmlStream) + => ParseFromReader(() => XmlReader.Create(xmlStream, HardenedReaderSettings)); + + // The factory is invoked INSIDE the try: XmlReader.Create itself can throw on a bad + // source, and that has always been reported as a parse error rather than propagated. + private ScorchImportResult ParseFromReader(Func createReader) { var result = new ScorchImportResult(); XDocument doc; try { - using var reader = XmlReader.Create(xmlStream, HardenedReaderSettings); + using var reader = createReader(); doc = XDocument.Load(reader); } catch (Exception ex) diff --git a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs index 53926068..090f1b4c 100644 --- a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs +++ b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs @@ -42,19 +42,14 @@ public static void Validate(IConfiguration config, string dir) { foreach (var blocked in HardBlockedWindowsRoots) { - if (normalized.Equals(blocked, StringComparison.OrdinalIgnoreCase) - || normalized.StartsWith(blocked + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + if (PathGuard.IsWithinRoot(normalized, blocked)) throw new InvalidOperationException( $"FileWatcherTrigger: directory '{dir}' is under a system path ('{blocked}'). " + "Set Trigger:FileWatcher:AllowSystemPaths=true and add it to AllowedRoots to override."); } } - var roots = config.GetSection("Trigger:FileWatcher:AllowedRoots").GetChildren() - .Select(c => c.Value) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Cast() - .ToArray(); + var roots = PathGuard.ReadConfiguredRoots(config, "Trigger:FileWatcher:AllowedRoots"); if (roots.Length == 0) return; var allowed = roots.Any(root => @@ -62,8 +57,7 @@ public static void Validate(IConfiguration config, string dir) string rFull; try { rFull = Path.GetFullPath(root); } catch { return false; } var r = rFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return normalized.Equals(r, StringComparison.OrdinalIgnoreCase) - || normalized.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + return PathGuard.IsWithinRoot(normalized, r); }); if (!allowed) throw new InvalidOperationException( diff --git a/src/NodePilot.Engine/Security/OutputRedactor.cs b/src/NodePilot.Engine/Security/OutputRedactor.cs index fd945049..445de146 100644 --- a/src/NodePilot.Engine/Security/OutputRedactor.cs +++ b/src/NodePilot.Engine/Security/OutputRedactor.cs @@ -165,6 +165,25 @@ public OutputRedactor(IConfiguration? configuration = null, ILogger + /// Length cap for untrusted text before it lands in a DB row or a log line, so a single + /// leaked blob can't blow the row or the audit log. The "... [truncated]" marker is + /// part of the persisted value — the inspector UI shows it as "this was truncated". + /// + public static string Cap(string value, int maxChars) + => value.Length > maxChars ? value.Substring(0, maxChars) + "... [truncated]" : value; + + /// + /// Apply the redactor to untrusted text and cap the length. Used for ErrorMessage, + /// InputParametersJson, ReturnData. + /// + public string? RedactAndCap(string? value, int maxChars) + { + if (string.IsNullOrEmpty(value)) return value; + var redacted = Redact(value) ?? value; + return Cap(redacted, maxChars); + } + /// /// Redacts a value with awareness of the field/variable name. Value-only regexes cannot /// recognize opaque secrets such as dbPassword = hunter2 once the key and value have diff --git a/src/NodePilot.Engine/Security/PathGuard.cs b/src/NodePilot.Engine/Security/PathGuard.cs index 90ee18ca..ac11ff83 100644 --- a/src/NodePilot.Engine/Security/PathGuard.cs +++ b/src/NodePilot.Engine/Security/PathGuard.cs @@ -71,12 +71,7 @@ public static void Validate(IConfiguration config, string path, bool allowWildca if (rejectTraversal && ContainsTraversal(path)) throw new InvalidOperationException($"File System Operation: path '{path}' contains '..' traversal (blocked by FileSystemOperation:RejectTraversal)"); - var roots = config.GetSection("FileSystemOperation:AllowedRoots") - .GetChildren() - .Select(c => c.Value) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Cast() - .ToArray(); + var roots = ReadConfiguredRoots(config, "FileSystemOperation:AllowedRoots"); if (roots.Length > 0) { string fullPath; @@ -93,18 +88,34 @@ public static void Validate(IConfiguration config, string path, bool allowWildca throw new InvalidOperationException($"File System Operation: path '{path}' final path could not be resolved: {ex.Message}"); } - var allowed = roots.Any(root => - { - var r = ResolveLocalFinalPath(Path.GetFullPath(root)); - return fullNormalized.Equals(r, StringComparison.OrdinalIgnoreCase) - || fullNormalized.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); - }); + var allowed = roots.Any(root => IsWithinRoot(fullNormalized, ResolveLocalFinalPath(Path.GetFullPath(root)))); if (!allowed) throw new InvalidOperationException($"File System Operation: path '{path}' is not within any configured FileSystemOperation:AllowedRoots"); } } + /// + /// Reads an allow-list section as a string array, dropping blank entries. Shared with + /// so both guards read their roots the same way. + /// + internal static string[] ReadConfiguredRoots(IConfiguration config, string sectionPath) + => config.GetSection(sectionPath) + .GetChildren() + .Select(c => c.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Cast() + .ToArray(); + + /// + /// Root-containment test shared by both path guards: the path is the root itself, or sits + /// below it. Both arguments must already be normalized (full path, no trailing separator) — + /// the separator suffix is what stops C:\Data2 from matching the root C:\Data. + /// + internal static bool IsWithinRoot(string normalizedPath, string normalizedRoot) + => normalizedPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) + || normalizedPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + public static void ValidateSiblingRenameTarget(IConfiguration config, string currentPath, string newName) { ValidateLeafName(newName); diff --git a/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs b/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs index d0cd9704..0a23806e 100644 --- a/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs +++ b/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs @@ -205,17 +205,9 @@ private static SocketsHttpHandler BuildHandler( ConnectCallback = (ctx, ct) => ConnectWithSsrfGuardAsync(ctx, configuration, ct), }; + // The string overload owns the http(s)-URL validation (same check, same message). if (useProxy && !string.IsNullOrWhiteSpace(address)) - { - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) - { - throw new InvalidOperationException( - $"REST API: proxy address '{address}' is not a valid http(s) URL."); - } - - handler.Proxy = CreateProxy(proxyUri, bypassPatterns, username, password); - } + handler.Proxy = CreateProxy(address, bypassPatterns, username, password); return handler; } diff --git a/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs b/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs index d99b2c12..5504748d 100644 --- a/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs +++ b/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs @@ -37,10 +37,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso { // If the orchestrator's polling source fired this trigger, it already ran the query and // just needs to surface the change-detection data to downstream steps. - var orchestratorParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - orchestratorParams[k["manual.".Length..]] = v; + var orchestratorParams = TriggerVariables.ExtractManualParams(context.Variables); if (orchestratorParams.TryGetValue("dbSentinel", out var dbSentinel)) { return new ActivityResult diff --git a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs index 1da39489..c747e90c 100644 --- a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs +++ b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs @@ -38,10 +38,7 @@ public EventLogTrigger(IConfiguration? config = null) public Task ExecuteAsync(StepExecutionContext context, JsonElement config, CancellationToken ct) { // If the orchestrator fired this trigger, event metadata is in context.Variables as manual.* - var orchestratorParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - orchestratorParams[k["manual.".Length..]] = v; + var orchestratorParams = TriggerVariables.ExtractManualParams(context.Variables); if (orchestratorParams.TryGetValue("eventId", out var triggeredEventId)) { diff --git a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs index 41760dc9..4b4a25e9 100644 --- a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs +++ b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs @@ -34,10 +34,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso } // If the orchestrator fired this trigger, event data is in context.Variables as manual.* - var outputParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - outputParams[k["manual.".Length..]] = v; + var outputParams = TriggerVariables.ExtractManualParams(context.Variables); if (outputParams.TryGetValue("filePath", out var triggeredFile)) { diff --git a/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs b/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs index fc19ef7e..bdd37e18 100644 --- a/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs +++ b/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs @@ -30,16 +30,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme { Success = true, Output = $"Schedule trigger fired at {DateTime.UtcNow:O}\nCron: {cron}\nDescription: {description ?? "N/A"}", - OutputParameters = ExtractManualParams(context.Variables), + OutputParameters = TriggerVariables.ExtractManualParams(context.Variables), }); } - - private static Dictionary ExtractManualParams(Dictionary vars) - { - var result = new Dictionary(); - foreach (var (k, v) in vars) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - result[k["manual.".Length..]] = v; - return result; - } } diff --git a/src/NodePilot.Engine/Triggers/TriggerVariables.cs b/src/NodePilot.Engine/Triggers/TriggerVariables.cs new file mode 100644 index 00000000..9b37c329 --- /dev/null +++ b/src/NodePilot.Engine/Triggers/TriggerVariables.cs @@ -0,0 +1,23 @@ +namespace NodePilot.Engine.Triggers; + +/// +/// Shared helpers for the trigger node-executors. When a background source fires a workflow, +/// the trigger payload arrives in context.Variables under the manual. prefix +/// (there is no trigger.* namespace); every trigger node surfaces the same flat, +/// prefix-stripped view of it as its OutputParameters. +/// +internal static class TriggerVariables +{ + /// + /// Copies every manual.* entry into a flat dictionary with the prefix stripped. + /// Returns an empty dictionary when the node runs manually without trigger payload. + /// + internal static Dictionary ExtractManualParams(IReadOnlyDictionary variables) + { + var result = new Dictionary(); + foreach (var (k, v) in variables) + if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) + result[k["manual.".Length..]] = v; + return result; + } +} diff --git a/src/NodePilot.Engine/Triggers/WebhookTrigger.cs b/src/NodePilot.Engine/Triggers/WebhookTrigger.cs index 921ec61e..379cb689 100644 --- a/src/NodePilot.Engine/Triggers/WebhookTrigger.cs +++ b/src/NodePilot.Engine/Triggers/WebhookTrigger.cs @@ -17,10 +17,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var path = config.TryGetProperty("path", out var p) ? p.GetString() : null; var method = config.TryGetProperty("method", out var m) ? m.GetString() : "POST"; - var outputParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - outputParams[k["manual.".Length..]] = v; + var outputParams = TriggerVariables.ExtractManualParams(context.Variables); var body = outputParams.GetValueOrDefault("webhookBody", "(no body)"); return Task.FromResult(new ActivityResult diff --git a/src/NodePilot.Engine/WorkflowEngine.cs b/src/NodePilot.Engine/WorkflowEngine.cs index a8f22188..da25e1d8 100644 --- a/src/NodePilot.Engine/WorkflowEngine.cs +++ b/src/NodePilot.Engine/WorkflowEngine.cs @@ -70,20 +70,6 @@ public static bool TryGetExecutionCancellation(Guid executionId, out Cancellatio return true; } - /// - /// Apply the redactor to untrusted text and cap the length so a single leaked blob - /// can't blow the DB row or the audit log. Internal helper used for ErrorMessage, - /// InputParametersJson, ReturnData. - /// - private string? RedactAndCap(string? value, int maxChars) - { - if (string.IsNullOrEmpty(value)) return value; - var redacted = _redactor.Redact(value) ?? value; - return redacted.Length > maxChars - ? redacted.Substring(0, maxChars) + "... [truncated]" - : redacted; - } - // Per-execution debug state: breakpoint pauses + step-over control. Runs parallel to // the _runningExecutions dict (same executionId key), so the cancel path can also // reach any paused steps (otherwise the engine thread would hang forever if the user @@ -575,7 +561,7 @@ private WorkflowExecution CreateOrResetExecution( // H-8 (security-audit finding): input parameter JSON may contain values // resolved from secrets/globals — run it through the redactor and cap it to // 32 KiB so a runaway caller can't blow up the DB row or the audit log. - InputParametersJson = RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024), + InputParametersJson = _redactor.RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024), StartedByUserId = startedByUserId, ParentExecutionId = parentExecutionId, CallDepth = callDepth, @@ -595,7 +581,7 @@ private WorkflowExecution CreateOrResetExecution( execution.ErrorMessage = null; execution.TraceId = activity?.TraceId.ToString(); execution.SpanId = activity?.SpanId.ToString(); - execution.InputParametersJson = RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024); + execution.InputParametersJson = _redactor.RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024); execution.StartedByUserId = startedByUserId; execution.ParentExecutionId = parentExecutionId; execution.CallDepth = callDepth; @@ -1033,15 +1019,7 @@ await PersistTerminalStateResilientAsync( errorMessage, cancelledBy: null, "execution.no_roots"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync(execution.Id, execution.WorkflowId, - execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); return execution; } @@ -1147,7 +1125,7 @@ await WorkflowScheduler.RunAsync(rootNodes, nodesById, adjacency, reverseAdjacen ? $" (+{failedStepCount - 1} more failed activities)" : string.Empty; - failureSummary = RedactAndCap( + failureSummary = _redactor.RedactAndCap( $"Activity \"{stepLabel}\" failed{errorDetail}{additionalFailures}", 32 * 1024); } @@ -1173,15 +1151,7 @@ await _notifier.StepCompletedAsync(execution.Id, execution.WorkflowId, id, label ExecutionStatus.Skipped, null, null, DateTime.UtcNow, stepType: type); } - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); if (execution.Status == ExecutionStatus.Failed) @@ -1214,15 +1184,7 @@ await PersistTerminalStateResilientAsync( errorMessage: null, cancelledBy, "execution.cancelled"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); run.Activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); run.Activity?.SetStatus(ActivityStatusCode.Error, "cancelled"); EngineMetrics.Cancellations.Add(1, run.WorkflowIdTag, run.WorkflowNameTag, new KeyValuePair("reason", "user_or_token")); @@ -1242,22 +1204,14 @@ private async Task CompleteAsFailedAsync(ExecutionRun run, Ex // H-8/H-9 (security-audit findings): redact + cap — the exception may carry a // leaked secret from a child activity (e.g. an HTTP body echoed back in a // deserialization error). - var errorMessage = RedactAndCap(ex.Message, 32 * 1024); + var errorMessage = _redactor.RedactAndCap(ex.Message, 32 * 1024); await PersistTerminalStateResilientAsync( run, ExecutionStatus.Failed, errorMessage, cancelledBy: null, "execution.failed"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); run.Activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); run.Activity?.SetStatus(ActivityStatusCode.Error, execution.Status == ExecutionStatus.Cancelled ? "cancelled" : ex.Message); @@ -1270,6 +1224,25 @@ await _notifier.ExecutionStatusChangedAsync( private static bool IsTerminalStatus(ExecutionStatus status) => status is ExecutionStatus.Succeeded or ExecutionStatus.Failed or ExecutionStatus.Cancelled; + /// + /// Emits the terminal SignalR event — but only if the terminal write actually landed. + /// A fenced write (execution ownership / leader lease lost) leaves the row non-terminal; + /// announcing a status this node no longer owns would race the owning node, so we log instead. + /// + private async Task NotifyTerminalStateAsync(ExecutionRun run) + { + var execution = run.Execution; + if (IsTerminalStatus(execution.Status)) + { + await _notifier.ExecutionStatusChangedAsync( + execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); + } + else + { + LogTerminalWriteFenced(run); + } + } + private void LogTerminalWriteFenced(ExecutionRun run) { _logger.LogWarning( diff --git a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs index 29e61caf..6bffe84e 100644 --- a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api.Dtos; using NodePilot.Mcp.Config; @@ -728,35 +729,12 @@ public async Task ExecuteDbReadQueryAsync(string sql, Canc return await ParseAsync(res, ct); } - private static async Task ParseAsync(HttpResponseMessage res, CancellationToken ct) - { - await EnsureSuccessAsync(res, ct); - if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) - return default!; - var stream = await res.Content.ReadAsStreamAsync(ct); - var value = await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); - if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); - return value; - } + // Response plumbing is shared with the CLI client — see ApiResponseReader in Core. + private static Task ParseAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.ParseAsync(res, JsonOptions, ct); - private static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) - { - if (res.IsSuccessStatusCode) return; - var body = await res.Content.ReadAsStringAsync(ct); - string? title = null, detail = null; - if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); - if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); - if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); - } - catch (JsonException) { /* leave body as raw */ } - } - throw new ApiException(res.StatusCode, title, detail, body); - } + private static Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.EnsureSuccessAsync(res, ct); } /// Thrown when the server URL is not configured — distinct from an HTTP error. diff --git a/src/NodePilot.Mcp/Auth/TokenStore.cs b/src/NodePilot.Mcp/Auth/TokenStore.cs index 7baaeee9..cadfd6a0 100644 --- a/src/NodePilot.Mcp/Auth/TokenStore.cs +++ b/src/NodePilot.Mcp/Auth/TokenStore.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using NodePilot.Mcp.Config; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Auth; @@ -19,7 +19,7 @@ public sealed class TokenStore private readonly string _baseDir; - public TokenStore() : this(ConfigStore.DefaultConfigDir()) { } + public TokenStore() : this(ClientConfigStore.DefaultConfigDir()) { } public TokenStore(string baseDir) { diff --git a/src/NodePilot.Mcp/CLAUDE.md b/src/NodePilot.Mcp/CLAUDE.md index fad77881..69e32576 100644 --- a/src/NodePilot.Mcp/CLAUDE.md +++ b/src/NodePilot.Mcp/CLAUDE.md @@ -6,4 +6,6 @@ Reiner HTTP-Client gegen die REST-API (wie die CLI) + In-Proc-Analyse gegen `Nod **Architektur-Konvention:** Neuer API-Endpoint → Methode in `Api/NodePilotApiClient.cs` (DTOs in `Api/Dtos/` dupliziert) + `[McpServerTool]`-Methode in der passenden `Tools/*Tools.cs` (destruktiv → `DestructiveTools` + `get_safety_status`-Liste pflegen), ggf. Klasse in `Program.cs` via `WithTools()` registrieren (**nie** `WithToolsFromAssembly`), WireMock-Test ergänzen. Frontend-Databus-/Lint-Logik wird in `Analysis/` gespiegelt (`upstreamVariables.ts`, `activityConfigFacts.ts`, `workflowLint.ts`). +**Geteilte Client-Infrastruktur:** `ApiException`, das Response-Plumbing (`ApiResponseReader`) und die Lese-Seite der `config.json` (`ClientConfigStore` + `CliConfig`) liegen in `NodePilot.Core.Clients` — gemeinsam mit der CLI. **Nur die DTOs bleiben bewusst dupliziert** (`Api/Dtos/`); neue Infrastruktur nicht erneut kopieren. + **Activity-Config-Reference:** liegt **nicht** unter `Resources/Embedded/`, sondern in `NodePilot.Core` (`Activities/Embedded/activity-config-reference.json`, gelesen über `ActivityConfigReference`) — `NodePilot.Ai` rendert daraus den Activity-Katalog der AI-Prompts. Neue/geänderte Config-Keys dort pflegen; `ActivityConfigReferenceTests` prüft, dass jeder dokumentierte Key vom Executor wirklich gelesen wird. diff --git a/src/NodePilot.Mcp/Config/McpServerConfig.cs b/src/NodePilot.Mcp/Config/McpServerConfig.cs index f900fa3a..4e2e5170 100644 --- a/src/NodePilot.Mcp/Config/McpServerConfig.cs +++ b/src/NodePilot.Mcp/Config/McpServerConfig.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using NodePilot.Core.Clients; using NodePilot.Mcp.Auth; namespace NodePilot.Mcp.Config; @@ -15,10 +16,10 @@ namespace NodePilot.Mcp.Config; [SupportedOSPlatform("windows")] public sealed class McpServerConfig { - private readonly ConfigStore _config; + private readonly ClientConfigStore _config; private readonly TokenStore _tokens; - public McpServerConfig(ConfigStore config, TokenStore tokens) + public McpServerConfig(ClientConfigStore config, TokenStore tokens) { _config = config; _tokens = tokens; diff --git a/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs b/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs index 729a9959..1a3e4f71 100644 --- a/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs +++ b/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs @@ -1,4 +1,5 @@ using ModelContextProtocol; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api; namespace NodePilot.Mcp.Mapping; diff --git a/src/NodePilot.Mcp/Program.cs b/src/NodePilot.Mcp/Program.cs index 003032cc..16b68ff7 100644 --- a/src/NodePilot.Mcp/Program.cs +++ b/src/NodePilot.Mcp/Program.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api; using NodePilot.Mcp.Auth; using NodePilot.Mcp.Config; @@ -20,7 +21,7 @@ // Connection plumbing — singletons. The NodePilotApiClient is built once from the resolved // session (env-first; falls back to the CLI's DPAPI session + config.json). -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/NodePilot.Scheduler/AuditLogRetentionService.cs b/src/NodePilot.Scheduler/AuditLogRetentionService.cs index 323f320b..5ce2cbe1 100644 --- a/src/NodePilot.Scheduler/AuditLogRetentionService.cs +++ b/src/NodePilot.Scheduler/AuditLogRetentionService.cs @@ -4,7 +4,6 @@ using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -45,73 +44,28 @@ namespace NodePilot.Scheduler; /// can't drift-check without their original hash. /// /// -public class AuditLogRetentionService : BackgroundService +public class AuditLogRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:AuditLog:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private AuditLogRetentionOptions Opts => _opts.CurrentValue.AuditLog; private DateTime _lastVerifyUtc = DateTime.MinValue; - private readonly IDatabaseAvailability _availability; - public AuditLogRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("AuditLogRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: only the leader sweeps audit log so two nodes don't race on DELETEs. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(5, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("AuditLogRetentionService stopped."); - } + protected override string ServiceName => nameof(AuditLogRetentionService); + protected override string MetricServiceTag => "audit_log"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 5; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -120,7 +74,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// Internal so unit tests can drive a single pass (incl. the hot-reload Enabled-toggle path) /// without the 60-second warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -143,14 +97,11 @@ internal async Task RunIterationAsync(CancellationToken ct) if (deleted > 0) _logger.LogInformation("AuditLog retention pass deleted {Count} old entries.", deleted); - var tags = new TagList { new("nodepilot.retention.service", "audit_log") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "AuditLogRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: $"ok: {deleted} deleted", ct: ct); + await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); // Archive integrity verify pass — independent cadence (default daily) so a // 12h-interval retention doesn't pay the SHA-256 walk twice per day. The @@ -174,7 +125,7 @@ await SystemHealthWriter.BeatAsync(db, "AuditLogRetentionService", catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "audit_log") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "AuditLog retention pass failed — will retry on next interval."); } diff --git a/src/NodePilot.Scheduler/ExecutionRetentionService.cs b/src/NodePilot.Scheduler/ExecutionRetentionService.cs index 1e30d6b4..c5403558 100644 --- a/src/NodePilot.Scheduler/ExecutionRetentionService.cs +++ b/src/NodePilot.Scheduler/ExecutionRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Core.Enums; @@ -37,74 +36,28 @@ namespace NodePilot.Scheduler; /// - Deletes in bounded batches (BatchSize, default 500) so one pass never holds /// a long-running transaction on SQLite. /// -public class ExecutionRetentionService : BackgroundService +public class ExecutionRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:Executions:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private ExecutionsRetentionOptions Opts => _opts.CurrentValue.Executions; - private readonly IDatabaseAvailability _availability; - public ExecutionRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Small initial delay so the patcher + trigger orchestrator have time to settle on a - // cold start before we start issuing DELETEs. - try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("ExecutionRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: only the leader may run retention sweeps. Otherwise a follower would - // contend on the same DELETEs and double the IO cost on the shared DB. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("ExecutionRetentionService stopped."); - } + protected override string ServiceName => nameof(ExecutionRetentionService); + protected override string MetricServiceTag => "execution"; + // Small initial delay so the patcher + trigger orchestrator have time to settle on a + // cold start before we start issuing DELETEs. + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(30); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -112,7 +65,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the 30-second warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. The outer @@ -135,7 +88,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Retention pass deleted {Count} old executions (and cascaded step rows).", deleted); - var tags = new TagList { new("nodepilot.retention.service", "execution") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); @@ -143,20 +96,12 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "execution") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Retention pass failed — will retry on next interval."); } } - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "ExecutionRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } - // Exposed as internal so unit tests can drive a single pass without waiting the 30-second // warm-up or a full interval. internal async Task PurgeOnceAsync(int maxAgeDays, int batchSize, CancellationToken ct) diff --git a/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs b/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs new file mode 100644 index 00000000..08d482c3 --- /dev/null +++ b/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodePilot.Core.Interfaces; +using NodePilot.Data; +using NodePilot.Data.Availability; +using NodePilot.Scheduler.Options; + +namespace NodePilot.Scheduler; + +/// +/// Shared frame of the leader-gated retention sweeps (executions, audit log, workflow versions, +/// support events, notification ledger). It owns exactly the part that was identical in all five +/// services: the cold-start warm-up, the started/stopped log pair, and the pass loop with its +/// availability gate → leader gate → one iteration → interval delay. +/// +/// +/// The sweep itself stays in the derived , deliberately: its broad +/// catch (Exception) must remain one level BELOW the host-fatal boundary, because +/// HostOptions.BackgroundServiceExceptionBehavior is left at StopHost and anything +/// escaping this loop takes the host with it. The only catch this class owns is the +/// that turns a shutdown into a clean loop exit. +/// +/// +public abstract class LeaderGatedRetentionService : BackgroundService +{ + protected readonly IServiceScopeFactory _scopeFactory; + // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of the sweep's + // Retention:* section takes effect on the next pass without a restart. + protected readonly IOptionsMonitor _opts; + // Non-generic on purpose: each derived service passes its own ILogger, so the log + // category stays the concrete service. + protected readonly ILogger _logger; + + private readonly IClusterStateProvider _cluster; + private readonly IDatabaseAvailability _availability; + + protected LeaderGatedRetentionService( + IServiceScopeFactory scopeFactory, + IOptionsMonitor opts, + IClusterStateProvider cluster, + ILogger logger, + IDatabaseAvailability availability) + { + _scopeFactory = scopeFactory; + _opts = opts; + _cluster = cluster; + _availability = availability; + _logger = logger; + } + + /// + /// The concrete service name — used verbatim in the two lifecycle log lines and as the + /// heartbeat key in SystemHealth. + /// + protected abstract string ServiceName { get; } + + /// Cold-start grace before the first pass. + protected abstract TimeSpan WarmUpDelay { get; } + + /// Lower bound (minutes) for the inter-pass delay. + protected abstract int MinIntervalMinutes { get; } + + /// Live IntervalMinutes of this sweep's options section — read per pass. + protected abstract int ConfiguredIntervalMinutes { get; } + + /// Value of the nodepilot.retention.service metric tag. + protected abstract string MetricServiceTag { get; } + + protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(WarmUpDelay, stoppingToken); } + catch (OperationCanceledException) { return; } + + // Composed instead of a literal so every service keeps the exact message (and template) + // it logged before the five loops were merged into this base. +#pragma warning disable CA2254 + _logger.LogInformation(ServiceName + " started (hot-reload: per-pass config)."); +#pragma warning restore CA2254 + + while (!stoppingToken.IsCancellationRequested) + { + // Availability gate, deliberately ABOVE the leader check: during a database outage no + // node can renew its cluster lease, so every node reads as a follower - gating on + // IsLeader first would park for the right reason and log the wrong one. + // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior + // is left at its default StopHost, so an escaping cancellation would stop the host). + if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; + + // HA gate: only the leader sweeps. Otherwise a follower would contend on the same + // DELETEs and double the IO cost on the shared DB. + if (!_cluster.IsLeader) + { + try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } + catch (OperationCanceledException) { break; } + continue; + } + + try + { + await RunIterationAsync(stoppingToken); + } + catch (OperationCanceledException) { break; } + + var interval = TimeSpan.FromMinutes(Math.Max(MinIntervalMinutes, ConfiguredIntervalMinutes)); + try { await Task.Delay(interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + +#pragma warning disable CA2254 + _logger.LogInformation(ServiceName + " stopped."); +#pragma warning restore CA2254 + } + + /// + /// Exactly one sweep iteration. Implemented by the derived service — including its own broad + /// catch, which must stay below the host-fatal boundary (see the class remarks). No + /// Task.Delay in there: this class owns the inter-pass spacing. + /// + internal abstract Task RunIterationAsync(CancellationToken ct); + + /// Metric tags for this sweep: the single nodepilot.retention.service tag. + protected TagList RetentionTags() => new TagList { new("nodepilot.retention.service", MetricServiceTag) }; + + /// Liveness beat recorded under . + protected async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SystemHealthWriter.BeatAsync(db, ServiceName, + expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); + } +} diff --git a/src/NodePilot.Scheduler/NotificationDispatcher.cs b/src/NodePilot.Scheduler/NotificationDispatcher.cs index e2962268..748a5f6e 100644 --- a/src/NodePilot.Scheduler/NotificationDispatcher.cs +++ b/src/NodePilot.Scheduler/NotificationDispatcher.cs @@ -219,18 +219,7 @@ private async Task DispatchSystemAlertsAsync( } } - // Always save — flushes evaluator-staged match/episode state even when nothing fires this pass. - await db.SaveChangesAsync(ct); - - var sent = 0; - foreach (var (attempt, route, ctx) in toSend) - { - if (!StillOwnsLease(leaseEpoch)) break; - await SendOneAsync(db, store, attempt, route, ctx, now, leaseEpoch, ct); - if (attempt.Status == NotificationDeliveryStatus.Sent) sent++; - } - if (toSend.Count > 0) await db.SaveChangesAsync(ct); - return sent; + return await PersistAndSendAsync(db, store, toSend, now, leaseEpoch, ct); } /// @@ -284,8 +273,24 @@ private async Task MatchAndSendAsync( } } - // Always save once — flushes the collector's staged state (watermark / signal-states) even - // when nothing matched, plus the Pending attempts. + return await PersistAndSendAsync(db, store, toSend, now, leaseEpoch, ct); + } + + /// + /// Shared tail of both delivery paths. One unconditional SaveChanges flushes whatever the caller + /// staged on the tracked context — the collector's watermark / signal-states, or the system-alert + /// evaluator's match/episode state — together with the Pending attempts, even when nothing fired + /// this pass; that ordering is what preserves persist-before-send crash-safety. Then every attempt + /// is sent under the lease fence and the resulting statuses are saved. + /// + private async Task PersistAndSendAsync( + NodePilotDbContext db, + INotificationRuleStore store, + List<(NotificationDeliveryAttempt attempt, NotificationRoute route, NotificationContext ctx)> toSend, + DateTime now, + long leaseEpoch, + CancellationToken ct) + { await db.SaveChangesAsync(ct); var sent = 0; diff --git a/src/NodePilot.Scheduler/NotificationRetentionService.cs b/src/NodePilot.Scheduler/NotificationRetentionService.cs index 4105a672..be7ce242 100644 --- a/src/NodePilot.Scheduler/NotificationRetentionService.cs +++ b/src/NodePilot.Scheduler/NotificationRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Core.Enums; @@ -26,71 +25,26 @@ namespace NodePilot.Scheduler; /// Config: (Retention:Notifications:* — /// Enabled=true, MaxAgeDays=90, IntervalMinutes=360). /// -public class NotificationRetentionService : BackgroundService +public class NotificationRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:Notifications:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private NotificationsRetentionOptions Opts => _opts.CurrentValue.Notifications; - private readonly IDatabaseAvailability _availability; - public NotificationRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("NotificationRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("NotificationRetentionService stopped."); - } + protected override string ServiceName => nameof(NotificationRetentionService); + protected override string MetricServiceTag => "notification_deliveries"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -98,7 +52,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -119,7 +73,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Pruned {Count} notification delivery/suppression rows older than {Days}d.", deleted, maxAgeDays); - var tags = new TagList { new("nodepilot.retention.service", "notification_deliveries") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} pruned", ct); @@ -127,7 +81,7 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "notification_deliveries") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Notification retention sweep failed — retrying on next interval."); } @@ -159,12 +113,4 @@ internal async Task PurgeOnceAsync(int maxAgeDays, CancellationToken ct) return deletedAttempts + deletedSuppressions + deletedPolicyStates; } - - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "NotificationRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } } diff --git a/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs new file mode 100644 index 00000000..6185e5ba --- /dev/null +++ b/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs @@ -0,0 +1,123 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Enums; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.Notifications; + +namespace NodePilot.Scheduler.Notifications; + +/// +/// Shared body of the in-flight execution-age collectors (long-running, queued-long). Both scan +/// executions that have been sitting in ONE non-terminal status longer than +/// and emit one execution-scoped context per row; they differ only in the config key, the event type, +/// the scanned status and the EventKey prefix. Each flavour stays its own collector instance so its +/// EventKey shape keeps exactly one owner across collection AND crash recovery. +/// +internal abstract class ElapsedExecutionCollector : INotificationCollector +{ + private readonly IConfiguration _configuration; + private readonly string _thresholdKey; + private readonly NotificationEventType _eventType; + private readonly ExecutionStatus _status; + private readonly string _eventKeyPrefix; + private readonly string _titlePrefix; + private readonly string _elapsedVerb; + + // An execution older than this fires the flavour's event (once per execution). + // Initialised from the flavour's config key; hot-reload overlaid per pass; + // settable in tests via the dispatcher's forwarding property. + internal TimeSpan Threshold { get; set; } + + protected ElapsedExecutionCollector( + IConfiguration configuration, + string thresholdKey, + int defaultSeconds, + NotificationEventType eventType, + ExecutionStatus status, + string eventKeyPrefix, + string titlePrefix, + string elapsedVerb) + { + _configuration = configuration; + _thresholdKey = thresholdKey; + _eventType = eventType; + _status = status; + _eventKeyPrefix = eventKeyPrefix; + _titlePrefix = titlePrefix; + _elapsedVerb = elapsedVerb; + Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue(thresholdKey, defaultSeconds))); + } + + public async Task CollectAsync( + NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) + { + // Hot-reload: overlay the threshold every pass so a live edit takes effect without a + // restart. Only overlay when the key is explicitly set — tests that set the property + // directly with an empty config keep their value. + var seconds = _configuration.GetValue(_thresholdKey); + if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); + + var rules = enabledRules + .Where(r => NotificationRuleSemantics.RuleWants(r, _eventType)) + .ToList(); + if (rules.Count == 0) return null; // nothing to alert on → skip the scan entirely + + var cutoff = now - Threshold; + // Local copy: the query closure must capture a local, not this collector instance. + var status = _status; + var batch = await db.WorkflowExecutions.AsNoTracking() + .Where(e => e.Status == status && e.StartedAt <= cutoff) + .OrderBy(e => e.StartedAt) + .Take(ExecutionEventSupport.ScanBatchSize) + .Select(ExecutionEventSupport.Projection) + .ToListAsync(ct); + if (batch.Count == 0) return null; + + var contexts = batch.Select(r => BuildContext(r, now)).ToList(); + return new NotificationCollection(rules, contexts); + } + + public async Task TryReconstructContextAsync( + NodePilotDbContext db, string eventKey, CancellationToken ct) + { + // Shape: {prefix}:{guidN}. Re-derive from the (still in-flight) row. Without this branch a + // crash-orphaned attempt would match no collector and be failed out (lost alert). + var parts = eventKey.Split(':'); + if (parts.Length != 2 || parts[0] != _eventKeyPrefix || !Guid.TryParse(parts[1], out var execId)) return null; + + var row = await db.WorkflowExecutions.AsNoTracking() + .Where(e => e.Id == execId) + .Select(ExecutionEventSupport.Projection) + .FirstOrDefaultAsync(ct); + return row is null ? null : BuildContext(row, DateTime.UtcNow); + } + + private NotificationContext BuildContext(ExecRow row, DateTime now) + { + var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; + return new NotificationContext( + EventType: _eventType, + Severity: NotificationSeverity.Warning, + // No time/type segment → one occurrence per execution; the existence-check dedups across passes + // so a still-in-flight job never re-alerts every 30s. + EventKey: $"{_eventKeyPrefix}:{row.Id:N}", + WorkflowId: row.WorkflowId, + WorkflowName: row.WorkflowName, + FolderId: row.FolderId, + FolderPath: row.FolderPath, + ExecutionId: row.Id, + Status: _status.ToString(), + ErrorMessage: null, + DurationMs: elapsedMs, + OccurredAt: now, + TriggeredBy: row.TriggeredBy, + CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, + IsSubWorkflow: row.ParentExecutionId.HasValue, + TargetMachine: null, + SourceKey: null, + Title: $"{_titlePrefix}: {row.WorkflowName}", + Summary: $"Execution has been {_elapsedVerb} for ~{(long)(now - row.StartedAt).TotalMinutes} min.", + DeepLinkPath: $"/executions/{row.Id}"); + } +} diff --git a/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs b/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs index 44fa0466..ca1c8dea 100644 --- a/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs @@ -47,19 +47,7 @@ internal sealed class ExecutionEventCollector : INotificationCollector && (e.CompletedAt > lastTs || (e.CompletedAt == lastTs && e.Id.CompareTo(lastId) > 0))) .OrderBy(e => e.CompletedAt).ThenBy(e => e.Id) .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - // Gate on Failed in SQL: for Succeeded/Cancelled rows (the common case on a - // healthy instance) the value is null anyway — don't pay a per-row ORDER BY - // subquery over StepExecutions for information that gets discarded. - e.Status == ExecutionStatus.Failed - ? e.Steps - .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) - .OrderByDescending(s => s.CompletedAt) - .Select(s => s.TargetMachine) - .FirstOrDefault() - : null)) + .Select(ExecutionEventSupport.ProjectionWithFailedStepMachine) .ToListAsync(ct); if (batch.Count == 0) return null; @@ -92,15 +80,7 @@ internal sealed class ExecutionEventCollector : INotificationCollector var row = await db.WorkflowExecutions.AsNoTracking() .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - e.Status == ExecutionStatus.Failed - ? e.Steps - .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) - .OrderByDescending(s => s.CompletedAt) - .Select(s => s.TargetMachine) - .FirstOrDefault() - : null)) + .Select(ExecutionEventSupport.ProjectionWithFailedStepMachine) .FirstOrDefaultAsync(ct); if (row is null) return null; row = (await ExecutionEventSupport.ResolveTargetMachineNamesAsync(db, [row], ct))[0]; diff --git a/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs b/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs index a262e4d4..8d3ac44f 100644 --- a/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs +++ b/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Enums; using NodePilot.Core.Models; @@ -25,6 +26,33 @@ internal static class ExecutionEventSupport /// Per-pass scan cap shared by the execution-shaped collectors. public const int ScanBatchSize = 200; + /// + /// The projection without a failed-step machine. Used by the + /// long-running and queued-long collectors, where the execution is still in flight and a + /// failed-step join would be meaningless. + /// + public static readonly Expression> Projection = e => new ExecRow( + e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, + e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, + null); + + /// + /// The projection for terminal scans, resolving the machine of the + /// last failing step. Gated on Failed in SQL: for Succeeded/Cancelled rows (the common case + /// on a healthy instance) the value is null anyway — don't pay a per-row ORDER BY subquery + /// over StepExecutions for information that gets discarded. + /// + public static readonly Expression> ProjectionWithFailedStepMachine = e => new ExecRow( + e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, + e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, + e.Status == ExecutionStatus.Failed + ? e.Steps + .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) + .OrderByDescending(s => s.CompletedAt) + .Select(s => s.TargetMachine) + .FirstOrDefault() + : null); + private static readonly string[] CredentialFailureNeedles = [ "credential", diff --git a/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs index 601358f6..914eaf47 100644 --- a/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs @@ -1,14 +1,10 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using NodePilot.Core.Enums; -using NodePilot.Core.Models; -using NodePilot.Data; -using NodePilot.Engine.Notifications; namespace NodePilot.Scheduler.Notifications; /// -/// Long-running collector: scans STILL-RUNNING executions older than +/// Long-running collector: scans STILL-RUNNING executions older than Threshold /// and emits an context per execution. /// It is execution-scoped (carries a real WorkflowId → Global/Folders/Workflows scope), NOT a /// gauge — the per-(rule,route,EventKey) existence-check on runlong:{execId} fires each @@ -16,93 +12,17 @@ namespace NodePilot.Scheduler.Notifications; /// pass so a newly-crossed execution is picked up; finished executions simply drop out of the /// RUNNING scan. /// -internal sealed class LongRunningExecutionCollector : INotificationCollector +internal sealed class LongRunningExecutionCollector : ElapsedExecutionCollector { - private readonly IConfiguration _configuration; - - // A still-running execution older than this fires ExecutionRunningLong (once per execution). - // Initialised from Alerting:LongRunningSeconds (default 600); hot-reload overlaid per pass; - // settable in tests via the dispatcher's forwarding property. - internal TimeSpan Threshold { get; set; } - public LongRunningExecutionCollector(IConfiguration configuration) + : base(configuration, + thresholdKey: "Alerting:LongRunningSeconds", + defaultSeconds: 600, + eventType: NotificationEventType.ExecutionRunningLong, + status: ExecutionStatus.Running, + eventKeyPrefix: "runlong", + titlePrefix: "Execution running long", + elapsedVerb: "running") { - _configuration = configuration; - Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue("Alerting:LongRunningSeconds", 600))); - } - - public async Task CollectAsync( - NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) - { - // Hot-reload: overlay the threshold every pass so a live edit takes effect without a - // restart. Only overlay when the key is explicitly set — tests that set the property - // directly with an empty config keep their value. - var seconds = _configuration.GetValue("Alerting:LongRunningSeconds"); - if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); - - var rules = enabledRules - .Where(r => NotificationRuleSemantics.RuleWants(r, NotificationEventType.ExecutionRunningLong)) - .ToList(); - if (rules.Count == 0) return null; // nothing to alert on → skip the running scan entirely - - var cutoff = now - Threshold; - var batch = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Status == ExecutionStatus.Running && e.StartedAt <= cutoff) - .OrderBy(e => e.StartedAt) - .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .ToListAsync(ct); - if (batch.Count == 0) return null; - - var contexts = batch.Select(r => BuildContext(r, now)).ToList(); - return new NotificationCollection(rules, contexts); - } - - public async Task TryReconstructContextAsync( - NodePilotDbContext db, string eventKey, CancellationToken ct) - { - // Shape: runlong:{guidN}. Re-derive from the (still-running) row. Without this branch a - // crash-orphaned runlong: attempt would match no collector and be failed out (lost alert). - var parts = eventKey.Split(':'); - if (parts.Length != 2 || parts[0] != "runlong" || !Guid.TryParse(parts[1], out var execId)) return null; - - var row = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .FirstOrDefaultAsync(ct); - return row is null ? null : BuildContext(row, DateTime.UtcNow); - } - - private static NotificationContext BuildContext(ExecRow row, DateTime now) - { - var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; - return new NotificationContext( - EventType: NotificationEventType.ExecutionRunningLong, - Severity: NotificationSeverity.Warning, - // No time/type segment → one occurrence per execution; the existence-check dedups across passes - // so a still-running job never re-alerts every 30s. - EventKey: $"runlong:{row.Id:N}", - WorkflowId: row.WorkflowId, - WorkflowName: row.WorkflowName, - FolderId: row.FolderId, - FolderPath: row.FolderPath, - ExecutionId: row.Id, - Status: "Running", - ErrorMessage: null, - DurationMs: elapsedMs, - OccurredAt: now, - TriggeredBy: row.TriggeredBy, - CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, - IsSubWorkflow: row.ParentExecutionId.HasValue, - TargetMachine: null, - SourceKey: null, - Title: $"Execution running long: {row.WorkflowName}", - Summary: $"Execution has been running for ~{(long)(now - row.StartedAt).TotalMinutes} min.", - DeepLinkPath: $"/executions/{row.Id}"); } } diff --git a/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs index a7146e4f..aea446c1 100644 --- a/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs @@ -1,99 +1,24 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using NodePilot.Core.Enums; -using NodePilot.Core.Models; -using NodePilot.Data; -using NodePilot.Engine.Notifications; namespace NodePilot.Scheduler.Notifications; /// -/// Queued-long collector: scans still-pending executions older than +/// Queued-long collector: scans still-pending executions older than Threshold /// and emits one workflow-scoped per /// execution. EventKey shape: queuedlong:{guidN} (existence-check dedups across passes). /// -internal sealed class QueuedLongExecutionCollector : INotificationCollector +internal sealed class QueuedLongExecutionCollector : ElapsedExecutionCollector { - private readonly IConfiguration _configuration; - - // A pending execution older than this fires ExecutionQueuedLong (once per execution). - // Initialised from Alerting:QueuedLongSeconds (default 300); hot-reload overlaid per pass; - // settable in tests via the dispatcher's forwarding property. - internal TimeSpan Threshold { get; set; } - public QueuedLongExecutionCollector(IConfiguration configuration) + : base(configuration, + thresholdKey: "Alerting:QueuedLongSeconds", + defaultSeconds: 300, + eventType: NotificationEventType.ExecutionQueuedLong, + status: ExecutionStatus.Pending, + eventKeyPrefix: "queuedlong", + titlePrefix: "Execution queued long", + elapsedVerb: "pending") { - _configuration = configuration; - Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue("Alerting:QueuedLongSeconds", 300))); - } - - public async Task CollectAsync( - NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) - { - // Hot-reload: overlay the threshold every pass (only when the key is explicitly set). - var seconds = _configuration.GetValue("Alerting:QueuedLongSeconds"); - if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); - - var rules = enabledRules - .Where(r => NotificationRuleSemantics.RuleWants(r, NotificationEventType.ExecutionQueuedLong)) - .ToList(); - if (rules.Count == 0) return null; - - var cutoff = now - Threshold; - var batch = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Status == ExecutionStatus.Pending && e.StartedAt <= cutoff) - .OrderBy(e => e.StartedAt) - .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .ToListAsync(ct); - if (batch.Count == 0) return null; - - var contexts = batch.Select(r => BuildContext(r, now)).ToList(); - return new NotificationCollection(rules, contexts); - } - - public async Task TryReconstructContextAsync( - NodePilotDbContext db, string eventKey, CancellationToken ct) - { - // Shape: queuedlong:{guidN}. Re-derive from the pending row. - var parts = eventKey.Split(':'); - if (parts.Length != 2 || parts[0] != "queuedlong" || !Guid.TryParse(parts[1], out var execId)) return null; - - var row = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .FirstOrDefaultAsync(ct); - return row is null ? null : BuildContext(row, DateTime.UtcNow); - } - - private static NotificationContext BuildContext(ExecRow row, DateTime now) - { - var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; - return new NotificationContext( - EventType: NotificationEventType.ExecutionQueuedLong, - Severity: NotificationSeverity.Warning, - EventKey: $"queuedlong:{row.Id:N}", - WorkflowId: row.WorkflowId, - WorkflowName: row.WorkflowName, - FolderId: row.FolderId, - FolderPath: row.FolderPath, - ExecutionId: row.Id, - Status: "Pending", - ErrorMessage: null, - DurationMs: elapsedMs, - OccurredAt: now, - TriggeredBy: row.TriggeredBy, - CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, - IsSubWorkflow: row.ParentExecutionId.HasValue, - TargetMachine: null, - SourceKey: null, - Title: $"Execution queued long: {row.WorkflowName}", - Summary: $"Execution has been pending for ~{(long)(now - row.StartedAt).TotalMinutes} min.", - DeepLinkPath: $"/executions/{row.Id}"); } } diff --git a/src/NodePilot.Scheduler/SupportEventRetentionService.cs b/src/NodePilot.Scheduler/SupportEventRetentionService.cs index 6509cebc..cfa5084a 100644 --- a/src/NodePilot.Scheduler/SupportEventRetentionService.cs +++ b/src/NodePilot.Scheduler/SupportEventRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -19,71 +18,26 @@ namespace NodePilot.Scheduler; /// Enabled=true, MaxAgeDays=90 matching the file-based support-log retention, /// IntervalMinutes=360). /// -public class SupportEventRetentionService : BackgroundService +public class SupportEventRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:SupportEvents:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private SupportEventsRetentionOptions Opts => _opts.CurrentValue.SupportEvents; - private readonly IDatabaseAvailability _availability; - public SupportEventRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("SupportEventRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("SupportEventRetentionService stopped."); - } + protected override string ServiceName => nameof(SupportEventRetentionService); + protected override string MetricServiceTag => "support_events"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -91,7 +45,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -112,7 +66,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Pruned {Count} support events older than {Days}d.", deleted, maxAgeDays); - var tags = new TagList { new("nodepilot.retention.service", "support_events") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} pruned", ct); @@ -120,7 +74,7 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "support_events") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Support-event retention sweep failed — retrying on next interval."); } @@ -135,12 +89,4 @@ internal async Task PurgeOnceAsync(int maxAgeDays, CancellationToken ct) return await db.SupportEvents.Where(e => e.Timestamp < cutoff) .ExecuteDeleteAsync(ct); } - - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "SupportEventRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } } diff --git a/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs b/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs index 913692f8..19d52c86 100644 --- a/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs +++ b/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -39,74 +38,28 @@ namespace NodePilot.Scheduler; /// } /// } /// -public class WorkflowVersionsRetentionService : BackgroundService +public class WorkflowVersionsRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:WorkflowVersions:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private WorkflowVersionsRetentionOptions Opts => _opts.CurrentValue.WorkflowVersions; - private readonly IDatabaseAvailability _availability; - public WorkflowVersionsRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Match ExecutionRetentionService's warm-up delay so we don't hammer the DB during - // the cold-start window where the migration bootstrap + TriggerOrchestrator do their - // initial work. - try { await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("WorkflowVersionsRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("WorkflowVersionsRetentionService stopped."); - } + protected override string ServiceName => nameof(WorkflowVersionsRetentionService); + protected override string MetricServiceTag => "workflow_versions"; + // Longer warm-up than the other sweeps so we don't hammer the DB during the cold-start + // window where the migration bootstrap + TriggerOrchestrator do their initial work. + protected override TimeSpan WarmUpDelay => TimeSpan.FromMinutes(2); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -114,7 +67,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the 2-minute warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -136,7 +89,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("WorkflowVersions retention deleted {Count} old history rows.", deleted); - var tags = new TagList { new("nodepilot.retention.service", "workflow_versions") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); @@ -144,20 +97,12 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "workflow_versions") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "WorkflowVersions retention pass failed — will retry on next interval."); } } - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "WorkflowVersionsRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } - // Exposed as internal so unit tests can drive a single pass without waiting the warm-up // or a full interval. internal async Task PurgeOnceAsync(int maxVersionsPerWorkflow, int batchSize, CancellationToken ct) diff --git a/src/nodepilot-ui/package.json b/src/nodepilot-ui/package.json index 09cbd486..c8dea7be 100644 --- a/src/nodepilot-ui/package.json +++ b/src/nodepilot-ui/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@carbon/icons-react": "^11.85.0", - "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/src/nodepilot-ui/src/api/backup.ts b/src/nodepilot-ui/src/api/backup.ts index 6f00e017..bf6a74a2 100644 --- a/src/nodepilot-ui/src/api/backup.ts +++ b/src/nodepilot-ui/src/api/backup.ts @@ -42,17 +42,6 @@ export interface BackupRestoreResult { warnings: string[]; } -/** Section keys, in display order — must match BackupSections on the server. */ -export const BACKUP_SECTIONS = [ - 'folders', - 'users', - 'credentials', - 'machines', - 'globalVariables', - 'workflows', - 'settings', -] as const; - export const backupApi = { getManifest: () => api.get('/backup/manifest'), diff --git a/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx b/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx index ec998b06..0e210a92 100644 --- a/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx @@ -1,6 +1,7 @@ -import { Close, DataBase, SecurityServices } from '@carbon/icons-react'; +import { DataBase, SecurityServices } from '@carbon/icons-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { TypedPhraseConfirmDialog } from '../common/TypedPhraseConfirmDialog'; import { useSectionForm, Card, @@ -124,82 +125,18 @@ export function DbAdminSection() { /> {ui.dialog} {pendingEnable && ( - { setPendingEnable(false); setConfirmInput(''); }} onConfirm={acceptEnable} + title={t('adminSettings:dbAdmin.confirmEnableTitle')} + body={t('adminSettings:dbAdmin.confirmEnableBody')} + prompt={t('adminSettings:dbAdmin.confirmEnablePrompt', { phrase: CONFIRM_PHRASE })} + confirmLabel={t('adminSettings:dbAdmin.confirmEnableButton')} /> )} ); } - -function ConfirmEnableWriteDialog({ - phrase, input, onInput, onCancel, onConfirm, -}: Readonly<{ - phrase: string; - input: string; - onInput: (v: string) => void; - onCancel: () => void; - onConfirm: () => void; -}>) { - const { t } = useTranslation(['adminSettings', 'common']); - const ok = input === phrase; - - return ( -
e.key === 'Escape' && onCancel()} - role="presentation" - tabIndex={-1} - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > -
-

- - {t('adminSettings:dbAdmin.confirmEnableTitle')} -

- -
-

- {t('adminSettings:dbAdmin.confirmEnableBody')} -

-

- {t('adminSettings:dbAdmin.confirmEnablePrompt', { phrase })} -

- onInput(e.target.value)} - autoFocus - className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" - /> -
- - -
-
-
- ); -} diff --git a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx index 3006eab1..e297d3c4 100644 --- a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx @@ -1,16 +1,9 @@ import { Activity, ChartBar, Chip, Document } from '@carbon/icons-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - adminSettings, - SettingsApiError, - type SettingsSectionResponse, -} from '../../api/adminSettings'; import { SecretField, serializeSecretField, type SecretFieldMode } from './SecretField'; import { EnvOverrideBadge } from './EnvOverrideBadge'; -import { EtagConflictDialog } from './EtagConflictDialog'; -import { GroupHeading, HotReloadHint } from './SectionFormHelpers'; +import { GroupHeading, HotReloadHint, useSectionForm } from './SectionFormHelpers'; /** * Three independently-saveable cards in one tab: Logging / OpenTelemetry / Stats. @@ -374,110 +367,9 @@ function StatsCard() { } // ───────────────────────────────────────────────────────────────────────────── -// useSectionForm — generic GET+PUT+ETag+conflict hook for the three cards above -// ───────────────────────────────────────────────────────────────────────────── - -type FormUi = { - loading: boolean; - data: SettingsSectionResponse; - form: T; - set: (next: T) => void; - isEnvLocked: (k: string) => boolean; - save: (payload: unknown) => void; - errors: string[] | null; - dialog: React.ReactNode; -}; - -function useSectionForm(section: string, fallback: T): FormUi | { loading: true } & Partial> { - const queryClient = useQueryClient(); - const [conflict, setConflict] = useState | null>(null); - const [errors, setErrors] = useState(null); - const pendingPayloadRef = useRef(null); - - const { data, isLoading } = useQuery({ - queryKey: ['admin-settings', section], - queryFn: () => adminSettings.getSection(section), - }); - - const [form, setForm] = useState(fallback); - useEffect(() => { if (data) setForm(data.payload); }, [data]); - - const isEnvLocked = (key: string) => { - const src = data?.effectiveSource[key]; - return src === 'env' || src === 'cli'; - }; - - const saveMutation = useMutation({ - mutationFn: async (payload: unknown) => { - setErrors(null); - if (!data) throw new Error('No section snapshot loaded yet.'); - pendingPayloadRef.current = payload; - return adminSettings.putSection(section, payload, data.etag); - }, - onSuccess: (fresh) => { - pendingPayloadRef.current = null; - queryClient.setQueryData(['admin-settings', section], fresh); - queryClient.invalidateQueries({ queryKey: ['admin-settings', 'status'] }); - }, - onError: (err: unknown) => { - if (err instanceof SettingsApiError && err.status === 412 && err.body?.current) { - setConflict(err.body.current as SettingsSectionResponse); - return; - } - pendingPayloadRef.current = null; - if (err instanceof SettingsApiError && err.status === 400 && err.body?.errors) { - setErrors(err.body.errors.map((e) => { - const fields = e.fields?.length ? `${e.fields.join(', ')}: ` : ''; - return `${fields}${e.message ?? JSON.stringify(e)}`; - })); - return; - } - setErrors([err instanceof Error ? err.message : String(err)]); - }, - }); - - if (isLoading || !data) { - return { loading: true }; - } - - const dialog = ( - { - if (!conflict) return; - const retryPayload = pendingPayloadRef.current ?? form; - queryClient.setQueryData(['admin-settings', section], conflict); - setConflict(null); - adminSettings.putSection(section, retryPayload, conflict.etag) - .then((fresh) => queryClient.setQueryData(['admin-settings', section], fresh)) - .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])) - .finally(() => { pendingPayloadRef.current = null; }); - }} - onTakeTheirs={() => { - if (!conflict) return; - queryClient.setQueryData(['admin-settings', section], conflict); - setConflict(null); - }} - onCancel={() => setConflict(null)} - /> - ); - - return { - loading: false, - data, - form, - set: setForm, - isEnvLocked, - save: (payload: unknown) => saveMutation.mutate(payload), - errors, - dialog, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared UI widgets (kept local to this section; could be hoisted later if reused) +// Local UI widgets. Card and ErrorsAndSave deliberately do NOT come from +// SectionFormHelpers: this tab's cards are the tighter p-4/mt-4 variant, and +// swapping in the shared ones would re-chrome all three cards. // ───────────────────────────────────────────────────────────────────────────── function Card({ icon: Icon, title, children }: Readonly<{ icon: React.ComponentType<{ size?: number }>; title: string; children: React.ReactNode }>) { diff --git a/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx b/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx index d4a784fe..8afc147a 100644 --- a/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronRight, Chip, FlashFilled } from '@carbon/icons-react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { @@ -31,6 +31,9 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l const queryClient = useQueryClient(); const [conflict, setConflict] = useState | null>(null); const [errors, setErrors] = useState(null); + // What the Save button actually PUT. "Keep mine" after a 412 has to re-send exactly that + // (the PascalCase DTO the card mapped), not the raw camelCase form state. + const pendingPayloadRef = useRef(null); const { data, isLoading } = useQuery({ queryKey: ['admin-settings', section], @@ -49,9 +52,11 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l mutationFn: async (payload: unknown) => { setErrors(null); if (!data) throw new Error('No section snapshot loaded yet.'); + pendingPayloadRef.current = payload; return adminSettings.putSection(section, payload, data.etag); }, onSuccess: (fresh) => { + pendingPayloadRef.current = null; queryClient.setQueryData(['admin-settings', section], fresh); queryClient.invalidateQueries({ queryKey: ['admin-settings', 'status'] }); // These sections drive the visibility of the AI entry points (buttons + AI-Chat nav) — @@ -64,6 +69,7 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l setConflict(err.body.current as SettingsSectionResponse); return; } + pendingPayloadRef.current = null; if (err instanceof SettingsApiError && err.status === 400 && err.body?.errors) { setErrors(err.body.errors.map((e) => { const fields = e.fields?.length ? `${e.fields.join(', ')}: ` : ''; @@ -86,14 +92,16 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l localDraft={form} onKeepMine={() => { if (!conflict) return; + const retryPayload = pendingPayloadRef.current ?? form; queryClient.setQueryData(['admin-settings', section], conflict); setConflict(null); - adminSettings.putSection(section, form, conflict.etag) + adminSettings.putSection(section, retryPayload, conflict.etag) .then((fresh) => { queryClient.setQueryData(['admin-settings', section], fresh); if (section === 'AiKnowledge' || section === 'Llm') refreshAiCapabilities(queryClient); }) - .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])); + .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])) + .finally(() => { pendingPayloadRef.current = null; }); }} onTakeTheirs={() => { if (!conflict) return; diff --git a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx index 747f4df7..c64724f3 100644 --- a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx +++ b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx @@ -26,80 +26,24 @@ import { hashDefinition, buildChangelog, assembleSelectiveDefinition, type Workf import { Markdown } from '../common/Markdown'; import { CopyButton } from '../common/CopyButton'; import { UsageFooter } from './UsageFooter'; +import { ChatThreadMenu } from './ChatThreadMenu'; import { useAiChatStore, aiChatScopeKey, aiChatFullKey, type ChatMessage, type ChatThreadMeta } from '../../stores/aiChatStore'; import { useAuthStore } from '../../stores/authStore'; import { buildChatMarkdown, chatFilenameSlug, downloadTextFile } from '../../lib/chatExport'; +import { + addToolCallToLast, + appendToLastAssistant, + finalizeStreaming, + isAbort, + markToolDoneOnLast, + patchLastAssistant, + trimHistory, +} from '../../lib/chatMessages'; import { formatDate } from '../../lib/format'; const EMPTY_THREAD: ChatMessage[] = []; const EMPTY_THREADS: ChatThreadMeta[] = []; const SUGGESTION_KEYS = ['suggestion1', 'suggestion2', 'suggestion3', 'suggestion4'] as const; -// The backend caps history at 20 turns / 50k characters (AiChatController) → trim hard here, -// otherwise long threads get a 400 HISTORY_TOO_LONG response. -const MAX_HISTORY_TURNS = 19; -const MAX_HISTORY_CHARS = 48_000; - -function isAbort(err: unknown): boolean { - return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; -} - -/** Trims the history sent to the backend down to its caps (most recent turns, ≤ character limit). */ -function trimHistory(history: AiChatTurn[]): AiChatTurn[] { - let turns = history.slice(-MAX_HISTORY_TURNS); - let total = turns.reduce((s, m) => s + m.content.length, 0); - while (turns.length > 0 && total > MAX_HISTORY_CHARS) { - total -= turns[0].content.length; - turns = turns.slice(1); - } - return turns; -} - -/** Appends text to the last assistant message (immutably). */ -function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } - } - return next; -} - -/** Patches the last assistant message (building/proposal/meta) immutably. */ -function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } - } - return next; -} - -/** Appends an in-progress tool call to the last assistant message. */ -function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; - break; - } - } - return next; -} - -/** Marks a tool call on the last assistant message as completed. */ -function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; - break; - } - } - return next; -} - -/** Marks all assistant messages as done (streaming/building=false). */ -function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { - return prev.map((m) => (m.streaming || m.building ? { ...m, streaming: false, building: false } : m)); -} interface Props { workflowId: string | undefined; @@ -367,7 +311,7 @@ export function AiWorkflowChatPanel({
- { createThread(scope, t('ai:chat.threadDefault', { n: threads.length + 1 })); setError(null); }} onRename={(id, name) => renameThread(scope, id, name)} onDelete={(id) => { removeThread(scope, id); setError(null); }} + triggerClassName="flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-sm font-headline font-bold text-on-surface hover:bg-surface-high" + align="left" />
@@ -754,124 +700,6 @@ function ActivityMenu({ workflowId }: Readonly<{ workflowId: string }>) { ); } -/** Compact thread switcher in the header: active chat name + dropdown (switch/rename/delete/new). */ -function ThreadMenu({ - threads, activeId, disabled, onSelect, onNew, onRename, onDelete, -}: Readonly<{ - threads: ChatThreadMeta[]; - activeId: string; - disabled?: boolean; - onSelect: (id: string) => void; - onNew: () => void; - onRename: (id: string, name: string) => void; - onDelete: (id: string) => void; -}>) { - const { t } = useTranslation(['ai']); - const [open, setOpen] = useState(false); - const [renaming, setRenaming] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as globalThis.Node)) { setOpen(false); setRenaming(null); } - }; - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [open]); - - const active = threads.find((th) => th.id === activeId); - const activeName = active?.name ?? t('ai:chat.threadDefault', { n: 1 }); - - const commitRename = (id: string) => { - const name = renameValue.trim(); - if (name) onRename(id, name); - setRenaming(null); - }; - - return ( -
- - {open && ( -
-
- {threads.length === 0 && ( -

{t('ai:chat.noThreads')}

- )} - {threads.map((th) => ( -
- {renaming === th.id ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename(th.id); - if (e.key === 'Escape') setRenaming(null); - }} - onBlur={() => commitRename(th.id)} - className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" - /> - ) : ( - - )} - - -
- ))} -
- -
- )} -
- ); -} - function ProposalCard({ proposal, baseDef, canApply, isViewer, getCurrentDefinition, applyDefinition, onApplied, onRefine, onUndo, onAutoLayout, }: Readonly<{ diff --git a/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx b/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx new file mode 100644 index 00000000..f8f492d6 --- /dev/null +++ b/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx @@ -0,0 +1,134 @@ +import { Add, ChevronDown, Edit, TrashCan } from '@carbon/icons-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ChatThreadMeta } from '../../stores/aiChatStore'; + +/** + * Compact thread switcher: active chat name + dropdown (switch/rename/delete/new). Shared by the + * docked workflow assistant and the global AI-Chat page, which only differ in how the trigger is + * chromed and on which side the panel is anchored — both are passed in verbatim so each surface + * keeps its exact look. `align` is mapped to a literal utility class (never interpolated) so + * Tailwind's source scan still sees both variants. + */ +export function ChatThreadMenu({ + threads, activeId, disabled, onSelect, onNew, onRename, onDelete, triggerClassName, align, +}: Readonly<{ + threads: ChatThreadMeta[]; + activeId: string; + disabled?: boolean; + onSelect: (id: string) => void; + onNew: () => void; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + /** Full class string for the trigger button — the two call sites are chromed differently. */ + triggerClassName: string; + /** Which edge of the trigger the dropdown is anchored to. */ + align: 'left' | 'right'; +}>) { + const { t } = useTranslation(['ai']); + const [open, setOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as globalThis.Node)) { setOpen(false); setRenaming(null); } + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const active = threads.find((th) => th.id === activeId); + const activeName = active?.name ?? t('ai:chat.threadDefault', { n: 1 }); + + const commitRename = (id: string) => { + const name = renameValue.trim(); + if (name) onRename(id, name); + setRenaming(null); + }; + + const alignClass = align === 'right' ? 'right-0' : 'left-0'; + + return ( +
+ + {open && ( +
+
+ {threads.length === 0 && ( +

{t('ai:chat.noThreads')}

+ )} + {threads.map((th) => ( +
+ {renaming === th.id ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(th.id); + if (e.key === 'Escape') setRenaming(null); + }} + onBlur={() => commitRename(th.id)} + className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" + /> + ) : ( + + )} + + +
+ ))} +
+ +
+ )} +
+ ); +} diff --git a/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx b/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx index fe3b7508..91265a39 100644 --- a/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx +++ b/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx @@ -9,6 +9,8 @@ interface ContextMenuShellProps { positioning?: 'absolute' | 'fixed'; /** Tailwind z-index utility (e.g. "z-30", "z-50"). */ zIndex?: string; + /** Tailwind min-width utility — the node menu is one step narrower than the rest. */ + minWidth?: string; /** data-testid forwarded to the outer div. */ testId?: string; children: ReactNode; @@ -20,7 +22,7 @@ interface ContextMenuShellProps { * (use the sibling MenuItem helper for consistent styling). */ export function ContextMenuShell({ - x, y, onClose, positioning = 'absolute', zIndex = 'z-30', testId, children, + x, y, onClose, positioning = 'absolute', zIndex = 'z-30', minWidth = 'min-w-[180px]', testId, children, }: Readonly) { const menuRef = useRef(null); @@ -42,7 +44,7 @@ export function ContextMenuShell({ return (
diff --git a/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx new file mode 100644 index 00000000..34429d95 --- /dev/null +++ b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx @@ -0,0 +1,82 @@ +import { Close, SecurityServices } from '@carbon/icons-react'; +import { useTranslation } from 'react-i18next'; + +/** + * Type-the-phrase confirmation for the two places that grant write access to the SQL console: + * flipping `DbAdmin:AllowWriteQueries` on (admin settings) and running a write statement from + * the query pane. Confirm stays disabled until the typed input matches `phrase` exactly. + * All wording is passed in already translated — the two call sites live in different i18n + * namespaces. + */ +export function TypedPhraseConfirmDialog({ + phrase, input, onInput, onCancel, onConfirm, title, body, prompt, confirmLabel, +}: Readonly<{ + phrase: string; + input: string; + onInput: (v: string) => void; + onCancel: () => void; + onConfirm: () => void; + title: string; + body: string; + /** Instruction line — the caller interpolates `phrase` into its own key. */ + prompt: string; + confirmLabel: string; +}>) { + const { t } = useTranslation(['common']); + const ok = input === phrase; + + return ( +
e.key === 'Escape' && onCancel()} + role="presentation" + tabIndex={-1} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > +
+

+ + {title} +

+ +
+

+ {body} +

+

+ {prompt} +

+ onInput(e.target.value)} + autoFocus + className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" + /> +
+ + +
+
+
+ ); +} diff --git a/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx b/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx index 33612edd..7d4d2ce8 100644 --- a/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx +++ b/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx @@ -1,4 +1,4 @@ -import { Close, DataBase, History, Play, SecurityServices, WarningAltFilled } from '@carbon/icons-react'; +import { DataBase, History, Play, SecurityServices, WarningAltFilled } from '@carbon/icons-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -9,6 +9,7 @@ import { keymap } from '@codemirror/view'; import { nodePilotCodeMirrorTheme } from '../../lib/codeMirrorTheme'; import { dbAdminApi, type DbAdminQueryResponse } from '../../api/dbadmin'; import { useThemeStore, resolveTheme } from '../../stores/themeStore'; +import { TypedPhraseConfirmDialog } from '../common/TypedPhraseConfirmDialog'; import { ResizeHandle, useResizableColumns, type ResizableColumn } from './useResizableColumns'; const HISTORY_KEY = 'nodepilot.dbAdmin.queryHistory'; @@ -335,7 +336,7 @@ export function QueryPane({ insertSignal }: Readonly) { )}
{showWriteDialog && ( - ) { setWriteConfirmInput(''); queryMutation.mutate({ sql: sql.trim(), mode: 'write' }); }} + title={t('database:query.confirmWriteTitle')} + body={t('database:query.confirmWriteHint')} + prompt={t('database:query.confirmWritePhrasePrompt', { phrase: WRITE_CONFIRM_PHRASE })} + confirmLabel={t('database:query.confirmWriteButton')} /> )}
@@ -431,71 +436,3 @@ function renderCell(value: unknown): React.ReactNode { if (s.length > 80) return {s.slice(0, 80)}…; return s; } - -function WriteConfirmDialog({ - phrase, input, onInput, onCancel, onConfirm, -}: Readonly<{ - phrase: string; - input: string; - onInput: (v: string) => void; - onCancel: () => void; - onConfirm: () => void; -}>) { - const { t } = useTranslation(['database', 'common']); - const ok = input === phrase; - - return ( -
e.key === 'Escape' && onCancel()} - role="presentation" - tabIndex={-1} - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > -
-

- - {t('database:query.confirmWriteTitle')} -

- -
-

- {t('database:query.confirmWriteHint')} -

-

- {t('database:query.confirmWritePhrasePrompt', { phrase })} -

- onInput(e.target.value)} - autoFocus - className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" - /> -
- - -
-
-
- ); -} diff --git a/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx b/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx index 9f05c25e..e69beb40 100644 --- a/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx +++ b/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx @@ -35,9 +35,6 @@ import { StepTestPanel } from './properties/StepTestPanel'; import { JsonPathTree } from './properties/JsonPathTree'; import { useDesignStore } from '../../stores/designStore'; -// Re-export truth-tables so existing import sites keep working. -export { REMOTE_ACTIVITY_TYPES, TIMEOUT_ACTIVITY_TYPES }; - interface Props { node: Node; allNodes: Node[]; diff --git a/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx b/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx index a563fe33..9d23fbc0 100644 --- a/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx +++ b/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx @@ -1,6 +1,6 @@ import { CircleStroke, Copy, TrashCan, View, ViewOff } from '@carbon/icons-react'; -import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { ContextMenuShell, ContextMenuItem, makeMenuAction } from '../../common/ContextMenuShell'; import { useDesignStore } from '../../../stores/designStore'; interface Props { @@ -18,57 +18,23 @@ interface Props { export function NodeContextMenu({ x, y, isDisabled, hasBreakpoint, onDuplicate, onToggleDisabled, onToggleBreakpoint, onDelete, onClose }: Readonly) { const { t } = useTranslation('editor'); const expertMode = useDesignStore((s) => s.designerMode === 'expert'); - const menuRef = useRef(null); - - useEffect(() => { - const handleMouseDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose]); - - const action = (fn: () => void) => () => { fn(); onClose(); }; + const action = makeMenuAction(onClose); return ( -
- } label={t('nodeMenu.duplicate')} onClick={action(onDuplicate)} /> - + } label={t('nodeMenu.duplicate')} onClick={action(onDuplicate)} /> + : } label={isDisabled ? t('nodeMenu.enableStep') : t('nodeMenu.disableStep')} onClick={action(onToggleDisabled)} /> - {expertMode && } label={hasBreakpoint ? t('nodeMenu.removeBreakpoint') : t('nodeMenu.addBreakpoint')} onClick={action(onToggleBreakpoint)} />}
- } label={t('nodeMenu.delete')} onClick={action(onDelete)} danger /> -
- ); -} - -function MenuItem({ icon, label, onClick, danger }: Readonly<{ icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }>) { - return ( - + } label={t('nodeMenu.delete')} onClick={action(onDelete)} danger /> + ); } diff --git a/src/nodepilot-ui/src/components/designer/properties/shared.tsx b/src/nodepilot-ui/src/components/designer/properties/shared.tsx index 02dc2a55..b9e991ce 100644 --- a/src/nodepilot-ui/src/components/designer/properties/shared.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/shared.tsx @@ -322,17 +322,12 @@ export function VariableInsertField({ } /** - * Compact variable picker — a single button that opens a searchable popover - * instead of rendering every upstream variable as a chip inline. Keeps per-field - * UI small even when many upstream variables exist. + * Open/close + search state shared by the field pickers below: outside-click and Escape close the + * popover and clear the query, and the search box autofocuses once the popover has mounted. + * The query lives here (not inside {@link PickerPopover}) so each picker can filter with a plain + * top-level `useMemo`. */ -export function VariablePicker({ - upstreamVars, onPick, -}: Readonly<{ - upstreamVars: UpstreamVariable[]; - onPick: (expression: string) => void; -}>) { - const { t } = useTranslation(['properties', 'common']); +function useSearchablePicker() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const containerRef = useRef(null); @@ -361,81 +356,133 @@ export function VariablePicker({ }; }, [open]); - // Group by step for readability; filter by query (matches expression or label) - const { groups, total } = useMemo(() => { - const q = query.trim().toLowerCase(); - const filtered = q - ? upstreamVars.filter((v) => v.expression.toLowerCase().includes(q) || v.label.toLowerCase().includes(q)) - : upstreamVars; - const byStep = new Map(); - for (const v of filtered) { - const baseLabel = v.label.split(' → ')[0]; - if (!byStep.has(baseLabel)) byStep.set(baseLabel, []); - byStep.get(baseLabel)!.push(v); - } - return { groups: [...byStep.entries()], total: filtered.length }; - }, [upstreamVars, query]); + const toggle = useCallback(() => setOpen((o) => !o), []); + const close = useCallback(() => { setOpen(false); setQuery(''); }, []); + return { open, toggle, close, query, setQuery, containerRef, popoverRef, searchRef }; +} + +/** Chip trigger + anchored popover + search box. `children` renders the (already filtered) rows. */ +function PickerPopover({ + picker, icon, chipLabel, count, title, placeholder, surfaceClass, children, +}: Readonly<{ + picker: ReturnType; + icon: React.ReactNode; + chipLabel: string; + count: number; + title: string; + placeholder: string; + surfaceClass?: string; + children: React.ReactNode; +}>) { return ( -
+
- +
setQuery(e.target.value)} - placeholder={t('properties:searchVariable')} + value={picker.query} + onChange={(e) => picker.setQuery(e.target.value)} + placeholder={placeholder} className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" />
- {total === 0 && ( -
{t('common:noResults')}
- )} - {groups.map(([stepLabel, items]) => ( -
-
- {stepLabel} -
- {items.map((v) => { - const suffix = v.label.includes(' → ') ? v.label.split(' → ')[1] : ''; - return ( - - ); - })} -
- ))} + {children}
); } +/** + * Compact variable picker — a single button that opens a searchable popover + * instead of rendering every upstream variable as a chip inline. Keeps per-field + * UI small even when many upstream variables exist. + */ +export function VariablePicker({ + upstreamVars, onPick, +}: Readonly<{ + upstreamVars: UpstreamVariable[]; + onPick: (expression: string) => void; +}>) { + const { t } = useTranslation(['properties', 'common']); + const picker = useSearchablePicker(); + + // Group by step for readability; filter by query (matches expression or label) + const { groups, total } = useMemo(() => { + const q = picker.query.trim().toLowerCase(); + const filtered = q + ? upstreamVars.filter((v) => v.expression.toLowerCase().includes(q) || v.label.toLowerCase().includes(q)) + : upstreamVars; + const byStep = new Map(); + for (const v of filtered) { + const baseLabel = v.label.split(' → ')[0]; + if (!byStep.has(baseLabel)) byStep.set(baseLabel, []); + byStep.get(baseLabel)!.push(v); + } + return { groups: [...byStep.entries()], total: filtered.length }; + }, [upstreamVars, picker.query]); + + return ( + } + chipLabel={t('properties:vars')} + count={upstreamVars.length} + title={t('properties:varsTooltip', { count: upstreamVars.length })} + placeholder={t('properties:searchVariable')} + > + {total === 0 && ( +
{t('common:noResults')}
+ )} + {groups.map(([stepLabel, items]) => ( +
+
+ {stepLabel} +
+ {items.map((v) => { + const suffix = v.label.includes(' → ') ? v.label.split(' → ')[1] : ''; + return ( + + ); + })} +
+ ))} +
+ ); +} + /** * Picker for admin-managed global variables — inserts {{globals.NAME}}. Renders the * button even when no globals exist (the popover then shows an empty-state hint with a link @@ -444,11 +491,7 @@ export function VariablePicker({ */ export function GlobalVariablePicker({ onPick }: Readonly<{ onPick: (expression: string) => void }>) { const { t } = useTranslation(['properties', 'common']); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const containerRef = useRef(null); - const popoverRef = useRef(null); - const searchRef = useRef(null); + const picker = useSearchablePicker(); const { data: globals = [], isLoading } = useQuery({ queryKey: ['global-variables'], @@ -457,98 +500,58 @@ export function GlobalVariablePicker({ onPick }: Readonly<{ onPick: (expression: staleTime: 60_000, }); - useEffect(() => { - if (!open) return; - const onClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (containerRef.current && !containerRef.current.contains(target) && !popoverRef.current?.contains(target)) { - setOpen(false); setQuery(''); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { setOpen(false); setQuery(''); } - }; - document.addEventListener('mousedown', onClickOutside); - document.addEventListener('keydown', onKey); - requestAnimationFrame(() => searchRef.current?.focus()); - return () => { - document.removeEventListener('mousedown', onClickOutside); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = picker.query.trim().toLowerCase(); if (!q) return globals; return globals.filter((g) => g.name.toLowerCase().includes(q) || (g.description?.toLowerCase().includes(q) ?? false)); - }, [globals, query]); + }, [globals, picker.query]); return ( -
- - -
-
- - setQuery(e.target.value)} - placeholder={t('properties:searchGlobalVariable')} - className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" - /> -
-
-
- {isLoading && ( -
{t('common:loading')}
- )} - {!isLoading && globals.length === 0 && ( -
- {t('properties:noGlobalsHint')} -
- )} - {!isLoading && globals.length > 0 && filtered.length === 0 && ( -
{t('common:noResults')}
+ } + chipLabel={t('properties:globals')} + count={globals.length} + title={t('properties:globalsTooltip', { count: globals.length })} + placeholder={t('properties:searchGlobalVariable')} + > + {isLoading && ( +
{t('common:loading')}
+ )} + {!isLoading && globals.length === 0 && ( +
+ {t('properties:noGlobalsHint')} +
+ )} + {!isLoading && globals.length > 0 && filtered.length === 0 && ( +
{t('common:noResults')}
+ )} + {filtered.map((g) => { + const expression = `{{globals.${g.name}}}`; + return ( + - ); - })} -
-
-
+ + ); + })} + ); } @@ -622,87 +625,39 @@ export function OptionsPicker({ label: string; }>) { const { t } = useTranslation(['properties', 'common']); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const containerRef = useRef(null); - const popoverRef = useRef(null); - const searchRef = useRef(null); - - useEffect(() => { - if (!open) return; - const onClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (containerRef.current && !containerRef.current.contains(target) && !popoverRef.current?.contains(target)) { - setOpen(false); setQuery(''); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { setOpen(false); setQuery(''); } - }; - document.addEventListener('mousedown', onClickOutside); - document.addEventListener('keydown', onKey); - requestAnimationFrame(() => searchRef.current?.focus()); - return () => { - document.removeEventListener('mousedown', onClickOutside); - document.removeEventListener('keydown', onKey); - }; - }, [open]); + const picker = useSearchablePicker(); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = picker.query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.label.toLowerCase().includes(q) || o.id.toLowerCase().includes(q)); - }, [options, query]); + }, [options, picker.query]); return ( -
- - -
-
- - setQuery(e.target.value)} - placeholder={t('common:searchEllipsis')} - className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" - /> -
-
-
- {filtered.length === 0 && ( -
{t('common:noResults')}
- )} - {filtered.map((o) => ( - - ))} -
-
-
+ } + chipLabel={t('properties:list')} + count={options.length} + title={t('properties:listTooltip', { label, count: options.length })} + placeholder={t('common:searchEllipsis')} + surfaceClass="bg-surface-container border-outline-variant/30" + > + {filtered.length === 0 && ( +
{t('common:noResults')}
+ )} + {filtered.map((o) => ( + + ))} +
); } diff --git a/src/nodepilot-ui/src/hooks/useDashboardFeed.ts b/src/nodepilot-ui/src/hooks/useDashboardFeed.ts index 422d8187..673b954b 100644 --- a/src/nodepilot-ui/src/hooks/useDashboardFeed.ts +++ b/src/nodepilot-ui/src/hooks/useDashboardFeed.ts @@ -1,90 +1,17 @@ -import { useEffect, useRef } from 'react'; -import * as signalR from '@microsoft/signalr'; -import { useQueryClient } from '@tanstack/react-query'; -import { readCsrfToken } from '../api/csrf'; -import { connectPersistently } from '../lib/signalrConnect'; +import { useLiveOpsFeed } from './useLiveOpsFeed'; -// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about -// ExecutionStatusChanged — it is the only event that changes dashboard aggregates -// (running/recent/queue-depth/counts). StepStarted/StepCompleted don't move KPIs. -interface StatusEvt { - executionId?: string; ExecutionId?: string; - workflowId?: string; WorkflowId?: string; - status?: string; Status?: string; -} - -function pickItems(batch: unknown): unknown[] { - if (Array.isArray(batch)) return batch; - const b = batch as { events?: unknown[]; Events?: unknown[] } | null; - return b?.events ?? b?.Events ?? []; -} - -function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { - const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; - const type = it.Type ?? it.type; - if (type !== 'ExecutionStatusChanged') return null; - const e = it.Event ?? it.evt; - if (!e) return null; - const executionId = e.executionId ?? e.ExecutionId; - const workflowId = e.workflowId ?? e.WorkflowId; - const status = e.status ?? e.Status; - if (!executionId || !workflowId || !status) return null; - return { executionId, workflowId, status }; -} +// Module-level so the identity is stable across renders — it feeds useLiveOpsFeed's effect deps. +const DASHBOARD_STATS_KEY = ['dashboard-stats']; /** * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub and debounce- * invalidates the dashboard-stats query so running/recent/queue KPIs reconcile in ~real * time instead of waiting for the 120 s polling fallback. Mirrors useOperationsFeed but * targets ['dashboard-stats'] (the dashboard's own cache key) rather than the operations - * graph. SignalR failures are swallowed — the page still works off the polled snapshot. + * graph. Any status transition (start/terminal) can move running/recent/queue/counts, so + * no per-event delta is applied — one debounced refetch covers the burst. */ export function useDashboardFeed() { - const queryClient = useQueryClient(); - const invalidateTimer = useRef | null>(null); - - useEffect(() => { - let disposed = false; - const connection = new signalR.HubConnectionBuilder() - .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) - .withAutomaticReconnect() - .configureLogging(signalR.LogLevel.Warning) - .build(); - - const scheduleInvalidate = () => { - if (invalidateTimer.current !== null) return; - invalidateTimer.current = setTimeout(() => { - invalidateTimer.current = null; - queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }); - }, 500); - }; - - connection.on('LiveEventsBatch', (batch: unknown) => { - let sawStatus = false; - for (const item of pickItems(batch)) { - const s = asStatus(item); - if (!s) continue; - sawStatus = true; - } - // Any status transition (start/terminal) can move running/recent/queue/counts — - // one debounced refetch covers a burst of events instead of N refetches. - if (sawStatus) scheduleInvalidate(); - }); - - const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; - // connectPersistently retries forever with capped backoff: the bare onreconnected + - // one-shot start() gave up for good after ~40 s of outage (and never retried a failed - // FIRST start at all), silently degrading this feed to snapshot polling until a reload. - const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); - - return () => { - disposed = true; - persistent.dispose(); - if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); - connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); - void connection.stop(); - }; - }, [queryClient]); - + useLiveOpsFeed({ queryKey: DASHBOARD_STATS_KEY, debounceMs: 500 }); return null; -} \ No newline at end of file +} diff --git a/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts b/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts new file mode 100644 index 00000000..f703af8c --- /dev/null +++ b/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; +import * as signalR from '@microsoft/signalr'; +import { useQueryClient } from '@tanstack/react-query'; +import { readCsrfToken } from '../api/csrf'; +import { connectPersistently } from '../lib/signalrConnect'; + +// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about ExecutionStatusChanged. +interface StatusEvt { + executionId?: string; ExecutionId?: string; + workflowId?: string; WorkflowId?: string; + status?: string; Status?: string; +} + +function pickItems(batch: unknown): unknown[] { + if (Array.isArray(batch)) return batch; + const b = batch as { events?: unknown[]; Events?: unknown[] } | null; + return b?.events ?? b?.Events ?? []; +} + +function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { + const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; + const type = it.Type ?? it.type; + if (type !== 'ExecutionStatusChanged') return null; + const e = it.Event ?? it.evt; + if (!e) return null; + const executionId = e.executionId ?? e.ExecutionId; + const workflowId = e.workflowId ?? e.WorkflowId; + const status = e.status ?? e.Status; + if (!executionId || !workflowId || !status) return null; + return { executionId, workflowId, status }; +} + +/** + * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub and debounce- + * invalidates `queryKey` whenever a batch carried at least one ExecutionStatusChanged — + * one refetch per burst instead of N. SignalR failures are swallowed; the consuming page + * still works off its polled snapshot. + * + * `queryKey` and `onStatus` go into the effect deps, so both must be referentially stable + * (a module-level constant / a zustand action) — an inline array would tear the connection + * down and rebuild it on every render. + */ +export function useLiveOpsFeed({ + queryKey, debounceMs, onStatus, +}: Readonly<{ + queryKey: unknown[]; + debounceMs: number; + /** Optional per-event delta applied before the debounced snapshot reconciliation. */ + onStatus?: (executionId: string, workflowId: string, status: string) => void; +}>) { + const queryClient = useQueryClient(); + const invalidateTimer = useRef | null>(null); + + useEffect(() => { + let disposed = false; + const connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + const scheduleInvalidate = () => { + if (invalidateTimer.current !== null) return; + invalidateTimer.current = setTimeout(() => { + invalidateTimer.current = null; + queryClient.invalidateQueries({ queryKey }); + }, debounceMs); + }; + + connection.on('LiveEventsBatch', (batch: unknown) => { + let sawStatus = false; + for (const item of pickItems(batch)) { + const s = asStatus(item); + if (!s) continue; + onStatus?.(s.executionId, s.workflowId, s.status); + sawStatus = true; + } + if (sawStatus) scheduleInvalidate(); + }); + + const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; + // connectPersistently retries forever with capped backoff: the bare onreconnected + + // one-shot start() gave up for good after ~40 s of outage (and never retried a failed + // FIRST start at all), silently degrading this feed to snapshot polling until a reload. + const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); + + return () => { + disposed = true; + persistent.dispose(); + if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); + connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); + void connection.stop(); + }; + }, [queryClient, queryKey, debounceMs, onStatus]); +} diff --git a/src/nodepilot-ui/src/hooks/useOperationsFeed.ts b/src/nodepilot-ui/src/hooks/useOperationsFeed.ts index b6098732..ba75a592 100644 --- a/src/nodepilot-ui/src/hooks/useOperationsFeed.ts +++ b/src/nodepilot-ui/src/hooks/useOperationsFeed.ts @@ -1,35 +1,8 @@ -import { useEffect, useRef } from 'react'; -import * as signalR from '@microsoft/signalr'; -import { useQueryClient } from '@tanstack/react-query'; import { useOperationsStore } from '../stores/operationsStore'; -import { readCsrfToken } from '../api/csrf'; -import { connectPersistently } from '../lib/signalrConnect'; +import { useLiveOpsFeed } from './useLiveOpsFeed'; -// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about ExecutionStatusChanged. -interface StatusEvt { - executionId?: string; ExecutionId?: string; - workflowId?: string; WorkflowId?: string; - status?: string; Status?: string; -} - -function pickItems(batch: unknown): unknown[] { - if (Array.isArray(batch)) return batch; - const b = batch as { events?: unknown[]; Events?: unknown[] } | null; - return b?.events ?? b?.Events ?? []; -} - -function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { - const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; - const type = it.Type ?? it.type; - if (type !== 'ExecutionStatusChanged') return null; - const e = it.Event ?? it.evt; - if (!e) return null; - const executionId = e.executionId ?? e.ExecutionId; - const workflowId = e.workflowId ?? e.WorkflowId; - const status = e.status ?? e.Status; - if (!executionId || !workflowId || !status) return null; - return { executionId, workflowId, status }; -} +// Module-level so the identity is stable across renders — it feeds useLiveOpsFeed's effect deps. +const OPERATIONS_GRAPH_KEY = ['operations-graph']; /** * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub. Applies @@ -39,49 +12,6 @@ function asStatus(item: unknown): { executionId: string; workflowId: string; sta * polled snapshot). */ export function useOperationsFeed() { - const queryClient = useQueryClient(); const applyStatus = useOperationsStore((s) => s.applyStatus); - const invalidateTimer = useRef | null>(null); - - useEffect(() => { - let disposed = false; - const connection = new signalR.HubConnectionBuilder() - .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) - .withAutomaticReconnect() - .configureLogging(signalR.LogLevel.Warning) - .build(); - - const scheduleInvalidate = () => { - if (invalidateTimer.current !== null) return; - invalidateTimer.current = setTimeout(() => { - invalidateTimer.current = null; - queryClient.invalidateQueries({ queryKey: ['operations-graph'] }); - }, 800); - }; - - connection.on('LiveEventsBatch', (batch: unknown) => { - let sawStatus = false; - for (const item of pickItems(batch)) { - const s = asStatus(item); - if (!s) continue; - applyStatus(s.executionId, s.workflowId, s.status); - sawStatus = true; - } - if (sawStatus) scheduleInvalidate(); - }); - - const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; - // connectPersistently retries forever with capped backoff: the bare onreconnected + - // one-shot start() gave up for good after ~40 s of outage (and never retried a failed - // FIRST start at all), silently degrading this feed to snapshot polling until a reload. - const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); - - return () => { - disposed = true; - persistent.dispose(); - if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); - connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); - void connection.stop(); - }; - }, [queryClient, applyStatus]); + useLiveOpsFeed({ queryKey: OPERATIONS_GRAPH_KEY, debounceMs: 800, onStatus: applyStatus }); } diff --git a/src/nodepilot-ui/src/lib/chatMessages.ts b/src/nodepilot-ui/src/lib/chatMessages.ts new file mode 100644 index 00000000..4108e2f7 --- /dev/null +++ b/src/nodepilot-ui/src/lib/chatMessages.ts @@ -0,0 +1,74 @@ +import type { AiChatTurn } from '../api/ai'; +import type { ChatMessage } from '../stores/aiChatStore'; + +// The backend caps history at 20 turns / 50k characters (AiChatController, AiKnowledgeController) +// → trim hard here, otherwise long threads get a 400 HISTORY_TOO_LONG response. +const MAX_HISTORY_TURNS = 19; +const MAX_HISTORY_CHARS = 48_000; + +/** True for a user-initiated stream abort — the partial bubble stays and no error is shown. */ +export function isAbort(err: unknown): boolean { + return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; +} + +/** Trims the history sent to the backend down to its caps (most recent turns, ≤ character limit). */ +export function trimHistory(history: AiChatTurn[]): AiChatTurn[] { + let turns = history.slice(-MAX_HISTORY_TURNS); + let total = turns.reduce((s, m) => s + m.content.length, 0); + while (turns.length > 0 && total > MAX_HISTORY_CHARS) { + total -= turns[0].content.length; + turns = turns.slice(1); + } + return turns; +} + +/** Appends text to the last assistant message (immutably). */ +export function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } + } + return next; +} + +/** Patches the last assistant message (building/proposal/meta) immutably. */ +export function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } + } + return next; +} + +/** Appends an in-progress tool call to the last assistant message. */ +export function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { + next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; + break; + } + } + return next; +} + +/** Marks a tool call on the last assistant message as completed. */ +export function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { + next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; + break; + } + } + return next; +} + +/** + * Marks all assistant messages as done (streaming/building=false). `building` only ever gets set + * by the designer panel (proposal buffering); for chats that never set it the extra clear is inert + * — and the store strips both flags before persisting anyway. + */ +export function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { + return prev.map((m) => (m.streaming || m.building ? { ...m, streaming: false, building: false } : m)); +} diff --git a/src/nodepilot-ui/src/lib/configClone.ts b/src/nodepilot-ui/src/lib/configClone.ts index 0948d0c9..5577fd68 100644 --- a/src/nodepilot-ui/src/lib/configClone.ts +++ b/src/nodepilot-ui/src/lib/configClone.ts @@ -32,19 +32,6 @@ export function skippedConfigKeys(activityType: string): ReadonlyArray { return [...SHARED_CONFIG_SKIP_KEYS, ...typeSpecific]; } -/** - * Back-compat alias for the older "include-list" API. Now returns an empty array when no - * skips are configured; callers should treat "no skips" as "every key is cloneable" rather - * than "nothing is cloneable" (the inverse semantics from before the cloning rules were - * relaxed to copy entire configs). - * - * @deprecated Read `skippedConfigKeys` directly — the include-list shape no longer maps onto - * the actual cloning behaviour. - */ -export function cloneableConfigKeys(activityType: string): ReadonlyArray { - return skippedConfigKeys(activityType); -} - /** * Returns true if `targetMachineId` + `credentialId` are meaningful for this activity. * Used to decide whether the clone-picker should offer cross-type Remote-→-Remote diff --git a/src/nodepilot-ui/src/pages/AiChatPage.tsx b/src/nodepilot-ui/src/pages/AiChatPage.tsx index d567a810..de9bf6f1 100644 --- a/src/nodepilot-ui/src/pages/AiChatPage.tsx +++ b/src/nodepilot-ui/src/pages/AiChatPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { - Add, BareMetalServer, ChartColumn, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code, - DataBase, DataShare, Debug, Document, Download, Edit, Email, Events, FlowModeler, InProgress, + BareMetalServer, ChartColumn, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code, + DataBase, DataShare, Debug, Document, Download, Email, Events, FlowModeler, InProgress, Locked, Renew, Reset, Save, Send, Time, Tools, TrashCan, UserRole, WarningAlt, } from '@carbon/icons-react'; import { @@ -13,77 +13,28 @@ import { useAiCapabilities } from '../hooks/useAiCapabilities'; import { Markdown } from '../components/common/Markdown'; import { CopyButton } from '../components/common/CopyButton'; import { UsageFooter } from '../components/ai/UsageFooter'; +import { ChatThreadMenu } from '../components/ai/ChatThreadMenu'; import { useAiChatStore, aiChatScopeKey, aiChatFullKey, type ChatMessage, type ChatThreadMeta, } from '../stores/aiChatStore'; import { useAuthStore } from '../stores/authStore'; import { buildChatMarkdown, chatFilenameSlug, downloadTextFile } from '../lib/chatExport'; +import { + addToolCallToLast, + appendToLastAssistant, + finalizeStreaming, + isAbort, + markToolDoneOnLast, + patchLastAssistant, + trimHistory, +} from '../lib/chatMessages'; // A non-`__new__` sentinel workflowId so the store's `isPersistableScope` KEEPS this page's // threads across reloads (unlike an unsaved canvas). One shared scope per user. const GLOBAL_SCOPE = 'global'; const EMPTY_THREAD: ChatMessage[] = []; const EMPTY_THREADS: ChatThreadMeta[] = []; -// The backend caps history at 20 turns / 50k chars (AiKnowledgeController) → trim hard here. -const MAX_HISTORY_TURNS = 19; -const MAX_HISTORY_CHARS = 48_000; - -function isAbort(err: unknown): boolean { - return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; -} - -function trimHistory(history: AiChatTurn[]): AiChatTurn[] { - let turns = history.slice(-MAX_HISTORY_TURNS); - let total = turns.reduce((s, m) => s + m.content.length, 0); - while (turns.length > 0 && total > MAX_HISTORY_CHARS) { - total -= turns[0].content.length; - turns = turns.slice(1); - } - return turns; -} - -function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } - } - return next; -} - -function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } - } - return next; -} - -function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; - break; - } - } - return next; -} - -function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; - break; - } - } - return next; -} - -function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { - return prev.map((m) => (m.streaming ? { ...m, streaming: false } : m)); -} /** * Global "AI Chat" — a read-only knowledge & operations assistant over NodePilot's docs, @@ -281,7 +232,7 @@ export function AiChatPage() {
- { createThread(scope, t('ai:chat.threadDefault', { n: threads.length + 1 })); setError(null); }} onRename={(id, name) => renameThread(scope, id, name)} onDelete={(id) => { removeThread(scope, id); setError(null); }} - t={t} + triggerClassName="flex min-w-0 items-center gap-1 rounded-lg border border-outline-variant/40 px-2.5 py-1.5 text-sm text-on-surface transition-colors hover:bg-surface-highest" + align="right" /> - {open && ( -
-
- {threads.length === 0 && ( -

{t('ai:chat.noThreads')}

- )} - {threads.map((th) => ( -
- {renaming === th.id ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename(th.id); - if (e.key === 'Escape') setRenaming(null); - }} - onBlur={() => commitRename(th.id)} - className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" - /> - ) : ( - - )} - - -
- ))} -
- -
- )} -
- ); -} diff --git a/src/nodepilot-ui/src/stores/aiChatStore.ts b/src/nodepilot-ui/src/stores/aiChatStore.ts index a57ec664..34b5b5d5 100644 --- a/src/nodepilot-ui/src/stores/aiChatStore.ts +++ b/src/nodepilot-ui/src/stores/aiChatStore.ts @@ -49,8 +49,6 @@ const MAX_PERSISTED_MESSAGES = 200; */ const MAX_PERSISTED_PROPOSAL_CHARS = 100_000; -const EMPTY_MESSAGES: ChatMessage[] = []; - /** * Holds chat history **per user, workflow, and thread**. Unlike an earlier version, this * store is now `persist`-ed (survives a page reload), but privacy-conscious: `partialize` @@ -222,6 +220,3 @@ export const useAiChatStore = create()( }, ), ); - -/** Stable empty reference for selectors (avoids re-render loops). */ -export const aiChatEmptyMessages = EMPTY_MESSAGES; diff --git a/src/nodepilot-ui/src/stores/authStore.ts b/src/nodepilot-ui/src/stores/authStore.ts index f46eb5b8..ae1e824b 100644 --- a/src/nodepilot-ui/src/stores/authStore.ts +++ b/src/nodepilot-ui/src/stores/authStore.ts @@ -150,9 +150,3 @@ export const useAuthStore = create((set) => ({ }, })); -// Re-export a void helper so existing call sites that treat `isAuthenticated` as boolean -// get the right fallback when init hasn't completed yet (treat "unknown" as "not yet signed in" -// for guard purposes; ProtectedRoute renders a loader for null instead). -export function isAuthResolved(): boolean { - return useAuthStore.getState().isAuthenticated !== null; -} diff --git a/src/nodepilot-ui/src/telemetry/otel.ts b/src/nodepilot-ui/src/telemetry/otel.ts index 9a2ad335..49b108ab 100644 --- a/src/nodepilot-ui/src/telemetry/otel.ts +++ b/src/nodepilot-ui/src/telemetry/otel.ts @@ -70,10 +70,6 @@ export function initTelemetry(config: ObservabilityConfig): Tracer | null { return tracer; } -export function getTracer(): Tracer | null { - return tracer; -} - /** * Run a function inside a manually-named span. No-ops when telemetry is disabled. */ diff --git a/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs b/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs index e19b93a1..5ea27aff 100644 --- a/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs @@ -158,15 +158,4 @@ public async Task ConnectAsync_ReachableEndpoint_LogsTheResolvedAddresses() logger.Messages.Should().Contain(m => m.Contains("TCP to")); } - private sealed class CapturingLogger : ILogger - { - public List Messages { get; } = new(); - - 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) - => Messages.Add(formatter(state, exception)); - } } diff --git a/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs b/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs index 8bcecdc8..43363a3e 100644 --- a/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs @@ -2,6 +2,7 @@ using System.Security.Authentication; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using NodePilot.TestCommons; using Xunit; namespace NodePilot.Ai.Tests; @@ -98,8 +99,4 @@ public void HandshakeTimeout_ExceedsTheConnectPhaseBudget() LlmConnectGuard.HandshakeTimeout.Should().BeGreaterThan(LlmConnectGuard.ConnectPhaseTimeout); } - private sealed class StubHttpClientFactory : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(); - } } diff --git a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs index 1fe54dbb..b7e51193 100644 --- a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs @@ -1,6 +1,5 @@ using NodePilot.Ai; using System.Net; -using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -1567,118 +1566,4 @@ private static string ExtractFieldValue(string json, string key) var doc = JsonDocument.Parse(json); return doc.RootElement.GetProperty("Smtp").GetProperty(key).GetString()!; } - - private sealed class FakeSmtpServer : IAsyncDisposable - { - private readonly TcpListener _listener; - private readonly TaskCompletionSource _sessionTcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly CancellationTokenSource _cts = new(); - private Task? _acceptLoop; - - private FakeSmtpServer(TcpListener listener, int port) - { - _listener = listener; - Port = port; - } - - public int Port { get; } - - public static Task StartAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var server = new FakeSmtpServer(listener, port); - server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); - return Task.FromResult(server); - } - - public async Task AwaitSessionAsync(TimeSpan timeout) - { - var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); - if (completed != _sessionTcs.Task) - throw new TimeoutException("Fake SMTP server did not record a session in time."); - return await _sessionTcs.Task; - } - - private async Task AcceptAsync(CancellationToken ct) - { - try - { - using var client = await _listener.AcceptTcpClientAsync(ct); - await using var stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; - var session = new SmtpSession(); - - await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); - - while (!ct.IsCancellationRequested) - { - var line = await reader.ReadLineAsync(ct); - if (line is null) break; - - if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250-fake.smtp.test"); - await writer.WriteLineAsync("250-SIZE 10485760"); - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250 OK"); - } - else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("354 End data with ."); - while (true) - { - var dataLine = await reader.ReadLineAsync(ct); - if (dataLine is null || dataLine == ".") break; - } - session.DataReceived = true; - await writer.WriteLineAsync("250 OK message accepted"); - } - else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("221 Bye"); - break; - } - else - { - await writer.WriteLineAsync("250 OK"); - } - } - - _sessionTcs.TrySetResult(session); - } - catch (OperationCanceledException) - { - _sessionTcs.TrySetCanceled(); - } - catch (Exception ex) - { - _sessionTcs.TrySetException(ex); - } - } - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - try { _listener.Stop(); } catch { /* already stopped */ } - if (_acceptLoop is not null) - { - try { await _acceptLoop; } catch { /* shutdown noise */ } - } - _cts.Dispose(); - } - } - - private sealed class SmtpSession - { - public bool DataReceived { get; set; } - } } diff --git a/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs b/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs index 0e4292e5..c1d03e01 100644 --- a/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs @@ -23,40 +23,16 @@ private static ControllerBase Controller() return new TestController { ControllerContext = new ControllerContext { HttpContext = http } }; } - [Fact] - public void NotFound_ProducesProblemResultWithCodeAndStatus() - { - var result = ApiProblems.NotFound(Controller(), "THING_MISSING", "no such thing"); - result.StatusCode.Should().Be(StatusCodes.Status404NotFound); - var problem = result.Value.Should().BeOfType().Subject; - problem.Status.Should().Be(404); - problem.Detail.Should().Be("no such thing"); - problem.Extensions["code"].Should().Be("THING_MISSING"); - problem.Instance.Should().Be("/api/things/42"); - } - - [Fact] - public void Conflict_ProducesConflictProblem() - { - var result = ApiProblems.Conflict(Controller(), "DUP", "already exists"); - result.StatusCode.Should().Be(StatusCodes.Status409Conflict); - result.Value.Should().BeOfType().Which.Extensions["code"].Should().Be("DUP"); - } - - [Fact] - public void Unauthorized_ProducesUnauthorizedProblem() - { - var result = ApiProblems.Unauthorized(Controller(), "NO_TOKEN", "missing token"); - result.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - result.Value.Should().BeOfType().Which.Status.Should().Be(401); - } - [Fact] public void BadRequest_ProducesBadRequestProblem() { var result = ApiProblems.BadRequest(Controller(), "BAD", "nope"); result.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - result.Value.Should().BeOfType().Which.Extensions["code"].Should().Be("BAD"); + var problem = result.Value.Should().BeOfType().Subject; + problem.Status.Should().Be(400); + problem.Detail.Should().Be("nope"); + problem.Extensions["code"].Should().Be("BAD"); + problem.Instance.Should().Be("/api/things/42"); } // ---- legacy payload adapter -------------------------------------------- diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs index 7b3e990e..6730a19f 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs index ed4b9048..b8e3d7bd 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs index 0e49cb43..b3121512 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs index e95f1aee..fbee5f34 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs @@ -7,6 +7,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs index 7526958e..53ce9a15 100644 --- a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs b/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs index ab831102..c88dc619 100644 --- a/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs +++ b/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs @@ -3,6 +3,7 @@ using NodePilot.Cli.Settings; using NodePilot.Cli.Tests.Infra; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Auth; diff --git a/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs b/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs index 5b793594..9c048408 100644 --- a/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs @@ -7,6 +7,7 @@ using NodePilot.Cli.Settings; using Spectre.Console.Cli; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Commands; diff --git a/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs b/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs index e9a1e071..a1fbbd87 100644 --- a/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs +++ b/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs @@ -6,6 +6,7 @@ using Spectre.Console.Cli.Testing; using Spectre.Console.Testing; using WireMock.Server; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Infra; diff --git a/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs b/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs index 3caf443f..d6fb8056 100644 --- a/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs +++ b/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs @@ -8,6 +8,7 @@ using Spectre.Console; using Spectre.Console.Testing; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Output; diff --git a/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs b/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs index 11b7ace9..1e9f0ed1 100644 --- a/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs +++ b/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using NodePilot.Cli.Settings; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Settings; diff --git a/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs b/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs index d65bdf55..f11c9a54 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs @@ -191,15 +191,23 @@ public void DocumentedKeys_HaveATypeAndADescription() public void SchemaVersion_IsCurrent() => ActivityConfigReference.SchemaVersion.Should().Be(2); /// - /// Maps activity/trigger type → the source text that must read its keys. For the background - /// triggers that is no longer the node executor alone: parsing moved into the shared - /// NodePilot.Core.Triggers settings, so the shared contract text is appended for every trigger - /// type. Without that this guard would flag every trigger key as phantom. + /// Maps activity/trigger type → the source text that must read its keys. Two kinds of key + /// reading happen outside the executor's own file, and both are followed here rather than + /// exempted — an exemption would stop verifying the key at all: + /// + /// Background triggers parse via the shared NodePilot.Core.Triggers settings, so the + /// shared contract text is appended for every trigger type. + /// Several activities share a base class or a helper in the same folder + /// (FileSystemOperationActivityBase, QueryPayloadSource, SubWorkflowInvocation). Any + /// Activities/ file that declares no ActivityType of its own but whose type name the executor + /// mentions is appended to that executor's text. + /// /// private static Dictionary LoadExecutorSources() { var root = FindRepoRoot(); var sources = new Dictionary(StringComparer.Ordinal); + var collaborators = new Dictionary(StringComparer.Ordinal); foreach (var dir in new[] { "Activities", "Triggers" }) { @@ -211,6 +219,17 @@ private static Dictionary LoadExecutorSources() var text = File.ReadAllText(file); var match = Regex.Match(text, @"(?:ActivityType|TriggerType)\s*=>\s*""([^""]+)"""); if (match.Success) sources[match.Groups[1].Value] = text; + else collaborators[Path.GetFileNameWithoutExtension(file)] = text; + } + } + + // Follow shared bases/helpers: a key moved into one of them is still read on every run. + foreach (var type in sources.Keys.ToList()) + { + foreach (var (name, text) in collaborators) + { + if (Regex.IsMatch(sources[type], $@"\b{Regex.Escape(name)}\b")) + sources[type] += text; } } diff --git a/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs index 41e6132e..44fe8750 100644 --- a/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Sockets; -using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -414,136 +412,4 @@ public async Task Email_HtmlBody_DeliversWithHtmlContentType() session.DataReceived.Should().BeTrue(); session.DataPayload.Should().Contain("text/html"); } - - /// - /// Minimal in-process SMTP server. Speaks just enough of RFC 5321 to satisfy - /// : 220 banner, 250 multi-line EHLO, 250 to - /// MAIL/RCPT, 354/250 around DATA, 221 to QUIT. Bound to loopback on an OS-assigned - /// port so parallel test runs don't collide. - /// - private sealed class FakeSmtpServer : IAsyncDisposable - { - private readonly TcpListener _listener; - private readonly TaskCompletionSource _sessionTcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly CancellationTokenSource _cts = new(); - private Task? _acceptLoop; - - public int Port { get; } - - private FakeSmtpServer(TcpListener listener, int port) - { - _listener = listener; - Port = port; - } - - public static Task StartAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var server = new FakeSmtpServer(listener, port); - server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); - return Task.FromResult(server); - } - - public async Task AwaitSessionAsync(TimeSpan timeout) - { - var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); - if (completed != _sessionTcs.Task) - throw new TimeoutException("Fake SMTP server did not record a session in time."); - return await _sessionTcs.Task; - } - - private async Task AcceptAsync(CancellationToken ct) - { - try - { - using var client = await _listener.AcceptTcpClientAsync(ct); - await using var stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; - - var session = new SmtpSession(); - var dataPayload = new StringBuilder(); - - await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); - - while (!ct.IsCancellationRequested) - { - var line = await reader.ReadLineAsync(ct); - if (line is null) break; - - if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250-fake.smtp.test"); - await writer.WriteLineAsync("250-SIZE 10485760"); - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase)) - { - session.MailFrom = line; - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) - { - session.RcptTo = line; - await writer.WriteLineAsync("250 OK"); - } - else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("354 End data with ."); - while (true) - { - var dataLine = await reader.ReadLineAsync(ct); - if (dataLine is null || dataLine == ".") break; - dataPayload.AppendLine(dataLine); - } - session.DataReceived = true; - session.DataPayload = dataPayload.ToString(); - await writer.WriteLineAsync("250 OK message accepted"); - } - else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("221 Bye"); - break; - } - else - { - await writer.WriteLineAsync("250 OK"); - } - } - - _sessionTcs.TrySetResult(session); - } - catch (OperationCanceledException) - { - _sessionTcs.TrySetCanceled(); - } - catch (Exception ex) - { - _sessionTcs.TrySetException(ex); - } - } - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - try { _listener.Stop(); } catch { /* listener may already be stopped */ } - if (_acceptLoop is not null) - { - try { await _acceptLoop; } catch { /* shutdown noise */ } - } - _cts.Dispose(); - } - } - - private sealed class SmtpSession - { - public string MailFrom { get; set; } = ""; - public string RcptTo { get; set; } = ""; - public bool DataReceived { get; set; } - public string DataPayload { get; set; } = ""; - } } diff --git a/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs b/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs index 684cefcf..d1040db1 100644 --- a/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs @@ -265,12 +265,4 @@ public async Task ExecuteAsync_IsolatedLocalNoProcessHost_ReturnsCleanStepFailur result.Success.Should().BeFalse(); result.ErrorOutput.Should().Contain("no PowerShell host"); } - - private sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine - { - public string EngineType => engineType; - public bool IsAvailable => available; - public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) - => Task.FromResult(new PowerShellExecutionResult { Success = true }); - } } diff --git a/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs b/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs index b5a5936f..4b02967f 100644 --- a/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs @@ -4,6 +4,7 @@ using NodePilot.Core.Models; using NodePilot.TestCommons; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.Execution; @@ -287,42 +288,4 @@ public async Task ListRunsAsync_FlagsStepRanCorrectly() target, data = new { disabled }, }; - - private sealed class StubGlobalVariableStore : NodePilot.Core.Interfaces.IGlobalVariableStore - { - private readonly Dictionary _values; - private readonly HashSet _secretNames = new(StringComparer.Ordinal); - public StubGlobalVariableStore(params (string Key, string Value)[] values) - => _values = values.ToDictionary(p => p.Key, p => p.Value); - public void SetSecret(string name, bool isSecret) - { - if (isSecret) _secretNames.Add(name); else _secretNames.Remove(name); - } - public Task> GetAllResolvedAsync(CancellationToken ct) - => Task.FromResult>(_values); - public Task GetAllResolvedDetailedAsync(CancellationToken ct) - => Task.FromResult(new NodePilot.Core.Interfaces.GlobalVariableResolutionResult( - _values, new HashSet())); - public Task> GetAllAsync(CancellationToken ct) - => Task.FromResult>( - _values.Select(kv => new NodePilot.Core.Models.GlobalVariable - { - Id = Guid.NewGuid(), - Name = kv.Key, - Value = kv.Value, - IsSecret = _secretNames.Contains(kv.Key), - }).ToList()); - public Task GetValueAsync(string name, CancellationToken ct) - => Task.FromResult(_values.TryGetValue(name, out var v) ? v : null); - public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); - public Task ReencryptAllSecretsAsync(CancellationToken ct) - => Task.FromResult(new NodePilot.Core.Interfaces.ReencryptionSummary( - 0, 0, Array.Empty())); - } } diff --git a/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs b/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs index ee6cf890..151f3a3c 100644 --- a/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs @@ -8,6 +8,7 @@ using NodePilot.Engine.Security; using NodePilot.TestCommons; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.Execution; @@ -520,25 +521,4 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme OutputParameters = { ["leakedToken"] = "eyJabcdef0123456789.eyJpayloadabcdefghij.signaturevaluexyz0" }, }); } - - private sealed class StubGlobalVariableStore : IGlobalVariableStore - { - public Task> GetAllResolvedAsync(CancellationToken ct) - => Task.FromResult>(new Dictionary()); - public Task GetAllResolvedDetailedAsync(CancellationToken ct) - => Task.FromResult(new GlobalVariableResolutionResult( - new Dictionary(), new HashSet())); - public Task> GetAllAsync(CancellationToken ct) - => Task.FromResult>(Array.Empty()); - public Task GetValueAsync(string name, CancellationToken ct) => Task.FromResult(null); - public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); - public Task ReencryptAllSecretsAsync(CancellationToken ct) - => Task.FromResult(new ReencryptionSummary(0, 0, Array.Empty())); - } } diff --git a/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs b/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs new file mode 100644 index 00000000..a860d684 --- /dev/null +++ b/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs @@ -0,0 +1,70 @@ +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Engine.PowerShell; + +namespace NodePilot.Engine.Tests.Helpers; + +/// +/// double that reports a fixed engine type and +/// availability and always succeeds. Used by the engine-selection tests, which care about +/// which engine the factory picks, never about what it executes. +/// +public sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine +{ + public string EngineType => engineType; + public bool IsAvailable => available; + public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) + => Task.FromResult(new PowerShellExecutionResult { Success = true }); +} + +/// +/// In-memory . Constructed with the name/value pairs a test +/// wants resolvable (none = an empty store); marks a name secret so +/// redaction paths can be exercised. Every mutating member throws — these tests only read. +/// +public sealed class StubGlobalVariableStore : IGlobalVariableStore +{ + private readonly Dictionary _values; + private readonly HashSet _secretNames = new(StringComparer.Ordinal); + + public StubGlobalVariableStore(params (string Key, string Value)[] values) + => _values = values.ToDictionary(p => p.Key, p => p.Value); + + public void SetSecret(string name, bool isSecret) + { + if (isSecret) _secretNames.Add(name); else _secretNames.Remove(name); + } + + public Task> GetAllResolvedAsync(CancellationToken ct) + => Task.FromResult>(_values); + + public Task GetAllResolvedDetailedAsync(CancellationToken ct) + => Task.FromResult(new GlobalVariableResolutionResult(_values, new HashSet())); + + public Task> GetAllAsync(CancellationToken ct) + => Task.FromResult>( + _values.Select(kv => new GlobalVariable + { + Id = Guid.NewGuid(), + Name = kv.Key, + Value = kv.Value, + IsSecret = _secretNames.Contains(kv.Key), + }).ToList()); + + public Task GetValueAsync(string name, CancellationToken ct) + => Task.FromResult(_values.TryGetValue(name, out var v) ? v : null); + + public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); + + public Task ReencryptAllSecretsAsync(CancellationToken ct) + => Task.FromResult(new ReencryptionSummary(0, 0, Array.Empty())); +} diff --git a/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs b/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs index 161f67b1..2787d740 100644 --- a/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs +++ b/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using NodePilot.Engine.PowerShell; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.PowerShell; @@ -13,13 +14,6 @@ namespace NodePilot.Engine.Tests.PowerShell; ///
public class PowerShellEngineFactoryIsolationTests { - private sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine - { - public string EngineType => engineType; - public bool IsAvailable => available; - public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) - => Task.FromResult(new PowerShellExecutionResult { Success = true }); - } private static PowerShellEngineFactory Factory(bool pwsh = true, bool windows = true, bool runspace = true) => new( diff --git a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs index b8c0e8a8..d5ae952f 100644 --- a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs +++ b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs @@ -8,6 +8,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Tests.Api; @@ -75,7 +76,7 @@ public void Resolve_RawEnvToken_IsNotRefreshable_AndServerFromEnv() var dir = Temp(); try { - var session = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)).Resolve(); session.Server.Should().Be("https://env-srv/"); session.Token.Should().Be("raw-bearer"); session.UsesRefreshableSession.Should().BeFalse(); @@ -98,7 +99,7 @@ public void Resolve_FallsBackToCliProfileServerAndDpapiSession() var tokens = new TokenStore(dir); tokens.Save("prod", new StoredSession { Server = "https://prod-srv/", Token = "dpapi-jwt", Username = "u", UserId = Guid.NewGuid(), Role = "Operator", ExpiresAt = DateTime.UtcNow.AddHours(12) }); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Profile.Should().Be("prod"); session.Server.Should().Be("https://prod-srv/"); session.Token.Should().Be("dpapi-jwt"); @@ -125,7 +126,7 @@ public void ApiClientFactory_BuildsConfiguredClientFromSession() var dir = Temp(); try { - var cfg = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + var cfg = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); var client = new ApiClientFactory(cfg, new TokenStore(dir)).Create(); client.Session!.HasServer.Should().BeTrue(); client.Session.HasToken.Should().BeTrue(); @@ -143,7 +144,7 @@ public void ApiClientFactory_RejectsInsecureServerUrl() var dir = Temp(); try { - var cfg = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + var cfg = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); Action act = () => new ApiClientFactory(cfg, new TokenStore(dir)).Create(); @@ -164,7 +165,7 @@ public void Resolve_McpServerEnvironmentOverride_DropsSessionFromDifferentOrigin var tokens = new TokenStore(dir); tokens.Save("default", StoredSessionFor("https://trusted.example", "origin-bound-token")); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Server.Should().Be("https://attacker.example"); session.HasToken.Should().BeFalse(); @@ -189,7 +190,7 @@ public void Resolve_EquivalentDefaultPortOrigin_KeepsRefreshableSession( var tokens = new TokenStore(dir); tokens.Save("default", StoredSessionFor(storedServer, "tok")); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Token.Should().Be("tok"); session.UsesRefreshableSession.Should().BeTrue(); diff --git a/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs b/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs index f5483ef9..2c19c85b 100644 --- a/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs +++ b/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs @@ -2,6 +2,7 @@ using NodePilot.Mcp.Auth; using NodePilot.Mcp.Config; using WireMock.Server; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Tests.Infra; @@ -28,7 +29,7 @@ public NodePilotApiClient Client(string? token = "test-token") public static McpServerConfig Config() { var dir = Path.Combine(Path.GetTempPath(), "np-mcp-test-" + Guid.NewGuid().ToString("N")); - return new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + return new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); } /// A full WorkflowResponse-shaped body for WireMock stubs. diff --git a/tests/NodePilot.TestCommons/FakeSmtpServer.cs b/tests/NodePilot.TestCommons/FakeSmtpServer.cs new file mode 100644 index 00000000..4df508e4 --- /dev/null +++ b/tests/NodePilot.TestCommons/FakeSmtpServer.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace NodePilot.TestCommons; + +/// +/// Minimal in-process SMTP server for tests that need to prove a mail was actually handed to a +/// server — the emailNotification activity, the admin-settings SMTP probe, alerting sinks. +/// Listens on an ephemeral loopback port, speaks just enough ESMTP to complete one delivery +/// (EHLO/HELO → MAIL FROM → RCPT TO → DATA → QUIT) and records what it saw in a +/// . Consolidates the private copies that previously lived in +/// Api.Tests and Engine.Tests. +/// +public sealed class FakeSmtpServer : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly TaskCompletionSource _sessionTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenSource _cts = new(); + private Task? _acceptLoop; + + /// Ephemeral loopback port the server bound to. + public int Port { get; } + + private FakeSmtpServer(TcpListener listener, int port) + { + _listener = listener; + Port = port; + } + + public static Task StartAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var server = new FakeSmtpServer(listener, port); + server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); + return Task.FromResult(server); + } + + /// Waits for the single recorded session, or throws once elapses. + public async Task AwaitSessionAsync(TimeSpan timeout) + { + var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); + if (completed != _sessionTcs.Task) + throw new TimeoutException("Fake SMTP server did not record a session in time."); + return await _sessionTcs.Task; + } + + private async Task AcceptAsync(CancellationToken ct) + { + try + { + using var client = await _listener.AcceptTcpClientAsync(ct); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; + + var session = new SmtpSession(); + var dataPayload = new StringBuilder(); + + await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + + if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("250-fake.smtp.test"); + await writer.WriteLineAsync("250-SIZE 10485760"); + await writer.WriteLineAsync("250 OK"); + } + else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase)) + { + session.MailFrom = line; + await writer.WriteLineAsync("250 OK"); + } + else if (line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) + { + session.RcptTo = line; + await writer.WriteLineAsync("250 OK"); + } + else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("354 End data with ."); + while (true) + { + var dataLine = await reader.ReadLineAsync(ct); + if (dataLine is null || dataLine == ".") break; + dataPayload.AppendLine(dataLine); + } + session.DataReceived = true; + session.DataPayload = dataPayload.ToString(); + await writer.WriteLineAsync("250 OK message accepted"); + } + else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("221 Bye"); + break; + } + else + { + await writer.WriteLineAsync("250 OK"); + } + } + + _sessionTcs.TrySetResult(session); + } + catch (OperationCanceledException) + { + _sessionTcs.TrySetCanceled(); + } + catch (Exception ex) + { + _sessionTcs.TrySetException(ex); + } + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + try { _listener.Stop(); } catch { /* already stopped */ } + if (_acceptLoop is not null) + { + try { await _acceptLoop; } catch { /* shutdown noise */ } + } + _cts.Dispose(); + } +} + +/// What observed during the one session it accepts. +public sealed class SmtpSession +{ + public string MailFrom { get; set; } = ""; + public string RcptTo { get; set; } = ""; + public bool DataReceived { get; set; } + public string DataPayload { get; set; } = ""; +} From 7a32bce040b82624b9c7bf59bdcf065bbe28a69d Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:53:08 +0200 Subject: [PATCH 2/6] Read the picker fields before rendering them Folding the three field pickers onto one PickerPopover passed the whole useSearchablePicker() result through as a prop. Inside the component that object is opaque, so the React Compiler treats every read on it as a ref access during render and eslint fails the build with seven "Cannot access refs during render" errors. Destructure once at the top of the component instead. The refs are then forwarded as plain values, which is what the pre-dedup code did and what AnchoredPickerPopover expects. --- .../components/designer/properties/shared.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/nodepilot-ui/src/components/designer/properties/shared.tsx b/src/nodepilot-ui/src/components/designer/properties/shared.tsx index b9e991ce..7be93685 100644 --- a/src/nodepilot-ui/src/components/designer/properties/shared.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/shared.tsx @@ -375,11 +375,12 @@ function PickerPopover({ surfaceClass?: string; children: React.ReactNode; }>) { + const { open, toggle, query, setQuery, containerRef, popoverRef, searchRef } = picker; return ( -
+
picker.setQuery(e.target.value)} + value={query} + onChange={(e) => setQuery(e.target.value)} placeholder={placeholder} className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" /> From 016c9e8b04177a335b9073c696b0b74f11300344 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:08:46 +0200 Subject: [PATCH 3/6] Anchor the blackout tests on the actual cron fire time Both armed-trigger blackout tests split their evaluator verdict on "now + 30 seconds", while the fixture's cron fires at the top of the coming hour. Whenever the suite runs in the last half minute of an hour the cutoff lands past the fire time and the split inverts, so the flagged case reports no window and the not-flagged case reports one. CI hit exactly that at 19:00:20Z. Derive the cutoff from the hour boundary instead and place it midway, so it is strictly between "now" and the fire time at every wall-clock moment. --- .../Controllers/DashboardControllerTests.cs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs index 45d88974..cf8371d5 100644 --- a/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs @@ -347,6 +347,20 @@ public async Task Get_WindowHours7d_IncludesExecutionOlderThan24h() // NOT flag the row, and vice versa. /// An always-armed workflow: hourly cron, so NextFireUtc is within the next hour. + /// + /// A point in time strictly between "now" and the next firing of 's + /// cron, which is the top of the coming hour. The two blackout tests below split their verdict on + /// this instant, so it must never land on or past the fire time — a fixed "now + 30 s" did exactly + /// that whenever the suite ran in the last half minute of an hour, silently inverting both of them + /// (observed in CI at 19:00:20Z). Anchoring on the actual boundary holds at every wall-clock moment. + /// + private static DateTime CutoffBeforeNextHourlyFire() + { + var now = DateTime.UtcNow; + var nextFire = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc).AddHours(1); + return now.AddTicks(Math.Max(1, (nextFire - now).Ticks / 2)); + } + private static Workflow ArmedCronWorkflow(string name = "Nightly Backup") => new() { @@ -396,8 +410,8 @@ public async Task Get_ArmedTrigger_BlackoutActiveAtFireTimeButNotNow_IsFlagged() db.Workflows.Add(ArmedCronWorkflow()); await db.SaveChangesAsync(); - // Blocks only from 30 s out — i.e. not "now", but by the time the cron fires. - var cutoff = DateTime.UtcNow.AddSeconds(30); + // Blocks only from the cutoff on — i.e. not "now", but by the time the cron fires. + var cutoff = CutoffBeforeNextHourlyFire(); var evaluator = new StubMaintenanceWindowEvaluator { VerdictAt = at => at >= cutoff @@ -419,7 +433,7 @@ public async Task Get_ArmedTrigger_BlackoutActiveNowButClosedByFireTime_IsNotFla await db.SaveChangesAsync(); // The honest inverse: evaluating at "now" would have flagged this row wrongly. - var cutoff = DateTime.UtcNow.AddSeconds(30); + var cutoff = CutoffBeforeNextHourlyFire(); var evaluator = new StubMaintenanceWindowEvaluator { VerdictAt = at => at < cutoff From c194ba2941ffe397ef14af5c8be7db856b6fd55e Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:47:29 +0200 Subject: [PATCH 4/6] Remove over-engineered code paths flagged by the audit A repo-wide sweep looked for code that a smaller solution would cover just as well. These twelve findings survived an adversarial check for hidden consumers, test doubles and documented conventions. Engine - ReturnDataActivity: drop the refcounted per-execution semaphore registry. It guarded a single atomic ExecuteUpdateAsync, and the determinism it implied is explicitly not promised (last-write-wins). - WorkflowDbWriteMetrics: SaveChangesMeasuredAsync now delegates to ExecuteMeasuredAsync instead of repeating its body; the two error catches collapse into one that keeps the cancelled/failure split. - BaseRemoteActivity: delete the unreachable four-argument constructor and make _configuration non-nullable. All thirteen subclasses already passed a configuration, so the null-config branches in StartProgramActivity, PowerManagementActivity and WaitForConditionActivity were dead - and they silently permitted useShellExecute and skipped the path guards. Api - CmTraceFormatter: replace the hand-rolled in-place substitution loop with StringBuilder.Replace. - Observability: drop four DTO records that were never constructed and three response fields that were always empty, plus their TypeScript mirrors. Mcp - DefinitionDiff: replace the custom canonicalising serializer and its JsonEquals(a, b) => a == b wrapper with JsonNode.DeepEquals. Ai - LlmConfiguredProxy: drop the six-field cache record and its SequenceEqual comparison. It avoided constructing a WebProxy on a path capped at 20 requests per minute. Frontend - NodeLibrary: remove the 23-entry Tailwind colour table that shadowed the generated --act-* tokens without a dark variant, and read the shared activity visual instead. Six catalog types that the table missed are now coloured correctly. - configClone: drop the skip-key registry for a provably empty set. - SystemSettingsPage: remove the unreachable comingSoon branch and replace the nine-way || chain and ternary with a lookup record. Tooling - dev-reset.ps1: remove the SkipTests switch that defaulted to true and therefore disabled its own block. - Delete the MockRemoteSession test helper; its single caller now builds the mock inline like the other fifteen activity test files. --- .gitignore | 2 +- docs/custom-activities.md | 7 +- scripts/dev-reset.ps1 | 42 ++--- src/NodePilot.Ai/LlmConfiguredProxy.cs | 63 +++----- .../Controllers/ObservabilityController.cs | 2 +- src/NodePilot.Api/Dtos/ObservabilityDtos.cs | 7 - src/NodePilot.Api/Logging/CmTraceFormatter.cs | 30 +--- .../Observability/MetricsDashboardCatalog.cs | 2 +- .../Activities/BaseActivity.cs | 13 +- .../Activities/PowerManagementActivity.cs | 2 +- .../Activities/ReturnDataActivity.cs | 79 ++------- .../Activities/StartProgramActivity.cs | 19 +-- .../Activities/WaitForConditionActivity.cs | 9 +- .../Execution/WorkflowDbWriteMetrics.cs | 55 ++----- src/NodePilot.Mcp/Analysis/DefinitionDiff.cs | 46 +----- .../components/designer/ActivityIcon.test.tsx | 74 +++++++++ .../src/__tests__/lib/configClone.test.ts | 22 +-- .../src/__tests__/pages/MetricsPage.test.tsx | 1 - .../designer/library/NodeLibrary.tsx | 48 ++---- .../src/i18n/locales/de/adminSettings.json | 6 +- .../src/i18n/locales/en/adminSettings.json | 6 +- src/nodepilot-ui/src/lib/configClone.ts | 38 +---- .../src/pages/SystemSettingsPage.tsx | 91 ++++------- src/nodepilot-ui/src/types/api.ts | 7 - .../LlmConfiguredProxyTests.cs | 62 +++++++- .../Logging/CmTraceFormatterTests.cs | 19 +++ .../Activities/BaseRemoteActivityTests.cs | 18 ++- .../Activities/ControlFlowActivityTests.cs | 19 --- .../Activities/StartProgramActivityTests.cs | 24 ++- .../Execution/WorkflowDbWriteMetricsTests.cs | 150 ++++++++++++++++++ .../Helpers/MockRemoteSession.cs | 47 ------ .../Analysis/DefinitionDiffTests.cs | 108 +++++++++++++ 32 files changed, 596 insertions(+), 522 deletions(-) create mode 100644 src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx delete mode 100644 tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs create mode 100644 tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs diff --git a/.gitignore b/.gitignore index 16020b6a..daaa423f 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,7 @@ coverage-report/ **/test-migrate.db **/test-migrate.db-* -# dev-reset.ps1 transient logs (build/test/start output, kept locally for debugging) +# dev-reset.ps1 transient logs (build/start output, kept locally for debugging) .dev-logs/ # Local artefact + backup folders produced by Build-Artifact.ps1 / manual zips diff --git a/docs/custom-activities.md b/docs/custom-activities.md index a9c12199..695dfaa3 100644 --- a/docs/custom-activities.md +++ b/docs/custom-activities.md @@ -95,9 +95,10 @@ Audit codes: `CUSTOM_ACTIVITY_CREATED|UPDATED|DELETED|ENABLED|DISABLED|IMPORTED| `activityCatalog.generated.ts` and its parity test are **untouched** — custom activities are a runtime catalog, never the static one. - Consumers wired via `isCustomActivityType` + runtime facts: palette (`activityCategories.ts`), - labels/icons (`shared.tsx`, `NodeLibrary.tsx`), node visuals (`nodes/activityConfig.ts`), - config form (`DynamicActivityConfig`), output variables (`upstreamVariables.ts`), and - PropertiesPanel remote/timeout gating. + labels (`shared.tsx`), node visuals (`nodes/activityConfig.ts` — `getActivityVisual` is also the + single source for the palette/picker glyph in `NodeLibrary.tsx`, which therefore carries no icon + or colour table of its own), config form (`DynamicActivityConfig`), output variables + (`upstreamVariables.ts`), and PropertiesPanel remote/timeout gating. ## System-configuration backup (ADR 0001) diff --git a/scripts/dev-reset.ps1 b/scripts/dev-reset.ps1 index 490609e9..7a551989 100644 --- a/scripts/dev-reset.ps1 +++ b/scripts/dev-reset.ps1 @@ -1,17 +1,15 @@ <# .SYNOPSIS - Kill -> Build -> Test -> Start (NodePilot dev reset) - Kills all running backend/frontend processes, builds both, runs all tests, - then starts backend (http://localhost:5000) and frontend (http://localhost:5173). - -.PARAMETER SkipTests - Skip all test runs and go straight to start. + Kill -> Build -> Start (NodePilot dev reset) + Kills all running backend/frontend processes, builds both, then starts + backend (http://localhost:5000) and frontend (http://localhost:5173). + Tests are not run here -- use scripts/nightly-tests.ps1 or a scoped + dotnet test / vitest run. .PARAMETER SkipBuild - Skip the build step (still runs tests and restarts processes). + Skip the build step (still restarts processes). #> param( - [switch]$SkipTests = $true, [switch]$SkipBuild = $false ) @@ -121,29 +119,7 @@ if (-not $SkipBuild) { } # --------------------------------------------------------------------------- -# 3. Tests -# --------------------------------------------------------------------------- - -if (-not $SkipTests) { - - Invoke-Checked "Backend tests (dotnet test)" { - Set-Location $root - $log = Join-Path $logDir "test-backend-$ts.log" - dotnet test --logger "console;verbosity=normal" | Tee-Object -FilePath $log - if ($LASTEXITCODE -ne 0) { throw "Backend tests failed -- see $log" } - } - - Invoke-Checked "Frontend tests (npm run test:run)" { - Set-Location $uiDir - $log = Join-Path $logDir "test-frontend-$ts.log" - cmd /c "npm run test:run" | Tee-Object -FilePath $log - if ($LASTEXITCODE -ne 0) { throw "Frontend tests failed -- see $log" } - } - -} - -# --------------------------------------------------------------------------- -# 4. Start backend +# 3. Start backend # --------------------------------------------------------------------------- Write-Step "Starting backend --> http://localhost:5000" @@ -163,7 +139,7 @@ $beProc = Start-Process ` Write-Ok "Backend PID $($beProc.Id) -> $beOut" # --------------------------------------------------------------------------- -# 5. Start frontend +# 4. Start frontend # --------------------------------------------------------------------------- Write-Step "Starting frontend --> http://localhost:5173" @@ -182,7 +158,7 @@ $feProc = Start-Process ` Write-Ok "Frontend PID $($feProc.Id) -> $feOut" # --------------------------------------------------------------------------- -# 6. Wait for backend to be ready +# 5. Wait for backend to be ready # --------------------------------------------------------------------------- Write-Step "Waiting for backend on :5000 ..." diff --git a/src/NodePilot.Ai/LlmConfiguredProxy.cs b/src/NodePilot.Ai/LlmConfiguredProxy.cs index 272f62fd..67ecd814 100644 --- a/src/NodePilot.Ai/LlmConfiguredProxy.cs +++ b/src/NodePilot.Ai/LlmConfiguredProxy.cs @@ -30,14 +30,6 @@ public sealed class LlmConfiguredProxy : IWebProxy { private readonly IOptionsMonitor _options; - /// - /// Last built custom proxy plus the values it was built from. Rebuilding a - /// (and recompiling its bypass regexes) per request would be wasteful; - /// comparing the source values is cheaper and needs no invalidation callback. A race just - /// builds twice, which is harmless. - /// - private volatile CustomProxyCache? _cache; - public LlmConfiguredProxy(IOptionsMonitor options) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -76,6 +68,12 @@ public ICredentials? Credentials { ArgumentNullException.ThrowIfNull(destination); + // A plaintext LLM endpoint is accepted only because it is physically on this host. Never + // hand such a request to a proxy: "localhost" would then refer to the proxy machine and + // the unencrypted prompt/key would leave the loopback boundary the endpoint guard promised. + if (MustStayOnLoopback(destination)) + return null; + var proxy = CurrentOptions; return proxy.Mode switch { @@ -91,6 +89,9 @@ public bool IsBypassed(Uri destination) { ArgumentNullException.ThrowIfNull(destination); + if (MustStayOnLoopback(destination)) + return true; + var proxy = CurrentOptions; return proxy.Mode switch { @@ -104,6 +105,10 @@ public bool IsBypassed(Uri destination) private LlmProxyOptions CurrentOptions => _options.CurrentValue.Proxy ?? new LlmProxyOptions(); + private static bool MustStayOnLoopback(Uri destination) + => destination.Scheme == Uri.UriSchemeHttp + && LlmEndpointGuard.IsLiteralLoopbackEndpoint(destination); + private static ICredentials? ResolveCustomCredentials(LlmProxyOptions proxy) { if (proxy.UseDefaultCredentials) return CredentialCache.DefaultCredentials; @@ -111,11 +116,13 @@ public bool IsBypassed(Uri destination) return new NetworkCredential(proxy.Username, proxy.Password ?? ""); } - private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) + /// + /// Builds the for the current settings on every call. No caching: the + /// LLM endpoints are rate-limited to 20 requests/minute, so an allocation plus a handful of + /// bypass regexes per request is not worth an invalidation mechanism of its own. + /// + private static WebProxy ResolveCustomProxy(LlmProxyOptions proxy) { - var cached = _cache; - if (cached is not null && cached.Matches(proxy)) return cached.Proxy; - // Same two rules the settings validation applies, from the same place — see LlmProfileValidation. if (!LlmProfileValidation.HasProxyAddress(proxy.Address, out var address)) { @@ -138,42 +145,12 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) .Select(v => v.Trim()) .ToArray(); - var built = new WebProxy( + return new WebProxy( proxyUri, BypassOnLocal: false, BypassList: bypass.Select(ProxyBypassPattern.ToRegex).ToArray()) { Credentials = ResolveCustomCredentials(proxy), }; - - _cache = new CustomProxyCache(built, address, bypass, proxy.Username, proxy.Password, proxy.UseDefaultCredentials); - return built; - } - - /// - /// Snapshot of the values a cached was built from. Compared field by - /// field rather than via a concatenated signature string so the proxy password does not get a - /// second, longer-lived copy in memory. - /// - private sealed record CustomProxyCache( - WebProxy Proxy, - string Address, - string[] Bypass, - string? Username, - string? Password, - bool UseDefaultCredentials) - { - public bool Matches(LlmProxyOptions options) - { - if (!string.Equals(Address, options.Address?.Trim(), StringComparison.Ordinal)) return false; - if (!string.Equals(Username, options.Username, StringComparison.Ordinal)) return false; - if (!string.Equals(Password, options.Password, StringComparison.Ordinal)) return false; - if (UseDefaultCredentials != options.UseDefaultCredentials) return false; - - var incoming = (options.BypassList ?? new List()) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()); - return Bypass.SequenceEqual(incoming, StringComparer.Ordinal); - } } } diff --git a/src/NodePilot.Api/Controllers/ObservabilityController.cs b/src/NodePilot.Api/Controllers/ObservabilityController.cs index 05522e8d..59f39be1 100644 --- a/src/NodePilot.Api/Controllers/ObservabilityController.cs +++ b/src/NodePilot.Api/Controllers/ObservabilityController.cs @@ -246,7 +246,7 @@ public async Task> Dashboard(string key, if (!MetricsDashboardCatalog.Exists(key)) return NotFound(new { message = "Unknown metrics dashboard." }); hours = hours switch { 1 or 24 or 168 or 720 => hours, _ => 24 }; if (!_prom.IsConfigured) - return Ok(new MetricsDashboardResponse(false, key, MetricsDashboardCatalog.Title(key), [], [], [], [])); + return Ok(new MetricsDashboardResponse(false, key, MetricsDashboardCatalog.Title(key), [])); return Ok(await MetricsDashboardCatalog.ExecuteAsync(key, hours, _prom, _logger, ct)); } diff --git a/src/NodePilot.Api/Dtos/ObservabilityDtos.cs b/src/NodePilot.Api/Dtos/ObservabilityDtos.cs index a79686a1..76f3e8fe 100644 --- a/src/NodePilot.Api/Dtos/ObservabilityDtos.cs +++ b/src/NodePilot.Api/Dtos/ObservabilityDtos.cs @@ -26,16 +26,9 @@ public record MetricsDashboardResponse( bool Available, string Key, string Title, - List Panels, - List Series, - List Tables, List? Widgets = null); -public record MetricsSeries(string Key, string Title, string Unit, List Lines); -public record MetricsSeriesLine(string Label, List Points); public record MetricsPoint(long Timestamp, double? Value); -public record MetricsTable(string Key, string Title, string Unit, List Rows); -public record MetricsTableRow(string Label, double Value); public record MetricsWidget( int Id, diff --git a/src/NodePilot.Api/Logging/CmTraceFormatter.cs b/src/NodePilot.Api/Logging/CmTraceFormatter.cs index f1c5a05f..da670d90 100644 --- a/src/NodePilot.Api/Logging/CmTraceFormatter.cs +++ b/src/NodePilot.Api/Logging/CmTraceFormatter.cs @@ -46,7 +46,7 @@ public void Format(LogEvent logEvent, TextWriter output) // (e.g. a copied log excerpt as script output) would truncate the message at the // terminator and dump the rest of the payload outside the SMS wrapper. Replace the // `!` with `_` to keep the substring recognisable while breaking the match. - ReplaceAll(msgBuf, "]LOG]!>", "]LOG]_>"); + msgBuf.Replace("]LOG]!>", "]LOG]_>"); // CMTrace's parser gives up at ~4096 chars per line — past that point the viewer // falls back to "raw line" rendering (no column extraction, all meta fields blank). @@ -94,34 +94,6 @@ public void Format(LogEvent logEvent, TextWriter output) $"thread=\"{Environment.CurrentManagedThreadId}\" file=\"\">"); } - /// - /// In-place replace of every occurrence of with - /// (lengths may differ). - /// - private static void ReplaceAll(System.Text.StringBuilder sb, string needle, string replacement) - { - if (needle.Length == 0 || sb.Length < needle.Length) return; - var idx = 0; - while (idx <= sb.Length - needle.Length) - { - var hit = true; - for (var k = 0; k < needle.Length; k++) - { - if (sb[idx + k] != needle[k]) { hit = false; break; } - } - if (hit) - { - sb.Remove(idx, needle.Length); - sb.Insert(idx, replacement); - idx += replacement.Length; - } - else - { - idx++; - } - } - } - /// /// Replaces CR/LF/TAB with spaces and collapses consecutive whitespace into one /// space. Operates in-place on the buffer. diff --git a/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs b/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs index c3d99c4a..2d06bfb5 100644 --- a/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs +++ b/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs @@ -59,7 +59,7 @@ public static async Task ExecuteAsync( var widgets = await Task.WhenAll(panels.Select(panel => ExecutePanelAsync( panel, hours, start, end, step, prometheus, logger, cancellationToken))); - return new MetricsDashboardResponse(true, key, title, [], [], [], widgets.ToList()); + return new MetricsDashboardResponse(true, key, title, widgets.ToList()); } private static bool IsMetricPanel(JsonElement panel) diff --git a/src/NodePilot.Engine/Activities/BaseActivity.cs b/src/NodePilot.Engine/Activities/BaseActivity.cs index e94b85fe..c29b4d8e 100644 --- a/src/NodePilot.Engine/Activities/BaseActivity.cs +++ b/src/NodePilot.Engine/Activities/BaseActivity.cs @@ -18,23 +18,16 @@ public abstract class BaseRemoteActivity : IActivityExecutor protected readonly ICredentialStore _credentialStore; protected readonly NodePilot.Data.NodePilotDbContext _db; protected readonly PowerShellEngineFactory _engineFactory; - protected readonly IConfiguration? _configuration; + protected readonly IConfiguration _configuration; public abstract string ActivityType { get; } - protected BaseRemoteActivity( - IRemoteSessionFactory sessionFactory, - ICredentialStore credentialStore, - NodePilot.Data.NodePilotDbContext db, - PowerShellEngineFactory engineFactory) - : this(sessionFactory, credentialStore, db, engineFactory, null) { } - protected BaseRemoteActivity( IRemoteSessionFactory sessionFactory, ICredentialStore credentialStore, NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, - IConfiguration? configuration) + IConfiguration configuration) { _sessionFactory = sessionFactory; _credentialStore = credentialStore; @@ -94,7 +87,7 @@ public virtual async Task ExecuteAsync(StepExecutionContext cont // Emitted via the engine's ActivitySource so it surfaces in OpenTelemetry // tracing without requiring an ILogger reference inside NodePilot.Core // (which is zero-deps by convention, see CLAUDE.md). - if (_configuration?.GetValue("Cluster:Enabled") ?? false) + if (_configuration.GetValue("Cluster:Enabled")) { var nodeId = _configuration["Cluster:NodeId"] ?? Environment.MachineName; Activity.Current?.AddEvent(new ActivityEvent("cluster.localhost_step", diff --git a/src/NodePilot.Engine/Activities/PowerManagementActivity.cs b/src/NodePilot.Engine/Activities/PowerManagementActivity.cs index 1cd0fcaf..d187ac98 100644 --- a/src/NodePilot.Engine/Activities/PowerManagementActivity.cs +++ b/src/NodePilot.Engine/Activities/PowerManagementActivity.cs @@ -100,7 +100,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c private bool AllowsLocalSelfShutdown() { - var raw = _configuration?["PowerManagement:AllowLocalSelfShutdown"]; + var raw = _configuration["PowerManagement:AllowLocalSelfShutdown"]; return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase); } diff --git a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs index 031f6626..1bcc26c4 100644 --- a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs +++ b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Text.Json; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Interfaces; @@ -16,31 +15,17 @@ namespace NodePilot.Engine.Activities; /// Config: /// data: { key1: "literal or {{template}}", key2: "...", ... } /// -/// Concurrency (audit M10): multiple returnData steps on parallel branches used to race — -/// each fetched the execution row via its scope-local DbContext, set ReturnData, saved, and -/// EF's default last-writer-wins gave nondeterministic results. The fix is two-fold: -/// 1. per-execution SemaphoreSlim serializes writes within a single process. -/// 2. ExecuteUpdate bypasses tracked-entity state entirely — no stale-entity concurrency -/// exception between the fetch and the update from a different scope. -/// -/// Designers are still encouraged to place a single terminal returnData step; this fix just -/// makes the "last-write" semantic deterministic rather than racy. +/// Concurrency: multiple returnData steps on parallel branches write the same row. The write +/// goes through ExecuteUpdate, which bypasses tracked-entity state entirely — no stale-entity +/// concurrency exception between a fetch and an update from a different scope. Which branch +/// wins is deliberately not promised: the documented semantic is last-write-wins on the whole +/// JSON (not per-key), so designers are expected to place a single terminal returnData step. /// public class ReturnDataActivity : IActivityExecutor { private readonly NodePilotDbContext _db; private readonly OutputRedactor? _redactor; - // Process-wide: one semaphore per WorkflowExecutionId. Slots are released by the finally - // block below. We don't aggressively evict completed executions — the dictionary grows - // linearly with lifetime executions which is bounded (existing executions live forever - // in the DB but fewer than a handful of returnData writes per execution) and the steady - // state is small. - private static readonly object _locksGate = new(); - private static readonly ConcurrentDictionary _perExecutionLocks = new(); - - internal static int ActiveLockCount => _perExecutionLocks.Count; - // Cap the serialized ReturnData so a single misbehaving workflow (or a caller trying // to stuff secrets) can't blow the column / audit trail. private const int MaxReturnDataChars = 32 * 1024; @@ -105,21 +90,11 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso }; } - var executionLock = AcquireExecutionLock(context.WorkflowExecutionId); - await executionLock.Gate.WaitAsync(ct); - try - { - // Atomic update — avoids fetching a tracked entity from this scope's DbContext - // while another scope's context might also be tracking the same row. - await _db.WorkflowExecutions - .Where(e => e.Id == context.WorkflowExecutionId) - .ExecuteUpdateAsync(setters => setters.SetProperty(e => e.ReturnData, persistJson), ct); - } - finally - { - executionLock.Gate.Release(); - ReleaseExecutionLock(context.WorkflowExecutionId, executionLock); - } + // Atomic update — avoids fetching a tracked entity from this scope's DbContext + // while another scope's context might also be tracking the same row. + await _db.WorkflowExecutions + .Where(e => e.Id == context.WorkflowExecutionId) + .ExecuteUpdateAsync(setters => setters.SetProperty(e => e.ReturnData, persistJson), ct); return new ActivityResult { @@ -128,38 +103,4 @@ await _db.WorkflowExecutions OutputParameters = outputParams, }; } - - private static ExecutionLock AcquireExecutionLock(Guid executionId) - { - lock (_locksGate) - { - if (!_perExecutionLocks.TryGetValue(executionId, out var executionLock)) - { - executionLock = new ExecutionLock(); - _perExecutionLocks[executionId] = executionLock; - } - - executionLock.RefCount++; - return executionLock; - } - } - - private static void ReleaseExecutionLock(Guid executionId, ExecutionLock executionLock) - { - lock (_locksGate) - { - executionLock.RefCount--; - if (executionLock.RefCount == 0) - { - _perExecutionLocks.TryRemove(KeyValuePair.Create(executionId, executionLock)); - executionLock.Gate.Dispose(); - } - } - } - - private sealed class ExecutionLock - { - public SemaphoreSlim Gate { get; } = new(1, 1); - public int RefCount { get; set; } - } } diff --git a/src/NodePilot.Engine/Activities/StartProgramActivity.cs b/src/NodePilot.Engine/Activities/StartProgramActivity.cs index 25ef9fd8..ff804ff3 100644 --- a/src/NodePilot.Engine/Activities/StartProgramActivity.cs +++ b/src/NodePilot.Engine/Activities/StartProgramActivity.cs @@ -76,15 +76,12 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c // The shell parser introduces a second injection surface beyond PowerShell quoting, // so default-on since Phase 3: a missing config key is treated as // "DisallowShellExecute=true". Dev/test deployments that need shell-mediated launches - // flip StartProgram:DisallowShellExecute=false explicitly. Activities running with a - // null configuration (test harness without IConfiguration) keep the old permissive - // behaviour — there's no operator at risk in that scenario. + // flip StartProgram:DisallowShellExecute=false explicitly. if (useShell) { - var raw = _configuration?["StartProgram:DisallowShellExecute"]; - var disallow = _configuration is not null - && (string.IsNullOrWhiteSpace(raw) - || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)); + var raw = _configuration["StartProgram:DisallowShellExecute"]; + var disallow = string.IsNullOrWhiteSpace(raw) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase); if (disallow) throw new InvalidOperationException( "StartProgram: useShellExecute=true is blocked by configuration. " + @@ -110,6 +107,10 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c var timeoutMs = PowerShellOperation.ToWaitForExitMilliseconds(timeoutSeconds); var useShellPs = useShell ? "$true" : "$false"; var waitPs = wait ? "$true" : "$false"; + var targetPathGuard = TargetPathGuardScript.Build( + _configuration, + ("$__filePath", "filePath"), + ("$__workingDir", "workingDirectory")); // Build a self-contained script that emits a JSON result block between markers. // Uses ProcessStartInfo directly for reliable stdout/stderr capture (Start-Process @@ -122,6 +123,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c $__useShell = {{useShellPs}} $__wait = {{waitPs}} $__timeoutMs = {{timeoutMs}} + {{targetPathGuard}} $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $__filePath @@ -322,8 +324,7 @@ private void ValidateLocalAbsolutePath(string fieldName, string path) { try { - if (_configuration is not null) - PathGuard.Validate(_configuration, path, allowWildcards: false); + PathGuard.Validate(_configuration, path, allowWildcards: false); } catch (InvalidOperationException ex) { diff --git a/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs b/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs index df1decf0..12df6477 100644 --- a/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs +++ b/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs @@ -224,22 +224,17 @@ private void ValidateNetworkTarget(JsonElement config) if (conditionType == "portopen") { var host = config.GetStringOrNull("host")!; // presence is validated by the builder first - NetworkGuard.RequireExplicitlyAllowlistedHost( - _configuration ?? throw new InvalidOperationException("WaitForCondition: network policy configuration is unavailable."), - host, - "WaitForCondition portOpen"); + NetworkGuard.RequireExplicitlyAllowlistedHost(_configuration, host, "WaitForCondition portOpen"); return; } if (conditionType == "httpok") { var url = config.GetStringOrNull("url")!; // presence is validated by the builder first - var configuration = _configuration - ?? throw new InvalidOperationException("WaitForCondition: network policy configuration is unavailable."); // Probe policy only — NOT NetworkGuard.ValidateUrl. That is the restApi SSRF guard; // running httpOk through it made WaitForCondition:AllowedHosts inert for exactly the // loopback/RFC1918 targets it ships enabled for. See NetworkGuard.ValidateProbeUrl. - NetworkGuard.ValidateProbeUrl(configuration, url, "WaitForCondition httpOk"); + NetworkGuard.ValidateProbeUrl(_configuration, url, "WaitForCondition httpOk"); } } diff --git a/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs b/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs index f9ddf11e..3ec2c30e 100644 --- a/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs +++ b/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs @@ -20,63 +20,28 @@ internal static async Task ExecuteMeasuredAsync( try { var rows = await write(); - var statusTag = new KeyValuePair("status", "success"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); + RecordCall("success", startTimestamp, operationTag); EngineMetrics.DbSaveChangesRows.Record(rows, operationTag); return rows; } - catch (OperationCanceledException) + catch (Exception ex) { - var statusTag = new KeyValuePair("status", "cancelled"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } - catch - { - var statusTag = new KeyValuePair("status", "failure"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); + RecordCall(ex is OperationCanceledException ? "cancelled" : "failure", startTimestamp, operationTag); throw; } } - internal static async Task SaveChangesMeasuredAsync( + internal static Task SaveChangesMeasuredAsync( this NodePilotDbContext db, string operation, CancellationToken ct) - { - if (!HasDbSaveMetricsListener()) - return await SaveChangesIdempotentAsync(db, ct); - - var startTimestamp = Stopwatch.GetTimestamp(); - var operationTag = new KeyValuePair("operation", operation); - - try - { - var rows = await SaveChangesIdempotentAsync(db, ct); + => ExecuteMeasuredAsync(operation, () => SaveChangesIdempotentAsync(db, ct)); - var statusTag = new KeyValuePair("status", "success"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - EngineMetrics.DbSaveChangesRows.Record(rows, operationTag); - return rows; - } - catch (OperationCanceledException) - { - var statusTag = new KeyValuePair("status", "cancelled"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } - catch - { - var statusTag = new KeyValuePair("status", "failure"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } + private static void RecordCall(string status, long startTimestamp, KeyValuePair operationTag) + { + var statusTag = new KeyValuePair("status", status); + EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); + EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); } /// diff --git a/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs b/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs index 1776ed04..22545503 100644 --- a/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs +++ b/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; namespace NodePilot.Mcp.Analysis; @@ -21,56 +22,23 @@ private static ArrayDiff DiffArray(JsonElement current, JsonElement proposed, st var added = prop.Keys.Where(k => !cur.ContainsKey(k)).OrderBy(k => k).ToList(); var removed = cur.Keys.Where(k => !prop.ContainsKey(k)).OrderBy(k => k).ToList(); - var modified = prop.Keys.Where(k => cur.ContainsKey(k) && !JsonEquals(cur[k], prop[k])).OrderBy(k => k).ToList(); + // Semantic comparison: property order and number representation (1 vs 1.0) are irrelevant, + // array order is not. + var modified = prop.Keys.Where(k => cur.ContainsKey(k) && !JsonNode.DeepEquals(cur[k], prop[k])).OrderBy(k => k).ToList(); return new ArrayDiff(added, removed, modified); } - private static Dictionary IndexById(JsonElement def, string arrayName) + private static Dictionary IndexById(JsonElement def, string arrayName) { - var map = new Dictionary(StringComparer.Ordinal); + var map = new Dictionary(StringComparer.Ordinal); if (def.ValueKind == JsonValueKind.Object && def.TryGetProperty(arrayName, out var arr) && arr.ValueKind == JsonValueKind.Array) foreach (var item in arr.EnumerateArray()) if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) { var id = idEl.GetString(); - if (!string.IsNullOrEmpty(id)) map[id] = Canonical(item); + if (!string.IsNullOrEmpty(id)) map[id] = JsonSerializer.SerializeToNode(item); } return map; } - - // Order-insensitive comparison of objects by canonicalising property order recursively. - private static bool JsonEquals(string a, string b) => a == b; - - private static string Canonical(JsonElement el) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) - WriteCanonical(el, writer); - return System.Text.Encoding.UTF8.GetString(stream.ToArray()); - } - - private static void WriteCanonical(JsonElement el, Utf8JsonWriter writer) - { - switch (el.ValueKind) - { - case JsonValueKind.Object: - writer.WriteStartObject(); - foreach (var p in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) - { - writer.WritePropertyName(p.Name); - WriteCanonical(p.Value, writer); - } - writer.WriteEndObject(); - break; - case JsonValueKind.Array: - writer.WriteStartArray(); - foreach (var item in el.EnumerateArray()) WriteCanonical(item, writer); - writer.WriteEndArray(); - break; - default: - el.WriteTo(writer); - break; - } - } } diff --git a/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx b/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx new file mode 100644 index 00000000..4603ac6e --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { ActivityIcon } from '../../../components/designer/library/NodeLibrary'; +import { + useCustomActivityCatalogStore, + type CustomActivityCatalogEntry, +} from '../../../lib/customActivities'; + +/** + * ActivityIcon is the palette/picker glyph. It used to carry its own 23-entry table of + * Tailwind colour literals (`text-blue-600`, …) parallel to the generated `--act-*` design + * tokens — incomplete and with no dark-mode variant. It now resolves icon AND colour through + * getActivityVisual, the same resolver the canvas ActivityNode uses. These tests pin that: + * the colour must arrive as a CSS variable on `style`, never as a Tailwind class. + */ +const customEntry = (over: Partial = {}): CustomActivityCatalogEntry => ({ + id: 'id-1', key: 'disk_check', type: 'custom:disk_check', name: 'Disk Check', description: null, + icon: 'bolt', color: '#ff8800', runsRemote: false, timeout: 'always', + inputs: [], outputs: [], isEnabled: true, version: 1, ...over, +}); + +function renderIcon(type: string, size?: number) { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + return svg!; +} + +describe('ActivityIcon', () => { + beforeEach(() => useCustomActivityCatalogStore.getState().setCatalog([])); + + it('rendersBuiltInType_UsesDesignTokenColor', () => { + const svg = renderIcon('sql'); + expect(svg.style.color).toBe('var(--act-sql-color)'); + }); + + it('rendersBuiltInType_CarriesNoTailwindColorLiteral', () => { + // The whole point of the token switch: no `text--` class may come back. + for (const type of ['runScript', 'manualTrigger', 'delay', 'decision', 'textFileEdit']) { + const svg = renderIcon(type); + expect(svg.getAttribute('class') ?? '').not.toMatch(/\btext-[a-z]+-\d{3}\b/); + expect(svg.style.color).toBe(`var(--act-${type}-color)`); + } + }); + + it('rendersTypesThatTheOldColorTableMissed', () => { + // These five were absent from the deleted `iconColors` map and fell through to the muted + // default; they now get their own token like every other catalog entry. + for (const type of ['textFileEdit', 'forEach', 'startWorkflow', 'returnData', 'llmQuery']) { + expect(renderIcon(type).style.color).toBe(`var(--act-${type}-color)`); + } + }); + + it('rendersCustomActivity_UsesRuntimeAccentColor', () => { + useCustomActivityCatalogStore.getState().setCatalog([customEntry()]); + // jsdom may serialise a hex literal as rgb() — accept either spelling of the same colour. + expect(['#ff8800', 'rgb(255, 136, 0)']).toContain(renderIcon('custom:disk_check').style.color); + }); + + it('rendersCustomActivityWithoutColor_FallsBackToIndigoAccent', () => { + useCustomActivityCatalogStore.getState().setCatalog([customEntry({ color: null })]); + expect(['#6366f1', 'rgb(99, 102, 241)']).toContain(renderIcon('custom:disk_check').style.color); + }); + + it('rendersUnknownType_FallsBackToRunScriptVisual', () => { + expect(renderIcon('totallyUnknown').style.color).toBe('var(--act-runScript-color)'); + }); + + it('honoursTheSizeProp', () => { + const svg = renderIcon('sql', 18); + expect(svg.getAttribute('width')).toBe('18'); + expect(svg.getAttribute('height')).toBe('18'); + }); +}); diff --git a/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts b/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts index 1d263375..6b40a8af 100644 --- a/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts +++ b/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts @@ -1,19 +1,10 @@ import { describe, it, expect } from 'vitest'; import { - skippedConfigKeys, isRemoteActivityType, buildClonedDataPatch, applyClonedPatch, } from '../../lib/configClone'; -describe('configClone — skippedConfigKeys', () => { - it('returns empty list for activities with no type-specific skip rules', () => { - expect(skippedConfigKeys('runScript')).toEqual([]); - expect(skippedConfigKeys('sql')).toEqual([]); - expect(skippedConfigKeys('unknownActivity')).toEqual([]); - }); -}); - describe('configClone — isRemoteActivityType', () => { it('identifies remote-capable activities', () => { expect(isRemoteActivityType('runScript')).toBe(true); @@ -60,6 +51,19 @@ describe('configClone — buildClonedDataPatch (scope=all)', () => { }); }); + it('copies unknown/arbitrary config keys verbatim — there is no skip list', () => { + const source = { + activityType: 'runScript', + config: { script: 'Get-Date', someFutureKey: 'kept', result: 'kept-too' }, + }; + const patch = buildClonedDataPatch(source, 'runScript', 'all'); + expect(patch.__configPatch).toEqual({ + script: 'Get-Date', + someFutureKey: 'kept', + result: 'kept-too', + }); + }); + it('does NOT copy label or outputVariable (those identify the step itself)', () => { const patch = buildClonedDataPatch(sourceRunScript, 'runScript', 'all'); expect(patch).not.toHaveProperty('label'); diff --git a/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx index aedc6e1e..baa24c3a 100644 --- a/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx @@ -15,7 +15,6 @@ const server = setupServer( http.get(`${BASE}/api/observability/config`, () => HttpResponse.json({ enabled: true, prometheusAvailable: true, grafanaBaseUrl: 'http://localhost:3000' })), http.get(`${BASE}/api/observability/dashboards/:key`, () => HttpResponse.json({ available: true, key: 'mission-control', title: 'Mission Control', - panels: [], series: [], tables: [], widgets: [ { id: 1, title: 'Active executions', description: null, type: 'stat', unit: 'short', grid: { x: 0, y: 0, width: 3, height: 4 }, data: [{ label: 'Value', labels: {}, points: [{ timestamp: 1, value: 3 }] }], error: null }, { id: 2, title: 'Top failing workflows', description: null, type: 'bargauge', unit: 'short', grid: { x: 0, y: 4, width: 12, height: 8 }, data: [{ label: 'Import users', labels: { workflow_name: 'Import users' }, points: [{ timestamp: 1, value: 4 }] }], error: null }, diff --git a/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx b/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx index 8c4c38b2..71080ca9 100644 --- a/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx +++ b/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx @@ -1,46 +1,24 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { getWorkflowSnippets } from '../../../lib/workflowSnippets'; -import { ACTIVITY_ICONS } from '../../../lib/activityCatalog.generated'; -import { isCustomActivityType, getCustomActivityFacts } from '../../../lib/customActivities'; import { ACTIVITY_ICON_COMPONENTS, FALLBACK_ACTIVITY_ICON } from '../../../lib/activityIcons'; +import { getActivityVisual } from '../nodes/activityConfig'; import { ChevronDown } from '@carbon/icons-react'; -const iconColors: Record = { - // Triggers - manualTrigger: 'text-red-600', scheduleTrigger: 'text-yellow-600', webhookTrigger: 'text-orange-600', - fileWatcherTrigger: 'text-green-600', databaseTrigger: 'text-purple-600', eventLogTrigger: 'text-sky-600', - // Activities - runScript: 'text-blue-600', fileOperation: 'text-amber-600', folderOperation: 'text-amber-600', fileHash: 'text-violet-600', - zipOperation: 'text-yellow-700', serviceManagement: 'text-green-600', scheduledTask: 'text-fuchsia-600', - registryOperation: 'text-purple-600', wmiQuery: 'text-cyan-600', startProgram: 'text-rose-600', - powerManagement: 'text-red-700', waitForCondition: 'text-cyan-600', - restApi: 'text-orange-600', - sql: 'text-sky-700', xmlQuery: 'text-teal-600', jsonQuery: 'text-amber-700', - emailNotification: 'text-pink-600', delay: 'text-on-surface-variant', log: 'text-slate-600', - // Control Flow - junction: 'text-indigo-600', decision: 'text-indigo-700', -}; - +/** + * Palette/picker glyph for an activity type. + * + * Icon *and* accent come from {@link getActivityVisual} — the same resolver the canvas nodes + * use, so palette and canvas can never drift apart. Built-ins resolve to the generated + * `--act--*` design tokens (which carry their own dark-mode values); custom activities + * (`custom:`) resolve to the runtime catalog's icon + accent. The colour is a CSS + * variable, so it rides on `style` — there is no Tailwind class for it. + */ export function ActivityIcon({ type, size = 20 }: Readonly<{ type: string; size?: number }>) { - // Custom activities (custom:) carry their own icon + optional accent colour in the runtime - // catalog — they have no static ACTIVITY_ICONS / iconColors entry. - if (isCustomActivityType(type)) { - const facts = getCustomActivityFacts(type); - const CustomIcon = ACTIVITY_ICON_COMPONENTS[facts?.icon ?? ''] ?? FALLBACK_ACTIVITY_ICON; - return ( - - ); - } - - const colorClass = iconColors[type] || 'text-on-surface-variant'; - const Icon = ACTIVITY_ICON_COMPONENTS[ACTIVITY_ICONS[type] || 'help'] ?? FALLBACK_ACTIVITY_ICON; + const { icon, color } = getActivityVisual(type); + const Icon = ACTIVITY_ICON_COMPONENTS[icon] ?? FALLBACK_ACTIVITY_ICON; - return ; + return ; } export function SnippetsSection({ collapsed, onToggle, onInsert, canWrite = true }: Readonly<{ diff --git a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json index f49eab40..14588fda 100644 --- a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json @@ -51,7 +51,6 @@ "confirmEnablePrompt": "Tippe: {{phrase}}", "confirmEnableButton": "Ja, aktivieren" }, - "comingSoon": "Kommt in einer späteren Version", "restartBannerTitle": "Service-Neustart erforderlich", "restartBannerBody": "Folgende Einstellungen wurden gespeichert, sind aber erst nach einem Service-Neustart aktiv:", "restartBannerSince": "ausstehend seit {{since}}", @@ -83,7 +82,6 @@ "errorTitle": "Fehler", "validationErrorsTitle": "Diese Werte würden den nächsten Service-Start verhindern:", "noSections": "Keine Sektionen konfiguriert.", - "comingSoonSectionTitle": "Diese Sektion kommt in einer späteren Version", "smtpEnableSslLabel": "TLS aktivieren (EnableSsl)", "smtpEnableSslWarning": "Warnung: Benutzername ist gesetzt, aber TLS ist deaktiviert. SMTP-Anmeldedaten und Nachrichteninhalte werden im Klartext übertragen. Nur für lokale, nicht öffentlich erreichbare Relays empfohlen.", "perf": { @@ -159,7 +157,9 @@ "requireWebhookSecret": "X-Webhook-Secret-Header erforderlich", "requireWebhookSecretHint": "Ein webhookTrigger ohne konfiguriertes Secret feuert nicht. Verifiziert wird je nach signatureMode des Triggers als X-Webhook-Secret-Header oder als HMAC-Signatur; der HMAC-Modus ist immer fail-closed, unabhängig von diesem Schalter. Aus heißt: wer die Webhook-URL kennt, kann den Workflow auslösen — jeder secret-lose Treffer wird zusätzlich als Warnung geloggt.", "externalTriggerApiKey": "API-Schlüssel", - "externalTriggerHint": "Leer/gelöscht deaktiviert <0>POST /api/trigger/... vollständig (503).", + "externalTriggerHint": "Legacy-Schlüssel für <0>POST /api/trigger/.... Er kann nur explizit gelistete Workflows mit aktivem Manual/API-Trigger starten. Neue Integrationen sollten gehashte, eingeschränkte Schlüssel-Einträge in der Konfiguration verwenden.", + "externalTriggerAllowedWorkflowIds": "Erlaubte Workflow-IDs (Legacy-Schlüssel)", + "externalTriggerAllowedWorkflowIdsHint": "Leer bedeutet alles verweigern. Unveränderliche Workflow-GUIDs verwenden, ein Eintrag pro Workflow; Namen und Wildcards sind nicht erlaubt.", "strictAllowedHosts": "AllowedHosts='*' beim Start ablehnen (StrictAllowedHosts)", "strictAllowedHostsHint": "Bricht den Dienststart ab, wenn AllowedHosts unsicher weit gefasst ist: '*', leer oder ein nicht ersetzter Platzhalter aus dem Deployment-Template. Wirkt ausschließlich beim Boot, nicht zur Laufzeit — nach einer Änderung hier entscheidet erst der nächste Start. Ohne diesen Schalter bleibt eine weit offene Liste nur eine Warnung im Log.", "allowedHostsField": "AllowedHosts (semikolon-getrennt)", diff --git a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json index 856224b5..f31fbf01 100644 --- a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json @@ -51,7 +51,6 @@ "confirmEnablePrompt": "Type: {{phrase}}", "confirmEnableButton": "Yes, enable" }, - "comingSoon": "Coming in a later version", "restartBannerTitle": "Service restart required", "restartBannerBody": "The following settings have been saved but will only take effect after a service restart:", "restartBannerSince": "pending since {{since}}", @@ -83,7 +82,6 @@ "errorTitle": "Error", "validationErrorsTitle": "These values would prevent the next service start:", "noSections": "No sections configured.", - "comingSoonSectionTitle": "This section is coming in a later version", "smtpEnableSslLabel": "Enable TLS (EnableSsl)", "smtpEnableSslWarning": "Warning: a username is configured but TLS is disabled. SMTP credentials and message bodies will travel in plaintext. Only recommended for local, non-public relays.", "perf": { @@ -159,7 +157,9 @@ "requireWebhookSecret": "Require X-Webhook-Secret header", "requireWebhookSecretHint": "A webhookTrigger without a configured secret will not fire. Verification follows the trigger's signatureMode — either the X-Webhook-Secret header or an HMAC signature; HMAC mode is always fail-closed regardless of this switch. Off means anyone who knows the webhook URL can start the workflow — every secret-less hit is additionally logged as a warning.", "externalTriggerApiKey": "API key", - "externalTriggerHint": "Empty/cleared disables <0>POST /api/trigger/... entirely (503).", + "externalTriggerHint": "Legacy key for <0>POST /api/trigger/.... It can start only explicitly listed workflows that contain an enabled Manual/API trigger. New integrations should use hashed, scoped key entries in configuration.", + "externalTriggerAllowedWorkflowIds": "Allowed workflow IDs (legacy key)", + "externalTriggerAllowedWorkflowIdsHint": "Empty means deny all. Use immutable workflow GUIDs, one entry per workflow; names and wildcards are not accepted.", "strictAllowedHosts": "Reject AllowedHosts='*' at boot (StrictAllowedHosts)", "strictAllowedHostsHint": "Aborts service start-up when AllowedHosts is unsafely wide: '*', empty, or a deployment-template placeholder that was never substituted. It applies at boot only, never at runtime — a change here takes effect on the next start. Without this switch, a wide-open list is only a warning in the log.", "allowedHostsField": "AllowedHosts (semicolon-separated)", diff --git a/src/nodepilot-ui/src/lib/configClone.ts b/src/nodepilot-ui/src/lib/configClone.ts index 5577fd68..0525bf9e 100644 --- a/src/nodepilot-ui/src/lib/configClone.ts +++ b/src/nodepilot-ui/src/lib/configClone.ts @@ -7,31 +7,6 @@ import { REMOTE_ACTIVITY_TYPES } from './activityCatalog.generated'; */ export const SHARED_NODE_CLONE_KEYS = ['targetMachineId', 'credentialId'] as const; -/** - * Per-activity-type list of `data.config.*` keys to skip when cloning. The default rule is - * "copy the entire config" — when the user picks a source step they want the whole step, - * not a half-copy that forces them to re-enter the script body. Skip-list exists only for - * fields that would always be wrong on the target (e.g. an inherited execution-time `result` - * stamped into the source's config from a previous run). - * - * Default = empty skip list, so adding a new activity type needs no entry here. - */ -const CONFIG_CLONE_SKIP_KEYS_BY_TYPE: Record> = { - // No type-specific skips today — defaults handled by SHARED_CONFIG_SKIP_KEYS below. -}; - -/** Keys that never make sense to copy regardless of activity type — runtime-stamped state. */ -const SHARED_CONFIG_SKIP_KEYS: ReadonlyArray = []; - -/** - * Returns the list of `config.*` keys that are explicitly skipped for an activity type. Used - * by tests + the popover preview text. Empty array → "everything in config is cloneable". - */ -export function skippedConfigKeys(activityType: string): ReadonlyArray { - const typeSpecific = CONFIG_CLONE_SKIP_KEYS_BY_TYPE[activityType] ?? []; - return [...SHARED_CONFIG_SKIP_KEYS, ...typeSpecific]; -} - /** * Returns true if `targetMachineId` + `credentialId` are meaningful for this activity. * Used to decide whether the clone-picker should offer cross-type Remote-→-Remote @@ -52,7 +27,7 @@ export type CloneScope = 'all' | 'remoteOnly'; * dragging timeout/retry policy along. * * `scope = 'all'` requires identical activity types — copies the shared keys plus the - * type-specific cloneable config keys. + * complete source config. */ export function buildClonedDataPatch( source: Record, @@ -83,15 +58,10 @@ export function buildClonedDataPatch( } // Take the entire source config (including the action payload — script bodies, queries, - // paths, URLs, etc.) and only drop runtime-stamped keys. Users explicitly want a full - // copy: "this new step should look exactly like that one, then I'll edit what I need." + // paths, URLs, etc.). Users explicitly want a full copy: "this new step should look + // exactly like that one, then I'll edit what I need." const sourceConfig = (source.config as Record | undefined) ?? {}; - const skip = new Set(skippedConfigKeys(targetActivityType)); - const configPatch: Record = {}; - for (const [k, v] of Object.entries(sourceConfig)) { - if (skip.has(k)) continue; - configPatch[k] = v; - } + const configPatch: Record = { ...sourceConfig }; if (Object.keys(configPatch).length > 0) { patch.__configPatch = configPatch; } diff --git a/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx b/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx index 63e3cbe1..8e605895 100644 --- a/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx +++ b/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx @@ -25,8 +25,6 @@ import { AiKnowledgeSection } from '../components/admin-settings/AiKnowledgeSect type SubTab = 'integrations' | 'ai-knowledge' | 'retention' | 'system-info' | 'authentication' | 'logging-telemetry' | 'security' | 'performance' | 'db-admin'; -// Tabs are progressively activated as their section is implemented. Disabled tabs -// keep the operator informed about what's on the roadmap. // Order is grouped by topic, not by implementation history: outbound integrations → // security → operations → data. `integrations` stays first because it is also the // default/fallback section for a bare `?tab=system`, and `system-info` sits LAST because @@ -34,21 +32,21 @@ type SubTab = 'integrations' | 'ai-knowledge' | 'retention' | 'system-info' // rather than splitting that run. // Order is presentation only: nothing indexes into this array and deep links address a // section by `?section=`, so reordering breaks no bookmark. -const TABS: { id: SubTab; ready: boolean }[] = [ +const TABS: SubTab[] = [ // External connections — AI knowledge sources hang off the LLM profile configured next door. - { id: 'integrations', ready: true }, - { id: 'ai-knowledge', ready: true }, + 'integrations', + 'ai-knowledge', // Security — the two access/hardening tabs stay adjacent. - { id: 'authentication', ready: true }, - { id: 'security', ready: true }, + 'authentication', + 'security', // Operations. - { id: 'logging-telemetry', ready: true }, - { id: 'performance', ready: true }, + 'logging-telemetry', + 'performance', // Data lifecycle — retention is a database-side concern, so it follows the DB tab. - { id: 'db-admin', ready: true }, - { id: 'retention', ready: true }, + 'db-admin', + 'retention', // Read-only, always last. - { id: 'system-info', ready: true }, + 'system-info', ]; const ICONS: Record> = { @@ -63,19 +61,33 @@ const ICONS: Record> = { 'db-admin': DataBase, }; +// i18n key per sub-tab. Doubles as the whitelist for the `?section=` deep link: a value is +// accepted iff it is a key here, so a new sub-tab cannot be link-addressable without a label. +const LABEL_KEYS: Record = { + 'integrations': 'subTabIntegrations', + 'ai-knowledge': 'subTabAiKnowledge', + 'retention': 'subTabRetention', + 'system-info': 'subTabSystemInfo', + 'authentication': 'subTabAuthentication', + 'logging-telemetry': 'subTabLoggingTelemetry', + 'security': 'subTabSecurity', + 'performance': 'subTabPerformance', + 'db-admin': 'subTabDbAdmin', +}; + +const DEFAULT_SUB_TAB: SubTab = 'integrations'; + +function isSubTab(value: string | null): value is SubTab { + return value !== null && Object.prototype.hasOwnProperty.call(LABEL_KEYS, value); +} + export function SystemSettingsPage() { const { t } = useTranslation(['adminSettings']); // Deep-link: /settings?tab=system§ion= opens the requested sub-tab directly. // The dashboard's "LLM config" shortcut targets `integrations` (SMTP + LLM cards). const [searchParams, setSearchParams] = useSearchParams(); const sectionParam = searchParams.get('section'); - const initialSub: SubTab = - sectionParam === 'integrations' || sectionParam === 'ai-knowledge' || sectionParam === 'retention' - || sectionParam === 'system-info' || sectionParam === 'authentication' - || sectionParam === 'logging-telemetry' || sectionParam === 'security' - || sectionParam === 'performance' || sectionParam === 'db-admin' - ? (sectionParam as SubTab) : 'integrations'; - const active = initialSub; + const active: SubTab = isSubTab(sectionParam) ? sectionParam : DEFAULT_SUB_TAB; const setActive = (next: SubTab) => { const params = new URLSearchParams(searchParams); params.set('tab', 'system'); @@ -83,53 +95,24 @@ export function SystemSettingsPage() { setSearchParams(params); }; - const labelFor = (tab: SubTab): string => { - const key = tab === 'integrations' ? 'subTabIntegrations' - : tab === 'ai-knowledge' ? 'subTabAiKnowledge' - : tab === 'retention' ? 'subTabRetention' - : tab === 'system-info' ? 'subTabSystemInfo' - : tab === 'authentication' ? 'subTabAuthentication' - : tab === 'logging-telemetry' ? 'subTabLoggingTelemetry' - : tab === 'security' ? 'subTabSecurity' - : tab === 'db-admin' ? 'subTabDbAdmin' - : 'subTabPerformance'; - return t(`adminSettings:${key}`); - }; + const labelFor = (tab: SubTab): string => t(`adminSettings:${LABEL_KEYS[tab]}`); return (
- {TABS.map(({ id, ready }) => { + {TABS.map((id) => { const Icon = ICONS[id]; - const isActive = active === id; - if (ready) { - return ( - - ); - } return ( ); })} @@ -149,7 +132,3 @@ export function SystemSettingsPage() {
); } - -// ComingSoonPlaceholder is no longer used now that all four V2 tabs are wired up. -// Keeping the function around would emit an unused-import lint; if a future tab is -// added with a placeholder during scaffolding, re-add it here. diff --git a/src/nodepilot-ui/src/types/api.ts b/src/nodepilot-ui/src/types/api.ts index e3ef99c7..c0fa07a0 100644 --- a/src/nodepilot-ui/src/types/api.ts +++ b/src/nodepilot-ui/src/types/api.ts @@ -135,10 +135,6 @@ export interface TelemetrySummary { } export interface MetricsPoint { timestamp: number; value: number | null; } -export interface MetricsSeriesLine { label: string; points: MetricsPoint[]; } -export interface MetricsSeries { key: string; title: string; unit: string; lines: MetricsSeriesLine[]; } -export interface MetricsTableRow { label: string; value: number; } -export interface MetricsTable { key: string; title: string; unit: string; rows: MetricsTableRow[]; } export interface MetricsDataSeries { label: string; labels: Record; points: MetricsPoint[]; } export interface MetricsWidget { id: number; @@ -154,9 +150,6 @@ export interface MetricsDashboard { available: boolean; key: string; title: string; - panels: TelemetryPanel[]; - series: MetricsSeries[]; - tables: MetricsTable[]; widgets: MetricsWidget[]; } diff --git a/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs b/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs index 8c3c75cc..d9097050 100644 --- a/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs @@ -77,6 +77,19 @@ public void Custom_BypassGlob_KeepsALocalEndpointDirect() proxy.IsBypassed(CloudEndpoint).Should().BeFalse(); } + [Fact] + public void Custom_PlaintextLoopback_IsAlwaysDirect_EvenWithoutBypassConfiguration() + { + var (proxy, _) = Build(new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://proxy.corp.local:8080", + }); + + proxy.IsBypassed(LocalEndpoint).Should().BeTrue(); + proxy.GetProxy(LocalEndpoint).Should().BeNull(); + } + [Fact] public void Custom_WithUsername_PresentsNetworkCredential() { @@ -148,8 +161,9 @@ public void ModeChange_TakesEffectWithoutRebuildingTheProxy() } [Fact] - public void AddressChange_RebuildsTheCachedProxy() + public void AddressChange_TakesEffectOnTheNextRequest() { + // The WebProxy is rebuilt per call, so a hot-reloaded address needs no invalidation step. var options = LlmTestOptions.WithProfile(); options.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080" }; var monitor = new MutableOptionsMonitor(options); @@ -165,27 +179,61 @@ public void AddressChange_RebuildsTheCachedProxy() } [Fact] - public void BypassListChange_RebuildsTheCachedProxy() + public void BypassListChange_TakesEffectOnTheNextRequest() { - // The cache compares the source values field by field; a changed bypass list must not be - // masked by an unchanged address. + // The address stays the same — only the bypass globs change. Nothing may carry the old + // bypass regexes over into the next request. var options = LlmTestOptions.WithProfile(); options.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080" }; var monitor = new MutableOptionsMonitor(options); var proxy = new LlmConfiguredProxy(monitor); - proxy.IsBypassed(LocalEndpoint).Should().BeFalse(); + var internalEndpoint = new Uri("https://llm.intern/v1/chat/completions"); + proxy.IsBypassed(internalEndpoint).Should().BeFalse(); var updated = LlmTestOptions.WithProfile(); updated.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080", - BypassList = ["localhost"], + BypassList = ["*.intern"], }; monitor.Set(updated); - proxy.IsBypassed(LocalEndpoint).Should().BeTrue(); + proxy.IsBypassed(internalEndpoint).Should().BeTrue(); + } + + [Fact] + public void CredentialChange_TakesEffectOnTheNextRequest() + { + // Same address, different credentials — the proxy must not serve a previously built one. + var options = LlmTestOptions.WithProfile(); + options.Proxy = new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://p1:8080", + Username = "old", + Password = "old-secret", + }; + var monitor = new MutableOptionsMonitor(options); + var proxy = new LlmConfiguredProxy(monitor); + + proxy.GetProxy(CloudEndpoint).Should().Be(new Uri("http://p1:8080")); + proxy.Credentials.Should().BeOfType().Which.UserName.Should().Be("old"); + + var updated = LlmTestOptions.WithProfile(); + updated.Proxy = new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://p1:8080", + Username = "new", + Password = "new-secret", + }; + monitor.Set(updated); + + var credential = proxy.Credentials.Should().BeOfType().Subject; + credential.UserName.Should().Be("new"); + credential.Password.Should().Be("new-secret"); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs b/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs index 36384c9d..8f51de51 100644 --- a/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs +++ b/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs @@ -107,6 +107,25 @@ public void Format_MessageWithLogTerminatorLiteral_IsSanitized() SmsLine.IsMatch(line).Should().BeTrue($"line was: {line}"); } + [Fact] + public void Format_MessageWithSeveralLogTerminatorLiterals_SanitizesEveryOccurrence() + { + // A pasted multi-line log excerpt carries the terminator once per copied entry. + // Sanitising only the first occurrence would still let the later ones close the + // SMS wrapper early. Includes two adjacent occurrences so a scan that skips past + // its own replacement is exercised too. + var ev = MakeEvent(LogEventLevel.Information, + "a ]LOG]!> b ]LOG]!>]LOG]!> c ]LOG]!>"); + + var line = FormatOne(ev); + + // Exactly one terminator survives — the one at the real end of the message body. + Regex.Count(line, Regex.Escape("]LOG]!>")).Should().Be(1); + SmsLine.IsMatch(line).Should().BeTrue($"line was: {line}"); + var msg = SmsLine.Match(line).Groups["msg"].Value; + msg.Should().Be("a ]LOG]_> b ]LOG]_>]LOG]_> c ]LOG]_>"); + } + [Fact] public void Format_ComponentWithDoubleQuotes_IsSanitized() { diff --git a/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs index 1605a24b..72a667bf 100644 --- a/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs @@ -26,7 +26,23 @@ public BaseRemoteActivityTests() { _db = TestDbContext.Create(); _credentialStore = new Mock(); - _sessionFactory = MockRemoteSession.CreateFactory(); + + var session = new Mock(); + session + .Setup(s => s.ExecuteScriptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RemoteExecutionResult + { + Success = true, + Output = "OK", + ErrorOutput = "", + Duration = TimeSpan.FromMilliseconds(100) + }); + session.Setup(s => s.DisposeAsync()).Returns(ValueTask.CompletedTask); + + _sessionFactory = new Mock(); + _sessionFactory + .Setup(f => f.CreateSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(session.Object); } public void Dispose() diff --git a/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs index 3e2bc516..1827742d 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs @@ -132,25 +132,6 @@ public async Task ExecuteAsync_PersistsReturnDataOnExecutionRow() reloaded.ReturnData.Should().NotBeNullOrEmpty(); reloaded.ReturnData.Should().Contain("\"status\":\"ok\""); } - - [Fact] - public async Task ExecuteAsync_RemovesPerExecutionLockAfterWrite() - { - var execId = Guid.NewGuid(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "WF", DefinitionJson = "{}" }; - _db.Workflows.Add(wf); - _db.WorkflowExecutions.Add(new WorkflowExecution { Id = execId, WorkflowId = wf.Id }); - await _db.SaveChangesAsync(); - - var activity = new ReturnDataActivity(_db); - var ctx = new StepExecutionContext { WorkflowExecutionId = execId, StepId = "r1" }; - - var before = ReturnDataActivity.ActiveLockCount; - var result = await activity.ExecuteAsync(ctx, Parse("{\"data\":{\"status\":\"ok\"}}"), CancellationToken.None); - - result.Success.Should().BeTrue(); - ReturnDataActivity.ActiveLockCount.Should().Be(before); - } } public sealed class StartWorkflowActivityTests : IDisposable diff --git a/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs index 4bb1d584..6f5158c6 100644 --- a/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs @@ -42,6 +42,26 @@ private static StepExecutionContext Ctx() => private static string CmdPath => Path.Combine(Environment.SystemDirectory, "cmd.exe"); + [Fact] + public void BuildScript_WithAllowedRoots_InjectsTargetGuardForExecutableAndWorkingDirectory() + { + var root = Path.GetPathRoot(CmdPath)!; + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = root, + }).Build(); + var script = new Accessor(config).CallBuildScript(Cfg(new + { + filePath = CmdPath, + workingDirectory = Environment.SystemDirectory, + }), Ctx()); + + script.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__filePath)"); + script.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__workingDir)"); + script.Should().Contain("FileAttributes]::ReparsePoint"); + } + [Fact] public void BuildScript_MissingFilePath_Throws() { @@ -117,8 +137,8 @@ public void BuildScript_UseShellExecuteTrue_RejectedByDefault() { // Phase-3 hardening: an empty IConfiguration now reads the missing // StartProgram:DisallowShellExecute as "true" so a stripped-down deployment falls - // on the safe side. Activities running with a NULL configuration (load harness) - // keep the old permissive behaviour — see BuildScript_UseShellExecuteTrue_NullConfig_Allowed. + // on the safe side. There is no permissive path left — BaseRemoteActivity requires a + // non-null IConfiguration, so the guard applies to every caller. var act = () => new Accessor().CallBuildScript(Cfg(new { filePath = @"C:\data\report.xlsx", diff --git a/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs b/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs index 8799c0d7..ec88f489 100644 --- a/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.Metrics; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Models; +using NodePilot.Core.Telemetry; using NodePilot.Data; +using NodePilot.Engine; using NodePilot.Engine.Execution; using Npgsql; using Xunit; @@ -50,6 +53,153 @@ public async Task SaveChangesMeasuredAsync_PostgresNonUniqueViolation_Propagates await act.Should().ThrowAsync(); } + [Fact] + public async Task ExecuteMeasuredAsync_Success_RecordsSuccessStatusAndRowCount() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var rows = await WorkflowDbWriteMetrics.ExecuteMeasuredAsync(operation, () => Task.FromResult(3)); + rows.Should().Be(3); + }); + + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes" && m.Status == "success"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration" && m.Status == "success"); + measurements.Should().ContainSingle(m => m.Instrument == "nodepilot.db.save_changes.rows") + .Which.Value.Should().Be(3); + } + + [Fact] + public async Task ExecuteMeasuredAsync_Cancellation_RecordsCancelledStatus() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await WorkflowDbWriteMetrics.ExecuteMeasuredAsync( + operation, () => Task.FromException(new OperationCanceledException())); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "cancelled"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration"); + measurements.Should().NotContain(m => m.Instrument == "nodepilot.db.save_changes.rows"); + } + + [Fact] + public async Task ExecuteMeasuredAsync_Failure_RecordsFailureStatus() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await WorkflowDbWriteMetrics.ExecuteMeasuredAsync( + operation, () => Task.FromException(new InvalidOperationException("boom"))); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "failure"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration"); + measurements.Should().NotContain(m => m.Instrument == "nodepilot.db.save_changes.rows"); + } + + [Fact] + public async Task SaveChangesMeasuredAsync_AbsorbedUniqueViolation_RecordsSuccessWithZeroRows() + { + var operation = UniqueOperation(); + var pgException = new PostgresException( + "duplicate key value violates unique constraint", + "ERROR", "ERROR", "23505"); + var (connection, ctx) = BuildThrowingContext(new DbUpdateException("retry replay", pgException)); + await using var _ = connection; + await using var __ = ctx; + ctx.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "test" }); + + var measurements = await MeasureAsync(operation, async () => + { + var rows = await ctx.SaveChangesMeasuredAsync(operation, CancellationToken.None); + rows.Should().Be(0); + }); + + measurements.Should().OnlyContain(m => m.Status == null || m.Status == "success"); + measurements.Should().ContainSingle(m => m.Instrument == "nodepilot.db.save_changes.rows") + .Which.Value.Should().Be(0); + } + + [Fact] + public async Task SaveChangesMeasuredAsync_Cancellation_RecordsCancelledStatus() + { + var operation = UniqueOperation(); + var (connection, ctx) = BuildThrowingContext(new OperationCanceledException()); + await using var _ = connection; + await using var __ = ctx; + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await ctx.SaveChangesMeasuredAsync(operation, CancellationToken.None); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "cancelled"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + } + + private static string UniqueOperation() => $"test.{Guid.NewGuid():N}"; + + /// + /// Runs with a live attached to the + /// DB-write instruments and returns everything that was emitted for . + /// The operation tag is unique per test, so measurements from tests running in parallel are filtered out. + /// + private static async Task> MeasureAsync(string operation, Func action) + { + _ = EngineMetrics.DbSaveChanges; // force instrument creation before the listener starts + var captured = new List(); + var gate = new object(); + + void Record(Instrument instrument, double value, ReadOnlySpan> tags) + { + string? taggedOperation = null; + string? status = null; + foreach (var tag in tags) + { + if (tag.Key == "operation") + taggedOperation = tag.Value as string; + else if (tag.Key == "status") + status = tag.Value as string; + } + + if (taggedOperation != operation) + return; + + lock (gate) + captured.Add(new CapturedMeasurement(instrument.Name, status, value)); + } + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == TelemetryConstants.Meters.Engine + && instrument.Name.StartsWith("nodepilot.db.save_changes", StringComparison.Ordinal)) + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, value, tags, _) => Record(instrument, value, tags)); + listener.SetMeasurementEventCallback((instrument, value, tags, _) => Record(instrument, value, tags)); + listener.Start(); + + await action(); + + lock (gate) + return captured.ToList(); + } + + private sealed record CapturedMeasurement(string Instrument, string? Status, double Value); + private static (SqliteConnection conn, ThrowingDbContext ctx) BuildThrowingContext(Exception toThrow) { var connection = new SqliteConnection("DataSource=:memory:"); diff --git a/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs b/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs deleted file mode 100644 index 58ba461d..00000000 --- a/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Moq; -using NodePilot.Core.Interfaces; -using NodePilot.Core.Models; - -namespace NodePilot.Engine.Tests.Helpers; - -public static class MockRemoteSession -{ - public static Mock CreateFactory(RemoteExecutionResult? defaultResult = null) - { - var result = defaultResult ?? new RemoteExecutionResult - { - Success = true, - Output = "OK", - ErrorOutput = "", - Duration = TimeSpan.FromMilliseconds(100) - }; - - var mockSession = new Mock(); - mockSession - .Setup(s => s.ExecuteScriptAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - mockSession - .Setup(s => s.DisposeAsync()) - .Returns(ValueTask.CompletedTask); - - var mockFactory = new Mock(); - mockFactory - .Setup(f => f.CreateSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(mockSession.Object); - - return mockFactory; - } - - public static Mock CreateFailingFactory(string errorMessage = "Connection failed") - { - var result = new RemoteExecutionResult - { - Success = false, - Output = "", - ErrorOutput = errorMessage, - Duration = TimeSpan.FromMilliseconds(50) - }; - - return CreateFactory(result); - } -} diff --git a/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs b/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs new file mode 100644 index 00000000..41344b4d --- /dev/null +++ b/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using FluentAssertions; +using NodePilot.Mcp.Analysis; +using Xunit; + +namespace NodePilot.Mcp.Tests.Analysis; + +/// +/// Pins the equality contract of the by-id definition diff. The comparison is semantic +/// (JsonNode.DeepEquals): property order and number representation do not matter, array +/// order and actual value changes do. +/// +public sealed class DefinitionDiffTests +{ + private static JsonElement E(string json) => JsonDocument.Parse(json).RootElement; + + [Fact] + public void Diff_SameNodeWithSwappedPropertyOrderAndNumberFormat_NotModified() + { + // Same node, three cosmetic differences only: top-level property order, nested property + // order inside data/position, and 100 written as 100.0 / 200 as 2e2. + var current = E(""" + {"nodes":[ + {"id":"a","type":"activity","position":{"x":100,"y":200}, + "data":{"label":"Check Disk","activityType":"runScript","config":{"timeoutSeconds":60}}}], + "edges":[]} + """); + var proposed = E(""" + {"nodes":[ + {"data":{"config":{"timeoutSeconds":60.0},"activityType":"runScript","label":"Check Disk"}, + "position":{"y":2e2,"x":100.0},"type":"activity","id":"a"}], + "edges":[]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + diff.Nodes.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_ChangedConfigValue_ReportsModified() + { + var current = E(""" + {"nodes":[{"id":"a","data":{"config":{"timeoutSeconds":60}}}],"edges":[]} + """); + var proposed = E(""" + {"nodes":[{"id":"a","data":{"config":{"timeoutSeconds":90}}}],"edges":[]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Modified.Should().Equal("a"); + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + } + + [Fact] + public void Diff_ReorderedArrayValue_ReportsModified() + { + // Arrays stay order-sensitive — only object property order is ignored. + var current = E(""" + {"nodes":[{"id":"a","data":{"config":{"tags":["x","y"]}}}],"edges":[]} + """); + var proposed = E(""" + {"nodes":[{"id":"a","data":{"config":{"tags":["y","x"]}}}],"edges":[]} + """); + + DefinitionDiff.Diff(current, proposed).Nodes.Modified.Should().Equal("a"); + } + + [Fact] + public void Diff_AddedRemovedAndModified_AreReportedSortedById() + { + var current = E(""" + {"nodes":[{"id":"keep"},{"id":"gone"},{"id":"b","data":{"label":"old"}}], + "edges":[{"id":"e1","source":"a","target":"b"}]} + """); + var proposed = E(""" + {"nodes":[{"id":"keep"},{"id":"zNew"},{"id":"aNew"},{"id":"b","data":{"label":"new"}}], + "edges":[{"id":"e1","target":"b","source":"a"}]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().Equal("aNew", "zNew"); + diff.Nodes.Removed.Should().Equal("gone"); + diff.Nodes.Modified.Should().Equal("b"); + diff.Edges.Added.Should().BeEmpty(); + diff.Edges.Removed.Should().BeEmpty(); + diff.Edges.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_MissingArraysAndNonObjectItems_AreIgnored() + { + var current = E("""{"nodes":["not-an-object",{"noId":true},{"id":42}]}"""); + var proposed = E("""{"edges":[{"id":"e1"}]}"""); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + diff.Nodes.Modified.Should().BeEmpty(); + diff.Edges.Added.Should().Equal("e1"); + } +} From 29b0417f07bea2dd8db5488862ca10429a39d6f6 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:50:05 +0200 Subject: [PATCH 5/6] Cover the system-settings sub-tab routing with a test SystemSettingsPage had no vitest file, so the LABEL_KEYS record that replaced the nine-way || chain went in unguarded. It doubles as the whitelist for the ?section= deep link, which makes the mapping worth pinning: one tab per sub-tab, every label translated (a missing key would render as "adminSettings:subTabX"), each section reachable by deep link, an unknown value falling back to integrations, and a click writing tab=system§ion= back into the query string. --- .../pages/SystemSettingsPage.test.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx diff --git a/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx new file mode 100644 index 00000000..e5409067 --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router'; +import { SystemSettingsPage } from '../../pages/SystemSettingsPage'; + +// The page's own job is tab selection: which section is active, and how `?section=` maps onto +// it. The nine sections each fetch their own config, so they are stubbed down to a marker — +// mounting them would test their data loading, not this page's routing. +vi.mock('../../components/admin-settings/RestartBanner', () => ({ RestartBanner: () => null })); +vi.mock('../../components/admin-settings/IntegrationsSection', () => ({ IntegrationsSection: () =>
section:integrations
})); +vi.mock('../../components/admin-settings/AiKnowledgeSection', () => ({ AiKnowledgeSection: () =>
section:ai-knowledge
})); +vi.mock('../../components/admin-settings/AuthenticationSection', () => ({ AuthenticationSection: () =>
section:authentication
})); +vi.mock('../../components/admin-settings/SecuritySection', () => ({ SecuritySection: () =>
section:security
})); +vi.mock('../../components/admin-settings/LoggingTelemetrySection', () => ({ LoggingTelemetrySection: () =>
section:logging-telemetry
})); +vi.mock('../../components/admin-settings/PerformanceSection', () => ({ PerformanceSection: () =>
section:performance
})); +vi.mock('../../components/admin-settings/DbAdminSection', () => ({ DbAdminSection: () =>
section:db-admin
})); +vi.mock('../../components/admin-settings/RetentionSection', () => ({ RetentionSection: () =>
section:retention
})); +vi.mock('../../components/admin-settings/SystemInfoSection', () => ({ SystemInfoSection: () =>
section:system-info
})); + +const SUB_TABS = [ + 'integrations', 'ai-knowledge', 'authentication', 'security', + 'logging-telemetry', 'performance', 'db-admin', 'retention', 'system-info', +]; + +function SearchParamsProbe() { + const [params] = useSearchParams(); + return
{params.toString()}
; +} + +function renderPage(path = '/settings?tab=system') { + return render( + + + } /> + + , + ); +} + +describe('SystemSettingsPage', () => { + it('renders one tab per sub-tab and opens the default section', () => { + renderPage(); + + expect(screen.getAllByRole('button')).toHaveLength(SUB_TABS.length); + expect(screen.getByText('section:integrations')).toBeInTheDocument(); + }); + + it('labels every tab from the i18n catalogue, never a raw key', () => { + renderPage(); + + for (const button of screen.getAllByRole('button')) { + // A missing translation makes i18next echo the key back — "adminSettings:subTabSecurity" + // rendered as a label is exactly the drift this asserts against. + expect(button.textContent?.trim()).not.toBe(''); + expect(button.textContent).not.toContain('subTab'); + expect(button.textContent).not.toContain('adminSettings:'); + } + }); + + it.each(SUB_TABS)('opens %s from a ?section= deep link', (section) => { + renderPage(`/settings?tab=system§ion=${section}`); + + expect(screen.getByText(`section:${section}`)).toBeInTheDocument(); + }); + + it('falls back to the default section for an unknown ?section= value', () => { + renderPage('/settings?tab=system§ion=does-not-exist'); + + expect(screen.getByText('section:integrations')).toBeInTheDocument(); + }); + + it('writes the selected section into the query string and keeps tab=system', async () => { + renderPage(); + + const securityTab = screen.getAllByRole('button').find((b) => b.textContent?.includes('Security')); + await userEvent.click(securityTab!); + + expect(screen.getByText('section:security')).toBeInTheDocument(); + expect(screen.getByTestId('query').textContent).toBe('tab=system§ion=security'); + }); +}); From 5de0b997688fe8b46ca9884ea3e2a40a45abf206 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:26:42 +0200 Subject: [PATCH 6/6] Scope external-trigger keys and contain the auth boundary Backend - External trigger: X-Api-Key is matched against SHA-256 hashes under ExternalTrigger:Keys:, and every entry carries a GUID-only AllowedWorkflowIds list. The whole Keys map now comes atomically from the highest-priority provider that declares it, so `Keys: {}` revokes everything below it instead of merging with it; scope arrays are provider-atomic the same way, which makes `[]` deny-all rather than inherit-all. The legacy ApiKey is inert without its own list. Idempotency is domain-separated by canonical integration id, key fingerprint and workflow, and only the digest reaches the database. - Remote activities inject a target-path guard into the generated script (TargetPathGuardScript), so filePath/workingDirectory are checked against the configured roots on the executing host, including a reparse-point check. - LLM: a plaintext loopback endpoint is never handed to a proxy - with one configured, "localhost" would resolve on the proxy machine and the unencrypted prompt would leave the boundary the endpoint guard promised. Frontend - An explicit auth boundary (security/authBoundary.ts, sensitiveBrowserState.ts) plus a ProtectedRoute keyed on the boundary epoch. React can batch a clear/accept pair into a single render during an A-to-B identity switch, so the key forces the protected subtree to unmount: component-local SQL and results drop, and AbortControllers for AI/SSE consumers run their cleanup. Docs are synced across README, CLAUDE.md, E2ETests.md, docs/, the deployment templates and the docs-ui corpus. --- CLAUDE.md | 4 +- E2ETests.md | 23 +- README.md | 6 +- deploy/Install-NodePilot.ps1 | 7 +- deploy/README.md | 8 +- deploy/desktop/README.md | 2 +- .../desktop/appsettings.Desktop.json.template | 2 +- .../appsettings.Production.json.template | 4 +- docs/ai-features.md | 4 + docs/claude-reference.md | 15 +- docs/deployment-guide.md | 15 +- src/NodePilot.Ai/LlmEndpointGuard.cs | 32 + src/NodePilot.Ai/LlmProfileValidation.cs | 23 +- .../LlmServiceCollectionExtensions.cs | 6 +- .../Configuration/EffectiveSourceDetector.cs | 8 +- .../Configuration/LlmAvailability.cs | 24 + .../Configuration/ProviderAtomicGuidList.cs | 180 +++++ .../Configuration/SettingsSchema.cs | 6 +- .../Configuration/SettingsSections.cs | 18 +- .../Controllers/AiChatController.cs | 7 +- src/NodePilot.Api/Controllers/AiController.cs | 14 +- .../Controllers/AiKnowledgeController.cs | 6 +- .../Controllers/ExternalTriggerController.cs | 148 ++-- .../Settings/SecurityHardeningSettingsDto.cs | 8 +- src/NodePilot.Api/Hosting/OpenApiSetup.cs | 2 +- .../ExternalTriggerKeyScopeResolver.cs | 242 +++++++ src/NodePilot.Api/appsettings.json | 4 +- .../Embedded/activity-config-reference.json | 2 +- src/NodePilot.Core/Models/IdempotencyKey.cs | 18 +- src/NodePilot.Data/NodePilotDbContext.cs | 5 +- .../Activities/EmailActivity.cs | 13 +- .../Activities/FileHashActivity.cs | 2 + .../Activities/FileOperationActivity.cs | 44 +- .../FileSystemOperationActivityBase.cs | 5 + .../Activities/FolderOperationActivity.cs | 132 +++- .../Activities/QueryPayloadSource.cs | 20 +- .../Activities/SqlActivity.cs | 103 ++- .../Activities/TextFileEditActivity.cs | 5 + .../Activities/ZipOperationActivity.cs | 442 +++++++++++- src/NodePilot.Engine/Mail/SmtpTransport.cs | 57 ++ .../Notifications/SmtpNotificationSink.cs | 9 +- .../Security/FileWatcherPathGuard.cs | 346 +++++++++- src/NodePilot.Engine/Security/PathGuard.cs | 202 ++++-- .../Security/TargetPathGuardScript.cs | 261 ++++++++ .../Triggers/EventLogTrigger.cs | 12 +- .../Triggers/FileWatcherTrigger.cs | 35 +- src/NodePilot.Mcp/Tools/ExecutionTools.cs | 4 +- .../Sources/EventLogTriggerSource.cs | 17 +- .../Sources/FileWatcherTriggerSource.cs | 29 +- .../content/activities-reference.md | 8 +- src/nodepilot-docs-ui/content/ai-features.md | 2 +- .../content/api/authentication.md | 2 +- .../content/api/endpoints.md | 4 +- src/nodepilot-docs-ui/content/cli.md | 2 +- .../content/configuration/appsettings.md | 7 +- .../content/security/hardening.md | 41 +- .../content/security/overview.md | 2 +- src/nodepilot-docs-ui/content/triggers.md | 56 +- src/nodepilot-ui/e2e/ai-chat.spec.ts | 2 +- src/nodepilot-ui/src/App.tsx | 42 +- .../src/__tests__/api/adminSettings.test.ts | 28 + src/nodepilot-ui/src/__tests__/api/ai.test.ts | 31 + .../src/__tests__/api/client.test.ts | 184 +++++ .../src/__tests__/api/operations.test.ts | 20 +- .../__tests__/components/LoginPage.test.tsx | 80 ++- .../components/ProtectedRoute.test.tsx | 56 +- .../admin-settings/SecuritySection.test.tsx | 41 +- .../components/dbviewer/QueryPane.test.tsx | 70 +- .../properties/propertyConfigs.test.tsx | 1 + .../src/__tests__/lib/queryErrorToast.test.ts | 7 + .../__tests__/lib/resolveWorkflowRef.test.ts | 19 +- .../pages/CustomActivitiesPage.test.tsx | 55 +- .../pages/WorkflowEditorPage.test.tsx | 21 +- .../__tests__/pages/WorkflowsPage.test.tsx | 68 +- .../__tests__/security/authBoundary.test.ts | 143 ++++ .../src/__tests__/stores/aiChatStore.test.ts | 14 +- .../src/__tests__/stores/authStore.test.ts | 594 ++++++++++++++++- .../src/__tests__/stores/confirmStore.test.ts | 13 + .../src/__tests__/stores/toastStore.test.ts | 10 + src/nodepilot-ui/src/api/adminSettings.ts | 29 +- src/nodepilot-ui/src/api/ai.ts | 31 +- src/nodepilot-ui/src/api/client.ts | 94 ++- src/nodepilot-ui/src/api/operations.ts | 17 +- .../src/components/ProtectedRoute.tsx | 30 + .../admin-settings/AuthenticationSection.tsx | 34 +- .../admin-settings/IntegrationsSection.tsx | 24 +- .../LoggingTelemetrySection.tsx | 31 +- .../admin-settings/RetentionSection.tsx | 27 +- .../admin-settings/SectionFormHelpers.tsx | 29 + .../admin-settings/SecuritySection.tsx | 23 +- .../admin-settings/SystemInfoSection.tsx | 32 +- .../src/components/dbviewer/QueryPane.tsx | 69 +- .../components/designer/PropertiesPanel.tsx | 7 + .../properties/activities/SqlConfig.tsx | 10 +- .../SharedFolderPermissionsModal.tsx | 36 +- src/nodepilot-ui/src/hooks/useSignalR.ts | 52 +- .../src/hooks/useWorkflowExecution.ts | 8 + src/nodepilot-ui/src/lib/queryErrorToast.ts | 3 + .../src/lib/resolveWorkflowRef.ts | 12 +- .../src/pages/CustomActivitiesPage.tsx | 47 +- src/nodepilot-ui/src/pages/LoginPage.tsx | 11 +- .../src/pages/WorkflowEditorPage.tsx | 6 + src/nodepilot-ui/src/pages/WorkflowsPage.tsx | 59 +- src/nodepilot-ui/src/queryClient.ts | 22 + src/nodepilot-ui/src/security/authBoundary.ts | 303 +++++++++ .../src/security/sensitiveBrowserState.ts | 67 ++ src/nodepilot-ui/src/stores/aiChatStore.ts | 13 +- src/nodepilot-ui/src/stores/authStore.ts | 249 ++++++- src/nodepilot-ui/src/stores/confirmStore.ts | 5 + src/nodepilot-ui/src/stores/toastStore.ts | 5 + .../LlmEndpointGuardTests.cs | 33 + .../LlmProfileValidationTests.cs | 13 + .../EffectiveSourceDetectorTests.cs | 22 + .../AdminSettingsControllerSectionTests.cs | 168 ++++- .../Controllers/ExecutionsControllerTests.cs | 630 ++++++++++++++++-- .../ActivityHardening2026_05_17Tests.cs | 7 +- .../Activities/BuildScriptTests.cs | 12 +- .../Activities/FileHashActivityTests.cs | 18 + .../Activities/FileOperationActivityTests.cs | 179 ++++- .../FolderOperationActivityTests.cs | 339 +++++++++- .../Activities/JsonQueryActivityTests.cs | 42 ++ .../Activities/SqlActivityTests.cs | 118 +++- .../Activities/TextFileEditActivityTests.cs | 18 + .../Activities/XmlQueryActivityTests.cs | 42 ++ .../Activities/ZipOperationActivityTests.cs | 428 +++++++++++- .../Security/PathGuardTests.cs | 136 +++- .../Security/TargetPathGuardScriptTests.cs | 196 ++++++ .../FileWatcherTriggerActivityTests.cs | 84 +++ .../Triggers/FileWatcherTriggerSourceTests.cs | 390 +++++++++++ 129 files changed, 7736 insertions(+), 728 deletions(-) create mode 100644 src/NodePilot.Api/Configuration/ProviderAtomicGuidList.cs create mode 100644 src/NodePilot.Api/Security/ExternalTriggerKeyScopeResolver.cs create mode 100644 src/NodePilot.Engine/Mail/SmtpTransport.cs create mode 100644 src/NodePilot.Engine/Security/TargetPathGuardScript.cs create mode 100644 src/nodepilot-ui/src/__tests__/api/adminSettings.test.ts create mode 100644 src/nodepilot-ui/src/__tests__/security/authBoundary.test.ts create mode 100644 src/nodepilot-ui/src/components/ProtectedRoute.tsx create mode 100644 src/nodepilot-ui/src/queryClient.ts create mode 100644 src/nodepilot-ui/src/security/authBoundary.ts create mode 100644 src/nodepilot-ui/src/security/sensitiveBrowserState.ts create mode 100644 tests/NodePilot.Engine.Tests/Security/TargetPathGuardScriptTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 390f9caf..dd2f735b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -357,7 +357,7 @@ Initial-Admin: erster Login bei leerer DB (One-Shot-Token `admin-setup.token`). - **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`. - **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:** nur aktiv wenn `ExternalTrigger:ApiKey` gesetzt. +- **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.** @@ -410,7 +410,7 @@ Getrennt vom Workflow-Export: voller DR-Snapshot der Konfiguration (Workflows+Fo - **Kein Root (trigger-los oder nur Zyklen):** Nodes vorhanden, aber kein (aktiver) Trigger → 0 Roots → Execution `Failed` (ErrorMessage nennt den fehlenden Trigger/Start). **Leerer** Workflow (0 Nodes) → läuft mit 0 Steps durch (`Succeeded`). - **`POST /execute`:** asynchron, 202 + ExecutionId. Fortschritt via SignalR. - **Workflow-Version-History:** `Update`/`Rollback` snapshotten vorherige Definition. -- **Idempotency-Keys:** `POST /api/trigger/{name}` akzeptiert `Idempotency-Key`-Header. +- **Idempotency-Keys:** `POST /api/trigger/{name}` akzeptiert `Idempotency-Key`-Header; Replay/Reservation gilt nur innerhalb desselben authentifizierten External-Trigger-Key-Principals und Workflows. - **Node-Level `disabled`:** `data.disabled: true` → Node wird `Skipped`, Downstream ohne andere Quellen auch. - **Step-Debugger:** `POST /execute` mit `debug: true` → Breakpoints, SignalR `StepPaused`, Resume via `POST /executions/{id}/resume`. diff --git a/E2ETests.md b/E2ETests.md index c5881d25..9ae1a1e5 100644 --- a/E2ETests.md +++ b/E2ETests.md @@ -2234,21 +2234,32 @@ Erstelle folgende Edges mit Comparison-Bedingungen: ### Test 23.1 — External Trigger via API-Key **Schritte:** -1. Config: `ExternalTrigger:ApiKey: "my-api-key-xyz"` -2. Call: +1. Einen aktivierten Workflow mit aktivem `manualTrigger` anlegen und seine GUID notieren. +2. Einen zufälligen Schlüssel mit mindestens 32 UTF-8-Bytes erzeugen, seinen SHA-256-Hash als Base64 berechnen und konfigurieren: + ```yaml + ExternalTrigger: + Keys: + e2e: + KeyHash: "" + AllowedWorkflowIds: + - "" + ``` +3. Call: ```bash curl -X POST http://localhost:5000/api/trigger/MyWorkflow \ - -H "X-Api-Key: my-api-key-xyz" \ + -H "X-Api-Key: " \ -H "Content-Type: application/json" \ -d '{"parameters": {"env": "prod"}}' ``` -3. Response: 202 Accepted + ExecutionId +4. Response: 202 Accepted + ExecutionId **Prüfpunkte:** -- [ ] Ohne Config → 503 +- [ ] Ohne Config → 401 - [ ] Ohne API-Key → 401 - [ ] Falscher Key → 401 -- [ ] Korrekter Key → 202 +- [ ] Korrekter, für die Workflow-GUID freigegebener Key → 202 +- [ ] Korrekter Key für eine andere Workflow-GUID → uniforme 404 +- [ ] Workflow ohne aktiven `manualTrigger` → uniforme 404 - [ ] Execution wird erstellt **Erwartung:** External Trigger ist sicher diff --git a/README.md b/README.md index 714661de..2c5e1c88 100644 --- a/README.md +++ b/README.md @@ -1165,7 +1165,9 @@ All settings live in [`src/NodePilot.Api/appsettings.json`](src/NodePilot.Api/ap | `Jwt:Issuer` / `Jwt:Audience` | `NodePilot` | Token validation — change for production | | `Remote:Provider` | `winrm` | `winrm` or `noop` (load-test stub) | | `Smtp:Host` / `Port` / `From` | `localhost:25` | SMTP for `emailNotification` | -| `ExternalTrigger:ApiKey` | *(unset)* | API key for `POST /api/trigger/{name}`; endpoint returns 401 if unset | +| `ExternalTrigger:Keys::KeyHash` | *(unset)* | Base64-encoded SHA-256 hash; the highest provider declaring `Keys` owns the complete map, and `Keys: {}` revokes all lower-provider keys | +| `ExternalTrigger:Keys::AllowedWorkflowIds` | `[]` | Provider-atomic GUID-only workflow scope; a higher list replaces all lower indices and empty is deny-all | +| `ExternalTrigger:ApiKey` / `AllowedWorkflowIds` | *(unset)* / `[]` | Transitional legacy key and its mandatory GUID scope; without the scope it authorizes nothing | ### Logging & Observability @@ -1349,7 +1351,7 @@ The full OpenAPI spec is served at `GET /openapi/v1.json`; Swagger UI at `GET /s | AI | `POST /api/ai/generate-script` + `/api/ai/chat` *(SSE streaming)*, `POST /api/ai/generate-workflow` *(JSON)*, `POST /api/ai/chat/applied` + `GET /api/ai/chat/activity/{workflowId}` *(Admin/Operator, folder-RBAC)* — `generate-*` Admin/Operator, `chat` all roles (edits Admin/Operator only); opt-in, rate-limited | | Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `POST /api/auth/refresh`, `GET /api/auth/me` | | Audit | `GET /api/audit` *(Admin only, max 500 entries)* | -| External trigger | `POST /api/trigger/{workflowNameOrId}` *(`X-Api-Key` header, optional `Idempotency-Key`)* | +| External trigger | `POST /api/trigger/{workflowNameOrId}` *(`X-Api-Key` scoped to the workflow GUID; workflow needs an enabled `manualTrigger`; optional `Idempotency-Key`, isolated per authenticated key principal)* | | Webhooks | `POST /api/webhooks/{workflow}/{path}` *(secret via `X-Webhook-Secret` or versioned NodePilot HMAC v2 over freshness metadata + method + path + canonical query + body)* | | Observability | `GET /api/observability/config\|query\|query_range\|summary` | | Health | `GET /healthz/live`, `GET /healthz/ready`, `GET /healthz/database` *(anonymous)* | diff --git a/deploy/Install-NodePilot.ps1 b/deploy/Install-NodePilot.ps1 index 24f9dbff..f7be3f49 100644 --- a/deploy/Install-NodePilot.ps1 +++ b/deploy/Install-NodePilot.ps1 @@ -95,7 +95,9 @@ Windows Service display name. Default: NodePilot Orchestrator. .PARAMETER ExternalTriggerApiKey - Pre-shared key for POST /api/trigger/{workflow}. Auto-generated (48 random bytes base64) if omitted. + Transitional legacy key for POST /api/trigger/{workflow}. Auto-generated (48 random bytes + base64) if omitted. It authorizes nothing until workflow GUIDs are explicitly configured in + ExternalTrigger:AllowedWorkflowIds; new integrations should use hashed ExternalTrigger:Keys entries. .PARAMETER JwtIssuer JWT issuer claim. Default: nodepilot:prod:. @@ -1709,8 +1711,9 @@ Write-Host " Health : https://$PublicHostname/healthz/ready" -Foreground Write-Host " Logs : $DataPath\logs" -ForegroundColor Gray Write-Host " Install log : $reportPath" -ForegroundColor Gray Write-Host "" -Write-Host " External-Trigger API key (store it now; it won't be shown again):" -ForegroundColor Yellow +Write-Host " External-Trigger API key (legacy; store it now, it won't be shown again):" -ForegroundColor Yellow Write-Host " $ExternalTriggerApiKey" -ForegroundColor Yellow +Write-Host " Deny-all until workflow GUIDs are added to ExternalTrigger:AllowedWorkflowIds." -ForegroundColor Yellow Write-Host "" if ($tokenContent) { Write-Host " FIRST-LOGIN ADMIN BOOTSTRAP" -ForegroundColor Yellow diff --git a/deploy/README.md b/deploy/README.md index c3043499..136ff896 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -358,7 +358,7 @@ Der Installer macht alles Weitere: 8. Dienst per `Win32_Service.Create` anlegen — gMSA (leeres Passwort + `sc.exe managedaccount` + „Log on as a service"-Grant) oder `LocalSystem` (keine dieser drei Schritte nötig), Recovery-Actions, `ASPNETCORE_ENVIRONMENT=Production` 9. Dienst starten, `https://localhost/healthz/ready` pollen 10. Installations-Marker `HKLM\SOFTWARE\NodePilot\Server` schreiben (`InstallPath`, `DataPath`, `ServiceName`, `Version`, `DbProvider`, `HttpsPort`) — nur auf dem Erfolgspfad, damit ein zurückgerollter Lauf keinen Marker hinterlässt -11. Admin-Bootstrap-Token + External-Trigger-API-Key auf der Konsole ausgeben +11. Admin-Bootstrap-Token + Legacy-External-Trigger-API-Key auf der Konsole ausgeben. Der Key ist zunächst deny-all und wird erst zusammen mit expliziten Workflow-GUIDs unter `ExternalTrigger:AllowedWorkflowIds` wirksam. Schritt 1 kommt aus [`Preflight.ps1`](Preflight.ps1) und ist bewusst als eigene Datei ausgelagert: die Checks sammeln nur (`Invoke-NodePilotPreflight`), das Abbrechen ist ein zweiter Schritt @@ -376,7 +376,7 @@ Nach erfolgreichem Install steht in der Konsole: - URL: `https:///` - **Admin-Setup-Token** (aus `C:\ProgramData\NodePilot\admin-setup.token`) → im Browser anmelden: beim ersten Versuch blendet die Login-Seite ein **„Setup-Token"-Feld** ein, Token dort einfügen, erneut anmelden → Admin-User wird erstellt, Token-Datei gelöscht, Bootstrap-Fenster schließt. Kann der Installer das Token nicht anzeigen (die Datei ist per Owner-only-ACL auf das **Dienstkonto** beschränkt — auch für Admins by design nicht direkt lesbar), per Backup-Semantik lesen statt die ACL anzufassen: `robocopy C:\ProgramData\NodePilot $env:TEMP admin-setup.token /B`, dann `Get-Content "$env:TEMP\admin-setup.token"` (Temp-Kopie danach löschen). ACL-Änderung nur mit Bedacht: Der Server validiert die Datei fail-closed; im Trusted-Set sind nur Dienstkonto, SYSTEM und die **Administrators-Gruppe** — `takeown /a` + Gruppen-Grant übersteht das, Ownership auf den persönlichen Admin-User invalidiert die Datei. -- **External-Trigger API Key** — einmalig sichern, wird nicht erneut angezeigt. +- **Legacy-External-Trigger API Key** — einmalig sichern, wird nicht erneut angezeigt. Er autorisiert zunächst keinen Workflow; für neue Integrationen werden gehashte, GUID-gescopte Einträge unter `ExternalTrigger:Keys` empfohlen. ### Parameter-Übersicht @@ -404,7 +404,7 @@ Nach erfolgreichem Install steht in der Konsole: | `-DataPath` | | `C:\ProgramData\NodePilot` | | `-ServiceName` | | `NodePilot` | | `-ServiceDisplayName` | | `NodePilot Orchestrator` | -| `-ExternalTriggerApiKey` | | auto-generiert (48 bytes base64) | +| `-ExternalTriggerApiKey` | | auto-generierter Legacy-Key (48 Bytes, Base64); mit leerer `AllowedWorkflowIds`-Liste zunächst deny-all | | `-JwtIssuer` | | `nodepilot:prod:` | | `-JwtAudience` | | `nodepilot:prod:` | | `-AllowedHosts` | | PublicHostname. `localhost` wird immer angehängt — die Health-Probe des Installers geht an `https://localhost:/healthz/ready`, und `UseHostFiltering` würde sie sonst mit 400 abweisen und eine fertige Installation zurückrollen | @@ -475,7 +475,7 @@ Erhält `appsettings.Production.json`, die DB (SQL Server oder Postgres) und den - **`-HttpsPort` muss nicht wiederholt werden**: die Health-Probe übernimmt `Kestrel:Https:HttpsPort` aus der installierten Config (explizites `-HttpsPort` gewinnt weiterhin). Ohne diese Ableitung probte ein Update einer 8443-Installation gegen 443 und rollte ein gesundes Upgrade zurück. - **Prozess-Guard vor dem Swap:** Ein gestoppter Dienst genügt nicht — ein verwaister Worker hält seine DLLs als Image gemappt, Windows meldet das als schlichtes „Access denied" mitten im Wipe. Der SCM meldet aber `SERVICE_STOPPED`, **bevor** der Prozess wirklich beendet ist (Host-Shutdown, Log-Flush). Deshalb wird nach dem Stopp bis zu 30 s gewartet, danach werden verbliebene Prozesse aus dem Install-Verzeichnis beendet — es sind NodePilot-Binaries, deren Dateien ohnehin gleich ersetzt werden. Erst wenn auch das nicht greift, bricht der Updater **vor der ersten Löschung** mit PID + Namen ab. Bis 2026-08-03 fehlte das Warten und der Lauf scheiterte an genau dem Prozess, den er selbst gestoppt hatte. -- Beim Swap fällt `appsettings.Production.json` bewusst **zuletzt**, damit ein Abbruch die Config nicht mit ins Grab nimmt (sie steht per Design nicht im Backup). Fehlt sie doch einmal, lehnt der Updater ab — dann `Install-NodePilot.ps1` fahren, das die Config aus seinen Parametern neu rendert (DB, DataPath und Konten bleiben; nur der External-Trigger-API-Key wird neu erzeugt). +- Beim Swap fällt `appsettings.Production.json` bewusst **zuletzt**, damit ein Abbruch die Config nicht mit ins Grab nimmt (sie steht per Design nicht im Backup). Fehlt sie doch einmal, lehnt der Updater ab — dann `Install-NodePilot.ps1` fahren, das die Config aus seinen Parametern neu rendert (DB, DataPath und Konten bleiben; der neu erzeugte Legacy-External-Trigger-Key bleibt bis zur erneuten GUID-Freigabe deny-all). - **Ein erfolgreicher Update lässt den Dienst LAUFEN**, egal ob er vorher gestoppt war. Nur ein fehlgeschlagener Update stellt den Ausgangszustand wieder her (ein Rollback startet nichts, was vorher bewusst gestoppt war). ## Uninstall diff --git a/deploy/desktop/README.md b/deploy/desktop/README.md index 1d7c7a2a..929c65d3 100644 --- a/deploy/desktop/README.md +++ b/deploy/desktop/README.md @@ -77,7 +77,7 @@ service-environment value. The practical rule: **anything NodePilot initiates works, anything that must reach in does not.** Schedule/file-watcher/database/event-log triggers and all outbound automation (WinRM, `restApi`, `sql`, SMTP, alerting webhooks) are unaffected; inbound webhooks and the external trigger API - (also disabled via an empty `ExternalTrigger:ApiKey`) are unusable. + (also disabled because no scoped external-trigger key is configured) are unusable. - **API runs as LocalSystem** (zero-config). Consequence: loopback `runScript` activities run with **SYSTEM** rights. This is an explicit v1 decision for a single-user local orchestrator. - **Postgres runs as NetworkService**, bound to 127.0.0.1 only. diff --git a/deploy/desktop/appsettings.Desktop.json.template b/deploy/desktop/appsettings.Desktop.json.template index 09d0e310..20ab839b 100644 --- a/deploy/desktop/appsettings.Desktop.json.template +++ b/deploy/desktop/appsettings.Desktop.json.template @@ -111,7 +111,7 @@ "SqlActivity": { "RequireConnectionRef": true }, "Trigger": { "Database": { "RequireConnectionRef": true } }, "StartProgram": { "DisallowShellExecute": true }, - "ExternalTrigger": { "ApiKey": "" }, + "ExternalTrigger": { "ApiKey": "", "AllowedWorkflowIds": [], "Keys": {} }, "Retention": { "Executions": { "Enabled": true, "MaxAgeDays": 30, "IntervalMinutes": 60, "BatchSize": 500, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\executions" }, "AuditLog": { "Enabled": true, "MaxAgeDays": 365, "IntervalMinutes": 720, "BatchSize": 1000, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\audit", "VerifyIntervalMinutes": 1440, "VerifyMaxFilesPerPass": 500 }, diff --git a/deploy/templates/appsettings.Production.json.template b/deploy/templates/appsettings.Production.json.template index 5362c905..640a75f2 100644 --- a/deploy/templates/appsettings.Production.json.template +++ b/deploy/templates/appsettings.Production.json.template @@ -197,7 +197,9 @@ "DisallowShellExecute": true }, "ExternalTrigger": { - "ApiKey": "{{EXTERNAL_TRIGGER_API_KEY}}" + "ApiKey": "{{EXTERNAL_TRIGGER_API_KEY}}", + "AllowedWorkflowIds": [], + "Keys": {} }, "Retention": { "Executions": { "Enabled": true, "MaxAgeDays": 30, "IntervalMinutes": 60, "BatchSize": 500, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\executions" }, diff --git a/docs/ai-features.md b/docs/ai-features.md index 06b994a2..1a70f4c6 100644 --- a/docs/ai-features.md +++ b/docs/ai-features.md @@ -85,6 +85,10 @@ Neu-Eintippen. } ``` +`BaseUrl` muss HTTPS verwenden. HTTP ist nur für exakte lokale Loopback-Ziele wie +`http://localhost:11434/v1`, `127.0.0.0/8` oder `::1` erlaubt; solche Ziele umgehen immer den +ausgehenden Proxy, damit Prompt und API-Key den Host nicht im Klartext verlassen. + **Section-Root:** | Key | Default | Erklärung | diff --git a/docs/claude-reference.md b/docs/claude-reference.md index cea2a094..1ae89104 100644 --- a/docs/claude-reference.md +++ b/docs/claude-reference.md @@ -20,9 +20,9 @@ in den Kontext-Window geladen werden müssen, aber bei Bedarf nachschlagbar sind | `textFileEdit` | Remote | `operation` (append/prepend/insert/delete/replace/replaceLine), `path`, `content`, `lineNumber`, `matchPattern`, `replace`, `useRegex`, `ignoreCase`, `occurrences`, `encoding` (auto/utf8/utf8-bom/utf16le/utf16be/ascii), `lineEnding` (preserve/crlf/lf), `createIfMissing`, `dryRun`, `backupSuffix`, `appendIfMissing`(Exact), `maxFileSizeMB` (default 50). BOM-aware, atomarer Write (tmp + `Move-Item -Force`). | `param.operation`, `param.path`, `param.linesBefore`/`linesAfter`/`linesChanged`, `param.encoding`, `param.lineEnding`, `param.backupPath`, `param.dryRun` | | `scheduledTask` | Remote | `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force`. Braucht i.d.R. Admin auf dem Target. Existing-task actions sind Cmdlet-first; ausschließlich bei CIM-Fehler `0x80041318` folgt ein lokaler Task-Scheduler-Automation-Fallback innerhalb derselben PowerShell-/WinRM-Session. `register` bleibt Cmdlet-only. | `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime` | | `fileHash` | Remote | `path`, `algorithm` (MD5/SHA1/SHA256/SHA384/SHA512, default SHA256), `expected` (optional — verifiziert; Mismatch ⇒ Step schlägt fehl). | `param.hash`, `param.algorithm`, `param.match` | -| `zipOperation` | Remote | `operation` (compress/extract, default `compress`), `source` (Wildcards bei compress erlaubt), `destination`, `compressionLevel` (Optimal/Fastest/NoCompression — nur compress), `force`. Extract macht einen Zip-Slip-Pre-Scan. | `param.destination`, `param.sizeBytes` (extract ⇒ 0) | +| `zipOperation` | Remote | `operation` (compress/extract, default `compress`), `source` (Wildcards bei compress nur im letzten Segment; `[]` literal), `destination`, `compressionLevel` (Optimal/Fastest/NoCompression — nur compress), `force`. Compress schreibt ein kontrolliert validiertes Manifest direkt mit `ZipArchive`; Extract validiert und schreibt jeden Entry einzeln, blockiert Zip-Slip und vorhandene Reparse Points und öffnet Output-Dateien mit `CreateNew`. | `param.destination`, `param.sizeBytes` (extract ⇒ 0) | | `restApi` | Engine-local | `url`, `method`, `body`, `headers`, `timeoutSeconds`, `proxyMode` (`default`/`direct`/`custom`), `proxyAddress`, `noProxy` | `param.statusCode` (Response-Body steht im `output`-Stdout als `HTTP {code}\n{body}`; Header werden nicht als `param` exponiert) | -| `sql` | Engine-local | `provider` (sqlserver/sqlite/postgres), `query`, `timeoutSeconds`. Verbindung: (a) Builder-Felder (SQL Server: `server`/`database`/`authentication`/`username`/`password`/`encrypt`/`trustServerCertificate`; Postgres: `host`/`port`/`database`/`username`/`password`/`sslMode` (default `Require`; explizit `Disable` zum Abschalten); SQLite: `dataSource`), (b) raw `connectionString`, (c) named `connectionRef` aus `SqlActivity:ConnectionStrings:{name}`. Reihenfolge: `connectionRef` > Builder > raw. | SELECT: `param.rowCount` + erste-Zeile-Spalten als `param.` + `param.row{i}_{col}` (erste 20 Zeilen) + `param.truncated`/`param.flatKeysTruncated`. DML/DDL: `param.rowsAffected` + `param.rowCount` | +| `sql` | Engine-local | `provider` (sqlserver/sqlite/postgres), `query`, `timeoutSeconds`. Verbindung: (a) Builder-Felder (SQL Server: `server`/`database`/`authentication`/`username`/`password`/`encrypt`/`trustServerCertificate`; Postgres: `host`/`port`/`database`/`username`/`password`/`sslMode` (default `VerifyFull` + `Trust Server Certificate=false`; schwächere Modi nur für literale Loopback-Hosts); SQLite: `dataSource`), (b) raw `connectionString`, (c) named `connectionRef` aus `SqlActivity:ConnectionStrings:{name}`. Reihenfolge: `connectionRef` > Builder > raw; die Postgres-TLS-Policy gilt für alle drei Pfade. | SELECT: `param.rowCount` + erste-Zeile-Spalten als `param.` + `param.row{i}_{col}` (erste 20 Zeilen) + `param.truncated`/`param.flatKeysTruncated`. DML/DDL: `param.rowsAffected` + `param.rowCount` | | `emailNotification` | Engine-local | `to`, `subject`, `body`, `isHtml`. Single-Recipient. SMTP via `Smtp:*` Config. | — | | `delay` | Engine-local | `seconds` | — | | `junction` | Engine-local | `mode` (waitAll/waitAny/waitNofM), `requiredCount` (bei waitNofM) | — | @@ -34,7 +34,7 @@ in den Kontext-Window geladen werden müssen, aber bei Bedarf nachschlagbar sind | `jsonQuery` | Engine-local | `source`, `path`/`content`, `jsonPath`, `resultMode` | `param.result`, `param.count` | | `log` | Engine-local | `level` (info/warning/error), `message` | — | | `generateText` | Engine-local | `mode` (`alphanumeric` default/`alphabetic`/`numeric`/`hex`/`guid`/`password`/`custom`), `length` (1–1024, default 16; bei `guid` ignoriert), `customCharset` (Pflicht bei `mode=custom`), `excludeAmbiguous` (entfernt verwechselbare Zeichen 0/O, 1/l/I …). Entropie aus `RandomNumberGenerator`, rejection-sampled (keine Modulo-Bias). `password` = nur Zeichensatz-Preset (keine Policy-Garantie). Generierter Wert wird **nicht** redigiert. | `output` (generierter String), `param.text` | -| `llmQuery` | Engine-local | `prompt` (Pflicht; `{{templates}}` erlaubt), `systemPrompt` (optional; leer = Passthrough, kein synthetischer Default), `jsonMode` (bool → `response_format:json_object`; Antwort wird **nicht** validiert). **Per-Node-Overrides** (leer → das aktive LLM-Profil): `baseUrl` (absolute http/https; via `LlmEndpointGuard` validiert, Cloud-Metadata blockiert), `model`, `apiKey` (Secret, auto-redigiert), `maxTokens` (>0), `temperature` (0..2, per-Node-only — kein globaler Knopf; leer = Provider-Default), `timeoutSeconds` (>0). **`Llm:Enabled=true` + auflösbares aktives Profil Pflicht** (sonst sauberer Step-Fehler). `output` = Antworttext (roh, füttert Downstream-Databus; `OutputRedactor` scrubbt nur Persistenz/SignalR). Token-Keys **immer** gesetzt, `""` wenn Server keine `usage` liefert (`type:"number"` ist nur UI-/Databus-Hint). Transiente Fehler (RateLimited/Unreachable) via `config.retry` wiederholbar. | `output` (Antwort), `param.model`, `param.promptTokens`, `param.completionTokens`, `param.totalTokens`, `param.finishReason` | +| `llmQuery` | Engine-local | `prompt` (Pflicht; `{{templates}}` erlaubt), `systemPrompt` (optional; leer = Passthrough, kein synthetischer Default), `jsonMode` (bool → `response_format:json_object`; Antwort wird **nicht** validiert). **Per-Node-Overrides** (leer → das aktive LLM-Profil): `baseUrl` (absolutes HTTPS; HTTP nur für literales `localhost`/Loopback; via `LlmEndpointGuard` validiert, Cloud-Metadata blockiert), `model`, `apiKey` (Secret, auto-redigiert), `maxTokens` (>0), `temperature` (0..2, per-Node-only — kein globaler Knopf; leer = Provider-Default), `timeoutSeconds` (>0). **`Llm:Enabled=true` + auflösbares aktives Profil Pflicht** (sonst sauberer Step-Fehler). `output` = Antworttext (roh, füttert Downstream-Databus; `OutputRedactor` scrubbt nur Persistenz/SignalR). Token-Keys **immer** gesetzt, `""` wenn Server keine `usage` liefert (`type:"number"` ist nur UI-/Databus-Hint). Transiente Fehler (RateLimited/Unreachable) via `config.retry` wiederholbar. | `output` (Antwort), `param.model`, `param.promptTokens`, `param.completionTokens`, `param.totalTokens`, `param.finishReason` | | `waitForCondition` | Hybrid | `conditionType` (`script` default / `pathExists` / `serviceRunning` / `portOpen` / `httpOk`), `intervalSeconds`, `timeoutSeconds`. Mode-spezifisch: `script` → `script` (PowerShell-Expr, **keine** `{{...}}`-Templates), `pathExists` → `path`, `serviceRunning` → `serviceName`, `portOpen` → `host`+`port`, `httpOk` → `url`. Getypten Modi nehmen `{{upstream.param.x}}` an — Engine quotet Werte sicher. | `param.attempts`, `param.elapsedSeconds`, `param.lastResult` | ### Prozess-Isolation (`runScript`, nur lokal) @@ -363,6 +363,7 @@ Background-Service-, Konfigurations- und Observability-Vertrag stehen vollständ - **`Mode: System` cacht**: `HttpClient.DefaultProxy` liest die OS-Konfiguration prozessweit einmal — eine Änderung der Windows-Proxy-Einstellungen greift erst nach Dienst-Neustart. Das ist die einzige nicht-hot-reloadbare Ecke der Sektion. **Hardening**: +- LLM-`BaseUrl` verlangt HTTPS. Klartext-HTTP ist ausschließlich für die exakten Loopback-Ziele `localhost`, `127.0.0.0/8` und `::1` erlaubt und wird auch bei konfiguriertem Proxy immer direkt verbunden, damit Prompt/API-Key den Host nicht unverschlüsselt verlassen. - SSRF-Block für Cloud-Metadata-IPs in **jeder** `Llm:Profiles::BaseUrl` (nicht nur der aktiven — Profilwechsel ist ein restart-freier Save). Eine geteilte Regel für Boot *und* Save-Simulation: [LlmProfileValidation.cs](src/NodePilot.Ai/LlmProfileValidation.cs), aufgerufen von `AddNodePilotAi` und `LlmConfigBootValidator`. Einziger BaseUrl-Validierungspunkt bleibt [LlmEndpointGuard.cs](src/NodePilot.Ai/LlmEndpointGuard.cs) (`NormalizeAndValidateBaseUrl`/`IsCloudMetadataEndpoint`), plus Connect-Zeit-Guard `LlmConnectGuard` in [LlmServiceCollectionExtensions.cs](src/NodePilot.Ai/LlmServiceCollectionExtensions.cs). `Enabled=true` ohne auflösbares Profil ist bewusst nur eine **Warning** — KI ist opt-in und darf den Boot nicht blockieren. - Eigener `SocketsHttpHandler` (NICHT der `RestApiHttpClientProvider` — der hat SSRF-Guards die `127.0.0.1:11434` blocken würden). Proxy-Verhalten kommt aus `Llm:Proxy:*` über `LlmConfiguredProxy`, default `Off` = Direktverbindung. - **Erreichbarkeit ist von der Antwortzeit getrennt.** `TimeoutSeconds` ist reines Antwort-Budget; der Verbindungsaufbau hat eigene Konstanten in `LlmConnectGuard`: `ConnectPhaseTimeout` (15 s, deckt DNS + TCP im ConnectCallback) und `HandshakeTimeout` (30 s, als `SocketsHttpHandler.ConnectTimeout` — die einzige Stelle, die den **TLS-Handshake** binden kann, weil der Callback nur den rohen Transport-Stream zurückgibt). Die Ordnung `HandshakeTimeout > ConnectPhaseTimeout` ist **tragend**: nur weil DNS und TCP immer an ihrer eigenen Frist scheitern, darf `LlmHttpTransport.DescribeUnreachable` aus einem gefeuerten `ConnectTimeout` auf die TLS-Stufe schließen. Ein Test pinnt die Ordnung. Meldungspräfixe: `LLM endpoint DNS:` / `TCP:` / `TLS:`; die Modell-Stufe sagt „accepted the request but sent no answer". Debug-Logging der aufgelösten Adressen unter der Kategorie `NodePilot.Ai.LlmConnect`. @@ -388,7 +389,7 @@ Operations-CLI für Operatoren — eigenes Projekt unter [src/NodePilot.Cli/](sr Globale Flags: `--server`, `--profile`, `-o table|json|yaml`, `--no-color`, `-v`. Exit-Codes: 0 ok, 1 generic, 2 run failed/cancelled, 3 auth required, 4 permission denied. -**External-Trigger-Spezialfall:** `np workflow trigger ` ist session-unabhängig — der Endpoint ist anonym, gegated nur durch `X-Api-Key`. Schlüsselquellen in Präzedenz-Reihenfolge: `--api-key ` > `--api-key-stdin` > `NODEPILOT_TRIGGER_API_KEY` env. Optional `--idempotency-key ` für Replay-Schutz. +**External-Trigger-Spezialfall:** `np workflow trigger ` ist session-unabhängig — der Endpoint ist anonym, aber der `X-Api-Key` ist per `ExternalTrigger:Keys::AllowedWorkflowIds` auf Workflow-GUIDs begrenzt; der Workflow braucht einen aktiven `manualTrigger`. Die vollständige `Keys`-Map kommt aus dem höchstprioren Provider, der sie deklariert (`Keys: {}` widerruft niedrigere Keys); Scope-Arrays kommen ebenfalls vollständig aus ihrem definierenden Provider statt aus dem indexweise gemergten `IConfiguration`-View. Schlüsselquellen in Präzedenz-Reihenfolge: `--api-key ` > `--api-key-stdin` > `NODEPILOT_TRIGGER_API_KEY` env. Optional `--idempotency-key ` für einen pro Key-Principal + Workflow isolierten Replay-Schutz; die DB speichert nur einen versionierten Digest. **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`. @@ -526,8 +527,8 @@ Electron als dünner Viewer. **Konsequenzen (nicht offensichtlich):** Der Loopback-Bind trifft den **kompletten Listener** — SPA, `/api/*`, `/hubs/*`, `/healthz`, `/api/webhooks/*`. Es ist **nicht** so, dass einzelne Routen gesperrt wären und der Rest der API aus dem Netz erreichbar bliebe (häufiges Missverständnis). Daraus: -**eingehende Webhooks und die externe Trigger-API unbrauchbar** (letztere zusätzlich per leerem -`ExternalTrigger:ApiKey` aus), kein Team-Zugriff; nur lokales Login (`LocalLoginMode=Enabled`); +**eingehende Webhooks und die externe Trigger-API unbrauchbar** (für letztere ist kein gescopter +Integrationsschlüssel konfiguriert), kein Team-Zugriff; nur lokales Login (`LocalLoginMode=Enabled`); lokale `runScript` laufen als **SYSTEM**; Remote-WinRM braucht hinterlegte Credentials; **HA unmöglich** (Cluster+DPAPI = Boot-Error, kein `Jwt:Key`). @@ -765,7 +766,7 @@ Admin-Settings-Saves persistieren atomar nach `appsettings.runtime.json` (hängt | `SqlActivity` | ✓ | `SqlActivity` liest `SqlActivity:RequireConnectionRef` pro Use aus `IConfiguration` | | `StartProgram` | ✓ | `StartProgramActivity` liest `StartProgram:DisallowShellExecute` pro Use aus `IConfiguration` | | `Webhook` | ✓ | `WebhooksController` liest `Webhook:RequireSecret` pro Request aus `IConfiguration` | -| `ExternalTrigger` | ✓ | `ExternalTriggerController` liest `ExternalTrigger:ApiKey` pro Request aus `IConfiguration` | +| `ExternalTrigger` | ✓ | `ExternalTriggerController` liest gehashte `Keys`-Einträge sowie den GUID-Scope des Legacy-Keys pro Request aus `IConfiguration` | | `DbAdmin` | ✓ | `DbAdminQueryExecutor` liest `IOptionsMonitor.CurrentValue` pro Query (Referenz-Consumer) | | `Authentication` | ✗ | LDAP/Windows-SSO-Options beim Boot in die Auth-Builder eingebunden | | `Logging` | ✗ | Serilog-Logger einmal beim Boot konfiguriert (Reload würde neue Pipeline needed) | diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 820857f6..7ff95b21 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -305,8 +305,10 @@ version, SQL reachability, **SQL version gate ≥ 2022 CU1**, gMSA retrievabilit snapshot any existing installation → extract → render `appsettings.Production.json` → register the service, grant *Log on as a service* + private-key read → firewall rule (Domain profile) → start and poll `https://localhost:/healthz/ready` for up to -180 s → print the External-Trigger API key (**shown once — store it**) and the first-login -setup token. **Any failure after mutation starts triggers an automatic rollback** to the +180 s → print the legacy External-Trigger API key (**shown once — store it**) and the first-login +setup token. The legacy key is initially deny-all; it only becomes usable after explicit workflow +GUIDs are added to `ExternalTrigger:AllowedWorkflowIds`. New integrations should use hashed, +per-integration entries under `ExternalTrigger:Keys`. **Any failure after mutation starts triggers an automatic rollback** to the snapshotted state. ## Step 4 — First login @@ -321,10 +323,10 @@ that whoever races to the login endpoint first cannot make themselves admin. server deletes the token file and the bootstrap window closes permanently. If you installed with the GUI setup, its final page carries all of this — address, setup -token, External-Trigger API key, certificate thumbprint, service name and paths — as +token, legacy External-Trigger API key, certificate thumbprint, service name and paths — as selectable text, with a button to save it to a file. The API key appears there and -nowhere else: it is not recoverable afterwards, and `install-report.txt` omits it by -design. +nowhere else: it is not recoverable afterwards, `install-report.txt` omits it by design, +and an empty `ExternalTrigger:AllowedWorkflowIds` list grants it no workflow. If the installer could not print the token: it lives in `C:\ProgramData\NodePilot\admin-setup.token`, which is ACL-restricted to the **service @@ -482,7 +484,8 @@ The health probe follows the port in the installed configuration, so a non-defau place — but if it is ever lost, do not re-run the update: it refuses a layout without a config. Re-run `Install-NodePilot.ps1` instead, which re-renders the config from its parameters. The database, the data directory and the admin accounts are untouched by - either path; only the External-Trigger API key is regenerated. + either path; only the legacy External-Trigger API key is regenerated. Its workflow allow-list + is empty in the newly rendered configuration, so it remains deny-all until explicitly re-scoped. **Uninstall:** [`Uninstall-NodePilot.ps1`](../deploy/Uninstall-NodePilot.ps1) stops and removes the service, its registry environment (which holds the Postgres password), the firewall rules, the diff --git a/src/NodePilot.Ai/LlmEndpointGuard.cs b/src/NodePilot.Ai/LlmEndpointGuard.cs index 45c8f800..51e45c70 100644 --- a/src/NodePilot.Ai/LlmEndpointGuard.cs +++ b/src/NodePilot.Ai/LlmEndpointGuard.cs @@ -71,9 +71,41 @@ public static string NormalizeAndValidateBaseUrl(string? baseUrl) + "(169.254.0.0/16, metadata.google.internal, metadata.azure.com)."); } + // Local model servers such as Ollama commonly expose HTTP on loopback. Keep that useful + // deployment mode, but never send prompts or bearer keys over cleartext to a remote host. + // The host test is deliberately literal: no DNS lookup means a hostname cannot be rebound + // from loopback to a remote address after validation. + if (uri.Scheme == Uri.UriSchemeHttp && !IsLiteralLoopbackEndpoint(uri)) + { + throw new LlmException(LlmErrorKind.Unreachable, + $"SECURITY: LLM baseUrl ('{trimmed}') uses plaintext HTTP for a non-loopback host. " + + "Use HTTPS; HTTP is allowed only for literal localhost/loopback endpoints."); + } + return trimmed.TrimEnd('/'); } + /// + /// True only for a URI whose host is the exact localhost label or a literal loopback + /// address. Hostnames which merely resolve to loopback are intentionally excluded: the + /// cleartext exception must not acquire DNS-rebinding semantics. + /// + public static bool IsLiteralLoopbackEndpoint(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + if (endpoint.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + var host = endpoint.Host.Trim('[', ']'); + if (!IPAddress.TryParse(host, out var address)) + return false; + + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + return IPAddress.IsLoopback(address); + } + /// /// Detects the typical cloud-provider metadata endpoints at the BaseUrl level: AWS/Azure IMDS /// via 169.254.169.254, GCP via metadata.google.internal, and Azure also under diff --git a/src/NodePilot.Ai/LlmProfileValidation.cs b/src/NodePilot.Ai/LlmProfileValidation.cs index db9bc93e..0079aded 100644 --- a/src/NodePilot.Ai/LlmProfileValidation.cs +++ b/src/NodePilot.Ai/LlmProfileValidation.cs @@ -9,7 +9,7 @@ namespace NodePilot.Ai; /// runs it at startup, and the API's /// LlmConfigBootValidator runs it against the simulated merged config on every settings PUT. /// -/// Scope rule for the metadata check: when Llm:Enabled=true, every +/// Scope rule for the endpoint check: when Llm:Enabled=true, every /// profile's BaseUrl must pass — not just the active one. Switching the active profile is a plain /// settings save with no restart, so a parked profile pointing at a metadata endpoint would be a /// loaded gun that only fires on the switch. With Enabled=false nothing is checked, so an @@ -24,8 +24,8 @@ public sealed record ProfileIssue(string ConfigKey, string Message); public const string ProfilesKey = $"{LlmOptions.SectionName}:Profiles"; /// - /// Cloud-metadata check across all configured profiles. Returns an empty list when - /// Llm:Enabled=false. + /// Format, transport and cloud-metadata checks across all configured profiles. Returns an + /// empty list when Llm:Enabled=false. /// public static IReadOnlyList ValidateProfileEndpoints(IConfiguration configuration) { @@ -39,14 +39,17 @@ public static IReadOnlyList ValidateProfileEndpoints(IConfiguratio { var baseUrl = profile["BaseUrl"]; if (string.IsNullOrWhiteSpace(baseUrl)) continue; - if (!LlmEndpointGuard.IsCloudMetadataEndpoint(baseUrl)) continue; - var name = string.IsNullOrWhiteSpace(profile["Name"]) ? profile.Key : profile["Name"]; - issues.Add(new ProfileIssue( - $"{ProfilesKey}:{profile.Key}:BaseUrl", - $"SECURITY: LLM profile '{name}' has a BaseUrl ('{baseUrl}') that points at a cloud-metadata " - + "endpoint. This range (169.254.0.0/16, metadata.google.internal, metadata.azure.com) is " - + "always blocked. Choose a real LLM endpoint, delete the profile, or disable Llm:Enabled.")); + try + { + _ = LlmEndpointGuard.NormalizeAndValidateBaseUrl(baseUrl); + } + catch (LlmException ex) + { + issues.Add(new ProfileIssue( + $"{ProfilesKey}:{profile.Key}:BaseUrl", + $"LLM profile '{name}' is not usable: {ex.Message}")); + } } return issues; diff --git a/src/NodePilot.Ai/LlmServiceCollectionExtensions.cs b/src/NodePilot.Ai/LlmServiceCollectionExtensions.cs index 9aa606ba..f3414049 100644 --- a/src/NodePilot.Ai/LlmServiceCollectionExtensions.cs +++ b/src/NodePilot.Ai/LlmServiceCollectionExtensions.cs @@ -219,9 +219,9 @@ public static IServiceCollection AddNodePilotAi(this IServiceCollection services }) .ConfigurePrimaryHttpMessageHandler(sp => new SocketsHttpHandler { - // Local endpoints (Ollama, llama.cpp) speak plaintext HTTP on 127.0.0.1. - // Cloud endpoints speak HTTPS — the default SocketsHttpHandler validates that - // normally. No forcing HTTPS. + // Local endpoints (Ollama, llama.cpp) may speak plaintext HTTP on literal + // loopback. LlmEndpointGuard rejects HTTP everywhere else; HTTPS certificate + // validation is the unmodified SocketsHttpHandler default. // // Proxying is decided per request by LlmConfiguredProxy from Llm:Proxy:*, NOT // here: this handler is built once per handler lifetime, so reading the config at diff --git a/src/NodePilot.Api/Configuration/EffectiveSourceDetector.cs b/src/NodePilot.Api/Configuration/EffectiveSourceDetector.cs index ac976a33..47cc998b 100644 --- a/src/NodePilot.Api/Configuration/EffectiveSourceDetector.cs +++ b/src/NodePilot.Api/Configuration/EffectiveSourceDetector.cs @@ -52,7 +52,7 @@ public static string Detect(IConfigurationRoot root, string key) // Reverse iteration mirrors configuration lookup semantics: last provider wins. foreach (var provider in root.Providers.Reverse()) { - if (provider.TryGet(key, out _)) + if (ProviderDefines(provider, key)) return Classify(provider); } return SourceDefault; @@ -79,7 +79,7 @@ public static string Detect(IConfigurationRoot root, string key) if (source == SourceRuntime) continue; foreach (var key in keyList) { - if (provider.TryGet(key, out _)) return source; + if (ProviderDefines(provider, key)) return source; } } return null; @@ -95,6 +95,10 @@ public static IReadOnlyDictionary DetectMany(IConfigurationRoot return map; } + private static bool ProviderDefines(IConfigurationProvider provider, string key) + => provider.TryGet(key, out _) + || provider.GetChildKeys([], key).Any(); + private static string Classify(IConfigurationProvider provider) { if (provider is EncryptingJsonConfigurationProvider) return SourceRuntime; diff --git a/src/NodePilot.Api/Configuration/LlmAvailability.cs b/src/NodePilot.Api/Configuration/LlmAvailability.cs index 6e907fcb..702bd206 100644 --- a/src/NodePilot.Api/Configuration/LlmAvailability.cs +++ b/src/NodePilot.Api/Configuration/LlmAvailability.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Mvc; using NodePilot.Ai; +using NodePilot.Api.Ai; namespace NodePilot.Api.Configuration; @@ -16,6 +18,8 @@ public static class LlmAvailability "No active LLM profile is configured. Add a profile under Settings → System → Integrations → LLM " + "and select it as the active profile."; + public const string DisabledMessage = "AI assistant is disabled. Set Llm:Enabled=true in configuration."; + /// /// True when is enabled but no profile resolves — i.e. the caller /// should answer . Returns false when the integration is off @@ -26,4 +30,24 @@ public static bool IsMissingActiveProfile(LlmOptions options) ArgumentNullException.ThrowIfNull(options); return options.Enabled && !options.TryResolveActiveProfile(out _); } + + /// + /// The gate every AI endpoint runs first: returns the 503 to send back, or null when the + /// integration is usable. Both checks live here so an endpoint cannot accidentally test one + /// and not the other — ILlmClientFactory.Create throws without an active profile, and + /// that has to surface as a clean 503 rather than an unhandled exception. + /// exists only because the knowledge endpoint answers in + /// German; every other caller takes the default. + /// + public static ObjectResult? Unavailable(ControllerBase controller, LlmOptions options, string? disabledMessage = null) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(options); + + if (!options.Enabled) + return controller.LlmServiceUnavailable(DisabledCode, disabledMessage ?? DisabledMessage); + if (IsMissingActiveProfile(options)) + return controller.LlmServiceUnavailable(NoActiveProfileCode, NoActiveProfileMessage); + return null; + } } diff --git a/src/NodePilot.Api/Configuration/ProviderAtomicGuidList.cs b/src/NodePilot.Api/Configuration/ProviderAtomicGuidList.cs new file mode 100644 index 00000000..0bab2fc9 --- /dev/null +++ b/src/NodePilot.Api/Configuration/ProviderAtomicGuidList.cs @@ -0,0 +1,180 @@ +using System.Globalization; +using System.Text.Json; + +namespace NodePilot.Api.Configuration; + +/// +/// Reads a GUID list from one configuration provider instead of from IConfiguration's merged +/// child view. Array indices are not replacement values in Microsoft.Extensions.Configuration: +/// a higher-priority one-element array otherwise leaves lower-priority indices 1..N visible. +/// Security allow-lists must instead use the complete value from the highest-priority provider +/// that declares the section. An explicitly empty JSON array is represented by that provider as +/// an exact key with a null value and therefore acts as a deny-all tombstone. +/// +internal static class ProviderAtomicGuidList +{ + public static bool TryRead( + IConfiguration configuration, + string sectionPath, + out IReadOnlySet values) + { + ArgumentNullException.ThrowIfNull(configuration); + if (string.IsNullOrWhiteSpace(sectionPath)) + throw new ArgumentException("Section path must not be empty.", nameof(sectionPath)); + + if (configuration is IConfigurationRoot root) + { + foreach (var provider in root.Providers.Reverse()) + { + var hasExactValue = provider.TryGet(sectionPath, out var exactValue); + var childKeys = provider.GetChildKeys([], sectionPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!hasExactValue && childKeys.Length == 0) + continue; + + return TryReadFromProvider( + provider, sectionPath, hasExactValue, exactValue, childKeys, out values); + } + + values = new HashSet(); + return true; + } + + // Backward-compatible fallback for unusual IConfiguration wrappers that do not expose + // their provider chain. The application and normal tests use IConfigurationRoot. + return TryReadMergedSection(configuration.GetSection(sectionPath), out values); + } + + /// + /// Reads one list strictly from . This is used by security + /// configuration whose containing object has already selected an authoritative provider; + /// falling back to a lower provider for one child would reintroduce split-snapshot scopes. + /// A provider which does not declare the list represents an empty (deny-all) scope. + /// + internal static bool TryReadFromProvider( + IConfigurationProvider provider, + string sectionPath, + out IReadOnlySet values) + { + ArgumentNullException.ThrowIfNull(provider); + if (string.IsNullOrWhiteSpace(sectionPath)) + throw new ArgumentException("Section path must not be empty.", nameof(sectionPath)); + + var hasExactValue = provider.TryGet(sectionPath, out var exactValue); + var childKeys = provider.GetChildKeys([], sectionPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!hasExactValue && childKeys.Length == 0) + { + values = new HashSet(); + return true; + } + + return TryReadFromProvider( + provider, sectionPath, hasExactValue, exactValue, childKeys, out values); + } + + private static bool TryReadFromProvider( + IConfigurationProvider provider, + string sectionPath, + bool hasExactValue, + string? exactValue, + IReadOnlyCollection childKeys, + out IReadOnlySet values) + { + // A provider declaring both an atomic scalar and indexed children is ambiguous. Never + // guess which scope was intended; malformed authorization configuration is deny-all. + if (hasExactValue && childKeys.Count > 0) + { + values = new HashSet(); + return false; + } + + if (hasExactValue) + { + // JsonConfigurationProvider uses an exact null entry for [] (and for an empty + // object). Both must replace lower providers with an empty, fail-closed scope. + if (string.IsNullOrWhiteSpace(exactValue)) + { + values = new HashSet(); + return true; + } + + return TryReadJsonArray(exactValue, out values); + } + + var parsed = new HashSet(); + foreach (var childKey in childKeys) + { + if (!int.TryParse(childKey, NumberStyles.None, CultureInfo.InvariantCulture, out _) + || !provider.TryGet($"{sectionPath}:{childKey}", out var raw) + || !Guid.TryParse(raw, out var id)) + { + values = new HashSet(); + return false; + } + + parsed.Add(id); + } + + values = parsed; + return true; + } + + private static bool TryReadJsonArray(string json, out IReadOnlySet values) + { + var parsed = new HashSet(); + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + values = new HashSet(); + return false; + } + + foreach (var element in document.RootElement.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String + || !Guid.TryParse(element.GetString(), out var id)) + { + values = new HashSet(); + return false; + } + + parsed.Add(id); + } + } + catch (JsonException) + { + values = new HashSet(); + return false; + } + + values = parsed; + return true; + } + + private static bool TryReadMergedSection( + IConfigurationSection section, + out IReadOnlySet values) + { + var parsed = new HashSet(); + foreach (var child in section.GetChildren()) + { + if (!Guid.TryParse(child.Value, out var id)) + { + values = new HashSet(); + return false; + } + + parsed.Add(id); + } + + values = parsed; + return true; + } +} diff --git a/src/NodePilot.Api/Configuration/SettingsSchema.cs b/src/NodePilot.Api/Configuration/SettingsSchema.cs index 63301720..ebf2cbf4 100644 --- a/src/NodePilot.Api/Configuration/SettingsSchema.cs +++ b/src/NodePilot.Api/Configuration/SettingsSchema.cs @@ -191,9 +191,9 @@ public static bool IsUnchangedSecretValue(string? incoming) new SettingsSectionDescriptor("Webhook", "Webhook Triggers", typeof(object), typeof(WebhookSettingsDto), ImmutableArray.Empty, true, AuditActions.SettingsWebhookUpdated), - // Hot-reload: ExternalTriggerController reads ExternalTrigger:ApiKey from the live - // IConfiguration indexer per request (apiKey is the active-when-set toggle), so a Settings-UI - // save takes effect without a restart. + // Hot-reload: ExternalTriggerController reads the legacy ApiKey + AllowedWorkflowIds and + // hashed Keys entries from live IConfiguration per request. The legacy key is inert when + // its GUID allow-list is empty. new SettingsSectionDescriptor("ExternalTrigger", "External Trigger API", typeof(object), typeof(ExternalTriggerSettingsDto), ImmutableArray.Create("ApiKey"), true, AuditActions.SettingsExternalTriggerUpdated), diff --git a/src/NodePilot.Api/Configuration/SettingsSections.cs b/src/NodePilot.Api/Configuration/SettingsSections.cs index cd09a365..4a1c3525 100644 --- a/src/NodePilot.Api/Configuration/SettingsSections.cs +++ b/src/NodePilot.Api/Configuration/SettingsSections.cs @@ -209,16 +209,27 @@ public static ISettingsSectionAdapterRegistry CreateDefault( new DelegateSettingsSectionAdapter( Descriptor("ExternalTrigger"), - ["ExternalTrigger:ApiKey"], + ["ExternalTrigger:ApiKey", "ExternalTrigger:AllowedWorkflowIds"], () => new ExternalTriggerSettingsDto { ApiKey = string.IsNullOrEmpty(configRoot["ExternalTrigger:ApiKey"]) ? null : "********", + AllowedWorkflowIds = ProviderAtomicGuidList.TryRead( + configRoot, "ExternalTrigger:AllowedWorkflowIds", out var workflowIds) + ? workflowIds.ToList() + : [], }, BuildExternalTriggerDtoFromJson, (dto, previous) => { var section = new JsonObject(); WriteSecretField(section, "ApiKey", dto.ApiKey, previous ?? new JsonObject(), protector); + section["AllowedWorkflowIds"] = ToJsonArray( + dto.AllowedWorkflowIds.Select(id => id.ToString())); + // Hashed integration entries are intentionally configured out-of-band. A + // legacy-key save in the Settings UI must never erase entries that already + // live in appsettings.runtime.json. + if (previous?["Keys"] is { } keys) + section["Keys"] = keys.DeepClone(); return section; }), @@ -1190,6 +1201,11 @@ private static ExternalTriggerSettingsDto BuildExternalTriggerDtoFromJson(JsonOb return new ExternalTriggerSettingsDto { ApiKey = HasNonNullValue(section, "ApiKey") ? "********" : null, + AllowedWorkflowIds = ReadJsonStringArray(section, "AllowedWorkflowIds") + .Select(value => Guid.TryParse(value, out var id) ? id : (Guid?)null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(), }; } diff --git a/src/NodePilot.Api/Controllers/AiChatController.cs b/src/NodePilot.Api/Controllers/AiChatController.cs index c88a45cf..d904c0e7 100644 --- a/src/NodePilot.Api/Controllers/AiChatController.cs +++ b/src/NodePilot.Api/Controllers/AiChatController.cs @@ -81,12 +81,7 @@ public AiChatController( [HttpPost("chat")] public async Task Chat(WorkflowChatRequest request, CancellationToken ct) { - if (!_options.CurrentValue.Enabled) - return this.LlmServiceUnavailable("LLM_DISABLED", - "AI assistant is disabled. Set Llm:Enabled=true in configuration."); - - if (LlmAvailability.IsMissingActiveProfile(_options.CurrentValue)) - return this.LlmServiceUnavailable(LlmAvailability.NoActiveProfileCode, LlmAvailability.NoActiveProfileMessage); + if (LlmAvailability.Unavailable(this, _options.CurrentValue) is { } gate) return gate; if (string.IsNullOrWhiteSpace(request.Question)) return BadRequest(new { code = "PROMPT_EMPTY", message = "Question must not be empty." }); diff --git a/src/NodePilot.Api/Controllers/AiController.cs b/src/NodePilot.Api/Controllers/AiController.cs index 0d262936..c62d6932 100644 --- a/src/NodePilot.Api/Controllers/AiController.cs +++ b/src/NodePilot.Api/Controllers/AiController.cs @@ -50,12 +50,7 @@ public AiController( [HttpPost("generate-script")] public async Task GenerateScript(GenerateScriptRequest request, CancellationToken ct) { - if (!_options.CurrentValue.Enabled) - return this.LlmServiceUnavailable("LLM_DISABLED", - "AI assistant is disabled. Set Llm:Enabled=true in configuration."); - - if (LlmAvailability.IsMissingActiveProfile(_options.CurrentValue)) - return this.LlmServiceUnavailable(LlmAvailability.NoActiveProfileCode, LlmAvailability.NoActiveProfileMessage); + if (LlmAvailability.Unavailable(this, _options.CurrentValue) is { } gate) return gate; if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest(new { code = "PROMPT_EMPTY", message = "Prompt must not be empty." }); @@ -164,12 +159,7 @@ private Task ScriptAuditAsync(string model, int durationMs, int responseChars, public async Task> GenerateWorkflow( GenerateWorkflowRequest request, CancellationToken ct) { - if (!_options.CurrentValue.Enabled) - return this.LlmServiceUnavailable("LLM_DISABLED", - "AI assistant is disabled. Set Llm:Enabled=true in configuration."); - - if (LlmAvailability.IsMissingActiveProfile(_options.CurrentValue)) - return this.LlmServiceUnavailable(LlmAvailability.NoActiveProfileCode, LlmAvailability.NoActiveProfileMessage); + if (LlmAvailability.Unavailable(this, _options.CurrentValue) is { } gate) return gate; if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest(new { code = "PROMPT_EMPTY", message = "Prompt must not be empty." }); diff --git a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs index a5c63527..90c5ebff 100644 --- a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs +++ b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs @@ -89,10 +89,8 @@ public ActionResult Capabilities() [HttpPost("knowledge/ask")] public async Task Ask(KnowledgeAskRequest request, CancellationToken ct) { - if (!_llmOptions.CurrentValue.Enabled) - return this.LlmServiceUnavailable("LLM_DISABLED", "AI ist deaktiviert. Setze Llm:Enabled=true in der Konfiguration."); - if (LlmAvailability.IsMissingActiveProfile(_llmOptions.CurrentValue)) - return this.LlmServiceUnavailable(LlmAvailability.NoActiveProfileCode, LlmAvailability.NoActiveProfileMessage); + if (LlmAvailability.Unavailable(this, _llmOptions.CurrentValue, + "AI ist deaktiviert. Setze Llm:Enabled=true in der Konfiguration.") is { } gate) return gate; var k = _knowledgeOptions.CurrentValue; if (!k.Enabled) return this.LlmServiceUnavailable("KNOWLEDGE_DISABLED", "Der KI-Chat ist deaktiviert. Aktiviere ihn in den Admin-Einstellungen (AI-Wissen)."); diff --git a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs index 15020207..eb472c22 100644 --- a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs +++ b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Dtos; @@ -10,6 +13,7 @@ using NodePilot.Core.ExecutionDispatch; using NodePilot.Core.Interfaces; using NodePilot.Core.Models; +using NodePilot.Core.WorkflowDefinitions; using NodePilot.Data; using NodePilot.Engine.Security; @@ -17,8 +21,8 @@ namespace NodePilot.Api.Controllers; /// /// External trigger surface: POST /api/trigger/{workflowNameOrId}. Anonymous transport, -/// gated by an X-Api-Key header that is matched against ExternalTrigger:ApiKey -/// in constant time. Idempotency-Key handling lives here too — internal callers (UI, scheduler, +/// gated by an X-Api-Key header that is matched against a configured, workflow-scoped +/// external-trigger key in constant time. Idempotency-Key handling lives here too — internal callers (UI, scheduler, /// CLI) hit instead, which is owner-tagged via JWT. /// [ApiController] @@ -91,13 +95,13 @@ private OkObjectResult IdempotentReplay(WorkflowExecution replay) private static async Task FindIdempotencyReplayAsync( NodePilotDbContext db, - string idempotencyKey, + string idempotencyStorageKey, Guid workflowId, CancellationToken ct) { var now = DateTime.UtcNow; var existing = await db.IdempotencyKeys.AsNoTracking() - .FirstOrDefaultAsync(k => k.Key == idempotencyKey && k.WorkflowId == workflowId && k.ExpiresAt > now, ct); + .FirstOrDefaultAsync(k => k.Key == idempotencyStorageKey && k.WorkflowId == workflowId && k.ExpiresAt > now, ct); if (existing is null) return null; return await db.WorkflowExecutions.AsNoTracking() @@ -106,23 +110,83 @@ private OkObjectResult IdempotentReplay(WorkflowExecution replay) private static async Task RemoveIdempotencyKeyAsync( NodePilotDbContext db, - string idempotencyKey, + string idempotencyStorageKey, Guid workflowId, CancellationToken ct) { var key = await db.IdempotencyKeys - .FirstOrDefaultAsync(k => k.Key == idempotencyKey && k.WorkflowId == workflowId, ct); + .FirstOrDefaultAsync(k => k.Key == idempotencyStorageKey && k.WorkflowId == workflowId, ct); if (key is null) return; db.IdempotencyKeys.Remove(key); await db.SaveChangesAsync(ct); } + /// + /// Produces the database key for one caller-supplied Idempotency-Key. The authenticated key + /// principal is part of the digest domain, so two integrations cannot replay or reserve one + /// another's token even when they target the same workflow. The raw header and key principal + /// are never persisted. The fixed-size v1 value fits the existing 200-character column. + /// + internal static string BuildIdempotencyStorageKey(string keyPrincipalId, string clientKey) + { + const string domain = "nodepilot:external-trigger:idempotency:v1"; + var domainBytes = Encoding.UTF8.GetBytes(domain); + var principalBytes = Encoding.UTF8.GetBytes(keyPrincipalId); + var clientKeyBytes = Encoding.UTF8.GetBytes(clientKey); + var material = new byte[ + 4 + domainBytes.Length + 4 + principalBytes.Length + 4 + clientKeyBytes.Length]; + var offset = 0; + WriteLengthPrefixed(domainBytes, material, ref offset); + WriteLengthPrefixed(principalBytes, material, ref offset); + WriteLengthPrefixed(clientKeyBytes, material, ref offset); + try + { + var digest = SHA256.HashData(material); + try + { + return $"ext:v1:{Convert.ToHexString(digest)}"; + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } + finally + { + CryptographicOperations.ZeroMemory(principalBytes); + CryptographicOperations.ZeroMemory(clientKeyBytes); + CryptographicOperations.ZeroMemory(material); + } + } + + private static void WriteLengthPrefixed( + ReadOnlySpan value, + Span destination, + ref int offset) + { + BinaryPrimitives.WriteInt32BigEndian(destination.Slice(offset, 4), value.Length); + offset += 4; + value.CopyTo(destination[offset..]); + offset += value.Length; + } + + /// + /// The external API is one transport for a workflow's manual entry point; it is not an + /// instance-wide bypass around the workflow definition. Parse the authoritative definition + /// instead of trusting the denormalized TriggerTypesJson column so a stale or malformed row + /// fails closed. Disabled manual-trigger nodes are omitted from TriggerDescriptors. + /// + private static bool AllowsExternalTrigger(Workflow workflow) + => WorkflowDefinitionDocument.TryParse(workflow.DefinitionJson, out var definition) + && definition is not null + && definition.TriggerDescriptors.Any(trigger => trigger.IsManual); + /// /// External trigger endpoint — start a workflow by name or ID with parameters. - /// Requires an API key via X-Api-Key header; the key is configured in appsettings - /// under ExternalTrigger:ApiKey (or env NODEPILOT__ExternalTrigger__ApiKey). If - /// the config is missing or empty, this endpoint is disabled entirely. + /// Requires an API key via X-Api-Key header. Preferred configuration uses SHA-256 hashes + /// under ExternalTrigger:Keys:<integration> with a GUID-only AllowedWorkflowIds scope. + /// The legacy ExternalTrigger:ApiKey is inert unless its own AllowedWorkflowIds is set. /// Example: POST /api/trigger/Deploy%20App {"parameters": {"version": "2.1.0"}} /// [HttpPost("/api/trigger/{workflowNameOrId}")] @@ -143,28 +207,12 @@ public async Task> ExternalTrigger( [FromServices] ILogger logger, CancellationToken ct) { - var expectedKey = config["ExternalTrigger:ApiKey"]; - // Don't distinguish "no key configured" from "wrong key" in the response — both - // return 401. Previously this returned 503, which confirmed to an unauthenticated - // caller that the endpoint existed but was unconfigured, aiding discovery. - if (string.IsNullOrWhiteSpace(expectedKey) - || System.Text.Encoding.UTF8.GetByteCount(expectedKey) < MinExternalApiKeyBytes) - { - if (string.IsNullOrWhiteSpace(expectedKey)) - logger.LogDebug("External trigger rejected: ExternalTrigger:ApiKey not configured."); - else - logger.LogWarning("External trigger rejected: ExternalTrigger:ApiKey is shorter than {Min} bytes. Rotate the key.", MinExternalApiKeyBytes); - - // Still run FixedTimeEquals on dummy data so the response time does not reveal - // whether the server is misconfigured versus presenting the wrong key. - _ = SecretComparer.FixedTimeEquals(Request.Headers["X-Api-Key"].ToString(), new string('x', MinExternalApiKeyBytes)); - NodePilot.Api.Telemetry.ApiMetrics.ExternalTriggerAuthFailures.Add(1); - return Unauthorized(new { message = "Invalid or missing X-Api-Key header" }); - } - - if (!Request.Headers.TryGetValue("X-Api-Key", out var presented) - || !SecretComparer.FixedTimeEquals(presented.ToString(), expectedKey)) + Request.Headers.TryGetValue("X-Api-Key", out var presented); + var keyScope = ExternalTriggerKeyScopeResolver.Authenticate( + config, presented.ToString(), MinExternalApiKeyBytes); + if (keyScope is null) { + logger.LogDebug("External trigger rejected: no configured key scope matched."); NodePilot.Api.Telemetry.ApiMetrics.ExternalTriggerAuthFailures.Add(1); return Unauthorized(new { message = "Invalid or missing X-Api-Key header" }); } @@ -182,11 +230,14 @@ public async Task> ExternalTrigger( workflow = resolved.Workflow; } - // M-29: uniform 404 to prevent workflow-name enumeration via external-trigger API key. - // Previously this returned 404 for "not found" but 400 for "exists but disabled", which - // let a holder of a valid API key confirm which names exist even when disabled. - // Now all non-executable cases (missing / disabled) collapse to the same 404. - if (workflow is null || !workflow.IsEnabled) + // Uniform 404 prevents workflow-name and external-trigger-scope enumeration by an API-key + // holder. A workflow must explicitly contain an enabled manualTrigger (the catalogued + // Manual/API entry point); merely being enabled is no longer sufficient. This keeps the + // instance-wide key from acting as a start-any-workflow capability. + if (workflow is null + || !workflow.IsEnabled + || !keyScope.AllowedWorkflowIds.Contains(workflow.Id) + || !AllowsExternalTrigger(workflow)) return NotFound(new { message = $"Workflow '{workflowNameOrId}' not found or not executable" }); // Maintenance-window gate. MUST run BEFORE the idempotency-key transaction below: if a @@ -204,18 +255,21 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" return NotFound(new { message = $"Workflow '{workflowNameOrId}' not found or not executable" }); } - // Idempotency-Key handling: if the caller supplies one, a replay of the same key - // returns the original execution instead of firing the workflow a second time. Keys - // are scoped per workflow so two different runbooks can reuse the same caller token. - // Limit: 200 chars (matches column); empty/whitespace treated as "no key". + // Idempotency-Key handling: if the caller supplies one, a replay by the same + // authenticated key principal returns the original execution. The DB sees only a + // versioned digest scoped by principal + workflow, never the raw header; another + // integration can therefore reuse the same client token without replay/preemption. + // Limit: 200 client characters; empty/whitespace is treated as "no key". string? idempotencyKey = Request.Headers.TryGetValue("Idempotency-Key", out var hdr) ? hdr.ToString().Trim() : null; + string? idempotencyStorageKey = null; if (!string.IsNullOrEmpty(idempotencyKey)) { if (idempotencyKey.Length > 200) return BadRequest(new { message = "Idempotency-Key must be 200 characters or less" }); - var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); + idempotencyStorageKey = BuildIdempotencyStorageKey(keyScope.PrincipalId, idempotencyKey); + var replay = await FindIdempotencyReplayAsync(_db, idempotencyStorageKey, workflow.Id, ct); if (replay is not null) return IdempotentReplay(replay); @@ -263,8 +317,9 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" EnqueueFailureMessage: "Queued external trigger was not dispatched because the request was cancelled before enqueue completed."); WorkflowExecution pending; - if (!string.IsNullOrEmpty(idempotencyKey)) + if (idempotencyStorageKey is not null) { + var scopedIdempotencyKey = idempotencyStorageKey; (WorkflowExecution? Replayed, WorkflowExecution? Fresh) outcome; try { @@ -287,7 +342,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" await using var tx = await _db.Database.BeginTransactionAsync(ct); var now = DateTime.UtcNow; var existingKey = await _db.IdempotencyKeys - .FirstOrDefaultAsync(k => k.Key == idempotencyKey && k.WorkflowId == workflow.Id, ct); + .FirstOrDefaultAsync(k => k.Key == scopedIdempotencyKey && k.WorkflowId == workflow.Id, ct); if (existingKey is not null && existingKey.ExpiresAt > now) { var cached = await _db.WorkflowExecutions.AsNoTracking() @@ -306,7 +361,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" _db.IdempotencyKeys.Add(new IdempotencyKey { Id = Guid.NewGuid(), - Key = idempotencyKey, + Key = scopedIdempotencyKey, WorkflowId = workflow.Id, ExecutionId = created.Id, FirstSeenAt = now, @@ -320,7 +375,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" catch (DbUpdateException) { _db.ChangeTracker.Clear(); - var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); + var replay = await FindIdempotencyReplayAsync(_db, scopedIdempotencyKey, workflow.Id, ct); if (replay is not null) return IdempotentReplay(replay); @@ -342,7 +397,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" } catch { - await RemoveIdempotencyKeyAsync(_db, idempotencyKey, workflow.Id, CancellationToken.None); + await RemoveIdempotencyKeyAsync(_db, scopedIdempotencyKey, workflow.Id, CancellationToken.None); throw; } } @@ -356,8 +411,9 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" await _audit.LogAsync(AuditActions.ExternalTriggerFired, "Workflow", workflow.Id, AuditDetails.Json( ("workflowName", workflow.Name), + ("integrationId", keyScope.IntegrationId), ("executionId", pending.Id), - ("idempotencyKeyUsed", !string.IsNullOrEmpty(idempotencyKey)), + ("idempotencyKeyUsed", idempotencyStorageKey is not null), ("parameterCount", parameters?.Count ?? 0)), ct); diff --git a/src/NodePilot.Api/Dtos/Settings/SecurityHardeningSettingsDto.cs b/src/NodePilot.Api/Dtos/Settings/SecurityHardeningSettingsDto.cs index 3dfa586d..e1142fa2 100644 --- a/src/NodePilot.Api/Dtos/Settings/SecurityHardeningSettingsDto.cs +++ b/src/NodePilot.Api/Dtos/Settings/SecurityHardeningSettingsDto.cs @@ -150,8 +150,14 @@ public sealed class WebhookSettingsDto public sealed class ExternalTriggerSettingsDto { - /// SecretField semantics — empty/null disables the external-trigger endpoint. + /// Legacy SecretField. It authorizes only . public string? ApiKey { get; set; } + + /// + /// GUID-only scope for the legacy key. Empty is deliberately fail-closed and authorizes no + /// workflows. New integrations should use hashed ExternalTrigger:Keys entries instead. + /// + public List AllowedWorkflowIds { get; set; } = []; } public sealed class SecuritySettingsDto diff --git a/src/NodePilot.Api/Hosting/OpenApiSetup.cs b/src/NodePilot.Api/Hosting/OpenApiSetup.cs index ad3baf5b..650d313c 100644 --- a/src/NodePilot.Api/Hosting/OpenApiSetup.cs +++ b/src/NodePilot.Api/Hosting/OpenApiSetup.cs @@ -57,7 +57,7 @@ public static IServiceCollection AddNodePilotOpenApi(this IServiceCollection ser opts.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme { Name = "X-Api-Key", - Description = "External-trigger API key (ExternalTrigger:ApiKey in appsettings). Only consumed by POST /api/trigger/{workflowNameOrId}.", + Description = "Workflow-scoped external-trigger key. Preferred configuration stores its SHA-256 hash under ExternalTrigger:Keys: with AllowedWorkflowIds. The workflow must also contain an enabled manualTrigger. Only consumed by POST /api/trigger/{workflowNameOrId}.", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }); diff --git a/src/NodePilot.Api/Security/ExternalTriggerKeyScopeResolver.cs b/src/NodePilot.Api/Security/ExternalTriggerKeyScopeResolver.cs new file mode 100644 index 00000000..007d84de --- /dev/null +++ b/src/NodePilot.Api/Security/ExternalTriggerKeyScopeResolver.cs @@ -0,0 +1,242 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using NodePilot.Api.Configuration; + +namespace NodePilot.Api.Security; + +/// +/// The authorization scope attached to one successfully authenticated external-trigger key. +/// Integration ids are operational labels only; keys are persisted as SHA-256 hashes. +/// +internal sealed record ExternalTriggerKeyScope( + string IntegrationId, + string PrincipalId, + IReadOnlySet AllowedWorkflowIds); + +/// +/// Authenticates external-trigger keys and returns their GUID-only workflow scope. +/// Every configured hash is compared on every request. Duplicate matches, malformed hashes, +/// and malformed workflow ids fail closed instead of silently widening a key's authority. +/// +internal static class ExternalTriggerKeyScopeResolver +{ + private const int Sha256Length = 32; + private const string HashedKeysPath = "ExternalTrigger:Keys"; + + private sealed record HashedKeyDefinition( + string IntegrationId, + string? KeyHash, + IReadOnlySet AllowedWorkflowIds); + + public static ExternalTriggerKeyScope? Authenticate( + IConfiguration configuration, + string? presentedKey, + int minimumKeyBytes) + { + if (string.IsNullOrWhiteSpace(presentedKey) + || Encoding.UTF8.GetByteCount(presentedKey) < minimumKeyBytes) + { + return null; + } + + var presentedBytes = Encoding.UTF8.GetBytes(presentedKey); + var presentedHash = SHA256.HashData(presentedBytes); + CryptographicOperations.ZeroMemory(presentedBytes); + ExternalTriggerKeyScope? matchedScope = null; + var matchCount = 0; + var hashedKeysAreValid = TryReadHashedKeyDefinitions( + configuration, out var hashedKeyDefinitions); + var invalidConfiguration = !hashedKeysAreValid; + + foreach (var definition in hashedKeyDefinitions) + { + var configuredHash = new byte[Sha256Length]; + var hashIsValid = TryDecodeSha256(definition.KeyHash, configuredHash); + + // Compare even invalid entries against a fixed zero buffer. This keeps the loop shape + // independent of which configured entry is malformed and avoids an early-match oracle. + var matches = CryptographicOperations.FixedTimeEquals(presentedHash, configuredHash); + if (!hashIsValid) + invalidConfiguration = true; + + if (!hashIsValid) + { + continue; + } + + if (!matches) + continue; + + matchCount++; + matchedScope = new ExternalTriggerKeyScope( + definition.IntegrationId, + BuildPrincipalId(definition.IntegrationId, presentedHash), + definition.AllowedWorkflowIds); + } + + // Transitional support for the former single plaintext key. It is no longer an + // instance-wide capability: without an explicit GUID allow-list it authenticates to an + // empty scope and therefore cannot execute any workflow. New installations should use + // ExternalTrigger:Keys and store only hashes. + var legacyKey = configuration["ExternalTrigger:ApiKey"]; + if (!string.IsNullOrWhiteSpace(legacyKey) + && Encoding.UTF8.GetByteCount(legacyKey) >= minimumKeyBytes) + { + var legacyScopeIsValid = ProviderAtomicGuidList.TryRead( + configuration, + "ExternalTrigger:AllowedWorkflowIds", + out var workflowIds); + if (!legacyScopeIsValid) + invalidConfiguration = true; + + var legacyMatches = SecretComparer.FixedTimeEquals(presentedKey, legacyKey); + if (legacyMatches) + { + matchCount++; + if (legacyScopeIsValid) + matchedScope = new ExternalTriggerKeyScope( + "legacy", + BuildPrincipalId("legacy", presentedHash), + workflowIds); + } + } + + CryptographicOperations.ZeroMemory(presentedHash); + + // Reusing one key in multiple integration entries is ambiguous: unioning scopes would + // grant more access than either entry declares, while choosing one is order-dependent. + return !invalidConfiguration && matchCount == 1 ? matchedScope : null; + } + + /// + /// Reads the complete hashed-key map from exactly one configuration provider. Microsoft's + /// merged configuration view is additive for dictionaries: a higher-priority Keys: {} + /// otherwise leaves every lower-provider integration visible. That makes emergency key + /// revocation appear successful while the old credential remains usable. The highest provider + /// which declares either the map or one of its children therefore owns the whole snapshot. + /// Empty objects/null values are deny-all tombstones; partial entries fail closed rather than + /// inheriting a hash or scope from another provider. + /// + private static bool TryReadHashedKeyDefinitions( + IConfiguration configuration, + out IReadOnlyList definitions) + { + if (configuration is not IConfigurationRoot root) + { + // Production uses IConfigurationRoot/ConfigurationManager. An opaque wrapper cannot + // prove provider ownership, so accepting its merged dictionary would weaken the + // authorization boundary this method exists to enforce. + definitions = []; + return false; + } + + foreach (var provider in root.Providers.Reverse()) + { + var hasExactMapValue = provider.TryGet(HashedKeysPath, out var exactMapValue); + var integrationIds = provider.GetChildKeys([], HashedKeysPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!hasExactMapValue && integrationIds.Length == 0) + continue; + + // JsonConfigurationProvider represents an empty object with an exact null value. + // A scalar plus children is ambiguous; a non-empty scalar is not a supported map. + if (hasExactMapValue) + { + definitions = []; + return integrationIds.Length == 0 && string.IsNullOrWhiteSpace(exactMapValue); + } + + var parsed = new List(integrationIds.Length); + foreach (var integrationId in integrationIds) + { + var entryPath = $"{HashedKeysPath}:{integrationId}"; + var hasExactEntryValue = provider.TryGet(entryPath, out var exactEntryValue); + var entryChildren = provider.GetChildKeys([], entryPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // An explicit empty integration object is a per-entry tombstone. Non-empty + // scalars and scalar/child mixtures are malformed and fail the whole map closed. + if (hasExactEntryValue) + { + if (entryChildren.Length != 0 || !string.IsNullOrWhiteSpace(exactEntryValue)) + { + definitions = []; + return false; + } + + continue; + } + + if (!provider.TryGet($"{entryPath}:KeyHash", out var keyHash) + || string.IsNullOrWhiteSpace(keyHash) + || !ProviderAtomicGuidList.TryReadFromProvider( + provider, + $"{entryPath}:AllowedWorkflowIds", + out var workflowIds)) + { + definitions = []; + return false; + } + + parsed.Add(new HashedKeyDefinition(integrationId, keyHash, workflowIds)); + } + + definitions = parsed; + return true; + } + + definitions = []; + return true; + } + + private static bool TryDecodeSha256(string? encoded, Span destination) + { + destination.Clear(); + return !string.IsNullOrWhiteSpace(encoded) + && Convert.TryFromBase64String(encoded, destination, out var bytesWritten) + && bytesWritten == Sha256Length; + } + + private static string BuildPrincipalId(string integrationId, ReadOnlySpan keyHash) + { + const string domain = "nodepilot:external-trigger:key-principal:v1"; + var domainBytes = Encoding.UTF8.GetBytes(domain); + // IConfiguration keys are case-insensitive. Canonicalizing prevents a casing-only config + // change from creating a new idempotency principal for the same integration. + var integrationBytes = Encoding.UTF8.GetBytes(integrationId.ToUpperInvariant()); + var material = new byte[4 + domainBytes.Length + 4 + integrationBytes.Length + 4 + keyHash.Length]; + var offset = 0; + WriteLengthPrefixed(domainBytes, material, ref offset); + WriteLengthPrefixed(integrationBytes, material, ref offset); + BinaryPrimitives.WriteInt32BigEndian(material.AsSpan(offset, 4), keyHash.Length); + keyHash.CopyTo(material.AsSpan(offset + 4)); + + try + { + return Convert.ToHexString(SHA256.HashData(material)); + } + finally + { + CryptographicOperations.ZeroMemory(integrationBytes); + CryptographicOperations.ZeroMemory(material); + } + } + + private static void WriteLengthPrefixed( + ReadOnlySpan value, + Span destination, + ref int offset) + { + BinaryPrimitives.WriteInt32BigEndian(destination.Slice(offset, 4), value.Length); + offset += 4; + value.CopyTo(destination[offset..]); + offset += value.Length; + } + +} diff --git a/src/NodePilot.Api/appsettings.json b/src/NodePilot.Api/appsettings.json index 86996608..b8c8ba1c 100644 --- a/src/NodePilot.Api/appsettings.json +++ b/src/NodePilot.Api/appsettings.json @@ -71,7 +71,9 @@ "DpapiScope": "CurrentUser" }, "ExternalTrigger": { - "ApiKey": "" + "ApiKey": "", + "AllowedWorkflowIds": [], + "Keys": {} }, "Webhook": { "RequireSecret": true diff --git a/src/NodePilot.Core/Activities/Embedded/activity-config-reference.json b/src/NodePilot.Core/Activities/Embedded/activity-config-reference.json index e39822c5..d2e2bcd4 100644 --- a/src/NodePilot.Core/Activities/Embedded/activity-config-reference.json +++ b/src/NodePilot.Core/Activities/Embedded/activity-config-reference.json @@ -194,7 +194,7 @@ { "key": "password", "type": "string", "required": false, "description": "Builder password (secret; dev/legacy)." }, { "key": "encrypt", "type": "boolean", "required": false, "description": "sqlserver: encrypt the connection." }, { "key": "trustServerCertificate", "type": "boolean", "required": false, "description": "sqlserver: skip certificate validation." }, - { "key": "sslMode", "type": "string", "required": false, "description": "postgres SSL mode." } + { "key": "sslMode", "type": "string", "required": false, "description": "PostgreSQL SSL mode. Defaults to VerifyFull with Trust Server Certificate=false; weaker modes are allowed only for literal loopback hosts." } ] }, "textFileEdit": { diff --git a/src/NodePilot.Core/Models/IdempotencyKey.cs b/src/NodePilot.Core/Models/IdempotencyKey.cs index c95fc736..dcd5e403 100644 --- a/src/NodePilot.Core/Models/IdempotencyKey.cs +++ b/src/NodePilot.Core/Models/IdempotencyKey.cs @@ -2,21 +2,25 @@ namespace NodePilot.Core.Models; /// /// Append-only cache of handled external-trigger requests plus short-lived webhook replay -/// claims. External-trigger rows are keyed by the client-supplied Idempotency-Key -/// and point at an execution. Webhook rows use a domain-separated keyed digest and an empty -/// execution ID; the shared unique index provides an atomic, cluster-wide nonce guard. +/// claims. External-trigger rows use a domain-separated digest of the authenticated key +/// principal plus the client-supplied Idempotency-Key and point at an execution. +/// Webhook rows use their own domain-separated keyed digest and an empty execution ID; the +/// shared unique index provides an atomic, cluster-wide nonce guard. /// /// Entries expire after and are pruned by /// IdempotencyKeyCleanupService. The key is scoped to (Key, WorkflowId) -/// so a genuinely different workflow can reuse the same key unambiguously — a sender -/// that ships one Idempotency-Key per fire-and-forget button still partitions cleanly -/// across runbooks. +/// so a genuinely different workflow can reuse the same digest unambiguously. For external +/// triggers the digest also partitions callers: two integration keys cannot replay or reserve +/// one another's token on the same workflow. /// public class IdempotencyKey { public Guid Id { get; set; } - /// Client-supplied token (header value). Case-sensitive, up to 200 chars. + /// + /// Domain-separated replay key. External triggers store a digest rather than the raw header; + /// other producers sharing this table use their own prefixed representation. + /// public string Key { get; set; } = string.Empty; /// Target workflow — the key is partitioned by workflow so collisions across runbooks don't matter. diff --git a/src/NodePilot.Data/NodePilotDbContext.cs b/src/NodePilot.Data/NodePilotDbContext.cs index 8efc5967..2460cc02 100644 --- a/src/NodePilot.Data/NodePilotDbContext.cs +++ b/src/NodePilot.Data/NodePilotDbContext.cs @@ -622,8 +622,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { e.HasKey(x => x.Id); e.Property(x => x.Key).HasMaxLength(200).IsRequired(); - // Composite unique: a key is scoped to a workflow so two different workflows - // can carry the same caller-provided token. + // Composite unique: external-trigger Key values already digest the authenticated + // key principal + caller token; WorkflowId keeps the same caller token reusable + // across runbooks. Other producers use their own domain-separated Key prefix. e.HasIndex(x => new { x.Key, x.WorkflowId }).IsUnique(); e.HasIndex(x => x.ExpiresAt); }); diff --git a/src/NodePilot.Engine/Activities/EmailActivity.cs b/src/NodePilot.Engine/Activities/EmailActivity.cs index d22dee1f..71df13f2 100644 --- a/src/NodePilot.Engine/Activities/EmailActivity.cs +++ b/src/NodePilot.Engine/Activities/EmailActivity.cs @@ -5,6 +5,7 @@ using NodePilot.Engine.Execution; using NodePilot.Engine.Options; using Microsoft.Extensions.Options; +using NodePilot.Engine.Mail; namespace NodePilot.Engine.Activities; @@ -29,7 +30,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme // Reject comma/semicolon-separated recipient lists. An Operator (or trigger payload // injected via {{...}}) could otherwise BCC attackers onto workflow notifications and // exfiltrate log contents. Single-recipient only — build a second step for fan-out. - if (to.IndexOfAny(new[] { ',', ';' }) >= 0) + if (SmtpTransport.IsRecipientList(to)) return new ActivityResult { Success = false, ErrorOutput = "Email: 'to' must be a single recipient (no comma/semicolon lists)" }; var subject = config.GetString("subject", ""); @@ -37,7 +38,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme // Header-injection defense: CR/LF in address or subject would split headers. .NET's // MailMessage already rejects these in most paths, but we fail early with a clear error. - if (to.IndexOfAny(new[] { '\r', '\n' }) >= 0 || subject.IndexOfAny(new[] { '\r', '\n' }) >= 0) + if (SmtpTransport.HasHeaderInjection(to, subject)) return new ActivityResult { Success = false, ErrorOutput = "Email: newline characters are not allowed in 'to' or 'subject'" }; // H-2 (security audit 2026-05-15): default-on TLS. SmtpClient defaults to @@ -49,13 +50,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme // Hot-reload: read SmtpOptions per execution so a live config edit takes effect // without a service restart. var o = _smtp.CurrentValue; - using var smtpClient = new SmtpClient(o.Host, o.Port) - { - EnableSsl = o.EnableSsl, - }; - - if (o.Username is not null && o.Password is not null) - smtpClient.Credentials = new NetworkCredential(o.Username, o.Password); + using var smtpClient = SmtpTransport.CreateClient(o); var message = new MailMessage(o.From, to, subject, body); diff --git a/src/NodePilot.Engine/Activities/FileHashActivity.cs b/src/NodePilot.Engine/Activities/FileHashActivity.cs index 5706965b..311a5be2 100644 --- a/src/NodePilot.Engine/Activities/FileHashActivity.cs +++ b/src/NodePilot.Engine/Activities/FileHashActivity.cs @@ -47,11 +47,13 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c PathGuard.Validate(_config, path); var qPath = PowerShellOperation.Literal(path); + var targetPathGuard = TargetPathGuardScript.Build(_config, ("$__npPath", "path")); return $$""" $ErrorActionPreference = 'Stop' $__npAlgorithm = '{{algorithm}}' $__npPath = {{qPath}} + {{targetPathGuard}} $__npAlg = [System.Security.Cryptography.HashAlgorithm]::Create($__npAlgorithm) try { $__npStream = [System.IO.File]::OpenRead($__npPath) diff --git a/src/NodePilot.Engine/Activities/FileOperationActivity.cs b/src/NodePilot.Engine/Activities/FileOperationActivity.cs index 38af6f9d..8d7cc227 100644 --- a/src/NodePilot.Engine/Activities/FileOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FileOperationActivity.cs @@ -6,9 +6,10 @@ namespace NodePilot.Engine.Activities; /// /// File-scoped operations: copy, move, delete, exists, create, rename. Operates on individual -/// files; PowerShell-side checks assert -PathType Leaf on destructive paths so a folder -/// accidentally typed into a file activity fails fast instead of silently being deleted or -/// renamed. Folder-equivalent operations live in . +/// files; PowerShell-side link-local attribute checks require a non-reparse leaf on destructive +/// paths so a folder or link accidentally typed into a file activity fails fast instead of +/// being followed, deleted, or renamed. Folder-equivalent operations live in +/// . /// /// Output format: every operation emits a JSON result object between marker lines, which /// PostProcess projects into OutputParameters (param.operation, param.path, param.destination, @@ -50,18 +51,40 @@ public FileOperationActivity( // Leaf-Assertion: ensures the path is a file before mutation, so a folder typed here // by mistake throws cleanly instead of being copied/moved/deleted as if it were a file. - private const string AssertLeaf = - " if (-not (Test-Path -LiteralPath $__path -PathType Leaf)) { throw \"Not a file: \" + $__path }"; + private const string AssertLeaf = """ + $__pathAttributes = Get-NodePilotPathAttributes -Path $__path + if ($null -eq $__pathAttributes -or + ($__pathAttributes -band [System.IO.FileAttributes]::Directory) -ne 0 -or + ($__pathAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Not a file: " + $__path + } + """; private static string BuildCopy() => $$""" {{AssertLeaf}} - Copy-Item -LiteralPath $__path -Destination $__destination -Force + $__effectiveDestination = Get-NodePilotEffectiveDestination ` + -Source $__path -Destination $__destination -Label 'copy destination' + # Re-check both endpoints immediately before the non-recursive copy. This blocks + # pre-existing destination\sourceLeaf junctions when Destination is a directory. + Assert-NodePilotAllowedPath -Candidate $__path -Label 'copy source' + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'copy destination effective path' + [System.IO.File]::Copy($__path, $__effectiveDestination, $true) $__result.destination = $__destination """; private static string BuildMove() => $$""" {{AssertLeaf}} - Move-Item -LiteralPath $__path -Destination $__destination -Force + $__effectiveDestination = Get-NodePilotEffectiveDestination ` + -Source $__path -Destination $__destination -Label 'move destination' + Assert-NodePilotAllowedPath -Candidate $__path -Label 'move source' + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'move destination effective path' + $__moveDestinationAttributes = Get-NodePilotPathAttributes -Path $__effectiveDestination + if ($null -ne $__moveDestinationAttributes -and + ($__moveDestinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "File System Operation: file move destination is a directory: '$__effectiveDestination'" + } + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'move destination effective path' + Move-Item -LiteralPath $__path -Destination $__effectiveDestination -Force $__result.destination = $__destination """; @@ -92,7 +115,12 @@ private static string BuildRename() => $$""" {{AssertLeaf}} $__parentDir = Split-Path -LiteralPath $__path $__target = Join-Path -Path $__parentDir -ChildPath $__newName - if (Test-Path -LiteralPath $__target) { throw "Target already exists: " + $__target } + Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target' + if ($null -ne (Get-NodePilotPathAttributes -Path $__target)) { + throw "Target already exists: " + $__target + } + Assert-NodePilotAllowedPath -Candidate $__path -Label 'rename source' + Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target' Rename-Item -LiteralPath $__path -NewName $__newName -Force $__result.newPath = $__target $__result.newName = $__newName diff --git a/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs index 969c33b0..9226e358 100644 --- a/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs +++ b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs @@ -74,6 +74,10 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c var qPath = PowerShellOperation.Literal(path); var qDest = PowerShellOperation.Literal(destination); var qNewName = PowerShellOperation.Literal(newName); + var targetPathGuard = TargetPathGuardScript.Build( + _config, + ("$__path", "path"), + ("$__destination", "destination")); var opBody = BuildOperationBody(operation); @@ -85,6 +89,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c $__newName = {{qNewName}} $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } try { + {{targetPathGuard}} {{opBody}} } catch { $__result.ok = $false diff --git a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs index e9d8e5b0..62a45eda 100644 --- a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs @@ -7,8 +7,8 @@ namespace NodePilot.Engine.Activities; /// /// Folder-scoped operations: copy, move, delete, exists, list, create, rename. PowerShell-side -/// checks assert -PathType Container on destructive paths so a file accidentally typed -/// into a folder activity fails fast. File-equivalent operations live in +/// link-local attribute checks require a non-reparse directory on destructive paths so a file +/// or link accidentally typed into a folder activity fails fast. File-equivalent operations live in /// . /// /// Output format: every operation emits a JSON result object between marker lines, which @@ -53,23 +53,136 @@ public FolderOperationActivity( // Container-Assertion: ensures the path is a folder before mutation, so a file typed // here by mistake throws cleanly instead of being copied/moved/deleted as if it were // a directory tree. Skipped for `create` (target must NOT exist yet). - private const string AssertContainer = - " if (-not (Test-Path -LiteralPath $__path -PathType Container)) { throw \"Not a directory: \" + $__path }"; + private const string AssertContainer = """ + $__pathAttributes = Get-NodePilotPathAttributes -Path $__path + if ($null -eq $__pathAttributes -or + ($__pathAttributes -band [System.IO.FileAttributes]::Directory) -eq 0 -or + ($__pathAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Not a directory: " + $__path + } + """; private static string BuildCopy() => $$""" {{AssertContainer}} - Copy-Item -LiteralPath $__path -Destination $__destination -Force -Recurse + $__effectiveDestination = Get-NodePilotEffectiveDestination ` + -Source $__path -Destination $__destination -Label 'copy destination' + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'copy destination effective path' + $__copySeparators = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar) + $__sourceFull = [System.IO.Path]::GetFullPath($__path) + $__sourceVolume = [System.IO.Path]::GetPathRoot($__sourceFull) + if ($__sourceFull.Length -gt $__sourceVolume.Length) { + $__sourceFull = $__sourceFull.TrimEnd($__copySeparators) + } + $__effectiveFull = [System.IO.Path]::GetFullPath($__effectiveDestination) + $__effectiveVolume = [System.IO.Path]::GetPathRoot($__effectiveFull) + if ($__effectiveFull.Length -gt $__effectiveVolume.Length) { + $__effectiveFull = $__effectiveFull.TrimEnd($__copySeparators) + } + $__sourcePrefix = $__sourceFull + [System.IO.Path]::DirectorySeparatorChar + if ($__effectiveFull.Equals($__sourceFull, [System.StringComparison]::OrdinalIgnoreCase) -or + $__effectiveFull.StartsWith($__sourcePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "File System Operation: copy destination must not be the source or one of its descendants" + } + + # Build/copy the tree ourselves. Copy-Item -Recurse performs a second provider walk + # which can follow nested junctions after only the source root was checked. + $__copyPending = New-Object 'System.Collections.Generic.Stack[object]' + $__copyPending.Push([pscustomobject]@{ + Source = $__sourceFull + Destination = $__effectiveFull + }) + while ($__copyPending.Count -gt 0) { + $__copyPair = $__copyPending.Pop() + $__copySourceDirectory = [string]$__copyPair.Source + $__copyDestinationDirectory = [string]$__copyPair.Destination + + Assert-NodePilotAllowedPath -Candidate $__copySourceDirectory -Label 'copy source tree' + $__copySourceDirectoryAttributes = Get-NodePilotPathAttributes -Path $__copySourceDirectory + if ($null -eq $__copySourceDirectoryAttributes -or + ($__copySourceDirectoryAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "File System Operation: copy source directory changed or disappeared: '$__copySourceDirectory'" + } + if (($__copySourceDirectoryAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: copy source tree contains reparse point '$__copySourceDirectory'" + } + + Assert-NodePilotAllowedPath -Candidate $__copyDestinationDirectory -Label 'copy destination tree' + $__copyDestinationAttributes = Get-NodePilotPathAttributes -Path $__copyDestinationDirectory + if ($null -eq $__copyDestinationAttributes) { + [void][System.IO.Directory]::CreateDirectory($__copyDestinationDirectory) + Assert-NodePilotAllowedPath -Candidate $__copyDestinationDirectory -Label 'copy destination tree' + $__copyDestinationAttributes = Get-NodePilotPathAttributes -Path $__copyDestinationDirectory + } + if ($null -eq $__copyDestinationAttributes -or + ($__copyDestinationAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "File System Operation: copy destination is not a directory: '$__copyDestinationDirectory'" + } + if (($__copyDestinationAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: copy destination tree contains reparse point '$__copyDestinationDirectory'" + } + + foreach ($__copySourceChild in [System.IO.Directory]::EnumerateFileSystemEntries( + $__copySourceDirectory, + '*', + [System.IO.SearchOption]::TopDirectoryOnly)) { + Assert-NodePilotAllowedPath -Candidate $__copySourceChild -Label 'copy source tree' + $__copySourceChildAttributes = Get-NodePilotPathAttributes -Path $__copySourceChild + if ($null -eq $__copySourceChildAttributes) { + throw "File System Operation: copy source item changed or disappeared: '$__copySourceChild'" + } + if (($__copySourceChildAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: copy source tree contains reparse point '$__copySourceChild'" + } + + $__copyDestinationChild = [System.IO.Path]::Combine( + $__copyDestinationDirectory, + [System.IO.Path]::GetFileName($__copySourceChild)) + Assert-NodePilotAllowedPath -Candidate $__copyDestinationChild -Label 'copy destination item' + if (($__copySourceChildAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $__copyPending.Push([pscustomobject]@{ + Source = $__copySourceChild + Destination = $__copyDestinationChild + }) + } else { + # Re-check after resolving the destination and immediately before opening + # either path. Handles are still path-bound, so trusted root ACLs remain + # necessary to exclude a concurrent parent-directory swap. + Assert-NodePilotAllowedPath -Candidate $__copySourceChild -Label 'copy source item' + Assert-NodePilotAllowedPath -Candidate $__copyDestinationChild -Label 'copy destination item' + $__copySourceChildAttributes = Get-NodePilotPathAttributes -Path $__copySourceChild + if ($null -eq $__copySourceChildAttributes -or + ($__copySourceChildAttributes -band [System.IO.FileAttributes]::Directory) -ne 0 -or + ($__copySourceChildAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: copy source file changed or became unsafe: '$__copySourceChild'" + } + [System.IO.File]::Copy($__copySourceChild, $__copyDestinationChild, $true) + } + } + } $__result.destination = $__destination """; private static string BuildMove() => $$""" {{AssertContainer}} - Move-Item -LiteralPath $__path -Destination $__destination -Force + Assert-NodePilotReparseFreeTree -Root $__path -Label 'move source' + $__effectiveDestination = Get-NodePilotEffectiveDestination ` + -Source $__path -Destination $__destination -Label 'move destination' + Assert-NodePilotAllowedPath -Candidate $__path -Label 'move source' + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'move destination effective path' + if ($null -ne (Get-NodePilotPathAttributes -Path $__effectiveDestination)) { + throw "File System Operation: folder move target already exists: '$__effectiveDestination'" + } + Assert-NodePilotAllowedPath -Candidate $__effectiveDestination -Label 'move destination effective path' + Move-Item -LiteralPath $__path -Destination $__effectiveDestination -Force $__result.destination = $__destination """; private static string BuildDelete() => $$""" {{AssertContainer}} + Assert-NodePilotReparseFreeTree -Root $__path -Label 'delete source' + Assert-NodePilotAllowedPath -Candidate $__path -Label 'delete source' Remove-Item -LiteralPath $__path -Force -Recurse """; @@ -114,7 +227,12 @@ private static string BuildRename() => $$""" {{AssertContainer}} $__parentDir = Split-Path -LiteralPath $__path $__target = Join-Path -Path $__parentDir -ChildPath $__newName - if (Test-Path -LiteralPath $__target) { throw "Target already exists: " + $__target } + Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target' + if ($null -ne (Get-NodePilotPathAttributes -Path $__target)) { + throw "Target already exists: " + $__target + } + Assert-NodePilotAllowedPath -Candidate $__path -Label 'rename source' + Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target' Rename-Item -LiteralPath $__path -NewName $__newName -Force $__result.newPath = $__target $__result.newName = $__newName diff --git a/src/NodePilot.Engine/Activities/QueryPayloadSource.cs b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs index 6f14df32..1b82f47a 100644 --- a/src/NodePilot.Engine/Activities/QueryPayloadSource.cs +++ b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs @@ -13,6 +13,9 @@ namespace NodePilot.Engine.Activities; /// internal static class QueryPayloadSource { + private static readonly IConfiguration EmptyPathGuardConfiguration = + new ConfigurationBuilder().Build(); + public static async Task<(string? Content, ActivityResult? Error)> LoadAsync( string source, JsonElement config, @@ -28,15 +31,16 @@ internal static class QueryPayloadSource if (string.IsNullOrWhiteSpace(path)) return (null, fail("'path' is required when source=file")); - // M-8: apply the same PathGuard config the FileSystemOperation activity uses, so ops - // can restrict file-mode queries to allow-listed roots / block traversal. - if (pathGuardConfig is not null) + // M-8: apply PathGuard unconditionally. AllowedRoots remain optional, but the + // link-local reparse check is not: a local-looking JSON/XML path may be a junction + // to an attacker-controlled UNC share even when no IConfiguration was injected. + try + { + PathGuard.Validate(pathGuardConfig ?? EmptyPathGuardConfiguration, path); + } + catch (InvalidOperationException ex) { - try { PathGuard.Validate(pathGuardConfig, path); } - catch (InvalidOperationException ex) - { - return (null, fail($"file access denied: {ex.Message}")); - } + return (null, fail($"file access denied: {ex.Message}")); } if (!File.Exists(path)) diff --git a/src/NodePilot.Engine/Activities/SqlActivity.cs b/src/NodePilot.Engine/Activities/SqlActivity.cs index f43191d7..a1f6c43d 100644 --- a/src/NodePilot.Engine/Activities/SqlActivity.cs +++ b/src/NodePilot.Engine/Activities/SqlActivity.cs @@ -21,8 +21,8 @@ namespace NodePilot.Engine.Activities; /// ("integrated"/"sql"), username, password, encrypt (default /// true), trustServerCertificate (default false). /// * Postgres: host (required), port (default 5432), database, -/// username, password, sslMode (default "Require" — set -/// "Disable"/"Prefer" explicitly to allow a plaintext fallback). +/// username, password, sslMode (default "VerifyFull"; weaker +/// modes are accepted only for literal loopback hosts). /// * SQLite: dataSource (required, file path). /// 3. connectionString — raw inline string. Rejected unless /// SqlActivity:RequireConnectionRef=false. @@ -211,7 +211,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var fromConfig = _configuration[$"SqlActivity:ConnectionStrings:{connectionRef}"]; if (string.IsNullOrWhiteSpace(fromConfig)) return (null, $"SQL: connectionRef '{connectionRef}' is not configured under SqlActivity:ConnectionStrings"); - return (fromConfig, null); + return ApplyProviderSecurityPolicy(fromConfig, provider); } var requireRef = RequireConnectionRef(); @@ -224,7 +224,10 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme "include credentials (username/password), which would put DB secrets in the workflow " + "JSON and defeat the strict-whitelist policy. Add the target under " + "SqlActivity:ConnectionStrings:{name} and reference it via 'connectionRef'."); - return BuildConnectionString(config, provider); + var built = BuildConnectionString(config, provider); + return built.Error is null + ? ApplyProviderSecurityPolicy(built.ConnStr!, provider) + : built; } var raw = config.GetStringOrNull("connectionString"); @@ -236,7 +239,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme "SQL: this deployment requires a named connectionRef. Add the target under " + "SqlActivity:ConnectionStrings:{name} and reference it via 'connectionRef'."); - return (raw, null); + return ApplyProviderSecurityPolicy(raw, provider); } private bool RequireConnectionRef() @@ -297,6 +300,83 @@ private static (string? ConnStr, string? Error) BuildConnectionString(JsonElemen _ => BuildSqlServerConnectionString(config), }; + /// + /// Enforces provider-level transport rules after every resolution path, including named and + /// raw connection strings. PostgreSQL defaults to full certificate/hostname verification; + /// explicitly weaker modes fail closed for non-loopback hosts. + /// + private static (string? ConnStr, string? Error) ApplyProviderSecurityPolicy( + string connectionString, + string provider) + { + if (provider is not ("postgres" or "postgresql" or "npgsql")) + return (connectionString, null); + + try + { + var supplied = new DbConnectionStringBuilder { ConnectionString = connectionString }; + var sslModeWasSpecified = supplied.Keys + .Cast() + .Any(key => string.Equals( + key.Replace(" ", "", StringComparison.Ordinal), + "sslmode", + StringComparison.OrdinalIgnoreCase)); + var trustServerCertificate = supplied.Keys + .Cast() + .Any(key => string.Equals( + key.Replace(" ", "", StringComparison.Ordinal), + "trustservercertificate", + StringComparison.OrdinalIgnoreCase) + // Fail closed on anything except an explicit false. The Npgsql parser will + // reject malformed values later, but no new truthy spelling may bypass this + // policy if its converter grows more permissive. + && !string.Equals(supplied[key]?.ToString(), "false", StringComparison.OrdinalIgnoreCase)); + + var builder = new NpgsqlConnectionStringBuilder(connectionString); + if (!sslModeWasSpecified) + builder.SslMode = Npgsql.SslMode.VerifyFull; + + var loopbackOnly = AllPostgresHostsAreLiteralLoopback(builder.Host); + if (!loopbackOnly + && (builder.SslMode != Npgsql.SslMode.VerifyFull || trustServerCertificate)) + { + return (null, + "SQL: PostgreSQL connections to non-loopback hosts require SSL Mode=VerifyFull " + + "and Trust Server Certificate=false. Settings which bypass server identity " + + "validation are blocked."); + } + + return (builder.ConnectionString, null); + } + catch (ArgumentException) + { + // Do not echo the source connection string or parser exception: either may contain a + // password. The caller only needs a safe, actionable configuration error. + return (null, "SQL: the PostgreSQL connection settings are invalid."); + } + } + + private static bool AllPostgresHostsAreLiteralLoopback(string? configuredHosts) + { + if (string.IsNullOrWhiteSpace(configuredHosts)) + return false; + var hosts = configuredHosts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return hosts.Length > 0 && hosts.All(IsLiteralLoopbackHost); + } + + private static bool IsLiteralLoopbackHost(string configuredHost) + { + var host = configuredHost.Trim().Trim('[', ']'); + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!System.Net.IPAddress.TryParse(host, out var address)) + return false; + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + return System.Net.IPAddress.IsLoopback(address); + } + private static (string? ConnStr, string? Error) BuildSqlServerConnectionString(JsonElement config) { var server = config.GetStringOrNull("server"); @@ -355,16 +435,17 @@ private static (string? ConnStr, string? Error) BuildPostgresConnectionString(Js if (!string.IsNullOrWhiteSpace(password)) b.Password = password; - // M-4 (security audit 2026-05-15): default to SslMode.Require instead of Npgsql's - // built-in Prefer. Prefer silently downgrades to a plaintext connection when the - // server doesn't offer TLS, so DB credentials can be sniffed by a MITM on the wire. - // Require forces an encrypted channel; operators who genuinely need plaintext (a - // trusted local socket, a server without TLS) opt in explicitly via sslMode="Disable". + // Encryption alone is insufficient: Require accepts any server certificate. VerifyFull + // authenticates both the issuing CA and the configured hostname, preventing a MITM from + // collecting the database credential. The common plaintext dev case remains available + // only for literal loopback hosts via the provider security policy above. var sslMode = config.GetStringOrNull("sslMode"); if (string.IsNullOrWhiteSpace(sslMode)) - b.SslMode = Npgsql.SslMode.Require; + b.SslMode = Npgsql.SslMode.VerifyFull; else if (Enum.TryParse(sslMode, ignoreCase: true, out var parsedSslMode)) b.SslMode = parsedSslMode; + else + return (null, "SQL: 'sslMode' is invalid for PostgreSQL."); return (b.ConnectionString, null); } diff --git a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs index 1d64f804..656b782f 100644 --- a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs +++ b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs @@ -142,6 +142,10 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c var lineNumberLit = lineNumber?.ToString() ?? "0"; var rangeFromLit = rangeFrom?.ToString() ?? "0"; var rangeToLit = rangeTo?.ToString() ?? "0"; + var targetPathGuard = TargetPathGuardScript.Build( + _config, + ("$__path", "path"), + ("$__path + $__backupSuffix", "backupPath")); return $$""" $ErrorActionPreference = 'Stop' @@ -178,6 +182,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c summary = $null } try { + {{targetPathGuard}} {{TextEditPowerShellBody}} } catch { $__result.ok = $false diff --git a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs index cac733be..1f069734 100644 --- a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs @@ -18,6 +18,56 @@ public class ZipOperationActivity : BaseRemoteActivity "Optimal", "Fastest", "NoCompression", }; + // Kept compatible with Windows PowerShell 5.1/.NET Framework. File.GetAttributes returns + // link-local metadata, so a dangling junction (including one aimed at a UNC share) is + // rejected without the validation itself dereferencing the target. + private const string ReparseGuardScript = """ + function Get-NodePilotPathAttributes { + param([Parameter(Mandatory = $true)][string]$Path) + + try { + return [System.IO.File]::GetAttributes($Path) + } catch { + $__npAttributeException = $_.Exception.GetBaseException() + if ($__npAttributeException -is [System.IO.FileNotFoundException] -or + $__npAttributeException -is [System.IO.DirectoryNotFoundException]) { + return $null + } + throw + } + } + + function Assert-NodePilotNoReparsePath { + param([Parameter(Mandatory = $true)][string]$Path) + + $__npFull = [System.IO.Path]::GetFullPath($Path) + $__npVolume = [System.IO.Path]::GetPathRoot($__npFull) + if ([string]::IsNullOrEmpty($__npVolume)) { + throw "Zip operation path '$Path' has no filesystem root" + } + $__npSeparators = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar) + $__npCurrent = $__npVolume + $__npVolumeAttributes = Get-NodePilotPathAttributes -Path $__npCurrent + if ($null -ne $__npVolumeAttributes -and + ($__npVolumeAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Zip operation blocked: path traverses reparse point '$__npCurrent'" + } + $__npRelative = $__npFull.Substring($__npVolume.Length) + foreach ($__npSegment in $__npRelative.Split( + $__npSeparators, [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($__npSegment.IndexOfAny([char[]]@('*', '?')) -ge 0) { break } + $__npCurrent = [System.IO.Path]::Combine($__npCurrent, $__npSegment) + $__npAttributes = Get-NodePilotPathAttributes -Path $__npCurrent + if ($null -eq $__npAttributes) { break } + if (($__npAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Zip operation blocked: path traverses reparse point '$__npCurrent'" + } + } + } + """; + private readonly IConfiguration _config; public override string ActivityType => "zipOperation"; @@ -52,76 +102,416 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c throw new InvalidOperationException("Zip Operation: 'destination' is required"); var allowSourceWildcards = string.Equals(operation, "compress", StringComparison.Ordinal); + var sourceHasWildcards = source.IndexOfAny(['*', '?']) >= 0; + if (sourceHasWildcards) + { + var lastSeparator = Math.Max(source.LastIndexOf('/'), source.LastIndexOf('\\')); + var parentPart = lastSeparator < 0 ? string.Empty : source[..lastSeparator]; + if (parentPart.IndexOfAny(['*', '?']) >= 0) + throw new InvalidOperationException( + "Zip Operation: source wildcards are allowed only in the final path segment"); + } PathGuard.Validate(_config, source, allowWildcards: allowSourceWildcards); PathGuard.Validate(_config, destination, allowWildcards: false); var force = config.GetBool("force", false); var qSrc = PowerShellOperation.Literal(source); var qDst = PowerShellOperation.Literal(destination); - var forceFlag = force ? " -Force" : string.Empty; + var targetPathGuard = TargetPathGuardScript.Build( + _config, + ("$__npSource", "source"), + ("$__npDestination", "destination")); return operation switch { - "compress" => BuildCompressScript(config, qSrc, qDst, forceFlag), - "extract" => BuildExtractScript(qSrc, qDst, forceFlag), + "compress" => BuildCompressScript( + config, qSrc, qDst, force, targetPathGuard, sourceHasWildcards), + "extract" => BuildExtractScript(qSrc, qDst, force, targetPathGuard), _ => throw new InvalidOperationException($"Unknown zip operation: {operation}"), }; } - private static string BuildCompressScript(JsonElement config, string qSrc, string qDst, string forceFlag) + private static string BuildCompressScript( + JsonElement config, + string qSrc, + string qDst, + bool force, + string targetPathGuard, + bool sourceHasWildcards) { var level = config.GetString("compressionLevel", "Optimal"); if (!AllowedCompressionLevels.Contains(level)) throw new InvalidOperationException( $"Zip Operation: unsupported compressionLevel '{level}'. Allowed: Optimal, Fastest, NoCompression"); + var canonicalLevel = AllowedCompressionLevels.Single( + candidate => candidate.Equals(level, StringComparison.OrdinalIgnoreCase)); + var sourceSetup = sourceHasWildcards + ? """ + # Windows PowerShell 5.1 runs on .NET Framework, whose GetFullPath rejects + # wildcards. Normalize the already-enforced literal parent separately. + $__npLastSourceSeparator = [Math]::Max( + $__npSource.LastIndexOf([char]92), + $__npSource.LastIndexOf([char]47)) + if ($__npLastSourceSeparator -lt 0) { + $__npSourceParentInput = '.' + $__npSourcePattern = $__npSource + } else { + $__npSourceParentLength = if ($__npLastSourceSeparator -eq 2 -and + $__npSource.Length -gt 1 -and $__npSource[1] -eq ':') { + 3 + } else { + $__npLastSourceSeparator + } + $__npSourceParentInput = $__npSource.Substring(0, $__npSourceParentLength) + $__npSourcePattern = $__npSource.Substring($__npLastSourceSeparator + 1) + } + $__npSourceParent = [System.IO.Path]::GetFullPath($__npSourceParentInput) + $__npSourceFull = [System.IO.Path]::Combine($__npSourceParent, $__npSourcePattern) + Assert-NodePilotNoReparsePath -Path $__npSourceParent + $__npParentAttributes = Get-NodePilotPathAttributes -Path $__npSourceParent + if ($null -eq $__npParentAttributes -or + ($__npParentAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "Zip compression source parent is not a directory: '$__npSourceParent'" + } + $__npSourcePaths = @([System.IO.Directory]::EnumerateFileSystemEntries( + $__npSourceParent, + $__npSourcePattern, + [System.IO.SearchOption]::TopDirectoryOnly)) + """ + : """ + $__npSourceFull = [System.IO.Path]::GetFullPath($__npSource) + $__npSourcePaths = @($__npSourceFull) + """; return $$""" $ErrorActionPreference = 'Stop' + $__npSource = {{qSrc}} $__npDestination = {{qDst}} - Compress-Archive -Path {{qSrc}} -DestinationPath $__npDestination -CompressionLevel {{level}}{{forceFlag}} - $__npItem = Get-Item -LiteralPath $__npDestination + $__npForce = ${{(force ? "true" : "false")}} + {{targetPathGuard}} + + Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + {{ReparseGuardScript}} + + $__npSeparators = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar) + {{sourceSetup}} + if ($__npSourcePaths.Count -eq 0) { + throw "Zip compression source '$__npSource' did not match any filesystem item" + } + + # Build a literal manifest without recursive provider/cmdlet expansion. Every item + # is inspected before a directory is enumerated, so selected or nested junctions + # are rejected rather than followed. Files are rechecked immediately before open. + $__npManifest = New-Object 'System.Collections.Generic.List[object]' + $__npPendingDirectories = New-Object System.Collections.Stack + foreach ($__npRootPath in $__npSourcePaths) { + Assert-NodePilotAllowedPath -Candidate ($__npRootPath) -Label 'expandedSource' + Assert-NodePilotNoReparsePath -Path $__npRootPath + $__npRootAttributes = Get-NodePilotPathAttributes -Path $__npRootPath + if ($null -eq $__npRootAttributes) { + throw "Zip compression source changed or disappeared: '$__npRootPath'" + } + if (($__npRootAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Zip compression blocked: selected source is a reparse point '$__npRootPath'" + } + + $__npTrimmedRoot = $__npRootPath.TrimEnd($__npSeparators) + $__npEntryBase = [System.IO.Path]::GetFileName($__npTrimmedRoot) + if ([string]::IsNullOrEmpty($__npEntryBase)) { + $__npEntryBase = $__npRootPath.Substring(0, 1) + } + + if (($__npRootAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $__npManifest.Add([pscustomobject]@{ + Path = $__npRootPath + EntryName = $__npEntryBase + '/' + IsDirectory = $true + }) + $__npPendingDirectories.Push([pscustomobject]@{ + Path = $__npRootPath + EntryPrefix = $__npEntryBase + }) + } else { + $__npManifest.Add([pscustomobject]@{ + Path = $__npRootPath + EntryName = $__npEntryBase + IsDirectory = $false + }) + } + } + while ($__npPendingDirectories.Count -gt 0) { + $__npDirectory = $__npPendingDirectories.Pop() + Assert-NodePilotAllowedPath -Candidate ($__npDirectory.Path) -Label 'sourceDirectory' + Assert-NodePilotNoReparsePath -Path $__npDirectory.Path + foreach ($__npChildPath in [System.IO.Directory]::EnumerateFileSystemEntries( + $__npDirectory.Path, + '*', + [System.IO.SearchOption]::TopDirectoryOnly)) { + Assert-NodePilotAllowedPath -Candidate ($__npChildPath) -Label 'sourceItem' + Assert-NodePilotNoReparsePath -Path $__npChildPath + $__npChildAttributes = Get-NodePilotPathAttributes -Path $__npChildPath + if ($null -eq $__npChildAttributes) { + throw "Zip compression source changed or disappeared: '$__npChildPath'" + } + if (($__npChildAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Zip compression blocked: source tree contains reparse point '$__npChildPath'" + } + + $__npChildName = [System.IO.Path]::GetFileName($__npChildPath) + $__npEntryName = $__npDirectory.EntryPrefix + '/' + $__npChildName + if (($__npChildAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $__npManifest.Add([pscustomobject]@{ + Path = $__npChildPath + EntryName = $__npEntryName + '/' + IsDirectory = $true + }) + $__npPendingDirectories.Push([pscustomobject]@{ + Path = $__npChildPath + EntryPrefix = $__npEntryName + }) + } else { + $__npManifest.Add([pscustomobject]@{ + Path = $__npChildPath + EntryName = $__npEntryName + IsDirectory = $false + }) + } + } + } + + $__npDestinationFull = [System.IO.Path]::GetFullPath($__npDestination) + Assert-NodePilotNoReparsePath -Path $__npDestinationFull + $__npDestinationAttributes = Get-NodePilotPathAttributes -Path $__npDestinationFull + if ($null -ne $__npDestinationAttributes) { + if (($__npDestinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "Zip compression destination exists as a directory: '$__npDestinationFull'" + } + if (-not $__npForce) { + throw "Zip compression destination already exists: '$__npDestinationFull'" + } + [System.IO.File]::Delete($__npDestinationFull) + } + + $__npDestinationParent = [System.IO.Path]::GetDirectoryName($__npDestinationFull) + Assert-NodePilotNoReparsePath -Path $__npDestinationParent + $__npDestinationParentAttributes = Get-NodePilotPathAttributes -Path $__npDestinationParent + if ($null -eq $__npDestinationParentAttributes -or + ($__npDestinationParentAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "Zip compression destination parent is not a directory: '$__npDestinationParent'" + } + + $__npCompressionLevel = [System.IO.Compression.CompressionLevel]::{{canonicalLevel}} + $__npOutput = $null + $__npArchive = $null + $__npCreatedDestination = $false + $__npCompleted = $false + $__npSizeBytes = 0 + try { + # CreateNew rejects a final-leaf swap. Existing parent components were checked + # immediately above; preventing a concurrent parent rename requires OS handles + # and ACLs beyond path-based PowerShell/.NET APIs. + Assert-NodePilotNoReparsePath -Path $__npDestinationParent + $__npOutput = [System.IO.File]::Open( + $__npDestinationFull, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + $__npCreatedDestination = $true + $__npArchive = [System.IO.Compression.ZipArchive]::new( + $__npOutput, + [System.IO.Compression.ZipArchiveMode]::Create, + $true) + + foreach ($__npManifestEntry in $__npManifest) { + if ($__npManifestEntry.IsDirectory) { + [void]$__npArchive.CreateEntry( + $__npManifestEntry.EntryName, + $__npCompressionLevel) + continue + } + + Assert-NodePilotAllowedPath -Candidate ($__npManifestEntry.Path) -Label 'sourceFile' + Assert-NodePilotNoReparsePath -Path $__npManifestEntry.Path + $__npFileAttributes = Get-NodePilotPathAttributes -Path $__npManifestEntry.Path + if ($null -eq $__npFileAttributes -or + ($__npFileAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "Zip compression source changed or is not a file: '$($__npManifestEntry.Path)'" + } + + $__npInput = [System.IO.File]::Open( + $__npManifestEntry.Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) + try { + $__npZipEntry = $__npArchive.CreateEntry( + $__npManifestEntry.EntryName, + $__npCompressionLevel) + $__npEntryOutput = $__npZipEntry.Open() + try { + $__npInput.CopyTo($__npEntryOutput) + } finally { + $__npEntryOutput.Dispose() + } + } finally { + $__npInput.Dispose() + } + } + + $__npArchive.Dispose() + $__npArchive = $null + $__npSizeBytes = $__npOutput.Length + $__npCompleted = $true + } finally { + if ($null -ne $__npArchive) { $__npArchive.Dispose() } + if ($null -ne $__npOutput) { $__npOutput.Dispose() } + if ($__npCreatedDestination -and -not $__npCompleted) { + [System.IO.File]::Delete($__npDestinationFull) + } + } + $__result = [ordered]@{ operation = 'compress' - destination = $__npDestination - sizeBytes = $__npItem.Length + destination = $__npDestinationFull + sizeBytes = $__npSizeBytes } {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 4)}} """; } - private static string BuildExtractScript(string qSrc, string qDst, string forceFlag) + private static string BuildExtractScript( + string qSrc, + string qDst, + bool force, + string targetPathGuard) => $$""" $ErrorActionPreference = 'Stop' $__npSource = {{qSrc}} $__npDestination = {{qDst}} - # Zip-Slip pre-scan: Expand-Archive on PS 5.1 does NOT validate entry paths. - # A malicious archive with entries like "..\..\Windows\System32\evil.dll" would - # write outside the destination. Resolve the destination, then verify every - # entry's full path lands inside it before touching Expand-Archive. + $__npForce = ${{(force ? "true" : "false")}} + {{targetPathGuard}} + + # A separate pre-scan plus the built-in archive cmdlet performs two path walks, so + # a writable destination can be swapped to a junction in between. Extract entries: + # validate and create each parent immediately before opening the output with + # CreateNew. Reparse points present at a validation point are rejected, including + # with force=true. + Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue - if (-not (Test-Path -LiteralPath $__npDestination)) { - [void](New-Item -ItemType Directory -Path $__npDestination -Force) + {{ReparseGuardScript}} + + Assert-NodePilotNoReparsePath -Path $__npSource + $__npSourceAttributes = Get-NodePilotPathAttributes -Path $__npSource + if ($null -eq $__npSourceAttributes -or + ($__npSourceAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "Zip source does not exist as a file: '$__npSource'" } + $__npResolvedDest = [System.IO.Path]::GetFullPath($__npDestination) - if (-not $__npResolvedDest.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { - $__npResolvedDest = $__npResolvedDest + [System.IO.Path]::DirectorySeparatorChar + Assert-NodePilotNoReparsePath -Path $__npResolvedDest + $__npDestinationAttributes = Get-NodePilotPathAttributes -Path $__npResolvedDest + if ($null -ne $__npDestinationAttributes -and + ($__npDestinationAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "Zip destination exists as a file: '$__npResolvedDest'" } - $__npZip = [System.IO.Compression.ZipFile]::OpenRead($__npSource) + [void][System.IO.Directory]::CreateDirectory($__npResolvedDest) + Assert-NodePilotNoReparsePath -Path $__npResolvedDest + + $__npDestinationPrefix = $__npResolvedDest + if (-not $__npDestinationPrefix.EndsWith( + [string][System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::Ordinal)) { + $__npDestinationPrefix += [System.IO.Path]::DirectorySeparatorChar + } + + $__npSourceStream = [System.IO.File]::Open( + $__npSource, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read) try { - foreach ($__npEntry in $__npZip.Entries) { - $__npEntryPath = [System.IO.Path]::GetFullPath( - [System.IO.Path]::Combine($__npResolvedDest, $__npEntry.FullName)) - if (-not $__npEntryPath.StartsWith($__npResolvedDest, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Zip-Slip blocked: entry '" + $__npEntry.FullName + "' escapes destination" + $__npZip = [System.IO.Compression.ZipArchive]::new( + $__npSourceStream, + [System.IO.Compression.ZipArchiveMode]::Read, + $false) + try { + foreach ($__npEntry in $__npZip.Entries) { + if ([System.IO.Path]::IsPathRooted($__npEntry.FullName)) { + throw "Zip-Slip blocked: rooted entry '$($__npEntry.FullName)'" + } + + $__npEntryName = $__npEntry.FullName.Replace( + [System.IO.Path]::AltDirectorySeparatorChar, + [System.IO.Path]::DirectorySeparatorChar) + $__npEntryPath = [System.IO.Path]::GetFullPath( + [System.IO.Path]::Combine($__npDestinationPrefix, $__npEntryName)) + if (-not $__npEntryPath.StartsWith( + $__npDestinationPrefix, + [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Zip-Slip blocked: entry '" + $__npEntry.FullName + "' escapes destination" + } + + $__npIsDirectory = [string]::IsNullOrEmpty($__npEntry.Name) -or + $__npEntry.FullName.EndsWith('/') -or + $__npEntry.FullName.EndsWith('\') + if ($__npIsDirectory) { + Assert-NodePilotNoReparsePath -Path $__npEntryPath + [void][System.IO.Directory]::CreateDirectory($__npEntryPath) + Assert-NodePilotNoReparsePath -Path $__npEntryPath + continue + } + + $__npParent = [System.IO.Path]::GetDirectoryName($__npEntryPath) + Assert-NodePilotNoReparsePath -Path $__npParent + [void][System.IO.Directory]::CreateDirectory($__npParent) + Assert-NodePilotNoReparsePath -Path $__npParent + + Assert-NodePilotNoReparsePath -Path $__npEntryPath + $__npEntryAttributes = Get-NodePilotPathAttributes -Path $__npEntryPath + if ($null -ne $__npEntryAttributes -and + ($__npEntryAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "Zip extraction blocked: file entry collides with directory '$__npEntryPath'" + } + if ($null -ne $__npEntryAttributes) { + if (-not $__npForce) { + throw "Zip extraction target already exists: '$__npEntryPath'" + } + [System.IO.File]::Delete($__npEntryPath) + } + + # Recheck after directory creation/deletion. CreateNew refuses an + # already-present final leaf (including a link). A concurrent parent + # replacement remains outside what path-based PowerShell/.NET APIs can + # close; destination ACLs must prevent untrusted renames while extracting. + Assert-NodePilotNoReparsePath -Path $__npParent + $__npOutput = [System.IO.File]::Open( + $__npEntryPath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + try { + $__npInput = $__npEntry.Open() + try { + $__npInput.CopyTo($__npOutput) + } finally { + $__npInput.Dispose() + } + } finally { + $__npOutput.Dispose() + } } + } finally { + $__npZip.Dispose() } } finally { - $__npZip.Dispose() + $__npSourceStream.Dispose() } - Expand-Archive -LiteralPath $__npSource -DestinationPath $__npDestination{{forceFlag}} $__result = [ordered]@{ operation = 'extract' - destination = $__npDestination + destination = $__npResolvedDest sizeBytes = 0 } {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 4)}} diff --git a/src/NodePilot.Engine/Mail/SmtpTransport.cs b/src/NodePilot.Engine/Mail/SmtpTransport.cs new file mode 100644 index 00000000..05ab7e9d --- /dev/null +++ b/src/NodePilot.Engine/Mail/SmtpTransport.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Mail; +using NodePilot.Engine.Options; + +namespace NodePilot.Engine.Mail; + +/// +/// The parts of sending mail that and +/// must decide identically: how the client is +/// built and which recipients/subjects are refused outright. +/// +/// Both previously carried their own copy. That is a bad place for a copy — H-2 of the +/// 2026-05-15 security audit turned on TLS by default here, and the recipient-list rule exists +/// so an operator (or a trigger payload interpolated via {{…}}) cannot BCC an attacker +/// onto a notification and exfiltrate log contents. A fix applied to one copy and not the other +/// would be a silent hole. +/// +/// +/// Deliberately NOT shared: the send/await itself. The activity bounds the await with +/// WaitAsync because SmtpClient's own cancellation is racy and an unresolved task strands +/// the whole execution in Running; the sink is self-isolating and uses a linked token instead. +/// The two have different failure contracts, so they keep their own send paths. +/// +/// +internal static class SmtpTransport +{ + /// + /// Builds the client with TLS as configured and credentials only when both parts are present. + /// Caller owns disposal. + /// + public static SmtpClient CreateClient(SmtpOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // H-2 (security audit 2026-05-15): default-on TLS. SmtpClient defaults to EnableSsl=false; + // that would send LOGIN/PLAIN credentials + the whole message body in plaintext. The + // option lives on SmtpOptions with a safe default, and SecurityHardeningWarnings yells at + // boot if an operator flipped it off while still configuring a Username. + var client = new SmtpClient(options.Host, options.Port) { EnableSsl = options.EnableSsl }; + if (options.Username is not null && options.Password is not null) + client.Credentials = new NetworkCredential(options.Username, options.Password); + return client; + } + + /// + /// True when the recipient is a comma/semicolon-separated list. Single-recipient only — build + /// a second step (or a second route) for fan-out. + /// + public static bool IsRecipientList(string recipient) => recipient.IndexOfAny([',', ';']) >= 0; + + /// + /// True when a CR/LF in the address or subject could split headers. MailMessage already + /// rejects these on most paths; failing early gives a clear error instead of a transport one. + /// + public static bool HasHeaderInjection(string recipient, string subject) => + recipient.IndexOfAny(['\r', '\n']) >= 0 || subject.IndexOfAny(['\r', '\n']) >= 0; +} diff --git a/src/NodePilot.Engine/Notifications/SmtpNotificationSink.cs b/src/NodePilot.Engine/Notifications/SmtpNotificationSink.cs index 23014bb7..07502a5c 100644 --- a/src/NodePilot.Engine/Notifications/SmtpNotificationSink.cs +++ b/src/NodePilot.Engine/Notifications/SmtpNotificationSink.cs @@ -5,6 +5,7 @@ using NodePilot.Core.Interfaces; using NodePilot.Core.Models; using NodePilot.Engine.Options; +using NodePilot.Engine.Mail; namespace NodePilot.Engine.Notifications; @@ -27,11 +28,11 @@ public async Task SendAsync(NotificationContext ctx, str { if (string.IsNullOrWhiteSpace(target)) return NotificationSendResult.Fail("Email route has no recipient."); - if (target.IndexOfAny([',', ';']) >= 0) + if (SmtpTransport.IsRecipientList(target)) return NotificationSendResult.Fail("Email route target must be a single recipient (no comma/semicolon lists)."); var subject = NotificationRenderer.Title(ctx); - if (target.IndexOfAny(['\r', '\n']) >= 0 || subject.IndexOfAny(['\r', '\n']) >= 0) + if (SmtpTransport.HasHeaderInjection(target, subject)) return NotificationSendResult.Fail("Email: newline characters are not allowed in recipient or subject."); try @@ -40,9 +41,7 @@ public async Task SendAsync(NotificationContext ctx, str // or Admin-Settings-UI save) takes effect without a service restart. The sink is a // singleton — IOptionsMonitor is the correct live source (IOptionsSnapshot would throw). var o = _smtp.CurrentValue; - using var client = new SmtpClient(o.Host, o.Port) { EnableSsl = o.EnableSsl }; - if (o.Username is not null && o.Password is not null) - client.Credentials = new NetworkCredential(o.Username, o.Password); + using var client = SmtpTransport.CreateClient(o); using var message = new MailMessage(o.From, target, subject, NotificationRenderer.EmailBody(ctx)); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(SendTimeoutSeconds)); diff --git a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs index 090f1b4c..80924873 100644 --- a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs +++ b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs @@ -1,4 +1,7 @@ using Microsoft.Extensions.Configuration; +using System.IO.Enumeration; +using System.Net; +using System.Net.NetworkInformation; namespace NodePilot.Engine.Security; @@ -11,56 +14,363 @@ namespace NodePilot.Engine.Security; /// /// Config keys (historical, kept stable for operator docs): /// -/// Trigger:FileWatcher:AllowedRoots — string[]. When set, the directory -/// must resolve inside one of these roots. +/// Trigger:FileWatcher:AllowedRoots — string[]. When non-empty, the directory +/// must be lexically inside one of these roots; reparse points are always rejected. /// Trigger:FileWatcher:AllowSystemPaths — bool, default false. Hard-block /// on Windows system roots unless this is explicitly enabled. /// /// public static class FileWatcherPathGuard { - private static readonly string[] HardBlockedWindowsRoots = - { - @"C:\Windows", - @"C:\Program Files", - @"C:\Program Files (x86)", - @"C:\ProgramData\Microsoft\Crypto", - }; + private static readonly string[] HardBlockedWindowsRoots = BuildHardBlockedWindowsRoots(); public static void Validate(IConfiguration config, string dir) { + RejectWindowsDeviceNamespace(dir, "directory"); + var allowSystemPaths = OperatingSystem.IsWindows() + && string.Equals( + config["Trigger:FileWatcher:AllowSystemPaths"], + "true", + StringComparison.OrdinalIgnoreCase); + string full; - try { full = Path.GetFullPath(dir); } + try + { + full = Path.GetFullPath(CanonicalizeLocalAdministrativeShareForPolicy( + dir, + rejectUnmappedLocalShare: !allowSystemPaths)); + } catch (Exception ex) { throw new InvalidOperationException($"FileWatcherTrigger: directory '{dir}' is not a valid path: {ex.Message}"); } - var normalized = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalized; + try { normalized = PathGuard.ResolveLocalFinalPath(full); } + catch (Exception ex) + { + throw new InvalidOperationException( + $"FileWatcherTrigger: directory '{dir}' final path could not be resolved: {ex.Message}"); + } - if (OperatingSystem.IsWindows() - && !string.Equals(config["Trigger:FileWatcher:AllowSystemPaths"], "true", StringComparison.OrdinalIgnoreCase)) + if (OperatingSystem.IsWindows() && !allowSystemPaths) { foreach (var blocked in HardBlockedWindowsRoots) { - if (PathGuard.IsWithinRoot(normalized, blocked)) + // A parent such as C:\ is just as capable of exposing C:\Windows when + // IncludeSubdirectories is enabled. Keep the default policy conservative + // for non-recursive watches too: the watched root may not intersect a + // protected system tree in either direction. + if (PathGuard.IsWithinRoot(normalized, blocked) || + PathGuard.IsWithinRoot(blocked, normalized)) throw new InvalidOperationException( - $"FileWatcherTrigger: directory '{dir}' is under a system path ('{blocked}'). " + + $"FileWatcherTrigger: directory '{dir}' intersects a system path ('{blocked}'). " + "Set Trigger:FileWatcher:AllowSystemPaths=true and add it to AllowedRoots to override."); } } - var roots = PathGuard.ReadConfiguredRoots(config, "Trigger:FileWatcher:AllowedRoots"); + var roots = PathGuard.ReadConfiguredRoots( + config, + "Trigger:FileWatcher:AllowedRoots", + out _); if (roots.Length == 0) return; + foreach (var root in roots) + RejectWindowsDeviceNamespace(root, "configured AllowedRoot"); + var allowed = roots.Any(root => { string rFull; - try { rFull = Path.GetFullPath(root); } catch { return false; } - var r = rFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + try + { + rFull = Path.GetFullPath(CanonicalizeLocalAdministrativeShareForPolicy( + root, + rejectUnmappedLocalShare: !allowSystemPaths)); + } + catch { return false; } + string r; + try { r = PathGuard.ResolveLocalFinalPath(rFull); } catch { return false; } return PathGuard.IsWithinRoot(normalized, r); }); if (!allowed) throw new InvalidOperationException( $"FileWatcherTrigger: directory '{dir}' is not within any configured Trigger:FileWatcher:AllowedRoots."); } + + /// + /// Enumerates a manual FileWatcher scan without asking + /// to recurse through the tree. Each directory is inspected link-locally before it is + /// enumerated, and reparse points are rejected rather than followed. + /// + public static IReadOnlyList EnumerateFilesReparseFree( + string root, + string searchPattern, + bool includeSubdirectories) + { + var files = new List(); + WalkReparseFree( + root, + includeSubdirectories, + path => + { + if (MatchesSearchPattern(searchPattern, Path.GetFileName(path))) + files.Add(path); + }); + return files; + } + + /// + /// Preflights the subtree used by . + /// This prevents a pre-existing child junction from extending a configured watched root. + /// A concurrent link creation/rename after the walk remains an OS-level race; emitted event + /// paths are revalidated by the scheduler before dispatch as a second line of defence. + /// + public static void ValidateReparseFreeSubtree(string root) => + WalkReparseFree(root, includeSubdirectories: true, onFile: null); + + private static void WalkReparseFree( + string root, + bool includeSubdirectories, + Action? onFile) + { + RejectWindowsDeviceNamespace(root, "directory"); + _ = PathGuard.ResolveLocalFinalPath(Path.GetFullPath(root)); + var pending = new Stack(); + pending.Push(Path.GetFullPath(root)); + + while (pending.Count > 0) + { + var directory = pending.Pop(); + _ = PathGuard.ResolveLocalFinalPath(directory); + + foreach (var entry in Directory.EnumerateFileSystemEntries( + directory, "*", SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes; + try { attributes = File.GetAttributes(entry); } + catch (Exception ex) + { + throw new InvalidOperationException( + $"FileWatcherTrigger: unable to inspect '{entry}' safely: {ex.Message}", ex); + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException( + $"FileWatcherTrigger: watched tree contains reparse point '{entry}'."); + + if ((attributes & FileAttributes.Directory) != 0) + { + if (includeSubdirectories) pending.Push(entry); + continue; + } + + onFile?.Invoke(entry); + } + } + } + + private static bool MatchesSearchPattern(string searchPattern, string fileName) + { + var translated = FileSystemName.TranslateWin32Expression( + string.IsNullOrWhiteSpace(searchPattern) ? "*.*" : searchPattern); + return FileSystemName.MatchesWin32Expression( + translated, + fileName, + ignoreCase: OperatingSystem.IsWindows()); + } + + private static void RejectWindowsDeviceNamespace(string path, string label) + { + if (!OperatingSystem.IsWindows()) return; + + // FileWatcher intentionally supports ordinary UNC shares (\\server\share), but Win32 + // device/extended namespaces are never a valid workflow input. In particular, + // \\?\C:\Windows remains textually outside the C:\Windows hard-block comparison while + // FileSystemWatcher still accepts it, bypassing the system-path policy. + var windowsPath = path.Replace('/', '\\'); + if (windowsPath.StartsWith(@"\\?\", StringComparison.Ordinal) || + windowsPath.StartsWith(@"\\.\", StringComparison.Ordinal) || + windowsPath.StartsWith(@"\??\", StringComparison.Ordinal) || + windowsPath.StartsWith(@"\\??\", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"FileWatcherTrigger: {label} '{path}' uses a Windows device namespace, which is not allowed."); + } + } + + /// + /// Maps a local-machine administrative UNC share to its local filesystem spelling for + /// policy comparisons. FileWatcher deliberately permits remote UNC shares, but paths such + /// as \\localhost\c$\Windows name the local system tree and must hit the same + /// hard-block/AllowedRoots decisions as C:\Windows. + /// + internal static string CanonicalizeLocalAdministrativeShareForPolicy( + string path, + bool rejectUnmappedLocalShare) + { + if (!OperatingSystem.IsWindows()) return path; + + var windowsPath = path.Replace('/', '\\'); + if (!windowsPath.StartsWith(@"\\", StringComparison.Ordinal) || + windowsPath.StartsWith(@"\\?\", StringComparison.Ordinal) || + windowsPath.StartsWith(@"\\.\", StringComparison.Ordinal)) + return path; + + // Apply Win32/SMB share-root semantics before mapping the share to a local + // directory. In particular, ADMIN$\.. is clamped to ADMIN$ by Windows; mapping + // first would incorrectly turn it into C:\Windows\.. and miss the hard-block. + // This also canonicalizes repeated separators and the accepted trailing-dot/ + // trailing-space spellings of administrative share roots. + windowsPath = Path.GetFullPath(windowsPath); + + var serverEnd = windowsPath.IndexOf('\\', 2); + if (serverEnd <= 2) return path; + var shareStart = serverEnd + 1; + // Win32 accepts and normalizes repeated separators between the server and + // share (for example \\localhost\\c$). Skip the empty UNC segments here so + // they cannot make the policy parser see an empty share while the watcher + // later opens the local administrative share. + while (shareStart < windowsPath.Length && windowsPath[shareStart] == '\\') + shareStart++; + if (shareStart == windowsPath.Length) return path; + + var shareEnd = windowsPath.IndexOf('\\', shareStart); + var server = windowsPath[2..serverEnd]; + var share = shareEnd < 0 + ? windowsPath[shareStart..] + : windowsPath[shareStart..shareEnd]; + if (!IsLocalServerAlias(server)) return path; + + string? localRoot = null; + if (share.Length == 2 && share[1] == '$' && char.IsAsciiLetter(share[0])) + { + localRoot = $"{char.ToUpperInvariant(share[0])}:\\"; + } + else if (share.Equals("ADMIN$", StringComparison.OrdinalIgnoreCase)) + { + localRoot = Environment.GetEnvironmentVariable("SystemRoot"); + if (string.IsNullOrWhiteSpace(localRoot)) + localRoot = Path.GetDirectoryName(Environment.SystemDirectory); + } + + if (string.IsNullOrWhiteSpace(localRoot)) + { + if (rejectUnmappedLocalShare) + { + throw new InvalidOperationException( + $"local UNC share '{share}' cannot be mapped safely while " + + "Trigger:FileWatcher:AllowSystemPaths is disabled"); + } + + return windowsPath; + } + if (shareEnd < 0 || shareEnd == windowsPath.Length - 1) + return Path.GetFullPath(localRoot); + + var relative = windowsPath[(shareEnd + 1)..].TrimStart('\\'); + return Path.GetFullPath(Path.Combine(localRoot, relative)); + } + + private static bool IsLocalServerAlias(string server) + { + var normalizedServer = server.Trim().TrimEnd('.'); + if (normalizedServer.Length > 1 && normalizedServer[0] == '[' && normalizedServer[^1] == ']') + normalizedServer = normalizedServer[1..^1]; + + if (normalizedServer.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + normalizedServer.Equals("localhost.localdomain", StringComparison.OrdinalIgnoreCase)) + return true; + + IPAddress? address; + if (IPAddress.TryParse(normalizedServer, out address) || + TryParseWindowsIpv6LiteralHost(normalizedServer, out address)) + { + if (address is null) return false; + address = NormalizeMappedIpv4Address(address); + if (IPAddress.IsLoopback(address)) return true; + try + { + return NetworkInterface.GetAllNetworkInterfaces() + .SelectMany(networkInterface => + networkInterface.GetIPProperties().UnicastAddresses) + .Any(unicast => + NormalizeMappedIpv4Address(unicast.Address).Equals(address)); + } + catch (NetworkInformationException) + { + return false; + } + } + + return LocalServerAliases.Value.Contains(normalizedServer); + } + + private static bool TryParseWindowsIpv6LiteralHost(string server, out IPAddress? address) + { + const string suffix = ".ipv6-literal.net"; + address = null; + if (!server.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false; + + // Windows represents IPv6 UNC servers without brackets: ':' becomes '-' and a + // scope-id '%' becomes 's', e.g. ::1 => --1.ipv6-literal.net. + var encoded = server[..^suffix.Length]; + var decoded = encoded.Replace('-', ':').Replace('s', '%').Replace('S', '%'); + return IPAddress.TryParse(decoded, out address); + } + + private static IPAddress NormalizeMappedIpv4Address(IPAddress address) => + address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + + private static readonly Lazy> LocalServerAliases = new( + static () => + { + var aliases = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Environment.MachineName, + }; + try + { + var dnsHost = Dns.GetHostName(); + if (!string.IsNullOrWhiteSpace(dnsHost)) aliases.Add(dnsHost.TrimEnd('.')); + } + catch { /* Environment.MachineName remains authoritative. */ } + + try + { + var properties = IPGlobalProperties.GetIPGlobalProperties(); + if (!string.IsNullOrWhiteSpace(properties.HostName)) + { + aliases.Add(properties.HostName.TrimEnd('.')); + if (!string.IsNullOrWhiteSpace(properties.DomainName)) + aliases.Add($"{properties.HostName}.{properties.DomainName}".TrimEnd('.')); + } + } + catch (NetworkInformationException) { } + return aliases; + }, + LazyThreadSafetyMode.ExecutionAndPublication); + + private static string[] BuildHardBlockedWindowsRoots() + { + if (!OperatingSystem.IsWindows()) return []; + + var configuredSystemRoot = Environment.GetEnvironmentVariable("SystemRoot"); + var systemRootFromSystemDirectory = Path.GetDirectoryName(Environment.SystemDirectory); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + return new[] + { + configuredSystemRoot, + systemRootFromSystemDirectory, + programFiles, + programFilesX86, + string.IsNullOrWhiteSpace(programData) + ? null + : Path.Combine(programData, "Microsoft", "Crypto"), + } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => Path.GetFullPath(path!)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } } diff --git a/src/NodePilot.Engine/Security/PathGuard.cs b/src/NodePilot.Engine/Security/PathGuard.cs index ac11ff83..2342f960 100644 --- a/src/NodePilot.Engine/Security/PathGuard.cs +++ b/src/NodePilot.Engine/Security/PathGuard.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Configuration; +using System.Globalization; +using System.Text.Json; namespace NodePilot.Engine.Security; @@ -13,18 +15,15 @@ namespace NodePilot.Engine.Security; /// .. traversal is rejected when FileSystemOperation:RejectTraversal=true /// (default since Phase 3 hardening). Setting it to false tolerates relative navigation /// for legacy admin scripts but is no longer the recommended posture. -/// FileSystemOperation:AllowedRoots (optional string array): when set, every path must -/// resolve inside one of the listed roots regardless of traversal. +/// FileSystemOperation:AllowedRoots (optional string array): when non-empty, every +/// path must be lexically inside one of the listed roots and no existing component may be a +/// reparse point. An explicit empty array means no containment restriction. /// Wildcard characters are rejected by default. Activities that intentionally support /// globbing must opt in at their specific source parameter. /// /// Config keys retain the historical FileSystemOperation: prefix so existing operator /// docs / appsettings deployments stay valid; the namespace is shared across all path-bearing /// activities. -/// -/// AllowedRoots final-path resolution is local to the NodePilot host. Remote WinRM targets do not -/// expose their reparse-point map to this guard; remote workflows still need target-side ACLs and -/// constrained working directories as the authoritative boundary. /// public static class PathGuard { @@ -71,41 +70,127 @@ public static void Validate(IConfiguration config, string path, bool allowWildca if (rejectTraversal && ContainsTraversal(path)) throw new InvalidOperationException($"File System Operation: path '{path}' contains '..' traversal (blocked by FileSystemOperation:RejectTraversal)"); - var roots = ReadConfiguredRoots(config, "FileSystemOperation:AllowedRoots"); - if (roots.Length > 0) + string fullPath; + string fullNormalized; + try { fullPath = Path.GetFullPath(path); } + catch (Exception ex) { - string fullPath; - string fullNormalized; - try { fullPath = Path.GetFullPath(path); } - catch (Exception ex) + throw new InvalidOperationException($"File System Operation: path '{path}' is not a valid absolute path: {ex.Message}"); + } + + // Reject existing reparse points even when no containment allow-list is configured. + // A syntactically-local path can otherwise be a junction to an attacker-controlled UNC + // share and make the process authenticate over SMB before any allow-root decision runs. + // ResolveLocalFinalPath is intentionally link-local: it inspects attributes on each path + // component and never resolves a link target. + try { fullNormalized = ResolveLocalFinalPath(fullPath); } + catch (Exception ex) + { + throw new InvalidOperationException( + $"File System Operation: path '{path}' traverses an unsafe filesystem component: {ex.Message}"); + } + + var roots = ReadConfiguredRoots( + config, + "FileSystemOperation:AllowedRoots", + out _); + if (roots.Length == 0) return; + + var allowed = roots.Any(root => + { + if (IsUncPath(root)) + throw new InvalidOperationException( + $"File System Operation: configured AllowedRoot '{root}' must not be a UNC or device path"); + + return IsWithinRoot(fullNormalized, ResolveLocalFinalPath(Path.GetFullPath(root))); + }); + + if (!allowed) + throw new InvalidOperationException($"File System Operation: path '{path}' is not within any configured FileSystemOperation:AllowedRoots"); + } + + /// + /// Reads an allow-list atomically from the highest-priority provider. Blank, sparse, mixed, + /// or otherwise malformed arrays are rejected fail-closed. Shared with + /// so both guards read their roots the same way. + /// + internal static string[] ReadConfiguredRoots( + IConfiguration config, + string sectionPath, + out bool configured) + { + if (config is not IConfigurationRoot root) + throw new InvalidOperationException( + $"Security allow-list '{sectionPath}' requires IConfigurationRoot provider metadata"); + + // IConfiguration's merged child view does not replace arrays atomically: a one-item + // runtime override otherwise inherits index 1..N from a lower-priority provider. Read + // the complete array from the highest-priority provider that declares the section. + foreach (var provider in root.Providers.Reverse()) + { + var hasExactValue = provider.TryGet(sectionPath, out var exactValue); + var childKeys = provider.GetChildKeys([], sectionPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (!hasExactValue && childKeys.Length == 0) continue; + + configured = true; + if (hasExactValue && childKeys.Length > 0) + throw MalformedRoots(sectionPath, "contains both a scalar value and indexed children"); + + if (hasExactValue) { - throw new InvalidOperationException($"File System Operation: path '{path}' is not a valid absolute path: {ex.Message}"); + // JsonConfigurationProvider represents [] as an exact null entry. It is an + // atomic provider tombstone: lower-provider indices disappear, while the + // established AllowedRoots contract still treats the resulting [] as no + // containment restriction (reparse rejection remains unconditional). + if (string.IsNullOrWhiteSpace(exactValue)) return []; + + try + { + using var document = JsonDocument.Parse(exactValue); + if (document.RootElement.ValueKind != JsonValueKind.Array) + throw MalformedRoots(sectionPath, "scalar value is not a JSON string array"); + + var values = new List(); + foreach (var element in document.RootElement.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(element.GetString())) + throw MalformedRoots(sectionPath, "contains an empty or non-string root"); + values.Add(element.GetString()!); + } + return values.ToArray(); + } + catch (JsonException ex) + { + throw MalformedRoots(sectionPath, $"scalar value is not valid JSON: {ex.Message}"); + } } - try { fullNormalized = ResolveLocalFinalPath(fullPath); } - catch (Exception ex) + var indexed = new SortedDictionary(); + foreach (var childKey in childKeys) { - throw new InvalidOperationException($"File System Operation: path '{path}' final path could not be resolved: {ex.Message}"); + if (!int.TryParse(childKey, NumberStyles.None, CultureInfo.InvariantCulture, out var index) + || index < 0 + || !provider.TryGet($"{sectionPath}:{childKey}", out var value) + || string.IsNullOrWhiteSpace(value) + || !indexed.TryAdd(index, value)) + throw MalformedRoots(sectionPath, "contains malformed, duplicate, or blank indices"); } - var allowed = roots.Any(root => IsWithinRoot(fullNormalized, ResolveLocalFinalPath(Path.GetFullPath(root)))); + if (indexed.Keys.Where((index, ordinal) => index != ordinal).Any()) + throw MalformedRoots(sectionPath, "contains sparse array indices"); - if (!allowed) - throw new InvalidOperationException($"File System Operation: path '{path}' is not within any configured FileSystemOperation:AllowedRoots"); + return indexed.Values.ToArray(); } + + configured = false; + return []; } - /// - /// Reads an allow-list section as a string array, dropping blank entries. Shared with - /// so both guards read their roots the same way. - /// - internal static string[] ReadConfiguredRoots(IConfiguration config, string sectionPath) - => config.GetSection(sectionPath) - .GetChildren() - .Select(c => c.Value) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Cast() - .ToArray(); + private static InvalidOperationException MalformedRoots(string sectionPath, string reason) => + new($"Security allow-list '{sectionPath}' is malformed and was rejected: {reason}"); /// /// Root-containment test shared by both path guards: the path is the root itself, or sits @@ -170,53 +255,56 @@ private static bool ContainsTraversal(string path) return normalized == ".."; } - private static string ResolveLocalFinalPath(string absolutePath) + internal static string ResolveLocalFinalPath(string absolutePath) { var full = Path.GetFullPath(absolutePath); var root = Path.GetPathRoot(full); - if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + if (string.IsNullOrEmpty(root)) return NormalizeForRootComparison(full); var relative = Path.GetRelativePath(root, full); - var current = ResolveExistingPath(root); - if (relative == ".") - return NormalizeForRootComparison(current); + var current = root; + + // File.GetAttributes maps to link-local metadata on Windows: unlike Exists followed by + // ResolveLinkTarget it does not dereference a junction/symlink to discover its target. + // That property is security-critical for links targeting UNC shares (SMB/NTLM coercion) + // and also lets us reject dangling links, which Exists reports as false. + AssertNotReparsePoint(current); + if (relative == ".") return NormalizeForRootComparison(full); var segments = relative.Split( [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); - for (var i = 0; i < segments.Length; i++) + foreach (var segment in segments) { - current = Path.Combine(current, segments[i]); - if (Directory.Exists(current) || File.Exists(current)) - { - current = ResolveExistingPath(current); - continue; - } + // Only Zip compression opts into wildcards. It separately restricts them to the + // leaf component and expands them on the target; no literal filesystem object can + // exist at or below this segment. + if (segment.IndexOfAny(WildcardChars) >= 0) break; - for (var j = i + 1; j < segments.Length; j++) - current = Path.Combine(current, segments[j]); - return NormalizeForRootComparison(Path.GetFullPath(current)); + current = Path.Combine(current, segment); + if (!AssertNotReparsePoint(current)) break; } - return NormalizeForRootComparison(Path.GetFullPath(current)); + return NormalizeForRootComparison(full); } - private static string ResolveExistingPath(string path) + /// when the path exists; otherwise false. + private static bool AssertNotReparsePoint(string path) { - if (Directory.Exists(path)) + FileAttributes attributes; + try { - var info = new DirectoryInfo(path); - if ((info.Attributes & FileAttributes.ReparsePoint) == 0) - return info.FullName; - return info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.FullName; + attributes = File.GetAttributes(path); } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException($"path traverses reparse point '{path}'"); - var file = new FileInfo(path); - if ((file.Attributes & FileAttributes.ReparsePoint) == 0) - return file.FullName; - return file.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? file.FullName; + return true; } private static string NormalizeForRootComparison(string path) @@ -230,7 +318,7 @@ private static string NormalizeForRootComparison(string path) /// extended-length paths (\\?\UNC\server\share\…) are also flagged: the device- /// namespace prefix is not a path component our workflows have any reason to express. /// - private static bool IsUncPath(string path) + internal static bool IsUncPath(string path) { if (path.Length < 2) return false; var c0 = path[0]; diff --git a/src/NodePilot.Engine/Security/TargetPathGuardScript.cs b/src/NodePilot.Engine/Security/TargetPathGuardScript.cs new file mode 100644 index 00000000..4bf4ca81 --- /dev/null +++ b/src/NodePilot.Engine/Security/TargetPathGuardScript.cs @@ -0,0 +1,261 @@ +using Microsoft.Extensions.Configuration; +using NodePilot.Engine.PowerShell; + +namespace NodePilot.Engine.Security; + +/// +/// Builds the target-side half of . The C# guard runs before a +/// WinRM session is opened, so its view of reparse points is authoritative only for local +/// execution. This script repeats the allow-root decision on the machine that will actually +/// touch the path and fails closed when an existing path component is a junction/symlink. +/// +internal static class TargetPathGuardScript +{ + internal static string Build( + IConfiguration config, + params (string Expression, string Label)[] candidates) + { + var roots = PathGuard.ReadConfiguredRoots( + config, + "FileSystemOperation:AllowedRoots", + out _); + if (candidates.Length == 0) + return string.Empty; + + var rootLiterals = string.Join(", ", roots.Select(PowerShellOperation.Literal)); + var enforceRootsLiteral = roots.Length > 0 ? "$true" : "$false"; + var assertions = string.Join( + Environment.NewLine, + candidates.Select(candidate => + $"Assert-NodePilotAllowedPath -Candidate ({candidate.Expression}) " + + $"-Label {PowerShellOperation.Literal(candidate.Label)}")); + + return $$""" + # Authoritative target-side path check. Reparse rejection is unconditional; only + # containment is optional when AllowedRoots is empty. File.GetAttributes is + # deliberately used instead of Test-Path/Get-Item so a dangling junction, including + # one aimed at a UNC share, is inspected without dereferencing its target. + $__npAllowedRoots = @({{rootLiterals}}) + $__npEnforceAllowedRoots = {{enforceRootsLiteral}} + function Get-NodePilotPathAttributes { + param([Parameter(Mandatory = $true)][string]$Path) + + try { + return [System.IO.File]::GetAttributes($Path) + } catch { + $__npAttributeException = $_.Exception.GetBaseException() + if ($__npAttributeException -is [System.IO.FileNotFoundException] -or + $__npAttributeException -is [System.IO.DirectoryNotFoundException]) { + return $null + } + throw + } + } + + function Assert-NodePilotAllowedPath { + param( + [AllowNull()][string]$Candidate, + [Parameter(Mandatory = $true)][string]$Label + ) + + if ([string]::IsNullOrWhiteSpace($Candidate)) { return } + + if ($Candidate.StartsWith('\\') -or $Candidate.StartsWith('//')) { + throw "File System Operation: $Label '$Candidate' is a UNC or device path on target" + } + + try { + $__npWildcardIndex = $Candidate.IndexOfAny([char[]]@('*', '?')) + if ($__npWildcardIndex -ge 0) { + # .NET Framework's Path.GetFullPath rejects wildcard characters. Only + # the leaf may contain them; normalize the literal parent first and then + # append the untrusted-as-pattern leaf without provider expansion. + $__npLastSeparator = [Math]::Max( + $Candidate.LastIndexOf([char]92), + $Candidate.LastIndexOf([char]47)) + if ($__npLastSeparator -lt 0) { + $__npCandidateParent = '.' + $__npCandidateLeaf = $Candidate + } else { + $__npParentLength = if ($__npLastSeparator -eq 2 -and + $Candidate.Length -gt 1 -and $Candidate[1] -eq ':') { + 3 + } else { + $__npLastSeparator + } + $__npCandidateParent = $Candidate.Substring(0, $__npParentLength) + $__npCandidateLeaf = $Candidate.Substring($__npLastSeparator + 1) + } + if ($__npCandidateParent.IndexOfAny([char[]]@('*', '?')) -ge 0) { + throw "wildcards are allowed only in the final path segment" + } + $__npParentFull = [System.IO.Path]::GetFullPath($__npCandidateParent) + $__npCandidateFull = [System.IO.Path]::Combine( + $__npParentFull, + $__npCandidateLeaf) + } else { + $__npCandidateFull = [System.IO.Path]::GetFullPath($Candidate) + } + } catch { + throw "File System Operation: $Label '$Candidate' is not a valid absolute path on target: $($_.Exception.Message)" + } + + $__npSeparators = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar) + $__npCandidateVolume = [System.IO.Path]::GetPathRoot($__npCandidateFull) + if ([string]::IsNullOrEmpty($__npCandidateVolume)) { + throw "File System Operation: $Label '$Candidate' has no filesystem root on target" + } + if ($__npCandidateFull.Length -gt $__npCandidateVolume.Length) { + $__npCandidateFull = $__npCandidateFull.TrimEnd($__npSeparators) + } + + # Walk existing components from the volume down. Stop at the first missing or + # wildcard component; a descendant cannot exist below a missing literal parent. + $__npCurrent = $__npCandidateVolume + $__npVolumeAttributes = Get-NodePilotPathAttributes -Path $__npCurrent + if ($null -ne $__npVolumeAttributes -and + ($__npVolumeAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: $Label '$Candidate' traverses reparse point '$__npCurrent' on target" + } + $__npRelative = $__npCandidateFull.Substring($__npCandidateVolume.Length) + foreach ($__npSegment in $__npRelative.Split( + $__npSeparators, [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($__npSegment.IndexOfAny([char[]]@('*', '?')) -ge 0) { break } + $__npCurrent = [System.IO.Path]::Combine($__npCurrent, $__npSegment) + $__npAttributes = Get-NodePilotPathAttributes -Path $__npCurrent + if ($null -eq $__npAttributes) { break } + if (($__npAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: $Label '$Candidate' traverses reparse point '$__npCurrent' on target" + } + } + + if (-not $__npEnforceAllowedRoots) { return } + + $__npMatchedRoot = $null + foreach ($__npConfiguredRoot in $__npAllowedRoots) { + if ($__npConfiguredRoot.StartsWith('\\') -or $__npConfiguredRoot.StartsWith('//')) { + throw "File System Operation: configured AllowedRoot '$__npConfiguredRoot' must not be a UNC or device path on target" + } + try { + $__npRootFull = [System.IO.Path]::GetFullPath($__npConfiguredRoot) + } catch { + throw "File System Operation: configured AllowedRoot '$__npConfiguredRoot' is invalid on target: $($_.Exception.Message)" + } + $__npRootVolume = [System.IO.Path]::GetPathRoot($__npRootFull) + if ([string]::IsNullOrEmpty($__npRootVolume)) { continue } + if ($__npRootFull.Length -gt $__npRootVolume.Length) { + $__npRootFull = $__npRootFull.TrimEnd($__npSeparators) + } + + $__npAtRoot = $__npCandidateFull.Equals( + $__npRootFull, [System.StringComparison]::OrdinalIgnoreCase) + $__npRootPrefix = if ($__npRootFull.EndsWith( + [string][System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::Ordinal)) { + $__npRootFull + } else { + $__npRootFull + [System.IO.Path]::DirectorySeparatorChar + } + $__npBelowRoot = $__npCandidateFull.StartsWith( + $__npRootPrefix, + [System.StringComparison]::OrdinalIgnoreCase) + if ($__npAtRoot -or $__npBelowRoot) { + $__npRootAttributes = Get-NodePilotPathAttributes -Path $__npRootFull + if ($null -eq $__npRootAttributes -or + ($__npRootAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "File System Operation: configured AllowedRoot '$__npConfiguredRoot' does not exist as a directory on target" + } + if (($__npRootAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: configured AllowedRoot '$__npConfiguredRoot' is a reparse point on target" + } + $__npMatchedRoot = $__npRootFull + break + } + } + + if ($null -eq $__npMatchedRoot) { + throw "File System Operation: $Label '$Candidate' is not within any configured FileSystemOperation:AllowedRoots on target" + } + } + + function Get-NodePilotEffectiveDestination { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination, + [Parameter(Mandatory = $true)][string]$Label + ) + + # Copy-Item/Move-Item append the source leaf when Destination already names a + # directory. Validate that effective leaf as well: an existing junction at + # destination\sourceLeaf must never redirect the operation. + Assert-NodePilotAllowedPath -Candidate $Destination -Label $Label + $__npDestinationFull = [System.IO.Path]::GetFullPath($Destination) + $__npDestinationAttributes = Get-NodePilotPathAttributes -Path $__npDestinationFull + if ($null -ne $__npDestinationAttributes -and + ($__npDestinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $__npSourceFull = [System.IO.Path]::GetFullPath($Source) + $__npSourceVolume = [System.IO.Path]::GetPathRoot($__npSourceFull) + if ($__npSourceFull.Length -gt $__npSourceVolume.Length) { + $__npSourceFull = $__npSourceFull.TrimEnd([char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar)) + } + $__npSourceLeaf = [System.IO.Path]::GetFileName($__npSourceFull) + if ([string]::IsNullOrEmpty($__npSourceLeaf)) { + throw "File System Operation: cannot derive a destination name from source '$Source'" + } + $__npEffectiveDestination = [System.IO.Path]::Combine( + $__npDestinationFull, + $__npSourceLeaf) + } else { + $__npEffectiveDestination = $__npDestinationFull + } + + Assert-NodePilotAllowedPath -Candidate $__npEffectiveDestination -Label "$Label effective path" + return $__npEffectiveDestination + } + + function Assert-NodePilotReparseFreeTree { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Label + ) + + $__npPendingDirectories = New-Object 'System.Collections.Generic.Stack[string]' + $__npPendingDirectories.Push([System.IO.Path]::GetFullPath($Root)) + while ($__npPendingDirectories.Count -gt 0) { + $__npDirectory = $__npPendingDirectories.Pop() + Assert-NodePilotAllowedPath -Candidate $__npDirectory -Label $Label + $__npDirectoryAttributes = Get-NodePilotPathAttributes -Path $__npDirectory + if ($null -eq $__npDirectoryAttributes -or + ($__npDirectoryAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw "File System Operation: $Label directory changed or disappeared: '$__npDirectory'" + } + if (($__npDirectoryAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: $Label tree contains reparse point '$__npDirectory'" + } + + foreach ($__npChild in [System.IO.Directory]::EnumerateFileSystemEntries( + $__npDirectory, + '*', + [System.IO.SearchOption]::TopDirectoryOnly)) { + Assert-NodePilotAllowedPath -Candidate $__npChild -Label $Label + $__npChildAttributes = Get-NodePilotPathAttributes -Path $__npChild + if ($null -eq $__npChildAttributes) { + throw "File System Operation: $Label item changed or disappeared: '$__npChild'" + } + if (($__npChildAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "File System Operation: $Label tree contains reparse point '$__npChild'" + } + if (($__npChildAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $__npPendingDirectories.Push($__npChild) + } + } + } + } + {{assertions}} + """; + } +} diff --git a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs index c747e90c..0ff58ae9 100644 --- a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs +++ b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs @@ -148,8 +148,16 @@ private static ScanResult ScanEventLogNewestFirst( return new ScanResult(matches, timeouts); } - /// Maps the framework enum onto the Core filter enum used by the shared matcher. - internal static EventLogEntryTypeFilter ToFilter(EventLogEntryType type) => type switch + /// + /// Maps the framework enum onto the Core filter enum used by the shared matcher. Public + /// because the background source (NodePilot.Scheduler) needs the identical mapping — the + /// Core settings type cannot host it without pulling the Windows-only + /// System.Diagnostics.EventLog package into Core, and from there into the CLI and MCP + /// executables that only speak HTTP. + /// A plain switch rather than a ToString round-trip: the scheduler runs this on + /// the EventLog callback thread for every entry written to the log, filtered or not. + /// + public static EventLogEntryTypeFilter ToFilter(EventLogEntryType type) => type switch { EventLogEntryType.Error => EventLogEntryTypeFilter.Error, EventLogEntryType.Warning => EventLogEntryTypeFilter.Warning, diff --git a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs index 4b4a25e9..f23b4ea0 100644 --- a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs +++ b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs @@ -12,6 +12,9 @@ namespace NodePilot.Engine.Triggers; /// public class FileWatcherTrigger : IActivityExecutor { + private static readonly IConfiguration EmptyPathGuardConfiguration = + new ConfigurationBuilder().Build(); + private readonly IConfiguration? _config; public FileWatcherTrigger(IConfiguration? config = null) @@ -51,13 +54,13 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // hard-block check as the scheduler-side source so a workflow author can't // enumerate C:\Windows via a manual "Run Step" while the live trigger // would have refused to start there. - if (_config is not null) + try { - try { FileWatcherPathGuard.Validate(_config, directory); } - catch (InvalidOperationException ex) - { - return new ActivityResult { Success = false, ErrorOutput = ex.Message }; - } + FileWatcherPathGuard.Validate(_config ?? EmptyPathGuardConfiguration, directory); + } + catch (InvalidOperationException ex) + { + return new ActivityResult { Success = false, ErrorOutput = ex.Message }; } return await Task.Run(() => @@ -67,13 +70,27 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso return new ActivityResult { Success = false, ErrorOutput = $"Directory not found: {directory}" }; } - var searchOption = includeSubdirs ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - var files = Directory.GetFiles(directory, filter ?? "*.*", searchOption); + IReadOnlyList files; + try + { + files = FileWatcherPathGuard.EnumerateFilesReparseFree( + directory, + filter ?? "*.*", + includeSubdirs); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + return new ActivityResult + { + Success = false, + ErrorOutput = $"FileWatcherTrigger: secure directory scan failed: {ex.Message}", + }; + } return new ActivityResult { Success = true, - Output = $"Directory: {directory}\nFilter: {filter}\nWatch type: {watchType}\nFiles found: {files.Length}\n" + + Output = $"Directory: {directory}\nFilter: {filter}\nWatch type: {watchType}\nFiles found: {files.Count}\n" + string.Join("\n", files.Take(20).Select(Path.GetFileName)) }; }, ct); diff --git a/src/NodePilot.Mcp/Tools/ExecutionTools.cs b/src/NodePilot.Mcp/Tools/ExecutionTools.cs index ba538b4d..18b11957 100644 --- a/src/NodePilot.Mcp/Tools/ExecutionTools.cs +++ b/src/NodePilot.Mcp/Tools/ExecutionTools.cs @@ -172,10 +172,10 @@ public async Task ResumeExecution( } [McpServerTool(Name = "trigger_external_workflow")] - [Description("Trigger a workflow via the external API-key endpoint (POST /api/trigger/{nameOrId}). Supply the X-Api-Key value. Supports an idempotencyKey (24h replay window). Returns the ExecutionId and whether an idempotent replay occurred.")] + [Description("Trigger a workflow via the external API-key endpoint (POST /api/trigger/{nameOrId}). The X-Api-Key must be scoped to the workflow GUID, and the workflow must contain an enabled manualTrigger. Supports an idempotencyKey (24h replay window). Returns the ExecutionId and whether an idempotent replay occurred.")] public async Task TriggerExternalWorkflow( [Description("The workflow name or GUID.")] string workflowNameOrId, - [Description("The external-trigger API key (sent as X-Api-Key).")] string apiKey, + [Description("The external-trigger API key, scoped to the target workflow GUID (sent as X-Api-Key).")] string apiKey, [Description("Optional input parameters passed to the run.")] Dictionary? parameters = null, [Description("Optional Idempotency-Key; a repeat within 24h replays the original run instead of starting a new one.")] string? idempotencyKey = null, CancellationToken cancellationToken = default) diff --git a/src/NodePilot.Scheduler/Sources/EventLogTriggerSource.cs b/src/NodePilot.Scheduler/Sources/EventLogTriggerSource.cs index 37863dff..dc38b17b 100644 --- a/src/NodePilot.Scheduler/Sources/EventLogTriggerSource.cs +++ b/src/NodePilot.Scheduler/Sources/EventLogTriggerSource.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using NodePilot.Core.Triggers; +using NodePilot.Engine.Triggers; namespace NodePilot.Scheduler.Sources; @@ -82,7 +83,7 @@ private void OnEntry(object? sender, EntryWrittenEventArgs e) new KeyValuePair("trigger_type", "eventLogTrigger"), new KeyValuePair("event_kind", entry.EntryType.ToString())); - var match = settings.Matches(entry.Source, entry.InstanceId, ToFilter(entry.EntryType), entry.Message); + var match = settings.Matches(entry.Source, entry.InstanceId, EventLogTrigger.ToFilter(entry.EntryType), entry.Message); if (match == EventLogMatch.PatternTimeout) { SchedulerMetrics.TriggerPollErrors.Add(1, @@ -105,20 +106,6 @@ private void OnEntry(object? sender, EntryWrittenEventArgs e) _logger, ActivityType, _ctx.WorkflowId, _ctx.NodeId); } - /// - /// Maps the framework enum onto the Core filter enum. A plain switch rather than a - /// ToString round-trip: this runs on the EventLog callback thread for every entry - /// written to the log, filtered or not. - /// - internal static EventLogEntryTypeFilter ToFilter(EventLogEntryType type) => type switch - { - EventLogEntryType.Error => EventLogEntryTypeFilter.Error, - EventLogEntryType.Warning => EventLogEntryTypeFilter.Warning, - EventLogEntryType.SuccessAudit => EventLogEntryTypeFilter.SuccessAudit, - EventLogEntryType.FailureAudit => EventLogEntryTypeFilter.FailureAudit, - _ => EventLogEntryTypeFilter.Information, - }; - public ValueTask DisposeAsync() { if (_log is not null) diff --git a/src/NodePilot.Scheduler/Sources/FileWatcherTriggerSource.cs b/src/NodePilot.Scheduler/Sources/FileWatcherTriggerSource.cs index e9ee7b37..e57a1c99 100644 --- a/src/NodePilot.Scheduler/Sources/FileWatcherTriggerSource.cs +++ b/src/NodePilot.Scheduler/Sources/FileWatcherTriggerSource.cs @@ -87,8 +87,6 @@ public async Task StartAsync(TriggerContext context, CancellationToken ct) if (string.IsNullOrWhiteSpace(dir)) throw new InvalidOperationException("FileWatcherTrigger: 'directory' is required"); - ValidateDirectory(dir); - var filter = cfg.TryGetProperty("filter", out var f) ? f.GetString() ?? "*" : "*"; var watchType = (cfg.TryGetProperty("watchType", out var wt) ? wt.GetString() : null)?.ToLowerInvariant() ?? "created"; var includeSub = cfg.TryGetProperty("includeSubdirectories", out var is_) && is_.ValueKind == JsonValueKind.True; @@ -229,12 +227,25 @@ internal static async Task RunBoundedAsync( /// private FileSystemWatcher BuildAndArmWatcher(string dir, string filter, string watchType, bool includeSub) { + // Keep canonicalization in the same bounded target-side operation as handle creation, + // immediately before the first filesystem touch. A concurrent rename after this check + // is still an OS-level race; ACLs on watched roots remain the authoritative control. + ValidateDirectory(dir); + // Kept even though the FileSystemWatcher constructor checks the path itself: this // produces the friendly DirectoryNotFoundException that callers and tests rely on, // where CheckPathValidity would throw a raw ArgumentException. if (!DirectoryProbe(dir)) throw new DirectoryNotFoundException($"FileWatcherTrigger: directory '{dir}' does not exist"); + if (includeSub) + FileWatcherPathGuard.ValidateReparseFreeSubtree(dir); + + // Revalidate the watched root after the subtree walk and immediately before the + // constructor obtains its native handle. This narrows, but path APIs cannot eliminate, + // a concurrent parent-directory replacement race. + ValidateDirectory(dir); + var watcher = new FileSystemWatcher(dir, filter) { IncludeSubdirectories = includeSub, @@ -257,6 +268,20 @@ void HandleEvent(string action, string path) // fire — otherwise a timed-out registration attempt would keep triggering workflows. if (!ReferenceEquals(watcher, Volatile.Read(ref _watcher))) return; + // A junction can be created after the startup preflight. Never dispatch an event + // whose current path traverses one; validation uses link-local attributes and thus + // does not itself follow a link to a UNC target. + try { FileWatcherPathGuard.Validate(_config, path); } + catch (InvalidOperationException ex) + { + _logger.LogWarning( + ex, + "FileWatcher suppressed {Action} event for unsafe path '{Path}'", + action, + path); + return; + } + // Count every raw FSW event before debounce — operators chasing "trigger fires too // often" need to see whether the noise is the watcher itself or our dispatch. SchedulerMetrics.TriggerEvents.Add(1, diff --git a/src/nodepilot-docs-ui/content/activities-reference.md b/src/nodepilot-docs-ui/content/activities-reference.md index 7fd22fd8..486030ff 100644 --- a/src/nodepilot-docs-ui/content/activities-reference.md +++ b/src/nodepilot-docs-ui/content/activities-reference.md @@ -96,7 +96,11 @@ Jeder Step unterstützt `config.retry` mit `maxAttempts`, `backoff`, `initialDel ## `zipOperation` -**Remote.** Extract führt einen Zip-Slip-Pre-Scan aus. +**Remote.** Compress baut einen explizit validierten Datei-Manifest auf und schreibt ihn direkt +mit `ZipArchive`; Wildcards sind nur im letzten Source-Segment erlaubt und eckige Klammern +werden literal behandelt. Extract validiert und schreibt jeden Entry einzeln. Zip-Slip, +vorhandene Junctions/Symlinks in Source oder Destination und das Folgen eines bestehenden +Output-Links werden abgelehnt; die Ziel-ACL bleibt die Grenze gegen parallele Parent-Renames. - **Config:** `operation` (compress/extract, default `compress`), `source` (Wildcards erlaubt für compress), `destination`, `compressionLevel` (Optimal/Fastest/NoCompression — compress only), `force` - **Outputs:** `param.destination`, `param.sizeBytes` (extract ⇒ 0) @@ -112,7 +116,7 @@ Jeder Step unterstützt `config.retry` mit `maxAttempts`, `backoff`, `initialDel **Engine-local.** Connection-Precedence: `connectionRef` > Builder > raw `connectionString`. -- **Config:** `provider` (sqlserver/sqlite/postgres), `query`, `timeoutSeconds`. Connection-Optionen: (a) Builder — SQL Server: `server`/`database`/`authentication`/`username`/`password`/`encrypt`/`trustServerCertificate`; Postgres: `host`/`port`/`database`/`username`/`password`/`sslMode`; SQLite: `dataSource`; (b) raw `connectionString`; (c) named `connectionRef` aus `SqlActivity:ConnectionStrings:{name}`. +- **Config:** `provider` (sqlserver/sqlite/postgres), `query`, `timeoutSeconds`. Connection-Optionen: (a) Builder — SQL Server: `server`/`database`/`authentication`/`username`/`password`/`encrypt`/`trustServerCertificate`; Postgres: `host`/`port`/`database`/`username`/`password`/`sslMode` (`VerifyFull` + `Trust Server Certificate=false` default; schwächere Modi nur für literale Loopback-Hosts); SQLite: `dataSource`; (b) raw `connectionString`; (c) named `connectionRef` aus `SqlActivity:ConnectionStrings:{name}`. Die Postgres-TLS-Policy gilt auch für raw/ref. - **Outputs:** SELECT → `param.rowCount` + erste-Row-Spalten als `param.` + `param.row{i}_{col}` (erste 20 Rows) + `param.truncated`/`param.flatKeysTruncated`. DML/DDL → `param.rowsAffected` + `param.rowCount` ## `emailNotification` diff --git a/src/nodepilot-docs-ui/content/ai-features.md b/src/nodepilot-docs-ui/content/ai-features.md index d8680f39..7a7828d8 100644 --- a/src/nodepilot-docs-ui/content/ai-features.md +++ b/src/nodepilot-docs-ui/content/ai-features.md @@ -166,7 +166,7 @@ Je Profil: | Einstellung | Bedeutung | |---|---| | `Name` | Anzeigename; frei änderbar, die Kennung bleibt bestehen | -| `BaseUrl` | Adresse eines OpenAI-kompatiblen Endpunkts; der Pfad bestimmt das Anfrageformat (siehe unten) | +| `BaseUrl` | HTTPS-Adresse eines OpenAI-kompatiblen Endpunkts; HTTP ist nur für exakte Loopback-Ziele (`localhost`, `127.0.0.0/8`, `::1`) erlaubt. Der Pfad bestimmt das Anfrageformat (siehe unten). | | `ApiKey` | API-Schlüssel; für lokale Modelle häufig nicht erforderlich | | `Model` | verwendeter Modellname | | `MaxTokens` | maximale Länge einer Modellantwort (256 bis 1.000.000) | diff --git a/src/nodepilot-docs-ui/content/api/authentication.md b/src/nodepilot-docs-ui/content/api/authentication.md index 0194d818..28cf3b1a 100644 --- a/src/nodepilot-docs-ui/content/api/authentication.md +++ b/src/nodepilot-docs-ui/content/api/authentication.md @@ -126,4 +126,4 @@ Die vollständige Tabelle über alle Bereiche steht — als einzige Quelle — u ## External Trigger -Nur aktiv wenn `ExternalTrigger:ApiKey` gesetzt. Gated via `X-Api-Key`-Header. Akzeptiert optionale `Idempotency-Key`-Header (24 h TTL, nicht abschaltbar). +Gated via `X-Api-Key`. Bevorzugt werden SHA-256-gehashte Schlüssel unter `ExternalTrigger:Keys:` mit einer GUID-basierten `AllowedWorkflowIds`-Liste. Zusätzlich muss der Workflow einen aktiven `manualTrigger` enthalten. Der Legacy-Key `ExternalTrigger:ApiKey` ist ohne eigene `AllowedWorkflowIds`-Liste wirkungslos. Die höchstprior deklarierte `Keys`-Map ist der vollständige Snapshot (`{}` widerruft niedrigere Keys); Allowlisten sind ebenfalls atomar (`[]` = deny-all). Optionale `Idempotency-Key`-Header gelten 24 Stunden und sind zusätzlich an den authentifizierten Key-Principal gebunden; gespeichert wird nur ein domain-separierter Digest. diff --git a/src/nodepilot-docs-ui/content/api/endpoints.md b/src/nodepilot-docs-ui/content/api/endpoints.md index 38599d58..e1efacdd 100644 --- a/src/nodepilot-docs-ui/content/api/endpoints.md +++ b/src/nodepilot-docs-ui/content/api/endpoints.md @@ -220,11 +220,11 @@ Audit-Codes folgen `VERB_NOMEN`. Vollständige Liste: [Audit-Log](../security/au | Endpoint | Zweck | |---|---| -| `POST /api/trigger/{workflowNameOrId}` | External Trigger (`X-Api-Key` required) | +| `POST /api/trigger/{workflowNameOrId}` | External Trigger (`X-Api-Key`, Key-Scope auf Workflow-GUID und aktiver `manualTrigger` erforderlich) | | `POST\|GET\|PUT\|DELETE /api/webhooks/{workflow}/{path}` | Webhook (Verb muss `webhookTrigger.method` matchen) | ```bash -# External Trigger — anonym, nur API-Key. Optional Idempotency-Key (24h TTL) +# External Trigger — anonym, aber per Integrations-Key auf Workflow-GUIDs begrenzt. Optional Idempotency-Key (24h TTL, pro Key-Principal isoliert) curl -s -X POST "$NP/api/trigger/nightly-reconcile" \ -H 'X-Api-Key: xyz' \ -H 'Idempotency-Key: ci-1719100000' \ diff --git a/src/nodepilot-docs-ui/content/cli.md b/src/nodepilot-docs-ui/content/cli.md index f17b9f21..860616e6 100644 --- a/src/nodepilot-docs-ui/content/cli.md +++ b/src/nodepilot-docs-ui/content/cli.md @@ -86,7 +86,7 @@ np auth logout ### run / trigger -`run` startet einen Lauf als authentifizierter User; `trigger` ist session-unabhängig und nur via `X-Api-Key` gegated. +`run` startet einen Lauf als authentifizierter User; `trigger` ist session-unabhängig und via `X-Api-Key` auf die im Schlüssel-Scope gelisteten Workflow-GUIDs begrenzt. Der Workflow benötigt außerdem einen aktiven `manualTrigger`. `run`-Flags: `-p|--params ` (wiederholbar; nur erstes `=` splittet), `--wait` (pollen bis terminal), `--follow` (live Step-Events via SignalR), `--debug`, `--timeout `. diff --git a/src/nodepilot-docs-ui/content/configuration/appsettings.md b/src/nodepilot-docs-ui/content/configuration/appsettings.md index abf2d2fb..93242387 100644 --- a/src/nodepilot-docs-ui/content/configuration/appsettings.md +++ b/src/nodepilot-docs-ui/content/configuration/appsettings.md @@ -49,7 +49,12 @@ Provider-Verbindungen, Timeout-Budgets und das Verhalten bei Datenbank-Ausfälle | `DataProtection:KeyRingPath` | `data-protection-keys` | bei HA+OIDC persistenter gemeinsamer Pfad für alle Nodes | | `DataProtection:CertificateThumbprint` | `null` | bei HA+OIDC gemeinsames Zertifikat mit Private Key in `LocalMachine\My` | | `DataProtection:SharedKeyRing` | `false` | muss bei HA+OIDC nach verifiziertem Shared Storage `true` sein | -| `ExternalTrigger:ApiKey` | leer | leer bedeutet: External Trigger inaktiv | +| `ExternalTrigger:Keys::KeyHash` | leer | SHA-256 des Integrationsschlüssels als Base64; Klartext wird nicht persistiert | +| `ExternalTrigger:Keys::AllowedWorkflowIds` | `[]` | unveränderliche Workflow-GUIDs; leer bedeutet deny-all | +| `ExternalTrigger:ApiKey` | leer | Legacy-Klartextschlüssel; nur zusammen mit der folgenden Allowlist wirksam | +| `ExternalTrigger:AllowedWorkflowIds` | `[]` | GUID-Scope des Legacy-Schlüssels; leer bedeutet deny-all | + +`ExternalTrigger:Keys` wird als vollständige Map aus dem höchstprioren Provider gelesen, der sie deklariert. Dadurch widerruft ein höheres `Keys: {}` zuverlässig alle niedrigeren Schlüssel; einzelne Hashes und Scopes werden nie aus verschiedenen Provider-Snapshots zusammengesetzt. `AllowedWorkflowIds` wird ebenfalls nicht indexweise zusammengeführt: `[A]` ersetzt eine niedrigere Liste `[A,B]`, und `[]` ist deny-all. Ein Provider-Override der `Keys`-Map muss daher alle weiterhin gewünschten Integrationen vollständig enthalten. Die komplette `Authentication`-Sektion ist boot-fest. Saves über die Admin-Einstellungen setzen den Restart-Marker; aktiv werden sie erst nach einem Service-Neustart. LDAP kann vor dem Speichern über `POST /api/admin/settings/test/ldap` gegen den aktuellen Entwurf geprüft werden. Secrets gehören in Umgebungsvariablen oder den Secret-Provider. diff --git a/src/nodepilot-docs-ui/content/security/hardening.md b/src/nodepilot-docs-ui/content/security/hardening.md index 168553ad..5fa18e79 100644 --- a/src/nodepilot-docs-ui/content/security/hardening.md +++ b/src/nodepilot-docs-ui/content/security/hardening.md @@ -32,13 +32,42 @@ Read-Mode ist Defense-in-Depth, kein Ersatz fuer einen gehaerteten Datenbank-Pri ## File Path Roots -`FileSystemOperation:AllowedRoots` loest lokale Symlinks/Junctions fuer existierende -Pfadsegmente auf, bevor der Root-Vergleich passiert. Ein Link innerhalb eines erlaubten Roots -auf ein Ziel ausserhalb wird dadurch lokal blockiert. +`FileSystemOperation:AllowedRoots` vergleicht Pfade nur innerhalb explizit erlaubter Roots. +Vorher werden alle vorhandenen Pfadsegmente ueber link-lokale Attribute geprueft: Symlinks, +Junctions und andere Reparse Points werden abgelehnt, nicht aufgeloest oder verfolgt. Diese +Reparse-Sperre gilt auch bei leerer oder fehlender Root-Liste. Remote-Aktivitaeten wiederholen +die Pruefung im PowerShell-Kontext des tatsaechlichen WinRM-Ziels. Ein nicht-leerer +konfigurierter Root muss dort existieren. -Remote-WinRM-Ziele bleiben eine explizite Grenze: die API kann die Reparse-Point-Map des -Remote-Hosts nicht lokal aufloesen. Remote-Workflows brauchen target-seitige ACLs, -eingeschraenkte Arbeitsverzeichnisse und keine breit beschreibbaren Link/Junction-Pfade. +Root-Arrays werden atomar aus dem hoechstprioren Configuration-Provider gelesen; ein kuerzeres +Runtime-Array erbt daher keine alten Indizes aus `appsettings.json`. `AllowedRoots: []` behaelt +die bestehende Semantik "keine Containment-Einschraenkung" bei, waehrend die Reparse-Sperre +aktiv bleibt. Sparse oder anderweitig fehlerhafte Arrays werden fail-closed abgelehnt. + +Die Pruefung schliesst vorhandene Junction-Bypaesse, ersetzt aber keine target-seitigen ACLs: +pfadbasierte PowerShell-/WinRM-Operationen koennen eine gleichzeitig durch einen anderen +Prozess umbenannte Parent-Directory nicht atomar an einen zuvor geprueften Handle binden. +Erlaubte Zielbaeume duerfen deshalb nicht fuer weniger privilegierte Benutzer beschreibbar sein. + +ZIP-Kompression akzeptiert Wildcards nur im letzten Pfadsegment. Die Expansion und der +Verzeichnis-Walk erfolgen kontrolliert und nicht rekursiv pro Schritt; jeder Manifest-Eintrag +wird vor dem Oeffnen erneut auf Reparse Points geprueft. Eckige Klammern sind dabei literale +Dateinamenzeichen und keine PowerShell-Provider-Wildcards. ZIP-Extraktion validiert vor und nach +jeder Verzeichniserstellung und schreibt Dateien mit `CreateNew`. + +Bei rekursiver Dateiueberwachung wird der vorhandene Baum vor dem Oeffnen des Watchers ohne +Folgen von Reparse Points geprueft. Auch der manuelle Scan laeuft iterativ, und Ereignispfade +werden vor dem Dispatch erneut validiert. Eine gleichzeitig durch einen privilegierten Prozess +ausgefuehrte Parent-Umbenennung kann mit pfadbasierten APIs weiterhin nicht atomar ausgeschlossen +werden; die ACL des Watched Roots bleibt deshalb sicherheitsrelevant. Gewoehnliche UNC-Shares +sind fuer FileWatcher weiterhin zulaessig, Windows-Device-/Extended-Pfade werden jedoch vor jedem +Filesystem-Zugriff verworfen, damit die Hard-Block-Liste nicht ueber `\\?\\C:\\...` umgangen wird. +Lokale administrative UNC-Aliase wie `\\localhost\\C$\\...` werden vor dem Vergleich auf den +lokalen Laufwerkspfad kanonisiert. Dabei gelten zuerst die Windows-/SMB-Normalisierungsregeln, +damit alternative Share-Schreibweisen und am Share-Root geklemmte `..`-Segmente dieselbe Policy +treffen. Ein Watch-Root, der einen gesperrten Systembaum enthält, wird ebenfalls abgelehnt. +Unbekannte benannte Shares des lokalen Rechners sind bei `AllowSystemPaths=false` fail-closed; +Remote-UNC-Shares werden nicht umgeschrieben. ## Rate-Limiting diff --git a/src/nodepilot-docs-ui/content/security/overview.md b/src/nodepilot-docs-ui/content/security/overview.md index e1e523d8..6763bac6 100644 --- a/src/nodepilot-docs-ui/content/security/overview.md +++ b/src/nodepilot-docs-ui/content/security/overview.md @@ -35,7 +35,7 @@ HSTS, CSP, `X-Frame-Options=DENY`, `nosniff`, `Referrer-Policy`. ## External Trigger -Nur aktiv wenn `ExternalTrigger:ApiKey` gesetzt. Gated via `X-Api-Key`-Header. +Gated via `X-Api-Key`; Schlüssel werden als SHA-256-Hash mit GUID-basierter Workflow-Allowlist konfiguriert. Der Zielworkflow muss zusätzlich einen aktiven `manualTrigger` enthalten. Der Legacy-Key ist ohne explizite `AllowedWorkflowIds`-Liste wirkungslos. Die höchste deklarierte `Keys`-Map ersetzt niedrigere Provider vollständig (`{}` widerruft alle Keys), Scope-Listen ebenso (`[]` = deny-all). Idempotency-Replays sind pro authentifiziertem Key-Principal isoliert und persistieren keinen rohen Headerwert. ## Rate-Limiting diff --git a/src/nodepilot-docs-ui/content/triggers.md b/src/nodepilot-docs-ui/content/triggers.md index bb9ee43c..7f33492f 100644 --- a/src/nodepilot-docs-ui/content/triggers.md +++ b/src/nodepilot-docs-ui/content/triggers.md @@ -110,6 +110,20 @@ Konfigurierbar sind: Der Pfad bezieht sich auf das Dateisystem des Rechners, auf dem NodePilot ausgeführt wird. Das Verzeichnis muss vorhanden sein, erreichbar sein und innerhalb der serverseitig erlaubten Pfade liegen. +Symlinks, Junctions und andere Reparse Points im überwachten Pfad werden immer abgelehnt. Bei +aktivierten Unterverzeichnissen gilt dies auch für den beim Start vorhandenen Unterbaum; der +manuelle Testlauf folgt solchen Einträgen ebenfalls nicht. Ereignispfade werden unmittelbar +vor dem Workflow-Start erneut geprüft. Reguläre UNC-Freigaben bleiben unterstützt; Windows- +Device- und Extended-Path-Namensräume (`\\?\\`, `\\.\\`, `\\??\\`) werden dagegen abgelehnt, +weil sie die Systempfad-Sperre durch eine alternative Schreibweise umgehen könnten. Administrative +Shares des lokalen Rechners (zum Beispiel `\\localhost\\C$`) werden für die Policy-Prüfung auf +den entsprechenden lokalen Pfad abgebildet; `\\localhost\\C$\\Windows` kann die Systemsperre +daher nicht umgehen. Ein Watch-Root darf standardmäßig auch keinen gesperrten Systempfad als +Unterbaum enthalten (zum Beispiel `C:\\` mit aktivierten Unterverzeichnissen). Benannte lokale +Shares ohne sicher ableitbaren Zielpfad werden bei `AllowSystemPaths=false` abgelehnt; mit der +expliziten Systempfad-Freigabe bleiben sie nutzbar. Shares anderer Rechner bleiben unverändert +unterstützt. + ### Verhalten bei unerreichbarem Verzeichnis Wird das überwachte Verzeichnis unerreichbar — etwa weil eine Netzwerkfreigabe durch einen Neustart oder eine gelöschte Freigabe wegfällt — erkennt NodePilot das und versucht die Überwachung regelmäßig neu aufzubauen. Die Abstände wachsen dabei bis auf fünf Minuten. Sobald das Verzeichnis wieder erreichbar ist, läuft die Überwachung selbsttätig weiter; ein Neustart oder ein manueller Eingriff ist nicht nötig. @@ -191,7 +205,7 @@ Einen Namespace `{{trigger.*}}` gibt es nicht. ## Workflow extern über die API starten -Ein veröffentlichter und aktivierter Workflow kann unabhängig von einem Webhook-Node über die External-Trigger-API gestartet werden: +Ein veröffentlichter und aktivierter Workflow kann unabhängig von einem Webhook-Node über die External-Trigger-API gestartet werden. Er muss dafür einen **aktiven manuellen Trigger** enthalten und seine GUID muss im Scope des verwendeten Integrationsschlüssels stehen: ```bash curl -X POST "https://nodepilot.example/api/trigger/Deploy" \ @@ -203,8 +217,42 @@ curl -X POST "https://nodepilot.example/api/trigger/Deploy" \ Voraussetzungen: -- `ExternalTrigger:ApiKey` ist administrativ konfiguriert und mindestens 32 UTF-8-Bytes lang. -- `X-Api-Key` enthält diesen Schlüssel. -- `Idempotency-Key` ist optional. Wiederholte Anfragen mit demselben Schlüssel starten innerhalb von 24 Stunden keine zweite Ausführung. +- Der Workflow enthält einen nicht deaktivierten `manualTrigger`. +- `X-Api-Key` enthält einen mindestens 32 UTF-8-Bytes langen Integrationsschlüssel. +- Der SHA-256-Hash dieses Schlüssels ist unter `ExternalTrigger:Keys` konfiguriert und `AllowedWorkflowIds` enthält die Workflow-GUID. Namen und Wildcards werden nicht akzeptiert. +- `Idempotency-Key` ist optional. Wiederholte Anfragen mit demselben Header **und demselben authentifizierten Integrationsschlüssel** starten innerhalb von 24 Stunden keine zweite Ausführung. Andere Integrationsschlüssel besitzen eine getrennte Replay-Domain und können sich nicht gegenseitig blockieren oder fremde Ergebnisse abrufen. NodePilot persistiert nur einen domain-separierten SHA-256-Digest, nie den Headerwert. + +Schlüssel und Hash können lokal erzeugt werden. Nur der Hash wird in NodePilot gespeichert; der Klartextschlüssel geht ausschließlich an die Integration: + +```powershell +$key = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) +$hash = [Convert]::ToBase64String( + [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($key))) +$key +$hash +``` + +```json +{ + "ExternalTrigger": { + "ApiKey": "", + "AllowedWorkflowIds": [], + "Keys": { + "ci-deploy": { + "KeyHash": "", + "AllowedWorkflowIds": ["21f1c0d4-0000-0000-0000-000000000000"] + } + } + } +} +``` + +Jeder Schlüssel besitzt einen eigenen Scope. Ein Schlüssel für Workflow A kann Workflow B nicht starten. Ein unbekannter Schlüssel liefert `401`; ein fehlender, deaktivierter, nicht freigegebener oder nicht per `manualTrigger` opt-in gesetzter Workflow liefert einheitlich `404`. + +Die gesamte `ExternalTrigger:Keys`-Map wird provider-atomar ausgewertet: Der höchstpriore Provider, der die Map deklariert, besitzt den vollständigen Snapshot; `Keys: {}` widerruft alle niedrigeren Integrationsschlüssel. Ein Override muss daher alle weiterhin gewünschten Einträge samt Hash und Scope enthalten. Auch Allowlisten sind atomar: Eine kürzere Liste ersetzt alle niedrigeren Indizes, `[]` ist deny-all. So können weder entfernte Schlüssel noch GUIDs durch das additive `IConfiguration`-Merging wieder sichtbar werden. + +Die Idempotency-Principal-ID enthält die case-insensitiv kanonisierte Integrations-ID und den Key-Fingerprint. Eine reine Änderung der Groß-/Kleinschreibung behält die Replay-Domain, eine Schlüsselrotation beginnt bewusst eine neue. Beim Upgrade auf diese Speicherung werden ältere rohe Cache-Einträge nicht mehr für Replays verwendet; sie laufen innerhalb der regulären 24-Stunden-TTL aus. Ein Retry über genau diese Upgrade-Grenze kann daher einmalig eine neue Ausführung erzeugen. + +Migration des alten `ExternalTrigger:ApiKey`: Die erlaubten GUIDs zunächst unter `ExternalTrigger:AllowedWorkflowIds` eintragen. Eine leere Liste verweigert alle Starts. Anschließend pro Integration einen neuen Hash-Eintrag anlegen und den Legacy-Key löschen. Derselbe Schlüssel darf während der Migration nicht gleichzeitig als Legacy-Key und Hash-Eintrag konfiguriert sein; doppelte Treffer werden fail-closed abgewiesen. Weitere Informationen enthält [Workflow-Steuerung](./api/workflow-control). diff --git a/src/nodepilot-ui/e2e/ai-chat.spec.ts b/src/nodepilot-ui/e2e/ai-chat.spec.ts index f7f57922..45235217 100644 --- a/src/nodepilot-ui/e2e/ai-chat.spec.ts +++ b/src/nodepilot-ui/e2e/ai-chat.spec.ts @@ -200,7 +200,7 @@ test.describe('AI Knowledge Chat (/ai-chat)', () => { await page.getByRole('button', { name: /^Chat 1$/i }).click(); await expect(page.getByText(/Hi there/i)).toBeVisible(); - // Reload — the store rehydrates from localStorage (key "nodepilot-aichat"); the active + // Reload — the store rehydrates from this tab's sessionStorage (key "nodepilot-aichat"); the active // thread ("Chat 1") and its messages survive. await page.reload(); await expect(page.locator('#np-main-scroll').getByRole('heading', { name: /^AI Chat$/i })).toBeVisible(); diff --git a/src/nodepilot-ui/src/App.tsx b/src/nodepilot-ui/src/App.tsx index fc5f7976..a6b742fb 100644 --- a/src/nodepilot-ui/src/App.tsx +++ b/src/nodepilot-ui/src/App.tsx @@ -4,23 +4,24 @@ import { createBrowserRouter, Navigate } from 'react-router'; // react-router v8 dissolved react-router-dom: general APIs live in 'react-router', the // DOM-specific ones (RouterProvider) in 'react-router/dom'. import { RouterProvider } from 'react-router/dom'; -import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { AppLayout } from './components/layout/AppLayout'; import { ErrorBoundary } from './components/ErrorBoundary'; import { ConfirmHost } from './components/common/ConfirmHost'; import { ToastHost } from './components/common/ToastHost'; import { DatabaseOutageBanner } from './components/layout/DatabaseOutageBanner'; +import { ProtectedRoute } from './components/ProtectedRoute'; import { useDatabaseHealth } from './hooks/useDatabaseHealth'; import { DashboardPage } from './pages/DashboardPage'; import { WorkflowsPage } from './pages/WorkflowsPage'; import { LoginPage } from './pages/LoginPage'; -import { useAuthStore } from './stores/authStore'; +import { startAuthBoundarySynchronization, useAuthStore } from './stores/authStore'; import { useThemeStore, applyTheme } from './stores/themeStore'; // Side-effect import: the module configures i18next on load. Nothing here reads its default // export any more — the one caller that did moved into lib/queryErrorToast. import './i18n'; import { applyFavicon } from './lib/appIcon'; -import { handleQueryError } from './lib/queryErrorToast'; +import { queryClient } from './queryClient'; // Lazy-loaded heavy pages — only fetched/compiled on first navigation. // Keeps the boot bundle lean (Dashboard + Workflows + Login eager covers the hot path). @@ -41,49 +42,22 @@ const DbViewerPage = lazy(() => import('./pages/DbViewerPage').then(m => ({ defa const BackupPage = lazy(() => import('./pages/BackupPage').then(m => ({ default: m.BackupPage }))); const MetricsPage = lazy(() => import('./pages/MetricsPage').then(m => ({ default: m.MetricsPage }))); -const queryClient = new QueryClient({ - // Surfaces failed queries the way mutations have always surfaced theirs. Policy and its - // reasoning live in lib/queryErrorToast so they can be tested directly. - queryCache: new QueryCache({ onError: handleQueryError }), - defaultOptions: { - queries: { - retry: 1, - staleTime: 10_000, - // By default React Query refetches every active query whenever the browser - // tab regains focus. With many tabs/pages holding active list queries - // (Executions, Audit, Dashboard), each tab switch would trigger a refetch - // storm against the backend. Turned off because SignalR events already - // invalidate the relevant caches precisely (see useSignalR). - refetchOnWindowFocus: false, - }, - }, -}); - // Apply persisted theme before first paint applyTheme(useThemeStore.getState().theme); // Recolor the browser-tab favicon to match the persisted skin pre-paint too, so the // tab icon never flashes the wrong hue before React mounts. applyFavicon(useThemeStore.getState().theme, useThemeStore.getState().resolvedTheme); +// Listen before the first probe so every tab observes logout and identity switches. The +// synchronization handler suppresses broadcasts from its own re-probe, preventing event loops. +startAuthBoundarySynchronization(); + // Kick off the auth probe once at bundle load. The store flips `isAuthenticated` from // null → true/false when the /auth/me call resolves. ProtectedRoute renders a loading // shell while `isAuthenticated === null` so we don't flash the login page for a user // whose cookie is still valid. void useAuthStore.getState().initialize(); -function ProtectedRoute({ children }: Readonly<{ children: React.ReactNode }>) { - const { t } = useTranslation(); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - if (isAuthenticated === null) { - return ( -
- {t('common:loading')} -
- ); - } - return isAuthenticated ? <>{children} : ; -} - /** * Admin-only route guard. Redirects non-Admin users to the dashboard silently. * The API layer enforces Admin-only on /api/users, this just hides the page. diff --git a/src/nodepilot-ui/src/__tests__/api/adminSettings.test.ts b/src/nodepilot-ui/src/__tests__/api/adminSettings.test.ts new file mode 100644 index 00000000..229db4a5 --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/api/adminSettings.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { adminSettings } from '../../api/adminSettings'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; + +describe('adminSettings auth-boundary binding', () => { + afterEach(() => vi.restoreAllMocks()); + + it('discardsAStaleSuccessfulResponseBeforeReturningItToTheCaller', async () => { + let resolveResponse!: (response: Response) => void; + const pendingResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pendingResponse); + + const staleRequest = adminSettings.getStatus(); + clearLocalAuthBoundary(); + resolveResponse(Response.json({ + overridesPath: 'user-a-path', + restartRequired: false, + restartRequiredSince: null, + restartRequiredFor: [], + lastSavedAt: null, + lastSavedBy: 'user-a', + })); + + await expect(staleRequest).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/src/nodepilot-ui/src/__tests__/api/ai.test.ts b/src/nodepilot-ui/src/__tests__/api/ai.test.ts index c09f685d..16091c33 100644 --- a/src/nodepilot-ui/src/__tests__/api/ai.test.ts +++ b/src/nodepilot-ui/src/__tests__/api/ai.test.ts @@ -8,6 +8,7 @@ vi.mock('../../api/client', () => ({ })); import { chatStream, generateScriptStream, type WorkflowChatProposal } from '../../api/ai'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; /** Builds a real Response backed by a chunked ReadableStream — exercises the SSE frame/boundary parsing logic. */ function sseResponse(chunks: string[]): Response { @@ -96,6 +97,36 @@ describe('chatStream SSE parser', () => { }); expect(deltas.join('')).toBe('ab'); }); + + it('discards buffered stream frames after the authentication boundary changes', async () => { + const encoder = new TextEncoder(); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + postEventStreamMock.mockResolvedValue(new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + })); + + const deltas: string[] = []; + const result = chatStream( + { question: 'q', workflowJson: '{}', baseDefinitionHash: 'x', history: [] }, + { onDelta: (text) => deltas.push(text), onProposal: () => {} }, + ); + + streamController.enqueue(encoder.encode('event: delta\ndata: {"text":"before"}\n\n')); + await vi.waitFor(() => expect(deltas).toEqual(['before'])); + + clearLocalAuthBoundary(); + streamController.enqueue(encoder.encode('event: delta\ndata: {"text":"stale"}\n\n')); + streamController.close(); + + await expect(result).rejects.toMatchObject({ name: 'AbortError' }); + expect(deltas).toEqual(['before']); + }); }); describe('generateScriptStream SSE parser', () => { diff --git a/src/nodepilot-ui/src/__tests__/api/client.test.ts b/src/nodepilot-ui/src/__tests__/api/client.test.ts index 6eb0cfcf..8f7a8365 100644 --- a/src/nodepilot-ui/src/__tests__/api/client.test.ts +++ b/src/nodepilot-ui/src/__tests__/api/client.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; +import { + AI_CHAT_STORAGE_KEY, + DB_ADMIN_QUERY_DRAFT_KEY, + DB_ADMIN_QUERY_HISTORY_KEY, +} from '../../security/sensitiveBrowserState'; +import { queryClient } from '../../queryClient'; +import { aiChatScopeKey, useAiChatStore } from '../../stores/aiChatStore'; +import { startAuthBoundarySynchronization, useAuthStore } from '../../stores/authStore'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; const BASE = 'http://localhost'; const server = setupServer(); @@ -10,6 +19,11 @@ beforeAll(() => { }); beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + queryClient.clear(); + useAiChatStore.setState({ messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }); + useAuthStore.setState({ userId: null, username: null, role: null, isAuthenticated: null }); // Clear cookies between tests. `document.cookie` accepts one entry at a time; // walking the current string and expiring each entry is the jsdom idiom. if (typeof document !== 'undefined') { @@ -147,10 +161,178 @@ describe('API Client', () => { ); patchFetch(); + sessionStorage.setItem(AI_CHAT_STORAGE_KEY, 'sensitive chat'); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT secret'); + localStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["legacy query"]'); + const scope = aiChatScopeKey('u-1', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'Sensitive'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'live secret' }, + ]); + useAuthStore.setState({ userId: 'u-1', username: 'alice', role: 'Admin', isAuthenticated: true }); + queryClient.setQueryData(['user-private'], { secret: true }); + const { api } = await import('../../api/client'); await expect(api.get('/protected')).rejects.toThrow('Unauthorized'); expect(window.location.href).toBe('/login'); + expect(sessionStorage.getItem(AI_CHAT_STORAGE_KEY)).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(localStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(queryClient.getQueryData(['user-private'])).toBeUndefined(); + }); + + it('get_stale401FromPreviousIdentity_doesNotClearOrRedirectCurrentIdentity', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + let resolveResponse!: (response: Response) => void; + const staleResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(staleResponse); + + const { api } = await import('../../api/client'); + const previousUserRequest = api.get('/protected'); + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT user_b_data'); + queryClient.setQueryData(['user-b-private'], { secret: 'belongs-to-b' }); + + resolveResponse(new Response(null, { status: 401, statusText: 'Unauthorized' })); + await expect(previousUserRequest).rejects.toMatchObject({ name: 'AbortError' }); + + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'bob', + isAuthenticated: true, + }); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBe('SELECT user_b_data'); + expect(queryClient.getQueryData(['user-b-private'])).toEqual({ secret: 'belongs-to-b' }); + expect(window.location.href).toBe(''); + }); + + it('stale login response re-probes after its Set-Cookie may have replaced the newer identity', async () => { + let resolveStaleLogin!: (response: Response) => void; + const staleLoginResponse = new Promise((resolve) => { + resolveStaleLogin = resolve; + }); + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockReturnValueOnce(staleLoginResponse) + .mockResolvedValueOnce(Response.json({ + id: 'u-cookie-owner', + username: 'cookie-owner', + role: 'Viewer', + })); + const stopSynchronization = startAuthBoundarySynchronization(); + + try { + const { api } = await import('../../api/client'); + const pendingUserALogin = api.post('/auth/login', { + username: 'alice', + password: 'secret', + }); + + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT user_b_data'); + queryClient.setQueryData(['user-b-private'], { secret: 'belongs-to-b' }); + + resolveStaleLogin(Response.json({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + })); + await expect(pendingUserALogin).rejects.toMatchObject({ name: 'AbortError' }); + + await vi.waitFor(() => expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-cookie-owner', + username: 'cookie-owner', + role: 'Viewer', + isAuthenticated: true, + })); + expect(fetchMock).toHaveBeenCalledWith('/api/auth/me', expect.objectContaining({ + credentials: 'include', + })); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(queryClient.getQueryData(['user-b-private'])).toBeUndefined(); + } finally { + stopSynchronization(); + } + }); + + it('get_staleSuccessCannotPopulateCurrentUsersQueryCache', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + let resolveResponse!: (response: Response) => void; + const staleResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(staleResponse); + + const { api } = await import('../../api/client'); + const staleCacheWrite = api.get<{ secret: string }>('/slow-user-a-data').then((data) => { + queryClient.setQueryData(['late-user-a-result'], data); + }); + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + }); + queryClient.setQueryData(['user-b-private'], { secret: 'belongs-to-b' }); + + resolveResponse(Response.json({ secret: 'belongs-to-a' })); + await expect(staleCacheWrite).rejects.toMatchObject({ name: 'AbortError' }); + + expect(queryClient.getQueryData(['late-user-a-result'])).toBeUndefined(); + expect(queryClient.getQueryData(['user-b-private'])).toEqual({ secret: 'belongs-to-b' }); + }); + + it('download_staleSuccessDoesNotCreateOrClickAnAnchor', async () => { + let resolveResponse!: (response: Response) => void; + const staleResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(staleResponse); + const originalCreateObjectUrl = URL.createObjectURL; + const originalRevokeObjectUrl = URL.revokeObjectURL; + const createObjectUrl = vi.fn(() => 'blob:stale'); + URL.createObjectURL = createObjectUrl; + URL.revokeObjectURL = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + const { downloadFromApi } = await import('../../api/client'); + const staleDownload = downloadFromApi('/slow-export', 'export.zip'); + clearLocalAuthBoundary(); + resolveResponse(new Response('User A export', { + status: 200, + headers: { 'Content-Disposition': 'attachment; filename="user-a.zip"' }, + })); + + await expect(staleDownload).rejects.toMatchObject({ name: 'AbortError' }); + expect(createObjectUrl).not.toHaveBeenCalled(); + expect(click).not.toHaveBeenCalled(); + } finally { + URL.createObjectURL = originalCreateObjectUrl; + URL.revokeObjectURL = originalRevokeObjectUrl; + } }); it('post_401_onLoginPage_surfacesServerPayloadWithoutRedirect', async () => { @@ -169,10 +351,12 @@ describe('API Client', () => { )) ); patchFetch(); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT previous_user_secret'); const { api } = await import('../../api/client'); await expect(api.post('/auth/login', {})).rejects.toThrow(/SETUP_TOKEN_REQUIRED/); expect(window.location.href).toBe('/login'); // unchanged + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); }); it('delete_204_returnsUndefined', async () => { diff --git a/src/nodepilot-ui/src/__tests__/api/operations.test.ts b/src/nodepilot-ui/src/__tests__/api/operations.test.ts index b2df1d9e..29e7390c 100644 --- a/src/nodepilot-ui/src/__tests__/api/operations.test.ts +++ b/src/nodepilot-ui/src/__tests__/api/operations.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { api } from '../../api/client'; -import { getOperationsGraph } from '../../api/operations'; +import { getOperationsGraph, quarantineWorkflow } from '../../api/operations'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; afterEach(() => { vi.restoreAllMocks(); @@ -22,4 +23,21 @@ describe('operations API', () => { expect(get).toHaveBeenCalledWith('/operations/graph?windowMinutes=60'); }); + + it('does not start cancel-all under a replacement identity after disable completes', async () => { + let finishDisable!: () => void; + const disableGate = new Promise((resolve) => { finishDisable = resolve; }); + const post = vi.spyOn(api, 'post').mockImplementation((path: string) => { + if (path.endsWith('/disable')) return disableGate as never; + return Promise.resolve({ total: 1, signalled: 1 }) as never; + }); + + const result = quarantineWorkflow('user-a-workflow'); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + clearLocalAuthBoundary(); + finishDisable(); + + await expect(result).rejects.toMatchObject({ name: 'AbortError' }); + expect(post).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/nodepilot-ui/src/__tests__/components/LoginPage.test.tsx b/src/nodepilot-ui/src/__tests__/components/LoginPage.test.tsx index e02df586..3d8c8967 100644 --- a/src/nodepilot-ui/src/__tests__/components/LoginPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/LoginPage.test.tsx @@ -1,10 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router'; import { LoginPage } from '../../pages/LoginPage'; import { useAuthStore } from '../../stores/authStore'; +import { aiChatScopeKey, useAiChatStore } from '../../stores/aiChatStore'; import { api, ApiError } from '../../api/client'; +import { queryClient } from '../../queryClient'; +import { + DB_ADMIN_QUERY_DRAFT_KEY, + DB_ADMIN_QUERY_HISTORY_KEY, +} from '../../security/sensitiveBrowserState'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; function renderLoginPage() { return render( @@ -16,7 +23,12 @@ function renderLoginPage() { describe('LoginPage', () => { beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + queryClient.clear(); + useAiChatStore.setState({ messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }); useAuthStore.setState({ + userId: null, username: null, role: null, isAuthenticated: false, @@ -147,10 +159,22 @@ describe('LoginPage', () => { windows: true, windowsEndpoint: '/api/auth/windows', }); - const postSpy = vi.spyOn(api, 'post').mockResolvedValue({ - token: 't', - userId: 'u-1', + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', username: 'FIRMA\\\\alice', + role: 'Admin', + }); + const scope = aiChatScopeKey('u-a', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'User A'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'user-a secret' }, + ]); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT user_a_secret'); + sessionStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["SELECT user_a_secret"]'); + queryClient.setQueryData(['user-a-result'], { secret: true }); + const postSpy = vi.spyOn(api, 'post').mockResolvedValue({ + userId: 'u-b', + username: 'FIRMA\\\\bob', role: 'Operator', }); @@ -159,6 +183,54 @@ describe('LoginPage', () => { await user.click(ssoButton); expect(postSpy).toHaveBeenCalledWith('/auth/windows'); + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'FIRMA\\\\bob', + role: 'Operator', + isAuthenticated: true, + }); + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + expect(queryClient.getQueryData(['user-a-result'])).toBeUndefined(); + }); + + it('staleWindowsSsoResponse_cannotOverwriteANewerAuthBoundary', async () => { + const user = userEvent.setup(); + vi.spyOn(api, 'get').mockResolvedValue({ + local: true, + ldap: false, + windows: true, + windowsEndpoint: '/api/auth/windows', + }); + let resolveWindows!: (identity: { userId: string; username: string; role: string }) => void; + const staleWindowsResponse = new Promise<{ userId: string; username: string; role: string }>((resolve) => { + resolveWindows = resolve; + }); + vi.spyOn(api, 'post').mockReturnValueOnce(staleWindowsResponse); + + renderLoginPage(); + await user.click(await screen.findByRole('button', { name: /windows account/i })); + expect(api.post).toHaveBeenCalledWith('/auth/windows'); + + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'FIRMA\\\\bob', + role: 'Operator', + }); + await act(async () => { + resolveWindows({ userId: 'u-a', username: 'FIRMA\\\\alice', role: 'Admin' }); + await staleWindowsResponse; + await Promise.resolve(); + }); + + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'FIRMA\\\\bob', + role: 'Operator', + isAuthenticated: true, + }); }); it('renders OIDC as a top-level browser navigation and can hide password login', async () => { diff --git a/src/nodepilot-ui/src/__tests__/components/ProtectedRoute.test.tsx b/src/nodepilot-ui/src/__tests__/components/ProtectedRoute.test.tsx index 0cf5744a..7e2e5fdc 100644 --- a/src/nodepilot-ui/src/__tests__/components/ProtectedRoute.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/ProtectedRoute.test.tsx @@ -1,21 +1,19 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Routes, Route, Navigate } from 'react-router'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router'; import { useAuthStore } from '../../stores/authStore'; - -// Inline ProtectedRoute matching App.tsx -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - return isAuthenticated ? <>{children} : ; -} +import { ProtectedRoute } from '../../components/ProtectedRoute'; describe('ProtectedRoute', () => { beforeEach(() => { useAuthStore.setState({ + userId: null, username: null, role: null, isAuthenticated: false, }); + sessionStorage.clear(); }); it('unauthenticated redirects to login', () => { @@ -61,4 +59,44 @@ describe('ProtectedRoute', () => { expect(screen.getByText('Dashboard')).toBeInTheDocument(); expect(screen.queryByText('Login Page')).not.toBeInTheDocument(); }); + + it('localIdentitySwitch_remountsProtectedComponentStateEvenWhenReactBatchesTheTransition', () => { + const unmounted = vi.fn(); + function StatefulProtectedChild() { + const [draft, setDraft] = React.useState(''); + React.useEffect(() => () => unmounted(), []); + return setDraft(event.target.value)} />; + } + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + render( + + + Login Page} /> + + + + )} /> + + , + ); + fireEvent.change(screen.getByRole('textbox', { name: 'private draft' }), { + target: { value: 'User A local state' }, + }); + + act(() => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + }); + }); + + expect(unmounted).toHaveBeenCalledOnce(); + expect(screen.getByRole('textbox', { name: 'private draft' })).toHaveValue(''); + }); }); 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 f4037bcd..dcddaedf 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 @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; @@ -32,16 +32,27 @@ const restApi = { }, etag: '"r-1"', isHotReloadable: false, effectiveSource: {}, }; +const waitForCondition = { + sectionPath: 'WaitForCondition', + payload: { allowedHosts: [] }, + etag: '"wfc-1"', isHotReloadable: true, effectiveSource: {}, +}; const fso = { sectionPath: 'FileSystemOperation', payload: { rejectTraversal: true, allowedRoots: [] }, etag: '"f-1"', isHotReloadable: true, effectiveSource: {} }; const sql = { sectionPath: 'SqlActivity', payload: { requireConnectionRef: false }, etag: '"s-1"', isHotReloadable: true, effectiveSource: {} }; const sp = { sectionPath: 'StartProgram', payload: { disallowShellExecute: true }, etag: '"sp-1"', isHotReloadable: true, effectiveSource: {} }; const wh = { sectionPath: 'Webhook', payload: { requireSecret: true }, etag: '"wh-1"', isHotReloadable: true, effectiveSource: {} }; -const et = { sectionPath: 'ExternalTrigger', payload: { apiKey: '********' }, etag: '"et-1"', isHotReloadable: true, effectiveSource: {} }; +const allowedWorkflowId = '11111111-1111-1111-1111-111111111111'; +const et = { + sectionPath: 'ExternalTrigger', + payload: { apiKey: '********', allowedWorkflowIds: [allowedWorkflowId] }, + etag: '"et-1"', isHotReloadable: true, effectiveSource: {}, +}; const sec = { sectionPath: 'Security', payload: { strictAllowedHosts: false, allowedHosts: '*' }, etag: '"sec-1"', isHotReloadable: false, effectiveSource: {} }; function renderAll() { server.use( http.get('/api/admin/settings/RestApi', () => HttpResponse.json(restApi)), + http.get('/api/admin/settings/WaitForCondition', () => HttpResponse.json(waitForCondition)), http.get('/api/admin/settings/FileSystemOperation', () => HttpResponse.json(fso)), http.get('/api/admin/settings/SqlActivity', () => HttpResponse.json(sql)), http.get('/api/admin/settings/StartProgram', () => HttpResponse.json(sp)), @@ -63,11 +74,31 @@ describe('SecuritySection', () => { expect(screen.getAllByRole('button', { name: /speichern|save/i }).length).toBeGreaterThanOrEqual(7); }); - it('shows the hot-reload hint on the five live hardening cards but not on RestApi/Security', async () => { + it('shows the hot-reload hint on the six live hardening cards but not on RestApi/Security', async () => { renderAll(); await waitFor(() => expect(screen.getByDisplayValue('*')).toBeInTheDocument(), { timeout: 3000 }); - // FileSystemOperation / SqlActivity / StartProgram / Webhook / ExternalTrigger → 5 hints. - expect(screen.getAllByText(/Changes apply immediately/i).length).toBe(5); + // WaitForCondition / FileSystemOperation / SqlActivity / StartProgram / Webhook / + // ExternalTrigger → 6 hints. + expect(screen.getAllByText(/Changes apply immediately/i).length).toBe(6); + }); + + it('External Trigger Save preserves the legacy workflow allow-list', async () => { + let putBody: unknown = null; + server.use(http.put('/api/admin/settings/ExternalTrigger', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json({ ...et, etag: '"et-2"' }); + })); + renderAll(); + await waitFor(() => expect(screen.getByDisplayValue(allowedWorkflowId)).toBeInTheDocument(), { timeout: 3000 }); + + const card = screen.getByText('External Trigger API').closest('.np-card') as HTMLElement | null; + expect(card).not.toBeNull(); + fireEvent.click(within(card!).getByRole('button', { name: /save/i })); + + await waitFor(() => expect(putBody).toEqual(expect.objectContaining({ + ApiKey: '__unchanged__', + AllowedWorkflowIds: [allowedWorkflowId], + }))); }); it('every hardening switch carries an explanatory hint', async () => { diff --git a/src/nodepilot-ui/src/__tests__/components/dbviewer/QueryPane.test.tsx b/src/nodepilot-ui/src/__tests__/components/dbviewer/QueryPane.test.tsx index edc44241..935302ad 100644 --- a/src/nodepilot-ui/src/__tests__/components/dbviewer/QueryPane.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/dbviewer/QueryPane.test.tsx @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryPane } from '../../../components/dbviewer/QueryPane'; -import { dbAdminApi } from '../../../api/dbadmin'; +import { dbAdminApi, type DbAdminQueryResponse } from '../../../api/dbadmin'; +import { useAuthStore } from '../../../stores/authStore'; +import { clearLocalAuthBoundary } from '../../../security/authBoundary'; +import { + DB_ADMIN_QUERY_DRAFT_KEY, + DB_ADMIN_QUERY_HISTORY_KEY, +} from '../../../security/sensitiveBrowserState'; vi.mock('../../../api/dbadmin', () => ({ dbAdminApi: { @@ -62,6 +68,8 @@ describe('QueryPane', () => { mode: 'read', }); globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + useAuthStore.setState({ userId: null, username: null, role: null, isAuthenticated: false }); }); it('rendersProviderBadge_fromInfoEndpoint', async () => { @@ -199,15 +207,15 @@ describe('QueryPane', () => { }); }); - it('history_persistsAcrossPaneRemount_viaLocalStorage', async () => { - globalThis.localStorage.setItem( + it('history_persistsAcrossPaneRemount_withinSessionStorage', async () => { + globalThis.sessionStorage.setItem( 'nodepilot.dbAdmin.queryHistory', JSON.stringify(['SELECT 1', 'SELECT 2']), ); wrap(); await waitFor(() => expect(screen.getByText('postgres')).toBeInTheDocument()); - // The history button shows the count from localStorage. + // The history button shows the count from this tab's session storage. expect(screen.getByRole('button', { name: /History \(2\)/ })).toBeInTheDocument(); }); @@ -222,7 +230,57 @@ describe('QueryPane', () => { expect(screen.getByRole('button', { name: /History \(1\)/ })).toBeInTheDocument(); }); - const stored = JSON.parse(globalThis.localStorage.getItem('nodepilot.dbAdmin.queryHistory') ?? '[]') as string[]; + const stored = JSON.parse(globalThis.sessionStorage.getItem('nodepilot.dbAdmin.queryHistory') ?? '[]') as string[]; expect(stored).toEqual(['SELECT 1']); + expect(globalThis.localStorage.getItem('nodepilot.dbAdmin.queryHistory')).toBeNull(); + }); + + it('inFlightQueryFromPreviousIdentity_cannotRestoreHistoryOrResultsAfterBoundary', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + let resolveQuery!: (result: DbAdminQueryResponse) => void; + const pendingQuery = new Promise((resolve) => { + resolveQuery = resolve; + }); + vi.mocked(dbAdminApi.query).mockReturnValueOnce(pendingQuery); + + wrap(); + await waitFor(() => expect(screen.getByText('postgres')).toBeInTheDocument()); + fireEvent.change(screen.getByTestId('sql-editor'), { + target: { value: 'SELECT user_a_secret' }, + }); + await userEvent.click(screen.getByRole('button', { name: /^Run$/ })); + await waitFor(() => expect(dbAdminApi.query).toHaveBeenCalledWith( + 'SELECT user_a_secret', + 'read', + )); + + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + }); + + await act(async () => { + resolveQuery({ + columns: [{ name: 'Secret', type: 'text' }], + rows: [['user-a-result']], + rowsAffected: null, + durationMs: 5, + truncated: false, + mode: 'read', + }); + await pendingQuery; + await Promise.resolve(); + }); + + expect(useAuthStore.getState().userId).toBe('u-b'); + expect(globalThis.sessionStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + expect(globalThis.sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(screen.queryByText('user-a-result')).not.toBeInTheDocument(); }); }); diff --git a/src/nodepilot-ui/src/__tests__/components/properties/propertyConfigs.test.tsx b/src/nodepilot-ui/src/__tests__/components/properties/propertyConfigs.test.tsx index 0c802f85..0fd7ab3b 100644 --- a/src/nodepilot-ui/src/__tests__/components/properties/propertyConfigs.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/properties/propertyConfigs.test.tsx @@ -224,6 +224,7 @@ describe('SqlConfig', () => { // Port defaults to 5432 — pinned so a typo'd default can't silently route to a wrong // listener on a multi-tenant Postgres host. expect(screen.getByDisplayValue('5432')).toBeInTheDocument(); + expect(screen.getByDisplayValue(/VerifyFull \(Default\)/)).toBeInTheDocument(); }); it('switchToRawMode_clearsBuilderFields', () => { diff --git a/src/nodepilot-ui/src/__tests__/lib/queryErrorToast.test.ts b/src/nodepilot-ui/src/__tests__/lib/queryErrorToast.test.ts index 0bced5b6..251f02a7 100644 --- a/src/nodepilot-ui/src/__tests__/lib/queryErrorToast.test.ts +++ b/src/nodepilot-ui/src/__tests__/lib/queryErrorToast.test.ts @@ -79,6 +79,13 @@ describe('handleQueryError', () => { expect(useToastStore.getState().toasts).toHaveLength(0); }); + it('shows nothing for an auth-boundary abort', () => { + const abort = new Error('Authentication context changed'); + abort.name = 'AbortError'; + handleQueryError(abort, fakeQuery()); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + it('does not accumulate a toast per poll for a persistently failing background query', () => { // The shape of the shipped defect: the header polls /healthz/live every 15 s, and a dead // backend turned that into an endless stream of "status 502" toasts saying exactly what the diff --git a/src/nodepilot-ui/src/__tests__/lib/resolveWorkflowRef.test.ts b/src/nodepilot-ui/src/__tests__/lib/resolveWorkflowRef.test.ts index 167ce76d..030b0701 100644 --- a/src/nodepilot-ui/src/__tests__/lib/resolveWorkflowRef.test.ts +++ b/src/nodepilot-ui/src/__tests__/lib/resolveWorkflowRef.test.ts @@ -1,12 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { resolveWorkflowRef } from '../../lib/resolveWorkflowRef'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; const server = setupServer(); beforeEach(() => server.listen({ onUnhandledRequest: 'error' })); -afterEach(() => { server.resetHandlers(); server.close(); }); +afterEach(() => { server.resetHandlers(); server.close(); vi.restoreAllMocks(); }); const mockWorkflow = { id: '11111111-1111-1111-1111-111111111111', @@ -93,4 +94,18 @@ describe('resolveWorkflowRef', () => { await resolveWorkflowRef(upperGuid.toUpperCase()); expect(usedPath).not.toContain('by-name'); }); + + it('discardsAStaleSuccessfulLookupAfterAnAuthBoundary', async () => { + let resolveResponse!: (response: Response) => void; + const pendingResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pendingResponse); + + const staleLookup = resolveWorkflowRef('Daily-Report'); + clearLocalAuthBoundary(); + resolveResponse(Response.json(mockWorkflow)); + + await expect(staleLookup).rejects.toMatchObject({ name: 'AbortError' }); + }); }); diff --git a/src/nodepilot-ui/src/__tests__/pages/CustomActivitiesPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/CustomActivitiesPage.test.tsx index b3c150c8..9eac8858 100644 --- a/src/nodepilot-ui/src/__tests__/pages/CustomActivitiesPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/CustomActivitiesPage.test.tsx @@ -7,6 +7,7 @@ import { CustomActivitiesPage } from '../../pages/CustomActivitiesPage'; import { useAuthStore } from '../../stores/authStore'; import { useToastStore } from '../../stores/toastStore'; import { confirmDialog } from '../../stores/confirmStore'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; vi.mock('../../stores/confirmStore', async (importOriginal) => { const mod = await importOriginal(); @@ -84,11 +85,12 @@ function renderPage(role: 'Admin' | 'Operator' | 'Viewer' = 'Admin') { useAuthStore.setState({ isAuthenticated: true, username: 'u', role }); patchFetch(); const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); - return render( + const view = render( , ); + return { ...view, queryClient: qc }; } describe('CustomActivitiesPage', () => { @@ -251,6 +253,57 @@ describe('CustomActivitiesPage', () => { expect(posted).toBe(false); }); + it('does not download an export whose response crosses an auth boundary', async () => { + let releaseExport!: () => void; + const exportGate = new Promise((resolve) => { releaseExport = resolve; }); + let requestStarted = false; + seed([]); + server.use(http.get(`${BASE}/api/custom-activities/export`, async () => { + requestStarted = true; + await exportGate; + return HttpResponse.json({ items: [{ name: 'User A private node' }] }); + })); + const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + renderPage('Admin'); + await waitFor(() => expect(screen.getByText(/No custom nodes yet/i)).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /^Export$/i })); + await waitFor(() => expect(requestStarted).toBe(true)); + clearLocalAuthBoundary(); + releaseExport(); + + await new Promise((resolve) => globalThis.setTimeout(resolve, 25)); + expect(anchorClick).not.toHaveBeenCalled(); + }); + + it('does not post or toast when a file read fails after the auth boundary changed', async () => { + let rejectRead!: (error: Error) => void; + const deferredRead = new Promise((_resolve, reject) => { rejectRead = reject; }); + let posted = false; + seed([]); + server.use(http.post(`${BASE}/api/custom-activities/import`, () => { + posted = true; + return HttpResponse.json([]); + })); + const { container, queryClient } = renderPage('Admin'); + await waitFor(() => expect(screen.getByText(/No custom nodes yet/i)).toBeInTheDocument()); + + const file = new File(['old-user'], 'user-a-private.npca', { type: 'application/json' }); + const readFile = vi.spyOn(file, 'text').mockReturnValue(deferredRead); + const fileInput = container.querySelector('input[type=file]')!; + fireEvent.change(fileInput, { target: { files: [file] } }); + await waitFor(() => expect(readFile).toHaveBeenCalledTimes(1)); + + clearLocalAuthBoundary(); + rejectRead(new Error('Could not read user-a-private.npca')); + + await waitFor(() => expect( + queryClient.getMutationCache().getAll().some((mutation) => mutation.state.status === 'error'), + ).toBe(true)); + expect(posted).toBe(false); + expect(useToastStore.getState().toasts).toEqual([]); + }); + it('createDialog_codeMirrorEdit_updatesScriptTemplateState', async () => { let postedBody: Record | null = null; seed([]); diff --git a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx index e2750d06..b02175f1 100644 --- a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx @@ -10,6 +10,7 @@ const signalRMock = vi.hoisted(() => ({ handlers: {} as Record void)[]>, connection: null as { stop: ReturnType; invoke: ReturnType } | null, })); +const toPngMock = vi.hoisted(() => vi.fn(() => Promise.resolve('data:image/png;base64,'))); // 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. @@ -39,7 +40,7 @@ vi.mock('@microsoft/signalr', () => { // `html-to-image` (used by the PNG-export button) tries to read CSS that jsdom doesn't // provide. The button is never clicked during smoke tests, but the static import must // not blow up at module-load time. -vi.mock('html-to-image', () => ({ toPng: () => Promise.resolve('data:image/png;base64,') })); +vi.mock('html-to-image', () => ({ toPng: toPngMock })); // 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 @@ -49,6 +50,7 @@ vi.mock('elkjs', () => ({ default: class { layout() { return Promise.resolve({}) import { WorkflowEditorPage } from '../../pages/WorkflowEditorPage'; import { useAuthStore } from '../../stores/authStore'; import { COMPLETED_EXECUTION_TTL_MS } from '../../hooks/useSignalR'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; const BASE = 'http://localhost'; @@ -164,6 +166,7 @@ beforeEach(() => { signalRMock.connection = null; useDesignStore.setState({ designerMode: 'expert' }); useToastStore.setState({ toasts: [] }); + toPngMock.mockReset().mockResolvedValue('data:image/png;base64,'); }); function emitSignalR(event: string, payload: unknown) { @@ -421,6 +424,22 @@ describe('WorkflowEditorPage — Workflow data variations', () => { await openToolsMenu(); expect(screen.getByTitle('Export as JSON')).not.toBeDisabled(); }); + + it('does not download a PNG rendered for the previous authentication context', async () => { + let finishRender!: (dataUrl: string) => void; + toPngMock.mockReturnValue(new Promise((resolve) => { finishRender = resolve; })); + const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + renderPage(); + await waitForCanvasReady(); + await openToolsMenu(); + + fireEvent.click(screen.getByTitle('Export as PNG')); + await waitFor(() => expect(toPngMock).toHaveBeenCalledTimes(1)); + clearLocalAuthBoundary(); + await act(async () => { finishRender('data:image/png;base64,user-a'); }); + + expect(anchorClick).not.toHaveBeenCalled(); + }); }); describe('WorkflowEditorPage — Sidebar (Node Library / Workflows tabs)', () => { diff --git a/src/nodepilot-ui/src/__tests__/pages/WorkflowsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/WorkflowsPage.test.tsx index 0cd3e453..78f92469 100644 --- a/src/nodepilot-ui/src/__tests__/pages/WorkflowsPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/WorkflowsPage.test.tsx @@ -8,6 +8,7 @@ import { WorkflowsPage } from '../../pages/WorkflowsPage'; import { useAuthStore } from '../../stores/authStore'; import { useToastStore } from '../../stores/toastStore'; import type { Workflow } from '../../types/api'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; const BASE = 'http://localhost'; @@ -53,13 +54,14 @@ function renderPage(role: 'Admin' | 'Operator' | 'Viewer' = 'Admin') { useAuthStore.setState({ isAuthenticated: true, username: 'admin', role }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); patchFetch(); - return render( + const view = render( ); + return { ...view, queryClient: qc }; } function mkWorkflow(overrides: Partial = {}): Workflow { @@ -291,6 +293,70 @@ describe('WorkflowsPage — import result toast', () => { expect(toastEntry.kind).toBe('error'); expect(toastEntry.message).toContain('workflow "X" is invalid'); }); + + it('stops a multi-file import instead of posting the next User-A file after an auth boundary', async () => { + let releaseFirst!: () => void; + const firstResponseGate = new Promise((resolve) => { releaseFirst = resolve; }); + let postCount = 0; + server.use( + http.get(`${BASE}/api/workflows`, () => HttpResponse.json([])), + http.post(`${BASE}/api/workflows/import`, async () => { + postCount++; + if (postCount === 1) await firstResponseGate; + return HttpResponse.json({ created: 1, workflows: [], errors: [] }); + }), + ); + + const { container, queryClient } = renderPage('Admin'); + await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()); + const secondFile = envelopeFile('user-a-second.json'); + const secondText = vi.spyOn(secondFile, 'text'); + const input = container.querySelector('input[accept="application/json,.json"]') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [envelopeFile('user-a-first.json'), secondFile] }, + }); + await waitFor(() => expect(postCount).toBe(1)); + + clearLocalAuthBoundary(); + releaseFirst(); + + await waitFor(() => expect( + queryClient.getMutationCache().getAll().some((mutation) => mutation.state.status === 'error'), + ).toBe(true)); + expect(postCount).toBe(1); + expect(secondText).not.toHaveBeenCalled(); + expect(useToastStore.getState().toasts).toEqual([]); + }); + + it('does not start a SCOrch upload after its local file read crosses an auth boundary', async () => { + let finishRead!: (xml: string) => void; + const deferredRead = new Promise((resolve) => { finishRead = resolve; }); + let posted = false; + server.use( + http.get(`${BASE}/api/workflows`, () => HttpResponse.json([])), + http.post(`${BASE}/api/workflows/import-scorch`, () => { + posted = true; + return HttpResponse.json({ created: 0, workflows: [], variables: [], warnings: [], errors: [] }); + }), + ); + const { container, queryClient } = renderPage('Admin'); + await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()); + const file = new File([''], 'user-a.ois_export', { type: 'application/xml' }); + vi.spyOn(file, 'text').mockReturnValue(deferredRead); + const input = container.querySelector( + 'input[accept=".ois_export,.ore,application/xml,text/xml,.xml"]', + ) as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); + + clearLocalAuthBoundary(); + finishRead(''); + + await waitFor(() => expect( + queryClient.getMutationCache().getAll().some((mutation) => mutation.state.status === 'error'), + ).toBe(true)); + expect(posted).toBe(false); + expect(useToastStore.getState().toasts).toEqual([]); + }); }); describe('WorkflowsPage — RBAC', () => { diff --git a/src/nodepilot-ui/src/__tests__/security/authBoundary.test.ts b/src/nodepilot-ui/src/__tests__/security/authBoundary.test.ts new file mode 100644 index 00000000..66f9b471 --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/security/authBoundary.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AUTH_BOUNDARY_STORAGE_KEY, + captureAuthBoundaryGeneration, + clearLocalAuthBoundary, + handleStaleAuthCookieResponseBoundary, + isAuthBoundaryGenerationCurrent, + publishAuthenticatedIdentity, + registerAuthBoundaryIdentityReprober, + registerAuthBoundaryLiveStateClearer, + registerAuthBoundaryQueryCacheClearer, + subscribeToAuthBoundaryEvents, + type AuthBoundaryEvent, +} from '../../security/authBoundary'; +import { DB_ADMIN_QUERY_DRAFT_KEY } from '../../security/sensitiveBrowserState'; + +class FakeBroadcastChannel { + static instances: FakeBroadcastChannel[] = []; + + readonly name: string; + readonly postMessage = vi.fn(); + readonly close = vi.fn(); + private readonly messageListeners = new Set<(event: MessageEvent) => void>(); + + constructor(name: string) { + this.name = name; + FakeBroadcastChannel.instances.push(this); + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === 'message') this.messageListeners.add(listener as (event: MessageEvent) => void); + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === 'message') this.messageListeners.delete(listener as (event: MessageEvent) => void); + } + + emitRemote(data: AuthBoundaryEvent): void { + for (const listener of this.messageListeners) listener({ data } as MessageEvent); + } +} + +describe('authBoundary', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + FakeBroadcastChannel.instances = []; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('uses BroadcastChannel and accepts only validated remote events', () => { + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + const listener = vi.fn(); + const unsubscribe = subscribeToAuthBoundaryEvents(listener); + const channel = FakeBroadcastChannel.instances[0]; + + channel.emitRemote({ + version: 1, + type: 'identity', + userId: 'user-b', + sourceId: 'another-tab', + eventId: 'event-1', + }); + channel.emitRemote({ + version: 1, + type: 'identity', + userId: 'user-b', + sourceId: 'another-tab', + eventId: 'event-1', + }); + + expect(listener).toHaveBeenCalledTimes(1); + publishAuthenticatedIdentity('user-a'); + expect(channel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + version: 1, + type: 'identity', + userId: 'user-a', + })); + + unsubscribe(); + expect(channel.close).toHaveBeenCalled(); + }); + + it('falls back safely to a transient storage event when BroadcastChannel is unavailable', () => { + vi.stubGlobal('BroadcastChannel', undefined); + const listener = vi.fn(); + const unsubscribe = subscribeToAuthBoundaryEvents(listener); + + const remote: AuthBoundaryEvent = { + version: 1, + type: 'unauthorized', + sourceId: 'another-tab', + eventId: 'event-2', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(remote), + })); + + expect(listener).toHaveBeenCalledWith(remote); + unsubscribe(); + }); + + it('clears live state, sensitive storage and registered query caches in one boundary', () => { + const clearLive = vi.fn(); + const clearQueries = vi.fn(); + const unregisterLive = registerAuthBoundaryLiveStateClearer(clearLive); + const unregisterQueries = registerAuthBoundaryQueryCacheClearer(clearQueries); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT secret'); + const previousGeneration = captureAuthBoundaryGeneration(); + + clearLocalAuthBoundary(); + + expect(clearLive).toHaveBeenCalledOnce(); + expect(clearQueries).toHaveBeenCalledOnce(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(isAuthBoundaryGenerationCurrent(previousGeneration)).toBe(false); + unregisterLive(); + unregisterQueries(); + }); + + it('clears, broadcasts and requests an authoritative identity after a stale auth cookie response', () => { + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + const unsubscribe = subscribeToAuthBoundaryEvents(vi.fn()); + const reprobe = vi.fn(); + const unregisterReprobe = registerAuthBoundaryIdentityReprober(reprobe); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT old_user_secret'); + + handleStaleAuthCookieResponseBoundary(); + + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(reprobe).toHaveBeenCalledOnce(); + expect(FakeBroadcastChannel.instances[0].postMessage).toHaveBeenCalledWith( + expect.objectContaining({ version: 1, type: 'cookie-changed' }), + ); + unregisterReprobe(); + unsubscribe(); + }); +}); diff --git a/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts b/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts index 11794bf6..f6c2a153 100644 --- a/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts +++ b/src/nodepilot-ui/src/__tests__/stores/aiChatStore.test.ts @@ -5,16 +5,17 @@ const STORAGE_KEY = 'nodepilot-aichat'; function reset() { useAiChatStore.setState({ messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }); + sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY); } -/** Reads the partialized state that the persist middleware wrote to localStorage. */ +/** Reads the partialized state that the persist middleware wrote to this tab's sessionStorage. */ function persisted(): { messagesByThread: Record; threadsByScope: Record; activeThreadByScope: Record; } { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = sessionStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw).state : { messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }; } @@ -100,6 +101,15 @@ describe('aiChatStore', () => { }); describe('persistence (partialize)', () => { + it('uses sessionStorage and never writes chat content to localStorage', () => { + const scope = aiChatScopeKey('u1', 'wf1'); + const id = useAiChatStore.getState().newThread(scope, 'Chat 1'); + useAiChatStore.getState().updateMessages(scope, id, () => [{ role: 'user', content: 'sensitive' }]); + + expect(sessionStorage.getItem(STORAGE_KEY)).toContain('sensitive'); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + it('strips baseDef and streaming/building flags but keeps proposal.definitionJson', () => { const scope = aiChatScopeKey('u1', 'wf1'); const id = useAiChatStore.getState().newThread(scope, 'Chat 1'); diff --git a/src/nodepilot-ui/src/__tests__/stores/authStore.test.ts b/src/nodepilot-ui/src/__tests__/stores/authStore.test.ts index 6bb0ebc2..6b4c3abc 100644 --- a/src/nodepilot-ui/src/__tests__/stores/authStore.test.ts +++ b/src/nodepilot-ui/src/__tests__/stores/authStore.test.ts @@ -1,5 +1,18 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useAuthStore } from '../../stores/authStore'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; +import { startAuthBoundarySynchronization, useAuthStore } from '../../stores/authStore'; +import { useAiChatStore, aiChatScopeKey } from '../../stores/aiChatStore'; +import { queryClient } from '../../queryClient'; +import { + AUTH_BOUNDARY_STORAGE_KEY, + clearLocalAuthBoundary, + type AuthBoundaryEvent, +} from '../../security/authBoundary'; +import { + AI_CHAT_STORAGE_KEY, + DB_ADMIN_QUERY_DRAFT_KEY, + DB_ADMIN_QUERY_HISTORY_KEY, + DB_ADMIN_QUERY_MODE_KEY, +} from '../../security/sensitiveBrowserState'; // Mock the api module — `post`, `get` and `postWithHeaders` (setup-token login) are // used by the auth flow now. @@ -30,17 +43,28 @@ vi.mock('../../api/client', () => { import { api } from '../../api/client'; describe('authStore (cookie-based, audit H-5)', () => { + const synchronizationStops: Array<() => void> = []; + beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); + useAiChatStore.setState({ messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }); sessionStorage.clear(); + queryClient.clear(); // Reset store state to a known "pre-init" shape (matches production bundle load). useAuthStore.setState({ + userId: null, username: null, role: null, isAuthenticated: null, }); }); + afterEach(() => { + while (synchronizationStops.length > 0) synchronizationStops.pop()?.(); + vi.unstubAllGlobals(); + }); + it('login_success_setsAuthenticated', async () => { // The browser login response carries identity only — never the JWT. The SPA stores // username + role and relies on the httpOnly np_auth cookie for auth. @@ -91,6 +115,38 @@ describe('authStore (cookie-based, audit H-5)', () => { expect(api.post).toHaveBeenCalledWith('/auth/logout'); }); + it('logout_clearsUiSensitiveStateAndQueryCache_beforeServerRequestSettles', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'admin', + role: 'Admin', + }); + const scope = aiChatScopeKey('u-1', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'Sensitive'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'customer secret' }, + ]); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT token FROM integrations'); + queryClient.setQueryData(['previous-user'], { secret: true }); + + let settleLogout!: () => void; + vi.mocked(api.post).mockReturnValueOnce(new Promise((resolve) => { + settleLogout = resolve; + })); + + const pendingLogout = useAuthStore.getState().logout(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().userId).toBeNull(); + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(queryClient.getQueryData(['previous-user'])).toBeUndefined(); + expect(api.post).toHaveBeenCalledWith('/auth/logout'); + + settleLogout(); + await pendingLogout; + }); + it('logout_removesLegacyWorkflowClipboardFromSessionStorage', async () => { sessionStorage.setItem('np_clipboard', JSON.stringify({ nodes: [{ data: { config: { apiKey: 'legacy-inline-secret' } } }], @@ -103,6 +159,29 @@ describe('authStore (cookie-based, audit H-5)', () => { expect(sessionStorage.getItem('np_clipboard')).toBeNull(); }); + it('logout_clearsPersistedSqlAndAiStateFromMemoryAndBrowserStorage', async () => { + const scope = aiChatScopeKey('u-1', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'Chat 1'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'customer secret' }, + ]); + sessionStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["SELECT password FROM users"]'); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT token FROM integrations'); + sessionStorage.setItem(DB_ADMIN_QUERY_MODE_KEY, 'write'); + // Residue from releases which used localStorage must be removed during the same boundary. + localStorage.setItem(AI_CHAT_STORAGE_KEY, 'legacy-chat'); + localStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'legacy-sql'); + vi.mocked(api.post).mockResolvedValueOnce(undefined); + + await useAuthStore.getState().logout(); + + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + for (const key of [AI_CHAT_STORAGE_KEY, DB_ADMIN_QUERY_HISTORY_KEY, DB_ADMIN_QUERY_DRAFT_KEY, DB_ADMIN_QUERY_MODE_KEY]) { + expect(sessionStorage.getItem(key)).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + } + }); + it('login_removesLegacyWorkflowClipboardEvenWhenPriorLogoutWasMissed', async () => { sessionStorage.setItem('np_clipboard', JSON.stringify({ nodes: [{ data: { config: { apiKey: 'previous-user-secret' } } }], @@ -121,6 +200,8 @@ describe('authStore (cookie-based, audit H-5)', () => { role: 'Admin', isAuthenticated: true, }); + vi.stubGlobal('BroadcastChannel', undefined); + const storageWrite = vi.spyOn(Storage.prototype, 'setItem'); vi.mocked(api.post).mockRejectedValueOnce(new Error('network down')); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -129,12 +210,19 @@ describe('authStore (cookie-based, audit H-5)', () => { const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); expect(warnSpy).toHaveBeenCalled(); + const logoutPhases = storageWrite.mock.calls + .filter(([key]) => key === AUTH_BOUNDARY_STORAGE_KEY) + .map(([, value]) => JSON.parse(String(value)) as AuthBoundaryEvent) + .filter((event) => event.type === 'logout') + .map((event) => event.phase); + expect(logoutPhases).toEqual(['started', 'failed']); warnSpy.mockRestore(); + storageWrite.mockRestore(); }); it('initialize_withValidCookie_restoresState', async () => { // Backend /auth/me returns the current user when the np_auth cookie validates. - vi.mocked(api.get).mockResolvedValueOnce({ username: 'testuser', role: 'Operator' }); + vi.mocked(api.get).mockResolvedValueOnce({ id: 'u-test', username: 'testuser', role: 'Operator' }); await useAuthStore.getState().initialize(); @@ -146,6 +234,8 @@ describe('authStore (cookie-based, audit H-5)', () => { }); it('initialize_noCookie_setsAnonymous', async () => { + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT previous_user_secret'); + localStorage.setItem(AI_CHAT_STORAGE_KEY, 'legacy-chat'); vi.mocked(api.get).mockRejectedValueOnce(new Error('Unauthorized')); await useAuthStore.getState().initialize(); @@ -154,6 +244,449 @@ describe('authStore (cookie-based, audit H-5)', () => { expect(state.isAuthenticated).toBe(false); expect(state.username).toBeNull(); expect(state.role).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(localStorage.getItem(AI_CHAT_STORAGE_KEY)).toBeNull(); + }); + + it('initialize_secondUser_discardsFirstUsersSqlAndAiState', async () => { + vi.mocked(api.get) + .mockResolvedValueOnce({ id: 'u-1', username: 'first', role: 'Admin' }) + .mockResolvedValueOnce({ id: 'u-2', username: 'second', role: 'Viewer' }); + + await useAuthStore.getState().initialize(); + const scope = aiChatScopeKey('u-1', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'Chat 1'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'first-user-only' }, + ]); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT first_user_only'); + sessionStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["SELECT first_user_only"]'); + queryClient.setQueryData(['first-user-api-result'], { secret: 'cached' }); + + await useAuthStore.getState().initialize(); + + expect(useAuthStore.getState().userId).toBe('u-2'); + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + expect(sessionStorage.getItem(AI_CHAT_STORAGE_KEY) ?? '').not.toContain('first-user-only'); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + expect(queryClient.getQueryData(['first-user-api-result'])).toBeUndefined(); + }); + + it('initialize_staleInitialProbeCannotOverwriteANewerRemoteIdentity', async () => { + let resolveInitial!: (identity: { id: string; username: string; role: string }) => void; + const staleInitialResponse = new Promise<{ id: string; username: string; role: string }>((resolve) => { + resolveInitial = resolve; + }); + vi.mocked(api.get) + .mockReturnValueOnce(staleInitialResponse) + .mockResolvedValueOnce({ id: 'u-b', username: 'bob', role: 'Operator' }); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const initialProbe = useAuthStore.getState().initialize(); + expect(api.get).toHaveBeenCalledWith('/auth/me'); + + const remoteIdentity: AuthBoundaryEvent = { + version: 1, + type: 'identity', + userId: 'u-b', + sourceId: 'another-tab', + eventId: 'newer-login-during-initial-probe', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(remoteIdentity), + })); + + await vi.waitFor(() => expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'bob', + isAuthenticated: true, + })); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT user_b_data'); + + resolveInitial({ id: 'u-a', username: 'alice', role: 'Admin' }); + await initialProbe; + + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + isAuthenticated: true, + }); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBe('SELECT user_b_data'); + }); + + it('initialize_staleFailureCannotClearANewerRemoteIdentity', async () => { + let rejectInitial!: (error: Error) => void; + const staleInitialFailure = new Promise((_, reject) => { + rejectInitial = reject; + }); + vi.mocked(api.get) + .mockReturnValueOnce(staleInitialFailure) + .mockResolvedValueOnce({ id: 'u-b', username: 'bob', role: 'Operator' }); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const initialProbe = useAuthStore.getState().initialize(); + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + type: 'identity', + userId: 'u-b', + sourceId: 'another-tab', + eventId: 'newer-login-before-stale-failure', + } satisfies AuthBoundaryEvent), + })); + await vi.waitFor(() => expect(useAuthStore.getState().userId).toBe('u-b')); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT still_user_b'); + + rejectInitial(new Error('Unauthorized')); + await initialProbe; + + expect(useAuthStore.getState()).toMatchObject({ userId: 'u-b', isAuthenticated: true }); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBe('SELECT still_user_b'); + }); + + it('login_responseCannotCommitAfterANewerBoundary', async () => { + let resolveLogin!: (identity: { userId: string; username: string; role: string }) => void; + const staleLoginResponse = new Promise<{ userId: string; username: string; role: string }>((resolve) => { + resolveLogin = resolve; + }); + vi.mocked(api.post).mockReturnValueOnce(staleLoginResponse); + + const login = useAuthStore.getState().login('alice', 'password'); + clearLocalAuthBoundary(); + resolveLogin({ userId: 'u-a', username: 'alice', role: 'Admin' }); + await login; + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().userId).toBeNull(); + }); + + it('forcedSameUserSignIn_discardsSqlStateDespiteMatchingOwnerMarker', () => { + const identity = { userId: 'u-1', username: 'alice', role: 'Admin' }; + useAuthStore.getState().acceptAuthenticatedIdentity(identity); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT same_user_previous_session'); + sessionStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["SELECT same_user_previous_session"]'); + + useAuthStore.getState().acceptAuthenticatedIdentity(identity, { forceBoundary: true }); + + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + }); + + it('remoteIdentity_alwaysClearsStateAndReprobes_withoutRebroadcasting', async () => { + // Same user id is deliberate: a new login can still carry a different role/security stamp. + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'alice', + role: 'Admin', + }); + const scope = aiChatScopeKey('u-1', 'wf-1'); + const threadId = useAiChatStore.getState().newThread(scope, 'Admin work'); + useAiChatStore.getState().updateMessages(scope, threadId, () => [ + { role: 'user', content: 'admin-only prompt' }, + ]); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'DELETE FROM audit_log'); + queryClient.setQueryData(['admin-only'], { rows: ['secret'] }); + vi.mocked(api.get).mockResolvedValueOnce({ id: 'u-1', username: 'alice', role: 'Viewer' }); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const storageWrite = vi.spyOn(Storage.prototype, 'setItem'); + storageWrite.mockClear(); + const remoteEvent: AuthBoundaryEvent = { + version: 1, + type: 'identity', + userId: 'u-1', + sourceId: 'another-tab', + eventId: 'same-user-new-session', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(remoteEvent), + })); + + // The protected tree is unmounted synchronously, before the async identity response lands. + expect(useAuthStore.getState().isAuthenticated).toBeNull(); + expect(api.get).not.toHaveBeenCalled(); + expect(useAiChatStore.getState().messagesByThread).toEqual({}); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(queryClient.getQueryData(['admin-only'])).toBeUndefined(); + + await vi.waitFor(() => { + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-1', + role: 'Viewer', + isAuthenticated: true, + }); + }); + expect(api.get).toHaveBeenCalledWith('/auth/me', { broadcastUnauthorized: false }); + expect(storageWrite).not.toHaveBeenCalledWith( + AUTH_BOUNDARY_STORAGE_KEY, + expect.any(String), + ); + }); + + it('remoteLogout_clearsImmediately_thenReprobesOnlyAfterServerLogoutSettles', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'alice', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT private_data'); + vi.mocked(api.get).mockRejectedValueOnce(new Error('Unauthorized')); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const started: AuthBoundaryEvent = { + version: 1, + type: 'logout', + phase: 'started', + sourceId: 'another-tab', + eventId: 'logout-started', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(started), + })); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(api.get).not.toHaveBeenCalled(); + + const settled: AuthBoundaryEvent = { + ...started, + phase: 'succeeded', + eventId: 'logout-succeeded', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(settled), + })); + + await vi.waitFor(() => expect(api.get).toHaveBeenCalledWith( + '/auth/me', + { broadcastUnauthorized: false }, + )); + await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false)); + }); + + it('remoteLogout_failed_releasesPendingStateButNeverReauthenticatesTheOldCookie', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'alice', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT private_data'); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const started: AuthBoundaryEvent = { + version: 1, + type: 'logout', + phase: 'started', + sourceId: 'another-tab', + eventId: 'failed-logout-started', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(started), + })); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(api.get).not.toHaveBeenCalled(); + + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify({ + ...started, + phase: 'failed', + eventId: 'failed-logout-finished', + } satisfies AuthBoundaryEvent), + })); + + // A failed server logout can leave the old cookie valid. The explicit logout intent wins: + // releasing the cross-tab pending flag must not probe /auth/me and remount that old user. + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + expect(api.get).not.toHaveBeenCalled(); + expect(useAuthStore.getState()).toMatchObject({ + userId: null, + username: null, + role: null, + isAuthenticated: false, + }); + }); + + it('remoteUnauthorized_clearsThenReprobesSoAStillValidCookieIsNotLoggedOut', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'alice', + role: 'Operator', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT private_data'); + vi.mocked(api.get).mockResolvedValueOnce({ id: 'u-1', username: 'alice', role: 'Operator' }); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + const unauthorized: AuthBoundaryEvent = { + version: 1, + type: 'unauthorized', + sourceId: 'another-tab', + eventId: 'remote-401', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(unauthorized), + })); + + expect(useAuthStore.getState().isAuthenticated).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(true)); + expect(useAuthStore.getState().userId).toBe('u-1'); + }); + + it('remoteCookieChanged_clearsThenAcceptsOnlyTheSharedCookiesAuthoritativeIdentity', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT user_a_private_data'); + queryClient.setQueryData(['user-a-private'], { secret: true }); + vi.mocked(api.get).mockResolvedValueOnce({ + id: 'u-cookie-owner', + username: 'cookie-owner', + role: 'Viewer', + }); + + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + type: 'cookie-changed', + sourceId: 'another-tab', + eventId: 'stale-login-set-cookie', + } satisfies AuthBoundaryEvent), + })); + + expect(useAuthStore.getState().isAuthenticated).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(queryClient.getQueryData(['user-a-private'])).toBeUndefined(); + await vi.waitFor(() => expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-cookie-owner', + username: 'cookie-owner', + role: 'Viewer', + isAuthenticated: true, + })); + expect(api.get).toHaveBeenCalledWith('/auth/me', { broadcastUnauthorized: false }); + }); + + it('remoteCookieChanged_waitsForAnInFlightRemoteLogoutToSettleBeforeReprobing', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', username: 'alice', role: 'Admin', + }); + vi.mocked(api.get).mockRejectedValueOnce(new Error('Unauthorized')); + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + + const dispatch = (event: AuthBoundaryEvent) => window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(event), + })); + dispatch({ + version: 1, + type: 'logout', + phase: 'started', + sourceId: 'another-tab', + eventId: 'logout-started-before-cookie-event', + }); + dispatch({ + version: 1, + type: 'cookie-changed', + sourceId: 'another-tab', + eventId: 'stale-logout-cookie-event', + }); + + expect(api.get).not.toHaveBeenCalled(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + dispatch({ + version: 1, + type: 'logout', + phase: 'succeeded', + sourceId: 'another-tab', + eventId: 'logout-succeeded-after-cookie-event', + }); + await vi.waitFor(() => expect(api.get).toHaveBeenCalledWith( + '/auth/me', + { broadcastUnauthorized: false }, + )); + }); + + it('remoteLogout_startedRejectsAnOlderInFlightIdentityProbe', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'SELECT must_not_return'); + queryClient.setQueryData(['stale-probe'], { secret: true }); + + let resolveIdentity!: (identity: { id: string; username: string; role: string }) => void; + const pendingIdentity = new Promise<{ id: string; username: string; role: string }>((resolve) => { + resolveIdentity = resolve; + }); + vi.mocked(api.get).mockReturnValueOnce(pendingIdentity); + vi.stubGlobal('BroadcastChannel', undefined); + synchronizationStops.push(startAuthBoundarySynchronization()); + + const identityEvent: AuthBoundaryEvent = { + version: 1, + type: 'identity', + userId: 'u-b', + sourceId: 'another-tab', + eventId: 'identity-before-logout', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(identityEvent), + })); + expect(useAuthStore.getState().isAuthenticated).toBeNull(); + await vi.waitFor(() => expect(api.get).toHaveBeenCalledWith( + '/auth/me', + { broadcastUnauthorized: false }, + )); + + const logoutStarted: AuthBoundaryEvent = { + version: 1, + type: 'logout', + phase: 'started', + sourceId: 'another-tab', + eventId: 'logout-during-probe', + }; + window.dispatchEvent(new StorageEvent('storage', { + key: AUTH_BOUNDARY_STORAGE_KEY, + newValue: JSON.stringify(logoutStarted), + })); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + resolveIdentity({ id: 'u-b', username: 'bob', role: 'Operator' }); + await pendingIdentity; + await Promise.resolve(); + await Promise.resolve(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().userId).toBeNull(); + expect(useAuthStore.getState().username).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(queryClient.getQueryData(['stale-probe'])).toBeUndefined(); }); it('login_failure_throwsError', async () => { @@ -167,7 +700,7 @@ describe('authStore (cookie-based, audit H-5)', () => { }); it('refresh_success_updatesState', async () => { - const mockResponse = { token: 'new-jwt', username: 'admin', role: 'Admin' }; + const mockResponse = { userId: 'u-1', username: 'admin', role: 'Admin' }; vi.mocked(api.post).mockResolvedValueOnce(mockResponse); await useAuthStore.getState().refresh(); @@ -178,6 +711,59 @@ describe('authStore (cookie-based, audit H-5)', () => { expect(api.post).toHaveBeenCalledWith('/auth/refresh'); }); + it('refresh_sameUserRoleChange_appliesFullBoundaryIncludingSqlAndQueryCache', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-1', + username: 'admin', + role: 'Admin', + }); + sessionStorage.setItem(DB_ADMIN_QUERY_DRAFT_KEY, 'DELETE FROM users'); + sessionStorage.setItem(DB_ADMIN_QUERY_HISTORY_KEY, '["DELETE FROM users"]'); + queryClient.setQueryData(['admin-users'], [{ id: 'u-2' }]); + vi.mocked(api.post).mockResolvedValueOnce({ + userId: 'u-1', + username: 'admin', + role: 'Viewer', + }); + + await useAuthStore.getState().refresh(); + + expect(useAuthStore.getState().role).toBe('Viewer'); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_DRAFT_KEY)).toBeNull(); + expect(sessionStorage.getItem(DB_ADMIN_QUERY_HISTORY_KEY)).toBeNull(); + expect(queryClient.getQueryData(['admin-users'])).toBeUndefined(); + }); + + it('refresh_responseCannotRestoreThePreviousIdentityAfterABoundary', async () => { + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-a', + username: 'alice', + role: 'Admin', + }); + let resolveRefresh!: (identity: { userId: string; username: string; role: string }) => void; + const staleRefresh = new Promise<{ userId: string; username: string; role: string }>((resolve) => { + resolveRefresh = resolve; + }); + vi.mocked(api.post).mockReturnValueOnce(staleRefresh); + + const refresh = useAuthStore.getState().refresh(); + clearLocalAuthBoundary(); + useAuthStore.getState().acceptAuthenticatedIdentity({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + }); + resolveRefresh({ userId: 'u-a', username: 'alice', role: 'Admin' }); + await refresh; + + expect(useAuthStore.getState()).toMatchObject({ + userId: 'u-b', + username: 'bob', + role: 'Operator', + isAuthenticated: true, + }); + }); + it('maybeRefresh_isNoOp', async () => { // maybeRefresh is intentionally a no-op in the cookie-based flow — JS cannot // introspect the JWT exp claim from an httpOnly cookie, so proactive refresh diff --git a/src/nodepilot-ui/src/__tests__/stores/confirmStore.test.ts b/src/nodepilot-ui/src/__tests__/stores/confirmStore.test.ts index 826589e9..20d43750 100644 --- a/src/nodepilot-ui/src/__tests__/stores/confirmStore.test.ts +++ b/src/nodepilot-ui/src/__tests__/stores/confirmStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useConfirmStore, confirmDialog } from '../../stores/confirmStore'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; const settle = (ok: boolean) => useConfirmStore.getState().settle(ok); const pending = () => useConfirmStore.getState().pending; @@ -55,6 +56,18 @@ describe('confirmStore', () => { await expect(second).resolves.toBe(true); }); + it('authBoundary_cancelsPendingContinuationAsFalse', async () => { + const destructiveContinuation = confirmDialog({ + message: 'Delete User A data?', + danger: true, + }); + + clearLocalAuthBoundary(); + + expect(pending()).toBeNull(); + await expect(destructiveContinuation).resolves.toBe(false); + }); + it('settle on an empty store is a no-op (does not throw)', () => { expect(() => settle(true)).not.toThrow(); expect(pending()).toBeNull(); diff --git a/src/nodepilot-ui/src/__tests__/stores/toastStore.test.ts b/src/nodepilot-ui/src/__tests__/stores/toastStore.test.ts index 6080f21f..110c752b 100644 --- a/src/nodepilot-ui/src/__tests__/stores/toastStore.test.ts +++ b/src/nodepilot-ui/src/__tests__/stores/toastStore.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { toast, useToastStore } from '../../stores/toastStore'; +import { clearLocalAuthBoundary } from '../../security/authBoundary'; describe('toastStore', () => { beforeEach(() => { @@ -7,6 +8,15 @@ describe('toastStore', () => { useToastStore.setState({ toasts: [] }); }); + it('clears potentially sensitive messages at an authentication boundary', () => { + toast.error('Delete failed for customer-db', 30_000); + expect(useToastStore.getState().toasts).toHaveLength(1); + + clearLocalAuthBoundary(); + + expect(useToastStore.getState().toasts).toEqual([]); + }); + afterEach(() => { vi.useRealTimers(); }); diff --git a/src/nodepilot-ui/src/api/adminSettings.ts b/src/nodepilot-ui/src/api/adminSettings.ts index c97c4f79..6c1d15b8 100644 --- a/src/nodepilot-ui/src/api/adminSettings.ts +++ b/src/nodepilot-ui/src/api/adminSettings.ts @@ -4,10 +4,21 @@ // here rather than "unexpected error". import { csrfHeaders } from './csrf'; +import { + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + handleUnauthorizedAuthBoundary, +} from '../security/authBoundary'; const BASE_URL = '/api'; -async function adminFetch(path: string, options?: RequestInit): Promise { +interface BoundaryResponse { + response: Response; + authBoundaryGeneration: number; +} + +async function adminFetch(path: string, options?: RequestInit): Promise { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const method = (options?.method ?? 'GET').toUpperCase(); const headers: Record = { ...(options?.body !== undefined ? { 'Content-Type': 'application/json' } : {}), @@ -18,13 +29,15 @@ async function adminFetch(path: string, options?: RequestInit): Promise(response: Response): Promise { +async function readJson({ response, authBoundaryGeneration }: BoundaryResponse): Promise { if (response.status === 204) return undefined as unknown as T; - return (await response.json()) as T; + const result = (await response.json()) as T; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + return result; } -async function expectOk(response: Response): Promise { - if (response.ok) return readJson(response); +async function expectOk(boundaryResponse: BoundaryResponse): Promise { + const { response, authBoundaryGeneration } = boundaryResponse; + if (response.ok) return readJson(boundaryResponse); let body: SettingsErrorBody | null = null; try { body = (await response.json()) as SettingsErrorBody; } catch { /* response had no JSON body */ } + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); throw new SettingsApiError( `Admin Settings API returned ${response.status}`, response.status, diff --git a/src/nodepilot-ui/src/api/ai.ts b/src/nodepilot-ui/src/api/ai.ts index dd3ad247..08d1aa5d 100644 --- a/src/nodepilot-ui/src/api/ai.ts +++ b/src/nodepilot-ui/src/api/ai.ts @@ -1,4 +1,8 @@ import { api, postEventStream } from './client'; +import { + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, +} from '../security/authBoundary'; /** * Frontend mirror of the backend's `UpstreamVariableDto` records. Deliberately uses the @@ -77,7 +81,11 @@ export interface WorkflowChatProposal { * with `\n`, ignores `:`-comment lines, and normalizes `\r\n`. Re-throws `AbortError` as-is * so callers can detect a user-initiated stop. */ -async function readEventStream(response: Response, onEvent: (event: string, data: string) => void): Promise { +async function readEventStream( + response: Response, + authBoundaryGeneration: number, + onEvent: (event: string, data: string) => void, +): Promise { const body = response.body; if (!body) return; const reader = body.getReader(); @@ -93,12 +101,19 @@ async function readEventStream(response: Response, onEvent: (event: string, data if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')); } - if (dataLines.length > 0) onEvent(event, dataLines.join('\n')); + if (dataLines.length > 0) { + // A buffered frame can be delivered after logout/identity replacement even when React has + // already unmounted the panel and aborted the fetch. Never let that old user's AI output + // repopulate the new authentication context. + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + onEvent(event, dataLines.join('\n')); + } }; try { for (;;) { const { value, done } = await reader.read(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep: number; @@ -154,8 +169,10 @@ export interface ChatStreamHandlers { /** Streams one chat turn: `onDelta` per prose token, `onBuilding` when it switches to * generating the workflow definition, `onProposal` at the end, `onDone` with metadata. */ export async function chatStream(req: WorkflowChatRequest, h: ChatStreamHandlers): Promise { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const resp = await postEventStream('/ai/chat', req, h.signal); - await readEventStream(resp, (event, data) => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + await readEventStream(resp, authBoundaryGeneration, (event, data) => { if (event === 'delta') h.onDelta((JSON.parse(data) as { text: string }).text); else if (event === 'building') h.onBuilding?.(); else if (event === 'tool_call') { @@ -207,8 +224,10 @@ export interface KnowledgeStreamHandlers { /** Streams one knowledge-chat turn: `onDelta` per token, tool-call indicators, `onDone` with metadata. */ export async function askStream(req: KnowledgeAskRequest, h: KnowledgeStreamHandlers): Promise { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const resp = await postEventStream('/ai/knowledge/ask', req, h.signal); - await readEventStream(resp, (event, data) => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + await readEventStream(resp, authBoundaryGeneration, (event, data) => { if (event === 'delta') h.onDelta((JSON.parse(data) as { text: string }).text); else if (event === 'tool_call') { const t = JSON.parse(data) as { toolName: string; toolId: string }; @@ -234,8 +253,10 @@ export interface ScriptStreamHandlers { /** Streams script generation: `onDelta` per token (with code-fence markers stripped) — used to * make the script appear to type itself live into the Monaco editor. */ export async function generateScriptStream(req: GenerateScriptRequest, h: ScriptStreamHandlers): Promise { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const resp = await postEventStream('/ai/generate-script', req, h.signal); - await readEventStream(resp, (event, data) => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + await readEventStream(resp, authBoundaryGeneration, (event, data) => { if (event === 'delta') h.onDelta((JSON.parse(data) as { text: string }).text); else if (event === 'error') throw sseError(data); }); diff --git a/src/nodepilot-ui/src/api/client.ts b/src/nodepilot-ui/src/api/client.ts index 9c6c637f..83302e2f 100644 --- a/src/nodepilot-ui/src/api/client.ts +++ b/src/nodepilot-ui/src/api/client.ts @@ -1,7 +1,21 @@ import { csrfHeaders } from './csrf'; import { reportDatabaseOutageSuspected } from '../stores/dbHealthStore'; +import { + AuthBoundaryChangedError, + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + handleStaleAuthCookieResponseBoundary, + handleUnauthorizedAuthBoundary, + isAuthBoundaryGenerationCurrent, +} from '../security/authBoundary'; const BASE_URL = '/api'; +const COOKIE_MUTATING_AUTH_PATHS = new Set([ + '/auth/login', + '/auth/windows', + '/auth/refresh', + '/auth/logout', +]); /** * Error thrown for every non-OK API response, carrying the machine-readable parts the display @@ -50,7 +64,19 @@ export function isDatabaseSlowError(err: unknown): boolean { * echo the CSRF cookie back in the `X-CSRF-Token` header; the server rejects mismatches. * No token is ever stored in localStorage, so a future XSS cannot exfiltrate it. */ -async function authedFetch(path: string, options?: RequestInit): Promise { +interface AuthBoundaryRequestPolicy { + /** Remote cross-tab identity probes handle their own result and must not echo a 401. */ + broadcastUnauthorized?: boolean; +} + +async function authedFetch( + path: string, + options?: RequestInit, + authBoundaryPolicy?: AuthBoundaryRequestPolicy, + requestBoundaryGeneration = captureAuthBoundaryGeneration(), +): Promise { + // Bind every request—not just auth endpoints—to the identity under which it started. A delayed + // User-A 401 must not clear or redirect a newer User-B session. const method = (options?.method ?? 'GET').toUpperCase(); const headers: Record = { // FormData must NOT carry an explicit Content-Type — the browser sets the multipart @@ -67,7 +93,33 @@ async function authedFetch(path: string, options?: RequestInit): Promise(path: string, options?: RequestInit): Promise { - const response = await authedFetch(path, options); +async function request( + path: string, + options?: RequestInit, + authBoundaryPolicy?: AuthBoundaryRequestPolicy, +): Promise { + const requestBoundaryGeneration = captureAuthBoundaryGeneration(); + const response = await authedFetch( + path, + options, + authBoundaryPolicy, + requestBoundaryGeneration, + ); if (response.status === 204) return undefined as T; - return response.json(); + const result = await response.json() as T; + assertAuthBoundaryGenerationCurrent(requestBoundaryGeneration); + return result; } export const api = { - get: (path: string) => request(path), + get: (path: string, authBoundaryPolicy?: AuthBoundaryRequestPolicy) => + request(path, undefined, authBoundaryPolicy), post: (path: string, body?: unknown) => request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), put: (path: string, body: unknown) => @@ -170,12 +236,13 @@ export const api = { * `AbortSignal` to cancel (Stop button / dialog close) — the reader then throws `AbortError`. */ export async function postEventStream(path: string, body: unknown, signal?: AbortSignal): Promise { + const requestBoundaryGeneration = captureAuthBoundaryGeneration(); return authedFetch(path, { method: 'POST', body: JSON.stringify(body), headers: { Accept: 'text/event-stream' }, signal, - }); + }, undefined, requestBoundaryGeneration); } /** @@ -184,7 +251,13 @@ export async function postEventStream(path: string, body: unknown, signal?: Abor * backup export. Honors the server-supplied Content-Disposition filename. */ export async function downloadFromApiPost(path: string, body: unknown, fallbackName: string): Promise { - const response = await authedFetch(path, { method: 'POST', body: JSON.stringify(body) }); + const requestBoundaryGeneration = captureAuthBoundaryGeneration(); + const response = await authedFetch( + path, + { method: 'POST', body: JSON.stringify(body) }, + undefined, + requestBoundaryGeneration, + ); let filename = fallbackName; const disposition = response.headers.get('Content-Disposition') ?? ''; @@ -192,6 +265,7 @@ export async function downloadFromApiPost(path: string, body: unknown, fallbackN if (match) filename = decodeURIComponent(match[1]); const blob = await response.blob(); + assertAuthBoundaryGenerationCurrent(requestBoundaryGeneration); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -205,7 +279,8 @@ export async function downloadFromApiPost(path: string, body: unknown, fallbackN // Triggers a browser download of the response body. Honors the server-supplied // Content-Disposition filename; falls back to `fallbackName` if missing. export async function downloadFromApi(path: string, fallbackName: string): Promise { - const response = await authedFetch(path); + const requestBoundaryGeneration = captureAuthBoundaryGeneration(); + const response = await authedFetch(path, undefined, undefined, requestBoundaryGeneration); let filename = fallbackName; const disposition = response.headers.get('Content-Disposition') ?? ''; @@ -213,6 +288,7 @@ export async function downloadFromApi(path: string, fallbackName: string): Promi if (match) filename = decodeURIComponent(match[1]); const blob = await response.blob(); + assertAuthBoundaryGenerationCurrent(requestBoundaryGeneration); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/src/nodepilot-ui/src/api/operations.ts b/src/nodepilot-ui/src/api/operations.ts index 6f2dcd95..521bd052 100644 --- a/src/nodepilot-ui/src/api/operations.ts +++ b/src/nodepilot-ui/src/api/operations.ts @@ -1,5 +1,10 @@ import { api } from './client'; import type { OperationsGraph, WorkflowExecution } from '../types/api'; +import { + AuthBoundaryChangedError, + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, +} from '../security/authBoundary'; /** * RBAC-folder-scoped snapshot for the live-ops Mission-Control view. @@ -108,10 +113,18 @@ export interface QuarantineOutcome { } export async function quarantineWorkflow(workflowId: string): Promise { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); await disableWorkflow(workflowId); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); try { - return { disabled: true, cancelled: await cancelAllForWorkflow(workflowId) }; - } catch { + const cancelled = await cancelAllForWorkflow(workflowId); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + return { disabled: true, cancelled }; + } catch (err) { + // Never reinterpret an identity switch as the documented same-user partial outcome; doing so + // would also permit the cancel step to start under a replacement user's cookie. + if (err instanceof AuthBoundaryChangedError) throw err; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); return { disabled: true, cancelled: null }; } } diff --git a/src/nodepilot-ui/src/components/ProtectedRoute.tsx b/src/nodepilot-ui/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..0b78e89b --- /dev/null +++ b/src/nodepilot-ui/src/components/ProtectedRoute.tsx @@ -0,0 +1,30 @@ +import { Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate } from 'react-router'; +import { useAuthStore } from '../stores/authStore'; + +export function ProtectedRoute({ children }: Readonly<{ children: React.ReactNode }>) { + const { t } = useTranslation(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const userId = useAuthStore((state) => state.userId); + const authBoundaryEpoch = useAuthStore((state) => state.authBoundaryEpoch); + + if (isAuthenticated === null) { + return ( +
+ {t('common:loading')} +
+ ); + } + + if (!isAuthenticated) return ; + + // React can batch clear(false) + accept(true) into one render during a local A→B identity + // switch. This key still forces the complete protected subtree to unmount/remount, dropping + // component-local SQL/results and running AbortController cleanup for AI/SSE consumers. + return ( + + {children} + + ); +} diff --git a/src/nodepilot-ui/src/components/admin-settings/AuthenticationSection.tsx b/src/nodepilot-ui/src/components/admin-settings/AuthenticationSection.tsx index 142ac3d1..fc19e08c 100644 --- a/src/nodepilot-ui/src/components/admin-settings/AuthenticationSection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/AuthenticationSection.tsx @@ -22,6 +22,7 @@ import { SecretField, serializeSecretField, type SecretFieldMode } from './Secre import { EnvOverrideBadge } from './EnvOverrideBadge'; import { EtagConflictDialog } from './EtagConflictDialog'; import { TestProbeModal } from './TestProbeModal'; +import { CompactCard } from './SectionFormHelpers'; type RoleMapping = { groupSid: string; role: 'Viewer' | 'Operator' | 'Admin' }; @@ -252,7 +253,7 @@ export function AuthenticationSection() { }); if (isLoading || !data) { - return

{t('adminSettings:loading')}

; + return

{t('adminSettings:loading')}

; } const ldap = form.ldap; @@ -262,7 +263,7 @@ export function AuthenticationSection() { return (
- + {t('adminSettings:testButton')}
- - + + setForm({ ...form, windows: { ...windows, enabled: v } })} configKey="Authentication:Windows:Enabled" @@ -402,8 +403,8 @@ export function AuthenticationSection() { {windows.enabled && !windows.ntlmDisabledByPolicy && (

{t('adminSettings:auth.ntlmPolicyRequired')}

)} -
- + + setForm({ ...form, oidc: { ...oidc, enabled: v } })} configKey="Authentication:Oidc:Enabled" @@ -477,8 +478,8 @@ export function AuthenticationSection() { mappings={oidc.globalRoleMappings} onChange={(globalRoleMappings) => setForm({ ...form, oidc: { ...oidc, globalRoleMappings } })} /> - - + + setForm({ ...form, scim: { ...scim, enabled: v } })} configKey="Authentication:Scim:Enabled" @@ -520,8 +521,8 @@ export function AuthenticationSection() {

{t('adminSettings:auth.scimEndpoint')} /api/scim/v2

-
- + + + {errors && errors.length > 0 && (

{t('adminSettings:validationErrorsTitle')}

@@ -798,17 +799,6 @@ function OidcRoleMappingsEditor({ ); } -function Card({ icon: Icon, title, children }: Readonly<{ icon: React.ComponentType<{ size?: number }>; title: string; children: React.ReactNode }>) { - return ( -
-

- {title} -

- {children} -
- ); -} - function ToggleRow({ label, checked, onChange, configKey, effectiveSource, isEnvLocked, disabled = false, }: Readonly<{ diff --git a/src/nodepilot-ui/src/components/admin-settings/IntegrationsSection.tsx b/src/nodepilot-ui/src/components/admin-settings/IntegrationsSection.tsx index 4636f7c7..eaadd1b8 100644 --- a/src/nodepilot-ui/src/components/admin-settings/IntegrationsSection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/IntegrationsSection.tsx @@ -17,6 +17,7 @@ import { StringListEditor, TextInput, Toggle, + CompactCard, } from './SectionFormHelpers'; import { refreshAiCapabilities } from '../../hooks/useAiCapabilities'; @@ -184,12 +185,12 @@ function SmtpCard() { }); if (isLoading || !data) { - return

{t('adminSettings:loading')}

; + return

{t('adminSettings:loading')}

; } return ( <> - +
- +

{t('adminSettings:loading')}

; + return

{t('adminSettings:loading')}

; } return ( <> - + + ; title: string; children: React.ReactNode }>) { - return ( -
-

- {title} -

- {children} -
- ); -} - function LabeledInput({ label, configKey, effectiveSource, value, onChange, type = 'text', disabled, hint, }: Readonly<{ diff --git a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx index e297d3c4..b1796788 100644 --- a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SecretField, serializeSecretField, type SecretFieldMode } from './SecretField'; import { EnvOverrideBadge } from './EnvOverrideBadge'; -import { GroupHeading, HotReloadHint, useSectionForm } from './SectionFormHelpers'; +import { GroupHeading, HotReloadHint, useSectionForm, CompactCard } from './SectionFormHelpers'; /** * Three independently-saveable cards in one tab: Logging / OpenTelemetry / Stats. @@ -54,11 +54,11 @@ function LoggingCard() { supportLog: { enabled: true, path: '', retainedFileCountLimit: 90, fileSizeLimitBytes: 10 * 1024 * 1024, dbProjectionEnabled: true }, }); - if (ui.loading) return

{t('loading')}

; + if (ui.loading) return

{t('loading')}

; const { form, set, data, isEnvLocked, save, errors } = ui; return ( - +
onUpdate({ sslMode: e.target.value })} className="input-field" > - + - + @@ -313,4 +313,4 @@ function BuilderFields({ )} ); -} \ No newline at end of file +} diff --git a/src/nodepilot-ui/src/components/workflows/SharedFolderPermissionsModal.tsx b/src/nodepilot-ui/src/components/workflows/SharedFolderPermissionsModal.tsx index ea2fb3c4..387a3a0b 100644 --- a/src/nodepilot-ui/src/components/workflows/SharedFolderPermissionsModal.tsx +++ b/src/nodepilot-ui/src/components/workflows/SharedFolderPermissionsModal.tsx @@ -8,6 +8,11 @@ import { type SharedFolderRole, } from '../../api/sharedFolders'; import { confirmDialog } from '../../stores/confirmStore'; +import { + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + isAuthBoundaryGenerationCurrent, +} from '../../security/authBoundary'; /** * Admin-only modal: list/grant/revoke folder permissions for one @@ -44,16 +49,20 @@ export function SharedFolderPermissionsModal({ const [groupAuthority, setGroupAuthority] = useState(''); const [pickedRole, setPickedRole] = useState('FolderViewer'); - const reload = async () => { + const reload = async ( + authBoundaryGeneration = captureAuthBoundaryGeneration(), + ) => { setLoading(true); setError(null); try { const list = await sharedFoldersApi.listPermissions(folderId); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); setPermissions(list); } catch (e) { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setError((e as Error).message); } finally { - setLoading(false); + if (isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) setLoading(false); } }; @@ -63,6 +72,7 @@ export function SharedFolderPermissionsModal({ }, [folderId]); const grant = async () => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const key = principalKey.trim(); if (!key) return; setBusy(true); @@ -72,44 +82,52 @@ export function SharedFolderPermissionsModal({ ? (groupAuthorityMode === 'ad' ? ACTIVE_DIRECTORY_AUTHORITY : groupAuthority.trim()) : undefined; await sharedFoldersApi.grantPermission(folderId, principalType, key, pickedRole, authority); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); setPrincipalKey(''); setPickedRole('FolderViewer'); - await reload(); + await reload(authBoundaryGeneration); } catch (e) { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setError((e as Error).message); } finally { - setBusy(false); + if (isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) setBusy(false); } }; const updateRole = async (perm: SharedFolderPermission, role: SharedFolderRole) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); setBusy(true); setError(null); try { await sharedFoldersApi.updatePermission(folderId, perm.id, role); - await reload(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + await reload(authBoundaryGeneration); } catch (e) { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setError((e as Error).message); } finally { - setBusy(false); + if (isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) setBusy(false); } }; const revoke = async (perm: SharedFolderPermission) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const ok = await confirmDialog({ message: t('workflows:folder.revokePermissionConfirm', { name: perm.principalDisplayName ?? perm.principalKey }), danger: true, }); - if (!ok) return; + if (!ok || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setBusy(true); setError(null); try { await sharedFoldersApi.revokePermission(folderId, perm.id); - await reload(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + await reload(authBoundaryGeneration); } catch (e) { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setError((e as Error).message); } finally { - setBusy(false); + if (isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) setBusy(false); } }; diff --git a/src/nodepilot-ui/src/hooks/useSignalR.ts b/src/nodepilot-ui/src/hooks/useSignalR.ts index 5e5db0b4..43b9a42d 100644 --- a/src/nodepilot-ui/src/hooks/useSignalR.ts +++ b/src/nodepilot-ui/src/hooks/useSignalR.ts @@ -13,6 +13,12 @@ import { sortLiveExecutions, } from './signalrReducer'; import { connectPersistently } from '../lib/signalrConnect'; +import { + AuthBoundaryChangedError, + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + isAuthBoundaryGenerationCurrent, +} from '../security/authBoundary'; import { COMPLETED_EXECUTION_TTL_MS, LIVE_EVENT_FLUSH_MS, @@ -189,13 +195,20 @@ export function useWorkflowSignalR(workflowId: string | undefined) { * On HTTP failure the dedup mark is removed so the next event burst can retry. */ const hydrateStepsForExecution = useCallback(async (executionId: string, execWorkflowId: string) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); + if (!mountedRef.current || workflowIdRef.current !== execWorkflowId) return; if (hydratedExecsRef.current.has(executionId)) return; hydratedExecsRef.current.add(executionId); try { const steps = await rateLimitedHydration( - () => api.get(`/executions/${executionId}/steps`), + () => { + // The hydration limiter may keep this callback queued across logout/user switch. + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + return api.get(`/executions/${executionId}/steps`); + }, ); - if (!mountedRef.current) return; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + if (!mountedRef.current || workflowIdRef.current !== execWorkflowId) return; setLiveExecutionsById((prev) => { const exec = prev[executionId]; const existing = new Map(exec ? exec.steps.map((s) => [s.stepId, s]) : []); @@ -258,7 +271,9 @@ export function useWorkflowSignalR(workflowId: string | undefined) { }; }); } catch (err) { - console.warn(`[useWorkflowSignalR] step hydration for ${executionId} failed`, err); + if (!(err instanceof AuthBoundaryChangedError)) { + console.warn(`[useWorkflowSignalR] step hydration for ${executionId} failed`, err); + } hydratedExecsRef.current.delete(executionId); } }, []); @@ -315,11 +330,15 @@ export function useWorkflowSignalR(workflowId: string | undefined) { * because the run finished while we weren't subscribed. */ const hydrateActive = useCallback(async (wfid: string, mode: 'initial' | 'periodic' = 'initial') => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); let all: ApiExecutionItem[]; try { all = await api.get(`/executions?workflowId=${wfid}&activeOnly=true`); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); } catch (err) { - console.warn('[useWorkflowSignalR] bulk hydration: list executions failed', err); + if (!(err instanceof AuthBoundaryChangedError)) { + console.warn('[useWorkflowSignalR] bulk hydration: list executions failed', err); + } return; } const cutoff = Date.now() - COMPLETED_EXECUTION_TTL_MS * 2; @@ -373,8 +392,12 @@ export function useWorkflowSignalR(workflowId: string | undefined) { await Promise.all(toAutoHydrate.map(async (exec) => { try { const steps = await rateLimitedHydration( - () => api.get(`/executions/${exec.id}/steps`), + () => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + return api.get(`/executions/${exec.id}/steps`); + }, ); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); result[exec.id] = { executionId: exec.id, workflowId: exec.workflowId, @@ -399,7 +422,9 @@ export function useWorkflowSignalR(workflowId: string | undefined) { }; hydratedExecsRef.current.add(exec.id); } catch (err) { - console.warn(`[useWorkflowSignalR] bulk hydration: steps for ${exec.id} failed`, err); + if (!(err instanceof AuthBoundaryChangedError)) { + console.warn(`[useWorkflowSignalR] bulk hydration: steps for ${exec.id} failed`, err); + } } })); @@ -479,8 +504,11 @@ export function useWorkflowSignalR(workflowId: string | undefined) { * Idempotent: if the execution is already fully hydrated the HTTP call is skipped. */ const joinExecution = useCallback(async (executionId: string, execWorkflowId: string) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); desiredExecutionGroupsRef.current.add(executionId); if (!await invokeSafely('JoinExecution', executionId)) return; + if (!mountedRef.current + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; // Always refetch on explicit expand — the auto-hydration snapshot (taken at mount) // only captured steps that existed at that moment. Steps that started between mount // and this expand arrived on the execution-only SignalR group, which the frontend @@ -502,16 +530,24 @@ export function useWorkflowSignalR(workflowId: string | undefined) { * for joins that failed while the hub filter was returning DATABASE_UNAVAILABLE. */ const reconcileSubscriptions = useCallback(async (wfid: string) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); if (!mountedRef.current || workflowIdRef.current !== wfid) return; await invokeSafely('JoinWorkflow', wfid); + if (!mountedRef.current + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; const desired = Array.from(desiredExecutionGroupsRef.current); await Promise.all(desired.map(async (executionId) => { - if (await invokeSafely('JoinExecution', executionId)) + if (await invokeSafely('JoinExecution', executionId) + && mountedRef.current + && isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) { await hydrateStepsForExecution(executionId, wfid); + } })); - if (!mountedRef.current || workflowIdRef.current !== wfid) return; + if (!mountedRef.current + || workflowIdRef.current !== wfid + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; await hydrateActive(wfid); scheduleQueryInvalidate(wfid); }, [hydrateActive, hydrateStepsForExecution, invokeSafely, scheduleQueryInvalidate]); diff --git a/src/nodepilot-ui/src/hooks/useWorkflowExecution.ts b/src/nodepilot-ui/src/hooks/useWorkflowExecution.ts index 6c72fdc9..97a05b74 100644 --- a/src/nodepilot-ui/src/hooks/useWorkflowExecution.ts +++ b/src/nodepilot-ui/src/hooks/useWorkflowExecution.ts @@ -7,6 +7,10 @@ import type { Workflow, WorkflowExecution } from '../types/api'; import { withSpan } from '../telemetry/otel'; import { extractManualTriggerConfig } from '../components/common/RunWorkflowDialog'; import { toast } from '../stores/toastStore'; +import { + captureAuthBoundaryGeneration, + isAuthBoundaryGenerationCurrent, +} from '../security/authBoundary'; interface UseWorkflowExecutionArgs { workflowId: string | undefined; @@ -83,6 +87,7 @@ export function useWorkflowExecution({ // depend on it honestly — an unmemoized `run` would force those handlers to either re-create // every render or capture a stale closure (stale isDirty/nodes/edges/saveAsync). const run = useCallback(async (debug = false) => { + const authBoundaryGeneration = captureAuthBoundaryGeneration(); if (workflow && !workflow.isEnabled) { toast.info(t('editor:workflowDisabledRunHint')); return; @@ -97,14 +102,17 @@ export function useWorkflowExecution({ void err; return; } + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; } // Check if workflow has a ManualTrigger node with parameters. const triggerConfig = extractManualTriggerConfig(JSON.stringify({ nodes, edges })); if (triggerConfig && triggerConfig.parameters.length > 0) { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; setShowRunDialog(true); setPendingRunIsDebug(debug); } else { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; executeMutation.mutate({ debug }, { onError: (err) => toast.error(t('editor:executionStartFailed', { message: (err as Error).message })), }); diff --git a/src/nodepilot-ui/src/lib/queryErrorToast.ts b/src/nodepilot-ui/src/lib/queryErrorToast.ts index 2025bbde..73e6e58d 100644 --- a/src/nodepilot-ui/src/lib/queryErrorToast.ts +++ b/src/nodepilot-ui/src/lib/queryErrorToast.ts @@ -22,6 +22,9 @@ type CachedQuery = Query; * thing rather than as a copy of it. */ export function handleQueryError(error: unknown, query: CachedQuery): void { + // Auth-boundary and user-initiated aborts are intentional cancellation, never a load failure + // belonging to the newly authenticated user. + if (error instanceof Error && error.name === 'AbortError') return; if (!shouldToastQueryError(query)) return; // Database OUTAGE only: the global banner owns that message (with live state and recovery), and // this handler fires once per failed query — during an outage that is every visible query at once. diff --git a/src/nodepilot-ui/src/lib/resolveWorkflowRef.ts b/src/nodepilot-ui/src/lib/resolveWorkflowRef.ts index 2b6bcd7d..4e7af176 100644 --- a/src/nodepilot-ui/src/lib/resolveWorkflowRef.ts +++ b/src/nodepilot-ui/src/lib/resolveWorkflowRef.ts @@ -1,4 +1,9 @@ import type { Workflow } from '../types/api'; +import { + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + handleUnauthorizedAuthBoundary, +} from '../security/authBoundary'; const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -27,14 +32,19 @@ export async function resolveWorkflowRef(nameOrId: string): Promise; + const workflow = await response.json() as Workflow; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + return workflow; } diff --git a/src/nodepilot-ui/src/pages/CustomActivitiesPage.tsx b/src/nodepilot-ui/src/pages/CustomActivitiesPage.tsx index e46270a9..2a945d5d 100644 --- a/src/nodepilot-ui/src/pages/CustomActivitiesPage.tsx +++ b/src/nodepilot-ui/src/pages/CustomActivitiesPage.tsx @@ -28,6 +28,12 @@ import { formatDate } from '../lib/format'; import { toast } from '../stores/toastStore'; import { confirmDialog } from '../stores/confirmStore'; import { useThemeStore, resolveTheme } from '../stores/themeStore'; +import { + AuthBoundaryChangedError, + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + isAuthBoundaryGenerationCurrent, +} from '../security/authBoundary'; /** * Admin/Operator management surface for custom activities ("Custom Nodes"). A definition is created @@ -193,34 +199,55 @@ export function CustomActivitiesPage() { }; const onExport = async () => { - const env = await api.get('/custom-activities/export'); - const blob = new Blob([JSON.stringify(env, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = 'custom-nodes.npca.json'; a.click(); - URL.revokeObjectURL(url); + const authBoundaryGeneration = captureAuthBoundaryGeneration(); + try { + const env = await api.get('/custom-activities/export'); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); + const blob = new Blob([JSON.stringify(env, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'custom-nodes.npca.json'; a.click(); + URL.revokeObjectURL(url); + } catch (err) { + // A stale export is intentionally silent; retain the existing behavior for real failures. + if (!(err instanceof AuthBoundaryChangedError)) throw err; + } }; // File-picker import (same pattern as WorkflowsPage): hidden , // read the .npca/.json envelope client-side, POST it as-is. Imported nodes land // disabled server-side and need an Admin review + enable. const importMutation = useMutation({ - mutationFn: async (file: File) => { + mutationFn: async ({ file, authBoundaryGeneration }: { + file: File; + authBoundaryGeneration: number; + }) => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); const text = await file.text(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); let envelope: unknown; try { envelope = JSON.parse(text); } catch { throw new Error(t('customActivities:importInvalidJson', { file: file.name })); } + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); return api.post('/custom-activities/import', envelope); }, - onSuccess: (imported) => { + onSuccess: (imported, { authBoundaryGeneration }) => { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; invalidate(); toast.success(t('customActivities:importDone', { count: imported.length })); }, - onError: (err: Error) => toast.error(err.message), + onError: (err: Error, { authBoundaryGeneration }) => { + if (err instanceof AuthBoundaryChangedError + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; + toast.error(err.message); + }, }); const handleImportFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) importMutation.mutate(file); + if (file) importMutation.mutate({ + file, + authBoundaryGeneration: captureAuthBoundaryGeneration(), + }); // Always reset so re-selecting the same file fires onChange again. e.target.value = ''; }; diff --git a/src/nodepilot-ui/src/pages/LoginPage.tsx b/src/nodepilot-ui/src/pages/LoginPage.tsx index a80d0c8f..2cd982bb 100644 --- a/src/nodepilot-ui/src/pages/LoginPage.tsx +++ b/src/nodepilot-ui/src/pages/LoginPage.tsx @@ -15,6 +15,7 @@ import { useAuthStore } from '../stores/authStore'; import { api, ApiError, isDatabaseOutageError } from '../api/client'; import { BrandLogo } from '../components/BrandLogo'; import type { AuthMethodsResponse, LoginResponse } from '../types/api'; +import { captureAuthBoundaryGeneration } from '../security/authBoundary'; const inputClass = 'w-full pl-10 pr-3 py-2.5 bg-surface-low/60 border border-outline-variant rounded-xl text-sm text-on-surface ' + @@ -32,6 +33,7 @@ export function LoginPage() { const [submitting, setSubmitting] = useState(false); const [methods, setMethods] = useState(null); const login = useAuthStore((s) => s.login); + const acceptAuthenticatedIdentity = useAuthStore((s) => s.acceptAuthenticatedIdentity); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -85,12 +87,11 @@ export function LoginPage() { // Local Intranet zone and the user has a valid Kerberos ticket. The server emits // np_auth + np_csrf cookies on success; the body carries identity only (no JWT — // Windows SSO is ambient-credential driven, so the token is never echoed back). + const expectedBoundaryGeneration = captureAuthBoundaryGeneration(); const response = await api.post('/auth/windows'); - useAuthStore.setState({ - userId: response.userId, - username: response.username, - role: response.role, - isAuthenticated: true, + acceptAuthenticatedIdentity(response, { + forceBoundary: true, + expectedBoundaryGeneration, }); navigate('/'); } catch { diff --git a/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx b/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx index 5eec2636..eede992d 100644 --- a/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx +++ b/src/nodepilot-ui/src/pages/WorkflowEditorPage.tsx @@ -78,6 +78,10 @@ import { useWorkflowSimulation } from '../hooks/useWorkflowSimulation'; import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts'; import { useNodeAnnotations } from '../hooks/useNodeAnnotations'; import { useCoverageHeatmap } from '../hooks/useCoverageHeatmap'; +import { + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, +} from '../security/authBoundary'; import { useCriticalPath } from '../hooks/useCriticalPath'; import { useNodeOperations } from '../hooks/useNodeOperations'; import { useCanvasConnect } from '../hooks/useCanvasConnect'; @@ -674,6 +678,7 @@ function WorkflowEditorInner() { const exportPng = useCallback(async () => { const flow = canvasRef.current?.querySelector('.react-flow') as HTMLElement | null; if (!flow) return; + const authBoundaryGeneration = captureAuthBoundaryGeneration(); const surfaceBg = canvasRef.current ? getComputedStyle(canvasRef.current).backgroundColor : '#ffffff'; @@ -692,6 +697,7 @@ function WorkflowEditorInner() { return true; }, }); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); const a = document.createElement('a'); a.href = dataUrl; a.download = `${name || 'workflow'}.png`; diff --git a/src/nodepilot-ui/src/pages/WorkflowsPage.tsx b/src/nodepilot-ui/src/pages/WorkflowsPage.tsx index 2ef1e10d..4dd2c928 100644 --- a/src/nodepilot-ui/src/pages/WorkflowsPage.tsx +++ b/src/nodepilot-ui/src/pages/WorkflowsPage.tsx @@ -26,6 +26,12 @@ import { useAiCapabilities } from '../hooks/useAiCapabilities'; import { MobileCardList } from '../components/common/MobileCardList'; import { toast } from '../stores/toastStore'; import { confirmDialog } from '../stores/confirmStore'; +import { + AuthBoundaryChangedError, + assertAuthBoundaryGenerationCurrent, + captureAuthBoundaryGeneration, + isAuthBoundaryGenerationCurrent, +} from '../security/authBoundary'; type ImportedWorkflowInfo = { id: string; name: string; originalName: string | null }; type ImportResponse = { created: number; workflows: ImportedWorkflowInfo[]; errors: string[] }; @@ -256,9 +262,14 @@ export function WorkflowsPage() { // tab-crash instead of a clean "file too large" message. const MAX_IMPORT_BYTES = 6 * 1024 * 1024; const importMutation = useMutation({ - mutationFn: async (files: File[]): Promise => { + mutationFn: async ({ files, authBoundaryGeneration }: { + files: File[]; + authBoundaryGeneration: number; + }): Promise => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); const results: PerFileResult[] = []; for (const file of files) { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); try { if (file.size > MAX_IMPORT_BYTES) { throw new Error(t('workflows:importFileTooLarge', { @@ -267,6 +278,7 @@ export function WorkflowsPage() { })); } const text = await file.text(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); let envelope: unknown; try { envelope = JSON.parse(text); @@ -278,15 +290,25 @@ export function WorkflowsPage() { const importUrl = selectedFolderId ? `/workflows/import?folderId=${selectedFolderId}` : '/workflows/import'; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); const resp = await api.post(importUrl, envelope); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); results.push({ kind: 'ok', file: file.name, resp }); } catch (err) { + // Boundary aborts are control flow, not malformed-file results. Continuing the loop + // would start the next User-A file under User B's cookie and authorization context. + if (err instanceof AuthBoundaryChangedError) throw err; + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) { + throw new AuthBoundaryChangedError(); + } results.push({ kind: 'fail', file: file.name, message: (err as Error).message }); } } + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); return results; }, - onSuccess: (results) => { + onSuccess: (results, { authBoundaryGeneration }) => { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; queryClient.invalidateQueries({ queryKey: ['workflows'] }); const lines: string[] = []; @@ -327,7 +349,11 @@ export function WorkflowsPage() { toast.success(lines.join('\n')); } }, - onError: (err: Error) => toast.error(t('common:importFailed', { message: err.message })), + onError: (err: Error, { authBoundaryGeneration }) => { + if (err instanceof AuthBoundaryChangedError + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; + toast.error(t('common:importFailed', { message: err.message })); + }, }); // Importing from System Center Orchestrator (SCOrch) produces much more detail @@ -340,7 +366,11 @@ export function WorkflowsPage() { // someone accidentally uploads a multi-gigabyte backup file instead. const MAX_SCORCH_BYTES = 50 * 1024 * 1024; const scorchMutation = useMutation({ - mutationFn: async (file: File): Promise => { + mutationFn: async ({ file, authBoundaryGeneration }: { + file: File; + authBoundaryGeneration: number; + }): Promise => { + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); if (file.size > MAX_SCORCH_BYTES) { throw new Error(t('workflows:importFileTooLarge', { file: file.name, @@ -348,21 +378,31 @@ export function WorkflowsPage() { })); } const text = await file.text(); + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); const scorchUrl = selectedFolderId ? `/workflows/import-scorch?folderId=${selectedFolderId}` : '/workflows/import-scorch'; + assertAuthBoundaryGenerationCurrent(authBoundaryGeneration); return api.postRaw(scorchUrl, text, 'application/xml'); }, - onSuccess: (resp, file) => { + onSuccess: (resp, { file, authBoundaryGeneration }) => { + if (!isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; queryClient.invalidateQueries({ queryKey: ['workflows'] }); setScorchResult({ resp, filename: file.name }); }, - onError: (err: Error) => toast.error(t('common:importFailed', { message: err.message })), + onError: (err: Error, { authBoundaryGeneration }) => { + if (err instanceof AuthBoundaryChangedError + || !isAuthBoundaryGenerationCurrent(authBoundaryGeneration)) return; + toast.error(t('common:importFailed', { message: err.message })); + }, }); const handleScorchClick = () => scorchInputRef.current?.click(); const handleScorchFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) scorchMutation.mutate(file); + if (file) scorchMutation.mutate({ + file, + authBoundaryGeneration: captureAuthBoundaryGeneration(), + }); e.target.value = ''; }; @@ -383,7 +423,10 @@ export function WorkflowsPage() { const handleImportClick = () => importInputRef.current?.click(); const handleImportFile = (e: React.ChangeEvent) => { const files = e.target.files ? Array.from(e.target.files) : []; - if (files.length > 0) importMutation.mutate(files); + if (files.length > 0) importMutation.mutate({ + files, + authBoundaryGeneration: captureAuthBoundaryGeneration(), + }); // Always reset so re-selecting the same file(s) fires onChange again. e.target.value = ''; }; diff --git a/src/nodepilot-ui/src/queryClient.ts b/src/nodepilot-ui/src/queryClient.ts new file mode 100644 index 00000000..3dbb9814 --- /dev/null +++ b/src/nodepilot-ui/src/queryClient.ts @@ -0,0 +1,22 @@ +import { QueryCache, QueryClient } from '@tanstack/react-query'; +import { handleQueryError } from './lib/queryErrorToast'; +import { registerAuthBoundaryQueryCacheClearer } from './security/authBoundary'; + +/** + * The one application-wide server-state cache. Keeping it outside App makes the auth boundary able + * to register a narrow `clear` callback without introducing an api-client <-> auth-store cycle. + */ +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError: handleQueryError }), + defaultOptions: { + queries: { + retry: 1, + staleTime: 10_000, + // SignalR invalidates affected queries precisely; refetch-on-focus otherwise creates a + // request storm across Dashboard, Executions and Audit tabs. + refetchOnWindowFocus: false, + }, + }, +}); + +registerAuthBoundaryQueryCacheClearer(() => queryClient.clear()); diff --git a/src/nodepilot-ui/src/security/authBoundary.ts b/src/nodepilot-ui/src/security/authBoundary.ts new file mode 100644 index 00000000..ce253f4b --- /dev/null +++ b/src/nodepilot-ui/src/security/authBoundary.ts @@ -0,0 +1,303 @@ +import { clearSensitiveBrowserState } from './sensitiveBrowserState'; + +const CHANNEL_NAME = 'nodepilot.auth-boundary.v1'; + +/** + * localStorage is only a compatibility transport for browsers without BroadcastChannel. The + * value is removed immediately and contains no SQL, prompt, credential, or cached API data. + */ +export const AUTH_BOUNDARY_STORAGE_KEY = 'nodepilot.authBoundary.event'; + +export type AuthBoundaryEvent = + | { + version: 1; + type: 'identity'; + userId: string; + sourceId: string; + eventId: string; + } + | { + version: 1; + type: 'logout'; + phase: 'started' | 'succeeded' | 'failed'; + sourceId: string; + eventId: string; + } + | { + version: 1; + type: 'unauthorized'; + sourceId: string; + eventId: string; + } + | { + version: 1; + /** A stale auth response may still have changed the shared browser cookie jar. */ + type: 'cookie-changed'; + sourceId: string; + eventId: string; + }; + +type BoundaryClearer = () => void; +type BoundaryListener = (event: AuthBoundaryEvent) => void; + +const liveStateClearers = new Set(); +const queryCacheClearers = new Set(); +const listeners = new Set(); +const identityReprobers = new Set(); +const seenEventIds = new Set(); + +let channel: BroadcastChannel | null | undefined; +let transportListening = false; +let authBoundaryGeneration = 0; + +/** Raised when a network result belongs to an identity which has already been replaced. */ +export class AuthBoundaryChangedError extends Error { + constructor() { + super('Request result discarded because the authentication context changed.'); + this.name = 'AbortError'; + } +} + +/** Capture before starting async work which must not survive the next authentication boundary. */ +export function captureAuthBoundaryGeneration(): number { + return authBoundaryGeneration; +} + +/** Validate an async result immediately before it reads or persists user-specific state. */ +export function isAuthBoundaryGenerationCurrent(generation: number): boolean { + return generation === authBoundaryGeneration; +} + +export function assertAuthBoundaryGenerationCurrent(generation: number): void { + if (!isAuthBoundaryGenerationCurrent(generation)) throw new AuthBoundaryChangedError(); +} + +function randomId(): string { + try { + return globalThis.crypto.randomUUID(); + } catch { + // This is only a same-origin message de-duplication id, not a credential or security token. + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } +} + +// A module instance maps to one browser tab. Events carrying this id came from this tab and must +// not be applied twice (the caller already performed its local cleanup synchronously). +const sourceId = randomId(); + +function getChannel(): BroadcastChannel | null { + if (channel !== undefined) return channel; + try { + channel = typeof globalThis.BroadcastChannel === 'function' + ? new globalThis.BroadcastChannel(CHANNEL_NAME) + : null; + } catch { + channel = null; + } + return channel; +} + +function rememberEvent(eventId: string): boolean { + if (seenEventIds.has(eventId)) return false; + seenEventIds.add(eventId); + // Bound memory even if a hostile same-origin page floods the channel. + if (seenEventIds.size > 100) { + const oldest = seenEventIds.values().next().value as string | undefined; + if (oldest) seenEventIds.delete(oldest); + } + return true; +} + +function parseEvent(value: unknown): AuthBoundaryEvent | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial & Record; + if (candidate.version !== 1 + || typeof candidate.sourceId !== 'string' + || typeof candidate.eventId !== 'string' + || candidate.sourceId.length > 200 + || candidate.eventId.length > 200) return null; + + if (candidate.type === 'identity') { + return typeof candidate.userId === 'string' && candidate.userId.length > 0 && candidate.userId.length <= 256 + ? candidate as AuthBoundaryEvent + : null; + } + if (candidate.type === 'logout') { + if (candidate.phase === 'started' + || candidate.phase === 'succeeded' + || candidate.phase === 'failed') return candidate as AuthBoundaryEvent; + // Fail closed for a tab still running the previous bundle, whose ambiguous `settled` event + // was emitted on both success and failure. It may suppress a harmless success re-probe, but + // must never remount an identity after a failed logout. + return candidate.phase === 'settled' + ? { ...candidate, phase: 'failed' } as AuthBoundaryEvent + : null; + } + return candidate.type === 'unauthorized' || candidate.type === 'cookie-changed' + ? candidate as AuthBoundaryEvent + : null; +} + +function deliverRemoteEvent(value: unknown): void { + const event = parseEvent(value); + if (!event || event.sourceId === sourceId || !rememberEvent(event.eventId)) return; + for (const listener of listeners) listener(event); +} + +function handleChannelMessage(event: MessageEvent): void { + deliverRemoteEvent(event.data); +} + +function handleStorageEvent(event: StorageEvent): void { + if (event.key !== AUTH_BOUNDARY_STORAGE_KEY || !event.newValue || event.newValue.length > 2048) return; + try { + deliverRemoteEvent(JSON.parse(event.newValue)); + } catch { + // Ignore malformed messages. They are never trusted as authentication proof. + } +} + +function startTransport(): void { + if (transportListening) return; + const broadcastChannel = getChannel(); + if (broadcastChannel) broadcastChannel.addEventListener('message', handleChannelMessage); + else if (typeof window !== 'undefined') window.addEventListener('storage', handleStorageEvent); + transportListening = true; +} + +function stopTransport(): void { + if (!transportListening) return; + if (channel) { + channel.removeEventListener('message', handleChannelMessage); + channel.close(); + } else if (typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorageEvent); + } + channel = undefined; + transportListening = false; +} + +function publish(event: AuthBoundaryEvent): void { + // App.tsx installs a long-lived listener before authentication starts. Isolated consumers (unit + // tests or a future lightweight entry point) still need to publish without leaking a channel. + if (!transportListening && channel === undefined && typeof globalThis.BroadcastChannel === 'function') { + try { + const transientChannel = new globalThis.BroadcastChannel(CHANNEL_NAME); + transientChannel.postMessage(event); + transientChannel.close(); + return; + } catch { + // Constructor/publish blocked: use the storage-event fallback below. + } + } + + const broadcastChannel = getChannel(); + if (broadcastChannel) { + try { + broadcastChannel.postMessage(event); + return; + } catch { + // Fall through to the storage-event transport if browser policy disables the channel. + } + } + + try { + globalThis.localStorage.setItem(AUTH_BOUNDARY_STORAGE_KEY, JSON.stringify(event)); + globalThis.localStorage.removeItem(AUTH_BOUNDARY_STORAGE_KEY); + } catch { + // Browser storage can be disabled. The initiating tab has already been cleared locally. + } +} + +function envelope>( + payload: T, +): AuthBoundaryEvent { + return { + ...payload, + version: 1, + sourceId, + eventId: randomId(), + } as AuthBoundaryEvent; +} + +/** Register non-persisted sensitive stores (AI state, auth UI state, legacy clipboard). */ +export function registerAuthBoundaryLiveStateClearer(clearer: BoundaryClearer): () => void { + liveStateClearers.add(clearer); + return () => liveStateClearers.delete(clearer); +} + +/** Register the application's singleton React Query client without importing it into auth code. */ +export function registerAuthBoundaryQueryCacheClearer(clearer: BoundaryClearer): () => void { + queryCacheClearers.add(clearer); + return () => queryCacheClearers.delete(clearer); +} + +/** + * Register the auth store's authoritative `/auth/me` probe without importing the store into the + * API client. App startup owns the registration lifetime, avoiding an api-client/store cycle. + */ +export function registerAuthBoundaryIdentityReprober(reprober: BoundaryClearer): () => void { + identityReprobers.add(reprober); + return () => identityReprobers.delete(reprober); +} + +/** + * Synchronous local half of every authentication boundary. Live stores are emptied before their + * persistence keys are removed, then all user-derived server caches are discarded. + */ +export function clearLocalAuthBoundary(): void { + // Invalidate in-flight callbacks first. Even if an individual cleanup hook fails, no response + // which started under the previous identity may commit after this point. + authBoundaryGeneration++; + for (const clear of liveStateClearers) { + try { clear(); } catch { /* Continue clearing the other independent stores. */ } + } + clearSensitiveBrowserState(); + for (const clear of queryCacheClearers) { + try { clear(); } catch { /* A cache failure must not retain the browser state above. */ } + } +} + +/** Clear only registered server-data caches after an authenticated identity changes. */ +export function clearAuthBoundaryQueryCaches(): void { + for (const clear of queryCacheClearers) { + try { clear(); } catch { /* Best effort; identity state is still replaced by the caller. */ } + } +} + +export function publishAuthenticatedIdentity(userId: string): void { + publish(envelope({ type: 'identity', userId })); +} + +export function publishLogoutBoundary(phase: 'started' | 'succeeded' | 'failed'): void { + publish(envelope({ type: 'logout', phase })); +} + +/** Called by the API client before any 401 redirect or error is surfaced. */ +export function handleUnauthorizedAuthBoundary(broadcast = true): void { + clearLocalAuthBoundary(); + if (broadcast) publish(envelope({ type: 'unauthorized' })); +} + +/** + * Browsers apply `Set-Cookie` before resolving fetch. Therefore discarding a stale login/SSO/ + * refresh/logout body is insufficient: the shared cookie jar may already represent another + * identity. Clear synchronously, notify sibling tabs, then let every tab ask `/auth/me` who the + * cookie belongs to now. + */ +export function handleStaleAuthCookieResponseBoundary(): void { + clearLocalAuthBoundary(); + publish(envelope({ type: 'cookie-changed' })); + for (const reprobe of identityReprobers) { + try { reprobe(); } catch { /* The local tab remains safely cleared if probing cannot start. */ } + } +} + +export function subscribeToAuthBoundaryEvents(listener: BoundaryListener): () => void { + listeners.add(listener); + startTransport(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) stopTransport(); + }; +} diff --git a/src/nodepilot-ui/src/security/sensitiveBrowserState.ts b/src/nodepilot-ui/src/security/sensitiveBrowserState.ts new file mode 100644 index 00000000..82a93101 --- /dev/null +++ b/src/nodepilot-ui/src/security/sensitiveBrowserState.ts @@ -0,0 +1,67 @@ +/** + * Browser-persisted state which can contain customer data, SQL text, workflow definitions or + * prompts. It may survive a component unmount/page reload, but must never survive an + * authentication boundary in the same browser profile. + */ +export const AI_CHAT_STORAGE_KEY = 'nodepilot-aichat'; +export const DB_ADMIN_QUERY_HISTORY_KEY = 'nodepilot.dbAdmin.queryHistory'; +export const DB_ADMIN_QUERY_DRAFT_KEY = 'nodepilot.dbAdmin.queryDraft'; +export const DB_ADMIN_QUERY_MODE_KEY = 'nodepilot.dbAdmin.queryMode'; + +const OWNER_KEY = 'nodepilot.sensitiveState.owner'; +const SENSITIVE_KEYS = [ + AI_CHAT_STORAGE_KEY, + DB_ADMIN_QUERY_HISTORY_KEY, + DB_ADMIN_QUERY_DRAFT_KEY, + DB_ADMIN_QUERY_MODE_KEY, +] as const; + +function removeKeys(storage: Storage, includeOwner: boolean): void { + for (const key of SENSITIVE_KEYS) storage.removeItem(key); + if (includeOwner) storage.removeItem(OWNER_KEY); +} + +/** + * Removes both current session state and residue left in localStorage by older releases. + * Storage access is best-effort because hardened/private browser profiles can disable it. + */ +export function clearSensitiveBrowserState(): void { + try { + removeKeys(globalThis.sessionStorage, true); + } catch { + // Persistence is optional; live stores are cleared separately by the auth store. + } + try { + removeKeys(globalThis.localStorage, false); + } catch { + // Same policy for legacy localStorage cleanup. + } +} + +/** Remove only pre-fix localStorage residue without destroying a valid same-user tab session. */ +export function clearLegacySensitiveLocalStorage(): void { + try { + removeKeys(globalThis.localStorage, false); + } catch { + // Best effort; new code never writes these values to localStorage. + } +} + +/** + * Binds the session-persisted state to the authenticated user. Returns true when state was + * discarded because there was no trustworthy owner marker or the identity changed. + */ +export function bindSensitiveBrowserStateToUser(userId: string): boolean { + clearLegacySensitiveLocalStorage(); + + try { + const previousOwner = globalThis.sessionStorage.getItem(OWNER_KEY); + const mustClear = previousOwner !== userId; + if (mustClear) removeKeys(globalThis.sessionStorage, false); + globalThis.sessionStorage.setItem(OWNER_KEY, userId); + return mustClear; + } catch { + // If ownership cannot be proven, callers clear their in-memory sensitive stores too. + return true; + } +} diff --git a/src/nodepilot-ui/src/stores/aiChatStore.ts b/src/nodepilot-ui/src/stores/aiChatStore.ts index 34b5b5d5..97edb506 100644 --- a/src/nodepilot-ui/src/stores/aiChatStore.ts +++ b/src/nodepilot-ui/src/stores/aiChatStore.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; import type { WorkflowChatProposal, ChatDoneMeta } from '../api/ai'; import type { WorkflowDefinition } from '../lib/workflowDiff'; +import { AI_CHAT_STORAGE_KEY } from '../security/sensitiveBrowserState'; /** One message in the workflow-assistant chat. */ export interface ChatMessage { @@ -45,13 +46,14 @@ const MAX_PERSISTED_MESSAGES = 200; * 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 localStorage quota. + * 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 (survives a page reload), but privacy-conscious: `partialize` + * store is now `persist`-ed in sessionStorage (survives a page reload, not a closed tab), but + * privacy-conscious: `partialize` * strips the heavy/sensitive fields (`baseDef` snapshots, `proposal.definitionJson`, * streaming flags) and does **not** persist threads for unsaved workflows (`__new__`). * Logout calls `clearAll()`, which also empties the persisted store — so the next user on @@ -103,7 +105,7 @@ function isPersistableScope(scopeKey: string): boolean { * 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 localStorage + * 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. */ @@ -199,8 +201,9 @@ export const useAiChatStore = create()( clearAll: () => set({ messagesByThread: {}, threadsByScope: {}, activeThreadByScope: {} }), }), { - name: 'nodepilot-aichat', + name: AI_CHAT_STORAGE_KEY, version: 1, + storage: createJSONStorage(() => globalThis.sessionStorage), // Only persist saved workflows; strip sensitive/heavy fields. partialize: (state) => { const threadsByScope: Record = {}; diff --git a/src/nodepilot-ui/src/stores/authStore.ts b/src/nodepilot-ui/src/stores/authStore.ts index ae1e824b..6252b767 100644 --- a/src/nodepilot-ui/src/stores/authStore.ts +++ b/src/nodepilot-ui/src/stores/authStore.ts @@ -3,6 +3,22 @@ import { api, ApiError } from '../api/client'; import type { LoginResponse } from '../types/api'; import { useAiChatStore } from './aiChatStore'; import { useDbHealthStore } from './dbHealthStore'; +import { + bindSensitiveBrowserStateToUser, + clearLegacySensitiveLocalStorage, +} from '../security/sensitiveBrowserState'; +import { + type AuthBoundaryEvent, + captureAuthBoundaryGeneration, + clearAuthBoundaryQueryCaches, + clearLocalAuthBoundary, + isAuthBoundaryGenerationCurrent, + publishAuthenticatedIdentity, + publishLogoutBoundary, + registerAuthBoundaryIdentityReprober, + registerAuthBoundaryLiveStateClearer, + subscribeToAuthBoundaryEvents, +} from '../security/authBoundary'; const LEGACY_WORKFLOW_CLIPBOARD_KEY = 'np_clipboard'; @@ -19,6 +35,15 @@ function clearLegacyWorkflowClipboard(): void { } } +interface AcceptIdentityOptions { + /** Remote re-probes suppress this to avoid echoing a cross-tab event back to its sender. */ + broadcastIdentity?: boolean; + /** Explicit sign-in endpoints are boundaries even if they return the same stable user id. */ + forceBoundary?: boolean; + /** Internal guard: a response from an older remote probe must never restore stale auth. */ + expectedBoundaryGeneration?: number; +} + /** * Auth store (rewritten by a security-audit fix): the JWT now lives in an httpOnly `np_auth` cookie * that JS cannot read. This store holds only the user-facing fields (username, role) @@ -37,27 +62,35 @@ interface AuthState { role: string | null; /** `null` = still initializing, `true` = signed in, `false` = anonymous */ isAuthenticated: boolean | null; + /** Changes on every auth boundary; keys the protected tree so component-local state remounts. */ + authBoundaryEpoch: number; /** `setupToken` is only used on a fresh installation: the server's one-shot admin * bootstrap gate (AdminBootstrap) expects it as the `X-Setup-Token` header. */ login: (username: string, password: string, setupToken?: string) => Promise; - /** Revoke server-side THEN clear local state. Awaitable. */ + /** The sole successful-identity transition used by local login, SSO, init and refresh. */ + acceptAuthenticatedIdentity: (identity: LoginResponse, options?: AcceptIdentityOptions) => void; + /** Clear locally immediately, then revoke server-side. Awaitable for callers that need it. */ logout: () => Promise; /** Probe `/auth/me` via cookie and set authenticated/anonymous accordingly. */ - initialize: () => Promise; + initialize: (options?: AcceptIdentityOptions) => Promise; /** Rotate the cookie; server issues a fresh JWT + CSRF token. */ refresh: () => Promise; /** Best-effort: trigger a rotation so long-lived sessions don't cold-expire. */ maybeRefresh: () => Promise; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ userId: null, username: null, role: null, isAuthenticated: null, + authBoundaryEpoch: captureAuthBoundaryGeneration(), login: async (username: string, password: string, setupToken?: string) => { - clearLegacyWorkflowClipboard(); + // Reaching the login form without a clean logout must not carry the previous identity's + // SQL/AI state into the next session. + clearLocalAuthBoundary(); + const expectedBoundaryGeneration = captureAuthBoundaryGeneration(); const response = setupToken ? await api.postWithHeaders( '/auth/login', @@ -68,45 +101,92 @@ export const useAuthStore = create((set) => ({ // The server set np_auth + np_csrf cookies on this response. The body carries only our // identity (userId/username/role) — never the JWT. The token reaches Bearer callers // (CLI/API) only, and only when they opt in; the SPA relies solely on the httpOnly cookie. + get().acceptAuthenticatedIdentity(response, { expectedBoundaryGeneration }); + }, + + acceptAuthenticatedIdentity: (identity, options) => { + if (options?.expectedBoundaryGeneration !== undefined + && !isAuthBoundaryGenerationCurrent(options.expectedBoundaryGeneration)) return; + + const previous = get(); + const identityChanged = previous.userId !== null && previous.userId !== identity.userId; + const displayedClaimsChanged = previous.isAuthenticated === true + && (previous.role !== identity.role || previous.username !== identity.username); + const requiresFullBoundary = identityChanged || displayedClaimsChanged || options?.forceBoundary; + if (requiresFullBoundary) { + // This clears SQL/session keys as well as hydrated AI/query data. Re-bind only after the + // owner marker has been removed, so subsequent same-user refreshes can preserve safe state. + clearLocalAuthBoundary(); + bindSensitiveBrowserStateToUser(identity.userId); + } else if (bindSensitiveBrowserStateToUser(identity.userId)) { + // The persistence binder removed SQL/AI keys before assigning the new owner. Empty the + // hydrated store and all server-derived cache entries within the same identity transition. + useAiChatStore.getState().clearAll(); + clearLegacyWorkflowClipboard(); + clearAuthBoundaryQueryCaches(); + } set({ - userId: response.userId, - username: response.username, - role: response.role, + userId: identity.userId, + username: identity.username, + role: identity.role, isAuthenticated: true, }); + if (options?.broadcastIdentity !== false) publishAuthenticatedIdentity(identity.userId); }, logout: async () => { - // Revoke server-side first so a stolen cookie copy can't be used after the user - // clicks "sign out". If the call fails (offline), fall through and clear local - // state anyway — the user shouldn't be stuck "signed in" because of network. + // The click is the boundary: clear locally before waiting for server revocation. Two phases + // keep another tab from racing its identity probe against the in-flight logout request. + set({ userId: null, username: null, role: null, isAuthenticated: false }); + clearLocalAuthBoundary(); + publishLogoutBoundary('started'); + // Server revocation remains best effort; local confidentiality does not depend on latency. + let serverLogoutSucceeded = false; try { await api.post('/auth/logout'); + serverLogoutSucceeded = true; } catch (err) { - // Server unreachable — local cleanup below still runs so the UI is usable, but the + // Server unreachable — local cleanup has already run, but the // cookie may remain valid server-side until it expires (~12h). Warn so this isn't silent. console.warn( '[auth] Logout request failed — state cleared locally but cookie may remain valid server-side until it expires (~12h).', err, ); + } finally { + publishLogoutBoundary(serverLogoutSucceeded ? 'succeeded' : 'failed'); } - set({ userId: null, username: null, role: null, isAuthenticated: false }); - clearLegacyWorkflowClipboard(); - // AI chat histories are per-user — clear them from memory on logout so the next - // person to sign in on this browser never sees someone else's conversation. - useAiChatStore.getState().clearAll(); }, - initialize: async () => { + initialize: async (options) => { + const expectedBoundaryGeneration = options?.expectedBoundaryGeneration + ?? captureAuthBoundaryGeneration(); + const commitOptions: AcceptIdentityOptions = { + ...options, + expectedBoundaryGeneration, + }; clearLegacyWorkflowClipboard(); + // New builds no longer write sensitive content to localStorage. Remove residue without + // discarding valid same-user sessionStorage state before /auth/me has identified its owner. + clearLegacySensitiveLocalStorage(); // Ask the server who we are. The browser auto-attaches np_auth if present. // Success → signed in. 401 → anonymous (the api client intercepts 401s and triggers // a /login redirect, but only when we're not already on /login, so the LoginPage // renders cleanly on first load). try { - const me = await api.get<{ id: string; username: string; role: string }>('/auth/me'); - set({ userId: me.id, username: me.username, role: me.role, isAuthenticated: true }); + const me = commitOptions.broadcastIdentity === false + ? await api.get<{ id: string; username: string; role: string }>( + '/auth/me', + { broadcastUnauthorized: false }, + ) + : await api.get<{ id: string; username: string; role: string }>('/auth/me'); + get().acceptAuthenticatedIdentity( + { userId: me.id, username: me.username, role: me.role }, + commitOptions, + ); } catch (err) { + // A response belonging to an older auth generation has no authority over the current UI, + // whether it is a stale 200, 401, or infrastructure failure. + if (!isAuthBoundaryGenerationCurrent(expectedBoundaryGeneration)) return; // A database outage or an unreachable process says NOTHING about this user's session — the // cookie may be perfectly valid. The old bare catch signed the user out, so a page reload // during an outage ejected them to a login form that itself answers 503 (and, before it @@ -118,23 +198,22 @@ export const useAuthStore = create((set) => ({ const unsubscribe = useDbHealthStore.subscribe((state) => { if (state.status !== 'ok') return; unsubscribe(); - void useAuthStore.getState().initialize(); + if (!isAuthBoundaryGenerationCurrent(expectedBoundaryGeneration)) return; + void useAuthStore.getState().initialize(commitOptions); }); return; } - set({ userId: null, username: null, role: null, isAuthenticated: false }); + // The real API client has already executed this boundary for a 401. Repeating it is + // intentionally idempotent and also covers mocked/non-HTTP anonymous initialization. + clearLocalAuthBoundary(); } }, refresh: async () => { + const expectedBoundaryGeneration = captureAuthBoundaryGeneration(); try { const response = await api.post('/auth/refresh'); - set({ - userId: response.userId, - username: response.username, - role: response.role, - isAuthenticated: true, - }); + get().acceptAuthenticatedIdentity(response, { expectedBoundaryGeneration }); } catch { // Refresh failure → client.ts already redirected to /login on 401. Nothing to do. } @@ -150,3 +229,119 @@ export const useAuthStore = create((set) => ({ }, })); +// The API client can observe a 401 without importing this store (which would create a module +// cycle). Register the in-memory half here; the boundary coordinator owns browser + query cleanup. +registerAuthBoundaryLiveStateClearer(() => { + useAuthStore.setState({ + userId: null, + username: null, + role: null, + isAuthenticated: false, + authBoundaryEpoch: captureAuthBoundaryGeneration(), + }); + clearLegacyWorkflowClipboard(); +}); +registerAuthBoundaryLiveStateClearer(() => useAiChatStore.getState().clearAll()); + +let synchronizationReferences = 0; +let stopSynchronization: (() => void) | null = null; +let stopCookieIdentityReprober: (() => void) | null = null; +let remoteProbeInFlight: Promise | null = null; +let remoteProbeQueued = false; +let remoteLogoutPending = false; + +function reProbeRemoteIdentity(): void { + if (remoteProbeInFlight) { + remoteProbeQueued = true; + return; + } + + // Protected content (including QueryPane component-local state) unmounts before /auth/me can + // issue a request or accept the next identity. Defer one task so React can commit that external + // store update and unmount protected component-local state before any next-user request starts. + useAuthStore.setState({ userId: null, username: null, role: null, isAuthenticated: null }); + const expectedBoundaryGeneration = captureAuthBoundaryGeneration(); + const probe = new Promise((resolve) => globalThis.setTimeout(resolve, 0)).then(async () => { + if (remoteLogoutPending || !isAuthBoundaryGenerationCurrent(expectedBoundaryGeneration)) return; + await useAuthStore.getState().initialize({ + broadcastIdentity: false, + expectedBoundaryGeneration, + }); + }); + remoteProbeInFlight = probe.finally(() => { + remoteProbeInFlight = null; + if (remoteLogoutPending) { + // A probe started before another tab clicked logout must not restore its old identity. + clearLocalAuthBoundary(); + return; + } + if (remoteProbeQueued) { + remoteProbeQueued = false; + reProbeRemoteIdentity(); + } + }); +} + +function handleRemoteAuthBoundary(event: AuthBoundaryEvent): void { + if (event.type === 'identity') { + // Always treat a successful login as a boundary, even for the same user id: roles and the + // server-side security stamp may have changed. Clear synchronously, then trust only /auth/me. + remoteLogoutPending = false; + clearLocalAuthBoundary(); + reProbeRemoteIdentity(); + return; + } + + if (event.type === 'logout' && event.phase === 'started') { + remoteLogoutPending = true; + clearLocalAuthBoundary(); + return; + } + + if (event.type === 'cookie-changed') { + // Another tab received a stale auth response whose Set-Cookie side effect cannot be undone. + // During a two-phase logout, wait for explicit success/failure; otherwise trust only /auth/me. + clearLocalAuthBoundary(); + if (!remoteLogoutPending) reProbeRemoteIdentity(); + return; + } + + if (event.type === 'logout' && event.phase === 'failed') { + // The shared cookie may still be valid, but logout intent wins: release the two-phase lock + // without asking /auth/me to authenticate the old identity again. + remoteLogoutPending = false; + clearLocalAuthBoundary(); + return; + } + + // A successful logout or a 401 discards local data, while the httpOnly cookie remains the source + // of truth. Re-probe once without echoing its success/failure back into a tab-to-tab event loop. + remoteLogoutPending = false; + clearLocalAuthBoundary(); + reProbeRemoteIdentity(); +} + +/** Start one cross-tab listener; reference counting keeps tests/HMR from duplicating handlers. */ +export function startAuthBoundarySynchronization(): () => void { + synchronizationReferences++; + if (!stopSynchronization) { + stopSynchronization = subscribeToAuthBoundaryEvents(handleRemoteAuthBoundary); + stopCookieIdentityReprober = registerAuthBoundaryIdentityReprober(reProbeRemoteIdentity); + } + + let released = false; + return () => { + if (released) return; + released = true; + synchronizationReferences--; + if (synchronizationReferences === 0) { + stopSynchronization?.(); + stopSynchronization = null; + stopCookieIdentityReprober?.(); + stopCookieIdentityReprober = null; + remoteProbeQueued = false; + remoteLogoutPending = false; + } + }; +} + diff --git a/src/nodepilot-ui/src/stores/confirmStore.ts b/src/nodepilot-ui/src/stores/confirmStore.ts index b08cb0c4..26db08a1 100644 --- a/src/nodepilot-ui/src/stores/confirmStore.ts +++ b/src/nodepilot-ui/src/stores/confirmStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { registerAuthBoundaryLiveStateClearer } from '../security/authBoundary'; export interface ConfirmRequest { message: string; @@ -46,3 +47,7 @@ export function confirmDialog(req: ConfirmRequest | string): Promise { useConfirmStore.getState().open({ ...normalized, resolve }); }); } + +// ConfirmHost lives outside ProtectedRoute. Cancel its continuation synchronously so a destructive +// User-A action can never be confirmed and resumed under User B's cookie after an auth switch. +registerAuthBoundaryLiveStateClearer(() => useConfirmStore.getState().settle(false)); diff --git a/src/nodepilot-ui/src/stores/toastStore.ts b/src/nodepilot-ui/src/stores/toastStore.ts index 9d4b8e36..8eddf68b 100644 --- a/src/nodepilot-ui/src/stores/toastStore.ts +++ b/src/nodepilot-ui/src/stores/toastStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { registerAuthBoundaryLiveStateClearer } from '../security/authBoundary'; export type ToastKind = 'success' | 'error' | 'info'; @@ -61,3 +62,7 @@ export const toast = { error: (message: string, timeoutMs?: number) => useToastStore.getState().push('error', message, timeoutMs), info: (message: string, timeoutMs?: number) => useToastStore.getState().push('info', message, timeoutMs), }; + +// ToastHost is mounted outside ProtectedRoute. Messages can contain workflow/database names, so +// do not display User A's notifications after the browser switches to User B. +registerAuthBoundaryLiveStateClearer(() => useToastStore.setState({ toasts: [] })); diff --git a/tests/NodePilot.Ai.Tests/LlmEndpointGuardTests.cs b/tests/NodePilot.Ai.Tests/LlmEndpointGuardTests.cs index 2ff9c6a8..616c88e8 100644 --- a/tests/NodePilot.Ai.Tests/LlmEndpointGuardTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmEndpointGuardTests.cs @@ -34,6 +34,28 @@ public void NormalizeAndValidateBaseUrl_NonHttpOrRelative_Throws(string input) act.Should().Throw().Where(e => e.Message.Contains("absolute http/https")); } + [Theory] + [InlineData("http://api.openai.com/v1")] + [InlineData("http://10.20.30.40:11434/v1")] + [InlineData("http://localhost.example.com:11434/v1")] + public void NormalizeAndValidateBaseUrl_RemotePlaintext_Throws(string input) + { + var act = () => LlmEndpointGuard.NormalizeAndValidateBaseUrl(input); + + act.Should().Throw() + .Where(e => e.Message.Contains("plaintext HTTP") && e.Message.Contains("HTTPS")); + } + + [Theory] + [InlineData("http://localhost:11434/v1")] + [InlineData("http://127.0.0.1:11434/v1")] + [InlineData("http://127.42.7.9:11434/v1")] + [InlineData("http://[::1]:11434/v1")] + public void NormalizeAndValidateBaseUrl_LiteralLoopbackPlaintext_IsAllowed(string input) + { + LlmEndpointGuard.NormalizeAndValidateBaseUrl(input).Should().Be(input); + } + [Theory] [InlineData("http://169.254.169.254/latest/meta-data")] [InlineData("http://metadata.google.internal/computeMetadata/v1")] @@ -144,4 +166,15 @@ public void IsCloudMetadataEndpoint_ClassifiesCorrectly(string baseUrl, bool exp { LlmEndpointGuard.IsCloudMetadataEndpoint(baseUrl).Should().Be(expected); } + + [Theory] + [InlineData("http://localhost:11434", true)] + [InlineData("http://127.0.0.1:11434", true)] + [InlineData("http://[::1]:11434", true)] + [InlineData("http://localhost.example.com:11434", false)] + [InlineData("http://10.0.0.5:11434", false)] + public void IsLiteralLoopbackEndpoint_ClassifiesWithoutDns(string input, bool expected) + { + LlmEndpointGuard.IsLiteralLoopbackEndpoint(new Uri(input)).Should().Be(expected); + } } diff --git a/tests/NodePilot.Ai.Tests/LlmProfileValidationTests.cs b/tests/NodePilot.Ai.Tests/LlmProfileValidationTests.cs index 9bfeb957..f447fe06 100644 --- a/tests/NodePilot.Ai.Tests/LlmProfileValidationTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmProfileValidationTests.cs @@ -38,6 +38,19 @@ public void ValidateProfileEndpoints_EnabledWithCleanProfiles_ReturnsNoIssues() issues.Should().BeEmpty(); } + [Fact] + public void ValidateProfileEndpoints_RemotePlaintextProfile_IsReportedBeforeSaveOrBoot() + { + var issues = LlmProfileValidation.ValidateProfileEndpoints(Config( + ("Llm:Enabled", "true"), + ("Llm:Profiles:remote:Name", "Remote HTTP"), + ("Llm:Profiles:remote:BaseUrl", "http://llm.corp.example/v1"))); + + issues.Should().ContainSingle(); + issues[0].ConfigKey.Should().Be("Llm:Profiles:remote:BaseUrl"); + issues[0].Message.Should().Contain("Remote HTTP").And.Contain("plaintext HTTP"); + } + [Fact] public void ValidateProfileEndpoints_MetadataEndpointInInactiveProfile_IsReported() { diff --git a/tests/NodePilot.Api.Tests/Configuration/EffectiveSourceDetectorTests.cs b/tests/NodePilot.Api.Tests/Configuration/EffectiveSourceDetectorTests.cs index 8429234e..4515b839 100644 --- a/tests/NodePilot.Api.Tests/Configuration/EffectiveSourceDetectorTests.cs +++ b/tests/NodePilot.Api.Tests/Configuration/EffectiveSourceDetectorTests.cs @@ -72,6 +72,28 @@ public void Env_ClassifiedAsEnv() } } + [Fact] + public void Env_ArrayChild_ClassifiesParentCollectionAsEnv() + { + var prefix = "NP_TEST_SCOPE_" + Guid.NewGuid().ToString("N") + "_"; + var childKey = prefix + "ExternalTrigger__AllowedWorkflowIds__0"; + Environment.SetEnvironmentVariable(childKey, Guid.NewGuid().ToString()); + try + { + var root = (IConfigurationRoot)new ConfigurationBuilder() + .AddEnvironmentVariables(prefix) + .Build(); + + EffectiveSourceDetector.Detect(root, "ExternalTrigger:AllowedWorkflowIds") + .Should().Be(EffectiveSourceDetector.SourceEnv, + "array-valued env overrides define child indices rather than the parent key"); + } + finally + { + Environment.SetEnvironmentVariable(childKey, null); + } + } + [Fact] public void Cli_ClassifiedAsCli() { diff --git a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs index b7e51193..5aeaefa5 100644 --- a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs @@ -544,9 +544,9 @@ await controller.PutSection("Llm", JsonDocument.Parse(""" { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "key-a" }, - { "Id": "b", "Name": "B", "BaseUrl": "http://b.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "b", "Name": "B", "BaseUrl": "https://b.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "key-b" } ] } @@ -562,9 +562,9 @@ await controller.PutSection("Llm", JsonDocument.Parse(""" { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "b", "Name": "B", "BaseUrl": "http://b.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "b", "Name": "B", "BaseUrl": "https://b.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "__unchanged__" }, - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "__unchanged__" } ] } @@ -619,9 +619,9 @@ public async Task PutSection_Llm_AuditDiff_RedactsEveryProfileApiKey() { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "sk-alpha" }, - { "Id": "b", "Name": "B", "BaseUrl": "http://b.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "b", "Name": "B", "BaseUrl": "https://b.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "sk-beta" } ] } @@ -661,9 +661,9 @@ await controller.PutSection("Llm", JsonDocument.Parse(""" { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null }, - { "Id": "b", "Name": "B", "BaseUrl": "http://b.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "b", "Name": "B", "BaseUrl": "https://b.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null } ] } @@ -674,7 +674,7 @@ await controller.PutSection("Llm", JsonDocument.Parse(""" { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": "__unchanged__" } ] } @@ -698,7 +698,7 @@ public async Task PutSection_Llm_InvalidBaseUrlOnSecondProfile_ReportsIndexedFie { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "A", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "A", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null }, { "Id": "b", "Name": "B", "BaseUrl": "not-a-url", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null } @@ -738,9 +738,9 @@ public async Task PutSection_Llm_DuplicateProfileName_Returns400() { "Enabled": true, "ActiveProfileId": "a", "Profiles": [ - { "Id": "a", "Name": "Same", "BaseUrl": "http://a.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "a", "Name": "Same", "BaseUrl": "https://a.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null }, - { "Id": "b", "Name": "same", "BaseUrl": "http://b.local/v1", "Model": "m", "MaxTokens": 4096, + { "Id": "b", "Name": "same", "BaseUrl": "https://b.local/v1", "Model": "m", "MaxTokens": 4096, "TimeoutSeconds": 60, "EnableToolCalling": false, "ToolCallMaxDepth": 6, "ApiKey": null } ] } @@ -1145,6 +1145,53 @@ public async Task PutSection_EnvLockedField_StrippedFromPersistedSection() // "did anyone break the happy path" guards. // ───────────────────────────────────────────────────────────────────────── + [Fact] + public async Task PutSection_EnvLockedExternalTriggerArray_IsNotPersistedAsDormantScope() + { + var (_, writer, _, cfg) = NewController(); + var envPrefix = "ADMIN_SCOPE_TEST_" + Guid.NewGuid().ToString("N") + "_"; + var envKey = envPrefix + "ExternalTrigger__AllowedWorkflowIds__0"; + Environment.SetEnvironmentVariable(envKey, Guid.NewGuid().ToString()); + try + { + var envCfg = new ConfigurationBuilder() + .AddInMemoryCollection(cfg.AsEnumerable()) + .AddEnvironmentVariables(envPrefix) + .Build(); + var probe = new SettingsTestProbe(NullLogger.Instance, new StubHttpClientFactory()); + var controller = new AdminSettingsController( + writer, envCfg, new PassthroughProtector(), + NoopAuditWriter.Instance, probe, + new StaticOptionsMonitor(new SmtpOptions()), + new StaticOptionsMonitor(new LlmOptions()), + new StaticOptionsMonitor(new RetentionOptions()), + new StaticOptionsMonitor(new LdapOptions()), + new StaticOptionsMonitor(new WindowsAuthOptions()), + new StaticOptionsMonitor(new NodePilotTelemetryOptions()), + new StaticOptionsMonitor(new AiKnowledgeOptions()), + new NoopClusterState()); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + controller.HttpContext.Request.Headers.IfMatch = writer.ComputeSectionEtag("ExternalTrigger"); + + var body = JsonSerializer.SerializeToElement(new ExternalTriggerSettingsDto + { + ApiKey = null, + AllowedWorkflowIds = [Guid.NewGuid()], + }); + (await controller.PutSection("ExternalTrigger", body, CancellationToken.None)) + .Should().BeOfType(); + + var section = JsonNode.Parse(File.ReadAllText(writer.OverridesPath))! + ["ExternalTrigger"]!.AsObject(); + section.ContainsKey("AllowedWorkflowIds").Should().BeFalse( + "an env-owned array must not leave a dormant UI scope that activates when the env var is removed"); + } + finally + { + Environment.SetEnvironmentVariable(envKey, null); + } + } + [Fact] public async Task PutSection_Logging_HappyPath_PersistsFormatAndLevels() { @@ -1404,8 +1451,8 @@ public async Task PutSection_Security_HappyPath_PersistsAllowedHosts() [Fact] public async Task PutSection_ExternalTrigger_ClearsApiKey_WithExplicitNull() { - // Regression for Finding 7 in the external-trigger path: clearing the API key must - // disable the endpoint entirely (503), not fall back to a value from appsettings.json. + // Clearing the legacy key must shadow a base-provider value. The external endpoint then + // rejects that key with the same 401 as every unknown key. var (controller, writer, _, _) = NewController(); controller.HttpContext.Request.Headers.IfMatch = writer.ComputeSectionEtag("ExternalTrigger"); var body = JsonDocument.Parse("{\"ApiKey\":null}").RootElement; @@ -1415,6 +1462,99 @@ public async Task PutSection_ExternalTrigger_ClearsApiKey_WithExplicitNull() var section = file["ExternalTrigger"]!.AsObject(); section.ContainsKey("ApiKey").Should().BeTrue(); section["ApiKey"].Should().BeNull("explicit JSON null shadows any base-provider ApiKey value"); + section["AllowedWorkflowIds"]!.AsArray().Should().BeEmpty(); + } + + [Fact] + public async Task PutSection_ExternalTrigger_PersistsLegacyScopeWithoutDeletingHashedKeys() + { + var (controller, writer, _, _) = NewController(); + var workflowId = Guid.NewGuid(); + File.WriteAllText(writer.OverridesPath, """ + { + "ExternalTrigger": { + "ApiKey": null, + "AllowedWorkflowIds": [], + "Keys": { + "ci": { + "KeyHash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "AllowedWorkflowIds": ["11111111-1111-1111-1111-111111111111"] + } + } + } + } + """); + controller.HttpContext.Request.Headers.IfMatch = writer.ComputeSectionEtag("ExternalTrigger"); + var body = JsonSerializer.SerializeToElement(new ExternalTriggerSettingsDto + { + ApiKey = null, + AllowedWorkflowIds = [workflowId], + }); + + var result = await controller.PutSection("ExternalTrigger", body, CancellationToken.None); + + result.Should().BeOfType(); + var section = JsonNode.Parse(File.ReadAllText(writer.OverridesPath))! + .AsObject()["ExternalTrigger"]!.AsObject(); + section["AllowedWorkflowIds"]!.AsArray().Select(node => node!.GetValue()) + .Should().ContainSingle().Which.Should().Be(workflowId.ToString()); + section["Keys"]!["ci"]!["KeyHash"]!.GetValue() + .Should().Be("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + } + + [Fact] + public async Task PutSection_ExternalTrigger_ShorterAndEmptyRuntimeScopesRevokeLowerProviderEntries() + { + var (controller, writer, _, _) = NewController(); + var retained = Guid.NewGuid(); + var revoked = Guid.NewGuid(); + var baseValues = new Dictionary + { + ["ExternalTrigger:AllowedWorkflowIds:0"] = retained.ToString(), + ["ExternalTrigger:AllowedWorkflowIds:1"] = revoked.ToString(), + }; + + controller.HttpContext.Request.Headers.IfMatch = writer.ComputeSectionEtag("ExternalTrigger"); + var shorterBody = JsonSerializer.SerializeToElement(new ExternalTriggerSettingsDto + { + ApiKey = null, + AllowedWorkflowIds = [retained], + }); + (await controller.PutSection("ExternalTrigger", shorterBody, CancellationToken.None)) + .Should().BeOfType(); + + IConfigurationRoot LoadLayeredConfiguration() => new ConfigurationBuilder() + .AddInMemoryCollection(baseValues) + .AddJsonStream(new MemoryStream(File.ReadAllBytes(writer.OverridesPath))) + .Build(); + + var shorterConfig = LoadLayeredConfiguration(); + shorterConfig.GetSection("ExternalTrigger:AllowedWorkflowIds").GetChildren() + .Select(child => child.Value).Should().Contain(revoked.ToString(), + "the stock IConfiguration child view demonstrates the lower-index merge hazard"); + ProviderAtomicGuidList.TryRead( + shorterConfig, "ExternalTrigger:AllowedWorkflowIds", out var shorterScope) + .Should().BeTrue(); + shorterScope.Should().BeEquivalentTo([retained], + "the runtime provider must replace, not extend, the base allow-list"); + + controller.HttpContext.Request.Headers.IfMatch = writer.ComputeSectionEtag("ExternalTrigger"); + var emptyBody = JsonSerializer.SerializeToElement(new ExternalTriggerSettingsDto + { + ApiKey = null, + AllowedWorkflowIds = [], + }); + (await controller.PutSection("ExternalTrigger", emptyBody, CancellationToken.None)) + .Should().BeOfType(); + + var emptyConfig = LoadLayeredConfiguration(); + emptyConfig.GetSection("ExternalTrigger:AllowedWorkflowIds").GetChildren() + .Should().NotBeEmpty("ordinary IConfiguration merging still exposes base indices"); + ProviderAtomicGuidList.TryRead( + emptyConfig, "ExternalTrigger:AllowedWorkflowIds", out var emptyScope) + .Should().BeTrue(); + emptyScope.Should().BeEmpty( + "the explicit [] emitted by the runtime writer is a deny-all tombstone"); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Controllers/ExecutionsControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/ExecutionsControllerTests.cs index b8b458fe..4664e7d6 100644 --- a/tests/NodePilot.Api.Tests/Controllers/ExecutionsControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/ExecutionsControllerTests.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using System.Security.Cryptography; +using System.Text; using NodePilot.Core.Audit; using NodePilot.Api.Controllers; using NodePilot.Api.Dtos; @@ -417,6 +419,11 @@ public async Task Cancel_NotFound_Returns404() // 32-byte test key — matches MinExternalApiKeyBytes. private const string LongKey = "test-api-key-needs-32-bytes-yep!"; + private const string OtherLongKey = "other-api-key-needs-32-bytes-ok!"; + private const string ManualApiDefinition = + """{"nodes":[{"id":"manual","type":"activity","data":{"activityType":"manualTrigger","config":{}}}],"edges":[]}"""; + private const string DisabledManualApiDefinition = + """{"nodes":[{"id":"manual","type":"activity","data":{"activityType":"manualTrigger","disabled":true,"config":{}}}],"edges":[]}"""; private static readonly NullLogger TriggerLogger = NullLogger.Instance; @@ -450,6 +457,15 @@ public ValueTask EnqueueAsync( } } + private sealed class ThrowingExecutionDispatchQueue : IExecutionDispatchQueue + { + public ValueTask EnqueueAsync( + Func workItem, + CancellationToken ct, + ExecutionDispatchPriority priority = ExecutionDispatchPriority.Normal) + => throw new InvalidOperationException("synthetic enqueue failure"); + } + private static ExternalTriggerController CreateTriggerController( NodePilotDbContext db, IWorkflowEngine engine, @@ -470,14 +486,53 @@ private static ExternalTriggerController CreateTriggerController( return controller; } - private static IConfiguration ConfigWithKey(string? key) + private static IConfiguration ConfigWithKey(string? key, params Guid[] allowedWorkflowIds) { var builder = new ConfigurationBuilder(); if (key is not null) - builder.AddInMemoryCollection(new Dictionary { ["ExternalTrigger:ApiKey"] = key }); + { + var values = new Dictionary { ["ExternalTrigger:ApiKey"] = key }; + for (var i = 0; i < allowedWorkflowIds.Length; i++) + values[$"ExternalTrigger:AllowedWorkflowIds:{i}"] = allowedWorkflowIds[i].ToString(); + builder.AddInMemoryCollection(values); + } return builder.Build(); } + private static IConfiguration ConfigWithHashedKeys( + params (string IntegrationId, string Key, Guid[] AllowedWorkflowIds)[] entries) + { + var values = new Dictionary(); + foreach (var entry in entries) + { + values[$"ExternalTrigger:Keys:{entry.IntegrationId}:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(entry.Key))); + for (var i = 0; i < entry.AllowedWorkflowIds.Length; i++) + { + values[$"ExternalTrigger:Keys:{entry.IntegrationId}:AllowedWorkflowIds:{i}"] = + entry.AllowedWorkflowIds[i].ToString(); + } + } + + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + private static IConfiguration ConfigWithJsonOverride( + IReadOnlyDictionary baseValues, + string overrideJson) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(overrideJson)); + return new ConfigurationBuilder() + .AddInMemoryCollection(baseValues) + .AddJsonStream(stream) + .Build(); + } + + private static Workflow ExternalWorkflow(string name) => new() + { + Id = Guid.NewGuid(), Name = name, DefinitionJson = ManualApiDefinition, IsEnabled = true, + }; + [Fact] public async Task ExternalTrigger_NoApiKeyConfigured_ReturnsUnauthorized() { @@ -557,16 +612,379 @@ public async Task ExternalTrigger_DisabledWorkflow_ReturnsNotFound() // 404. Previously a BadRequest for disabled let a holder of a valid API key enumerate // which named workflows exist even while disabled. var db = CreateContext(); - db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "Off", DefinitionJson = "{}", IsEnabled = false }); + var workflow = new Workflow + { + Id = Guid.NewGuid(), Name = "Off", DefinitionJson = ManualApiDefinition, IsEnabled = false, + }; + db.Workflows.Add(workflow); await db.SaveChangesAsync(); var controller = CreateTriggerController(db, Mock.Of(), presentedKey: LongKey); - var result = await controller.ExternalTrigger("Off", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var result = await controller.ExternalTrigger( + "Off", null, ConfigWithKey(LongKey, workflow.Id), TriggerLogger, CancellationToken.None); result.Result.Should().BeOfType(); } + [Fact] + public async Task ExternalTrigger_LegacyKeyWithoutWorkflowScope_ReturnsNotFoundWithoutEnqueue() + { + var db = CreateContext(); + var workflow = new Workflow + { + Id = Guid.NewGuid(), Name = "Manual", DefinitionJson = ManualApiDefinition, IsEnabled = true, + }; + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + + var queue = new CountingNoopExecutionDispatchQueue(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey, queue); + + var result = await controller.ExternalTrigger( + workflow.Name, null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(0); + } + + [Fact] + public async Task ExternalTrigger_HashedKey_CanStartOnlyItsAllowedWorkflow() + { + var db = CreateContext(); + var allowed = new Workflow + { + Id = Guid.NewGuid(), Name = "Allowed", DefinitionJson = ManualApiDefinition, IsEnabled = true, + }; + var denied = new Workflow + { + Id = Guid.NewGuid(), Name = "Denied", DefinitionJson = ManualApiDefinition, IsEnabled = true, + }; + db.Workflows.AddRange(allowed, denied); + await db.SaveChangesAsync(); + + var config = ConfigWithHashedKeys( + ("integration-a", LongKey, [allowed.Id]), + ("integration-b", OtherLongKey, [denied.Id])); + var queue = new CountingNoopExecutionDispatchQueue(); + + var deniedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var deniedResult = await deniedController.ExternalTrigger( + denied.Name, null, config, TriggerLogger, CancellationToken.None); + deniedResult.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(0); + + var allowedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var allowedResult = await allowedController.ExternalTrigger( + allowed.Name, null, config, TriggerLogger, CancellationToken.None); + allowedResult.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(1); + } + + [Fact] + public async Task ExternalTrigger_HashedScope_HigherProviderReplacesLowerArrayAtomically() + { + var db = CreateContext(); + var retained = ExternalWorkflow("Retained"); + var revoked = ExternalWorkflow("Revoked"); + db.Workflows.AddRange(retained, revoked); + await db.SaveChangesAsync(); + + var encodedKeyHash = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))); + var baseValues = new Dictionary + { + ["ExternalTrigger:Keys:ci:KeyHash"] = encodedKeyHash, + ["ExternalTrigger:Keys:ci:AllowedWorkflowIds:0"] = retained.Id.ToString(), + ["ExternalTrigger:Keys:ci:AllowedWorkflowIds:1"] = revoked.Id.ToString(), + }; + var shorterOverride = $$""" + { + "ExternalTrigger": { + "Keys": { + "ci": { + "KeyHash": "{{encodedKeyHash}}", + "AllowedWorkflowIds": ["{{retained.Id}}"] + } + } + } + } + """; + var config = ConfigWithJsonOverride(baseValues, shorterOverride); + var queue = new CountingNoopExecutionDispatchQueue(); + + var revokedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var revokedResult = await revokedController.ExternalTrigger( + revoked.Name, null, config, TriggerLogger, CancellationToken.None); + revokedResult.Result.Should().BeOfType( + "lower-provider index 1 must not survive a one-element higher-provider list"); + + var retainedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var retainedResult = await retainedController.ExternalTrigger( + retained.Name, null, config, TriggerLogger, CancellationToken.None); + retainedResult.Result.Should().BeOfType(); + + var emptyOverride = $$""" + { + "ExternalTrigger": { + "Keys": { + "ci": { "KeyHash": "{{encodedKeyHash}}", "AllowedWorkflowIds": [] } + } + } + } + """; + var denyAllConfig = ConfigWithJsonOverride(baseValues, emptyOverride); + var denyAllController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var denyAllResult = await denyAllController.ExternalTrigger( + retained.Name, null, denyAllConfig, TriggerLogger, CancellationToken.None); + denyAllResult.Result.Should().BeOfType( + "an explicit empty JSON array is a higher-provider deny-all tombstone"); + queue.EnqueueCount.Should().Be(1); + } + + [Fact] + public async Task ExternalTrigger_HashedKeys_HigherProviderEmptyMapRevokesLowerProviderKeys() + { + var db = CreateContext(); + var workflow = ExternalWorkflow("EmergencyRevocation"); + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + + var baseValues = new Dictionary + { + ["ExternalTrigger:Keys:ci:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:ci:AllowedWorkflowIds:0"] = workflow.Id.ToString(), + }; + var config = ConfigWithJsonOverride( + baseValues, + """{ "ExternalTrigger": { "Keys": {} } }"""); + var queue = new CountingNoopExecutionDispatchQueue(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey, queue); + + var result = await controller.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType( + "an empty higher-provider key map must tombstone every lower-provider integration"); + queue.EnqueueCount.Should().Be(0); + } + + [Fact] + public async Task ExternalTrigger_HashedKeys_ReplacementHashDoesNotInheritLowerProviderScope() + { + var db = CreateContext(); + var workflow = ExternalWorkflow("RotatedKey"); + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + + var baseValues = new Dictionary + { + ["ExternalTrigger:Keys:ci:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:ci:AllowedWorkflowIds:0"] = workflow.Id.ToString(), + }; + var replacementHash = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(OtherLongKey))); + var config = ConfigWithJsonOverride( + baseValues, + $$"""{ "ExternalTrigger": { "Keys": { "ci": { "KeyHash": "{{replacementHash}}" } } } }"""); + var queue = new CountingNoopExecutionDispatchQueue(); + + var replacementController = CreateTriggerController( + db, Mock.Of(), OtherLongKey, queue); + var replacementResult = await replacementController.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + replacementResult.Result.Should().BeOfType( + "an omitted scope is deny-all and must not fall back to the old provider's allow-list"); + + var oldController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var oldResult = await oldController.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + oldResult.Result.Should().BeOfType( + "the lower-provider hash must not survive a replacement map"); + queue.EnqueueCount.Should().Be(0); + } + + [Fact] + public async Task ExternalTrigger_LegacyScope_HigherProviderReplacesLowerArrayAtomically() + { + var db = CreateContext(); + var retained = ExternalWorkflow("LegacyRetained"); + var revoked = ExternalWorkflow("LegacyRevoked"); + db.Workflows.AddRange(retained, revoked); + await db.SaveChangesAsync(); + + var baseValues = new Dictionary + { + ["ExternalTrigger:ApiKey"] = LongKey, + ["ExternalTrigger:AllowedWorkflowIds:0"] = retained.Id.ToString(), + ["ExternalTrigger:AllowedWorkflowIds:1"] = revoked.Id.ToString(), + }; + var shorterOverride = $$""" + { "ExternalTrigger": { "AllowedWorkflowIds": ["{{retained.Id}}"] } } + """; + var config = ConfigWithJsonOverride(baseValues, shorterOverride); + var queue = new CountingNoopExecutionDispatchQueue(); + + var revokedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var revokedResult = await revokedController.ExternalTrigger( + revoked.Name, null, config, TriggerLogger, CancellationToken.None); + revokedResult.Result.Should().BeOfType(); + + var retainedController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var retainedResult = await retainedController.ExternalTrigger( + retained.Name, null, config, TriggerLogger, CancellationToken.None); + retainedResult.Result.Should().BeOfType(); + + var denyAllConfig = ConfigWithJsonOverride( + baseValues, """{ "ExternalTrigger": { "AllowedWorkflowIds": [] } }"""); + var denyAllController = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var denyAllResult = await denyAllController.ExternalTrigger( + retained.Name, null, denyAllConfig, TriggerLogger, CancellationToken.None); + denyAllResult.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(1); + } + + [Fact] + public async Task ExternalTrigger_DisabledManualTrigger_ReturnsNotFoundWithoutEnqueue() + { + var db = CreateContext(); + var workflow = new Workflow + { + Id = Guid.NewGuid(), Name = "DisabledManual", DefinitionJson = DisabledManualApiDefinition, IsEnabled = true, + }; + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + + var queue = new CountingNoopExecutionDispatchQueue(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey, queue); + var result = await controller.ExternalTrigger( + workflow.Name, null, ConfigWithHashedKeys(("integration-a", LongKey, [workflow.Id])), + TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(0); + } + + [Fact] + public async Task ExternalTrigger_MalformedWorkflowIdInMatchingKeyScope_FailsClosed() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ExternalTrigger:Keys:broken:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:broken:AllowedWorkflowIds:0"] = "not-a-guid", + }).Build(); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task ExternalTrigger_MalformedWorkflowIdInAnyConfiguredScope_FailsClosed() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ExternalTrigger:Keys:valid:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:valid:AllowedWorkflowIds:0"] = Guid.NewGuid().ToString(), + ["ExternalTrigger:Keys:broken:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(OtherLongKey))), + ["ExternalTrigger:Keys:broken:AllowedWorkflowIds:0"] = "not-a-guid", + }).Build(); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task ExternalTrigger_MalformedLegacyScope_FailsClosedForHashedKey() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ExternalTrigger:Keys:valid:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:valid:AllowedWorkflowIds:0"] = Guid.NewGuid().ToString(), + ["ExternalTrigger:ApiKey"] = OtherLongKey, + ["ExternalTrigger:AllowedWorkflowIds:0"] = "not-a-guid", + }).Build(); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task ExternalTrigger_MalformedHashInAnyConfiguredEntry_FailsClosed() + { + var id = Guid.NewGuid(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ExternalTrigger:Keys:valid:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:valid:AllowedWorkflowIds:0"] = id.ToString(), + ["ExternalTrigger:Keys:broken:KeyHash"] = "not-base64", + ["ExternalTrigger:Keys:broken:AllowedWorkflowIds:0"] = id.ToString(), + }).Build(); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task ExternalTrigger_DuplicateMatchingHashes_FailClosed() + { + var id = Guid.NewGuid(); + var config = ConfigWithHashedKeys( + ("duplicate-a", LongKey, [id]), + ("duplicate-b", LongKey, [id])); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + + [Fact] + public async Task ExternalTrigger_SameKeyInLegacyAndHashedEntry_FailsClosed() + { + var id = Guid.NewGuid(); + var values = new Dictionary + { + ["ExternalTrigger:ApiKey"] = LongKey, + ["ExternalTrigger:AllowedWorkflowIds:0"] = id.ToString(), + ["ExternalTrigger:Keys:duplicate:KeyHash"] = Convert.ToBase64String( + SHA256.HashData(Encoding.UTF8.GetBytes(LongKey))), + ["ExternalTrigger:Keys:duplicate:AllowedWorkflowIds:0"] = id.ToString(), + }; + var config = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + var db = CreateContext(); + var controller = CreateTriggerController(db, Mock.Of(), LongKey); + + var result = await controller.ExternalTrigger( + "anything", null, config, TriggerLogger, CancellationToken.None); + + result.Result.Should().BeOfType(); + } + [Fact] public async Task ExternalTrigger_CorrectKey_EnqueuesPendingExecutionAndInvokesEngine() { @@ -576,11 +994,8 @@ public async Task ExternalTrigger_CorrectKey_EnqueuesPendingExecutionAndInvokesE Id = Guid.NewGuid(), Username = "publisher", PasswordHash = "hash", Role = UserRole.Admin, IsActive = true, }; - var wf = new Workflow - { - Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true, - PublishedByUserId = publisher.Id, - }; + var wf = ExternalWorkflow("Enabled"); + wf.PublishedByUserId = publisher.Id; db.AddRange(publisher, wf); await db.SaveChangesAsync(); @@ -606,7 +1021,8 @@ public async Task ExternalTrigger_CorrectKey_EnqueuesPendingExecutionAndInvokesE var queue = new ImmediateExecutionDispatchQueue(); var controller = CreateTriggerController(db, engine.Object, presentedKey: LongKey, queue); - var result = await controller.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var result = await controller.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); result.Result.Should().BeOfType(); queue.EnqueueCount.Should().Be(1); @@ -643,7 +1059,7 @@ private static async Task WaitForInvocationAsync(Mock engine, T public async Task ExternalTrigger_BlockedByMaintenanceWindow_Returns404AndDoesNotConsumeIdempotencyKey() { var db = CreateContext(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }; + var wf = ExternalWorkflow("Enabled"); db.Workflows.Add(wf); await db.SaveChangesAsync(); @@ -652,7 +1068,8 @@ public async Task ExternalTrigger_BlockedByMaintenanceWindow_Returns404AndDoesNo maintenance: NodePilot.TestCommons.StubMaintenanceWindowEvaluator.Blocking("PatchWindow")); controller.HttpContext.Request.Headers["Idempotency-Key"] = "blocked-request"; - var result = await controller.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var result = await controller.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); // Uniform 404 (anti-enumeration) + the critical invariant: the maintenance check runs // BEFORE the idempotency-key transaction, so a blocked fire neither persists the key nor @@ -669,7 +1086,8 @@ public async Task ExternalTrigger_TooManyParameters_ReturnsBadRequestWithoutEnqu // M-32: the parameter map is bound before the API key is compared and every entry is // copied into the execution's variable dictionary, so an unbounded map is engine work. var db = CreateContext(); - db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }); + var wf = ExternalWorkflow("Enabled"); + db.Workflows.Add(wf); await db.SaveChangesAsync(); var queue = new CountingNoopExecutionDispatchQueue(); @@ -680,7 +1098,7 @@ public async Task ExternalTrigger_TooManyParameters_ReturnsBadRequestWithoutEnqu var result = await controller.ExternalTrigger( "Enabled", new ExecuteWorkflowRequest(parameters), - ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); result.Result.Should().BeOfType(); queue.EnqueueCount.Should().Be(0); @@ -691,7 +1109,8 @@ public async Task ExternalTrigger_TooManyParameters_ReturnsBadRequestWithoutEnqu public async Task ExternalTrigger_OversizedParameterValue_ReturnsBadRequestWithoutEnqueue() { var db = CreateContext(); - db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }); + var wf = ExternalWorkflow("Enabled"); + db.Workflows.Add(wf); await db.SaveChangesAsync(); var queue = new CountingNoopExecutionDispatchQueue(); @@ -703,7 +1122,7 @@ public async Task ExternalTrigger_OversizedParameterValue_ReturnsBadRequestWitho var result = await controller.ExternalTrigger( "Enabled", new ExecuteWorkflowRequest(parameters), - ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); result.Result.Should().BeOfType(); queue.EnqueueCount.Should().Be(0); @@ -715,7 +1134,8 @@ public async Task ExternalTrigger_ParametersWithinCaps_StillFires() { // Guards the caps against being set so tight they break ordinary runbooks. var db = CreateContext(); - db.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }); + var wf = ExternalWorkflow("Enabled"); + db.Workflows.Add(wf); await db.SaveChangesAsync(); var queue = new CountingNoopExecutionDispatchQueue(); @@ -724,7 +1144,7 @@ public async Task ExternalTrigger_ParametersWithinCaps_StillFires() var result = await controller.ExternalTrigger( "Enabled", new ExecuteWorkflowRequest(parameters), - ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); result.Result.Should().NotBeOfType(); queue.EnqueueCount.Should().Be(1); @@ -734,7 +1154,7 @@ public async Task ExternalTrigger_ParametersWithinCaps_StillFires() public async Task ExternalTrigger_IdempotencyKey_ReplayReturnsPendingExecutionWithoutSecondEnqueue() { var db = CreateContext(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }; + var wf = ExternalWorkflow("Enabled"); db.Workflows.Add(wf); await db.SaveChangesAsync(); @@ -743,7 +1163,8 @@ public async Task ExternalTrigger_IdempotencyKey_ReplayReturnsPendingExecutionWi var first = CreateTriggerController(db, engine.Object, presentedKey: LongKey, queue); first.HttpContext.Request.Headers["Idempotency-Key"] = "same-request"; - var firstResult = await first.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var firstResult = await first.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); firstResult.Result.Should().BeOfType(); queue.EnqueueCount.Should().Be(1); @@ -751,7 +1172,8 @@ public async Task ExternalTrigger_IdempotencyKey_ReplayReturnsPendingExecutionWi var second = CreateTriggerController(db, engine.Object, presentedKey: LongKey, queue); second.HttpContext.Request.Headers["Idempotency-Key"] = "same-request"; - var secondResult = await second.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var secondResult = await second.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); secondResult.Result.Should().BeOfType(); second.Response.Headers["Idempotent-Replayed"].ToString().Should().Be("true"); @@ -769,6 +1191,123 @@ public async Task ExternalTrigger_IdempotencyKey_ReplayReturnsPendingExecutionWi It.IsAny()), Times.Never); } + [Fact] + public async Task ExternalTrigger_IdempotencyKey_IsSeparatedByAuthenticatedKeyPrincipal() + { + var db = CreateContext(); + var workflow = ExternalWorkflow("PrincipalScoped"); + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + + var config = ConfigWithHashedKeys( + ("integration-a", LongKey, [workflow.Id]), + ("integration-b", OtherLongKey, [workflow.Id])); + var queue = new CountingNoopExecutionDispatchQueue(); + + var firstA = CreateTriggerController(db, Mock.Of(), LongKey, queue); + firstA.HttpContext.Request.Headers["Idempotency-Key"] = "shared-client-token"; + var firstAResult = await firstA.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + var firstAResponse = firstAResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + + var firstB = CreateTriggerController(db, Mock.Of(), OtherLongKey, queue); + firstB.HttpContext.Request.Headers["Idempotency-Key"] = "shared-client-token"; + var firstBResult = await firstB.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + var firstBResponse = firstBResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + + firstBResponse.Id.Should().NotBe(firstAResponse.Id, + "a different authenticated key principal owns a separate idempotency domain"); + queue.EnqueueCount.Should().Be(2); + var storedKeys = await db.IdempotencyKeys.OrderBy(k => k.Key).Select(k => k.Key).ToListAsync(); + storedKeys.Should().HaveCount(2).And.OnlyHaveUniqueItems(); + storedKeys.Should().OnlyContain(key => key.StartsWith("ext:v1:", StringComparison.Ordinal)); + storedKeys.Should().NotContain("shared-client-token", "the raw header is never persisted"); + + var replayA = CreateTriggerController(db, Mock.Of(), LongKey, queue); + replayA.HttpContext.Request.Headers["Idempotency-Key"] = "shared-client-token"; + var replayAResult = await replayA.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + var replayAResponse = replayAResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + replayAResponse.Id.Should().Be(firstAResponse.Id); + queue.EnqueueCount.Should().Be(2); + } + + [Fact] + public async Task ExternalTrigger_IdempotencyPrincipal_CanonicalizesIntegrationIdCasing() + { + var db = CreateContext(); + var workflow = ExternalWorkflow("CanonicalPrincipal"); + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + var queue = new CountingNoopExecutionDispatchQueue(); + + var upperConfig = ConfigWithHashedKeys(("CI-Agent", LongKey, [workflow.Id])); + var first = CreateTriggerController(db, Mock.Of(), LongKey, queue); + first.HttpContext.Request.Headers["Idempotency-Key"] = "case-stable"; + var firstResult = await first.ExternalTrigger( + workflow.Name, null, upperConfig, TriggerLogger, CancellationToken.None); + var firstResponse = firstResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + + var lowerConfig = ConfigWithHashedKeys(("ci-agent", LongKey, [workflow.Id])); + var replay = CreateTriggerController(db, Mock.Of(), LongKey, queue); + replay.HttpContext.Request.Headers["Idempotency-Key"] = "case-stable"; + var replayResult = await replay.ExternalTrigger( + workflow.Name, null, lowerConfig, TriggerLogger, CancellationToken.None); + var replayResponse = replayResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + + replayResponse.Id.Should().Be(firstResponse.Id); + queue.EnqueueCount.Should().Be(1); + } + + [Fact] + public void ExternalTrigger_IdempotencyStorageKey_UsesUnambiguousLengthPrefixedEncoding() + { + var first = ExternalTriggerController.BuildIdempotencyStorageKey("a\0b", "c"); + var second = ExternalTriggerController.BuildIdempotencyStorageKey("a", "b\0c"); + + first.Should().NotBe(second); + first.Should().StartWith("ext:v1:").And.HaveLength(71); + second.Should().StartWith("ext:v1:").And.HaveLength(71); + } + + [Fact] + public async Task ExternalTrigger_EnqueueFailure_RemovesPrincipalScopedReservationSoRetryCanRun() + { + var db = CreateContext(); + var workflow = ExternalWorkflow("RetryAfterFailure"); + db.Workflows.Add(workflow); + await db.SaveChangesAsync(); + var config = ConfigWithHashedKeys(("ci", LongKey, [workflow.Id])); + + var failing = CreateTriggerController( + db, Mock.Of(), LongKey, new ThrowingExecutionDispatchQueue()); + failing.HttpContext.Request.Headers["Idempotency-Key"] = "retry-after-enqueue-failure"; + Func firstAttempt = async () => await failing.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + + await firstAttempt.Should().ThrowAsync() + .WithMessage("synthetic enqueue failure"); + (await db.IdempotencyKeys.CountAsync()).Should().Be(0, + "cleanup must remove the internal principal-scoped digest after enqueue failure"); + + var queue = new CountingNoopExecutionDispatchQueue(); + var retry = CreateTriggerController(db, Mock.Of(), LongKey, queue); + retry.HttpContext.Request.Headers["Idempotency-Key"] = "retry-after-enqueue-failure"; + var retryResult = await retry.ExternalTrigger( + workflow.Name, null, config, TriggerLogger, CancellationToken.None); + + retryResult.Result.Should().BeOfType(); + queue.EnqueueCount.Should().Be(1); + (await db.IdempotencyKeys.CountAsync()).Should().Be(1); + (await db.IdempotencyKeys.SingleAsync()).Key.Should().StartWith("ext:v1:"); + } + [Fact] public async Task ExternalTrigger_Replay_RedactsSensitiveExecutionFields() { @@ -777,22 +1316,8 @@ public async Task ExternalTrigger_Replay_RedactsSensitiveExecutionFields() // ExecutionsController does for callers below Admin/Operator — otherwise step-stdout // tokens or webhook-body secrets leak straight back to the API-key holder. var db = CreateContext(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }; + var wf = ExternalWorkflow("Enabled"); db.Workflows.Add(wf); - var exec = new WorkflowExecution - { - Id = Guid.NewGuid(), WorkflowId = wf.Id, Status = ExecutionStatus.Succeeded, - StartedAt = DateTime.UtcNow, - ReturnData = "result token=SECRET-XYZ", - ErrorMessage = "failure detail SECRET-XYZ", - InputParametersJson = "{\"pw\":\"SECRET-XYZ\"}", - }; - db.WorkflowExecutions.Add(exec); - db.IdempotencyKeys.Add(new IdempotencyKey - { - Id = Guid.NewGuid(), Key = "replay-secret", WorkflowId = wf.Id, - ExecutionId = exec.Id, FirstSeenAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(1), - }); await db.SaveChangesAsync(); var redactorConfig = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary @@ -801,11 +1326,28 @@ public async Task ExternalTrigger_Replay_RedactsSensitiveExecutionFields() ["Logging:Redaction:Patterns:0"] = "SECRET-[A-Z]+", }).Build(); var redactor = new NodePilot.Engine.Security.OutputRedactor(redactorConfig); + var triggerConfig = ConfigWithKey(LongKey, wf.Id); + var queue = new CountingNoopExecutionDispatchQueue(); + + var initial = CreateTriggerController( + db, Mock.Of(), presentedKey: LongKey, queue, redactor: redactor); + initial.HttpContext.Request.Headers["Idempotency-Key"] = "replay-secret"; + var initialResult = await initial.ExternalTrigger( + "Enabled", null, triggerConfig, TriggerLogger, CancellationToken.None); + var initialExecution = initialResult.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + var exec = (await db.WorkflowExecutions.FindAsync(initialExecution.Id))!; + exec.Status = ExecutionStatus.Succeeded; + exec.ReturnData = "result token=SECRET-XYZ"; + exec.ErrorMessage = "failure detail SECRET-XYZ"; + exec.InputParametersJson = "{\"pw\":\"SECRET-XYZ\"}"; + await db.SaveChangesAsync(); var controller = CreateTriggerController(db, Mock.Of(), presentedKey: LongKey, redactor: redactor); controller.HttpContext.Request.Headers["Idempotency-Key"] = "replay-secret"; - var result = await controller.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var result = await controller.ExternalTrigger( + "Enabled", null, triggerConfig, TriggerLogger, CancellationToken.None); var ok = result.Result.Should().BeOfType().Subject; var resp = ok.Value.Should().BeOfType().Subject; @@ -820,7 +1362,7 @@ public async Task ExternalTrigger_CorrectKey_WritesExternalTriggerFiredAudit() // Anonymous external invocations must leave an audit trail. Without it, an attacker // (or a buggy integration) holding the API key can fire workflows without trace. var db = CreateContext(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "Audited", DefinitionJson = "{}", IsEnabled = true }; + var wf = ExternalWorkflow("Audited"); db.Workflows.Add(wf); await db.SaveChangesAsync(); @@ -830,7 +1372,7 @@ public async Task ExternalTrigger_CorrectKey_WritesExternalTriggerFiredAudit() var result = await controller.ExternalTrigger("Audited", new ExecuteWorkflowRequest(new Dictionary { ["v"] = "1" }), - ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); var accepted = result.Result.Should().BeOfType().Subject; var response = accepted.Value.Should().BeOfType().Subject; @@ -839,6 +1381,8 @@ public async Task ExternalTrigger_CorrectKey_WritesExternalTriggerFiredAudit() call.ResourceType.Should().Be("Workflow"); call.ResourceId.Should().Be(wf.Id); call.Details.Should().Contain("\"workflowName\":\"Audited\""); + call.Details.Should().Contain("\"integrationId\":\"legacy\""); + call.Details.Should().NotContain(LongKey); call.Details.Should().Contain($"\"executionId\":\"{response.Id}\""); call.Details.Should().Contain("\"idempotencyKeyUsed\":false"); call.Details.Should().Contain("\"parameterCount\":1"); @@ -851,7 +1395,7 @@ public async Task ExternalTrigger_IdempotencyReplay_DoesNotEmitSecondAudit() // EXTERNAL_TRIGGER_FIRED. Otherwise a misbehaving caller retrying the same key // would inflate the audit log. var db = CreateContext(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "Enabled", DefinitionJson = "{}", IsEnabled = true }; + var wf = ExternalWorkflow("Enabled"); db.Workflows.Add(wf); await db.SaveChangesAsync(); @@ -860,11 +1404,13 @@ public async Task ExternalTrigger_IdempotencyReplay_DoesNotEmitSecondAudit() var first = CreateTriggerController(db, Mock.Of(), presentedKey: LongKey, queue, audit); first.HttpContext.Request.Headers["Idempotency-Key"] = "replay-key"; - await first.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + await first.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); var second = CreateTriggerController(db, Mock.Of(), presentedKey: LongKey, queue, audit); second.HttpContext.Request.Headers["Idempotency-Key"] = "replay-key"; - var secondResult = await second.ExternalTrigger("Enabled", null, ConfigWithKey(LongKey), TriggerLogger, CancellationToken.None); + var secondResult = await second.ExternalTrigger( + "Enabled", null, ConfigWithKey(LongKey, wf.Id), TriggerLogger, CancellationToken.None); secondResult.Result.Should().BeOfType(); audit.Calls.Where(c => c.Action == "EXTERNAL_TRIGGER_FIRED").Should().HaveCount(1, diff --git a/tests/NodePilot.Engine.Tests/Activities/ActivityHardening2026_05_17Tests.cs b/tests/NodePilot.Engine.Tests/Activities/ActivityHardening2026_05_17Tests.cs index 60b729ef..05025537 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ActivityHardening2026_05_17Tests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ActivityHardening2026_05_17Tests.cs @@ -54,7 +54,7 @@ public async Task A1_WaitForCondition_RejectsTemplateResidueInScript() result.ErrorOutput.Should().Contain("{{"); } - // ----- A2: ZipOperation extract embeds a Zip-Slip pre-scan block ----- + // ----- A2: ZipOperation validates and writes every entry itself ----- [Fact] public async Task A2_ZipExtract_ScriptContainsZipSlipGuard() @@ -95,8 +95,11 @@ await activity.ExecuteAsync(ctx, CancellationToken.None); captured.Should().Contain("Zip-Slip blocked"); - captured.Should().Contain("ZipFile]::OpenRead"); captured.Should().Contain("StartsWith"); + captured.Should().Contain("ZipArchive]::new"); + captured.Should().Contain("FileMode]::CreateNew"); + captured.Should().Contain("FileAttributes]::ReparsePoint"); + captured.Should().NotContain("Expand-Archive"); } // ----- A3: RegistryActivity rejects non-registry keyPath ----- diff --git a/tests/NodePilot.Engine.Tests/Activities/BuildScriptTests.cs b/tests/NodePilot.Engine.Tests/Activities/BuildScriptTests.cs index ed2a70ae..0e232315 100644 --- a/tests/NodePilot.Engine.Tests/Activities/BuildScriptTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/BuildScriptTests.cs @@ -258,11 +258,13 @@ public async Task ServiceManagement_SetStartType_MissingStartupType_Throws() [Theory] [InlineData("copy", "{\"operation\": \"copy\", \"path\": \"C:\\\\temp\\\\file.txt\", \"destination\": \"D:\\\\backup\\\\file.txt\"}", new[] { "$__path = 'C:\\temp\\file.txt'", "$__destination = 'D:\\backup\\file.txt'", - "Copy-Item -LiteralPath $__path -Destination $__destination -Force", + "Get-NodePilotEffectiveDestination", "Assert-NodePilotAllowedPath -Candidate $__effectiveDestination", + "[System.IO.File]::Copy($__path, $__effectiveDestination, $true)", "$__result.destination = $__destination", "Not a file:" })] [InlineData("move", "{\"operation\": \"move\", \"path\": \"C:\\\\temp\\\\file.txt\", \"destination\": \"D:\\\\backup\\\\file.txt\"}", new[] { "$__path = 'C:\\temp\\file.txt'", "$__destination = 'D:\\backup\\file.txt'", - "Move-Item -LiteralPath $__path -Destination $__destination -Force", + "Get-NodePilotEffectiveDestination", "Assert-NodePilotAllowedPath -Candidate $__effectiveDestination", + "Move-Item -LiteralPath $__path -Destination $__effectiveDestination -Force", "$__result.destination = $__destination", "Not a file:" })] [InlineData("delete", "{\"operation\": \"delete\", \"path\": \"C:\\\\temp\\\\file.txt\"}", new[] { "$__path = 'C:\\temp\\file.txt'", "Remove-Item -LiteralPath $__path -Force", "Not a file:" })] @@ -294,11 +296,13 @@ public async Task FileOperation_GeneratesCorrectScript(string _, string configJs [Theory] [InlineData("copy", "{\"operation\": \"copy\", \"path\": \"C:\\\\temp\\\\src\", \"destination\": \"D:\\\\backup\"}", new[] { "$__path = 'C:\\temp\\src'", "$__destination = 'D:\\backup'", - "Copy-Item -LiteralPath $__path -Destination $__destination -Force -Recurse", + "Get-NodePilotEffectiveDestination", "[System.IO.Directory]::EnumerateFileSystemEntries", + "[System.IO.File]::Copy($__copySourceChild, $__copyDestinationChild, $true)", "Not a directory:" })] [InlineData("move", "{\"operation\": \"move\", \"path\": \"C:\\\\temp\\\\src\", \"destination\": \"D:\\\\backup\"}", new[] { "$__path = 'C:\\temp\\src'", "$__destination = 'D:\\backup'", - "Move-Item -LiteralPath $__path -Destination $__destination -Force", + "Assert-NodePilotReparseFreeTree -Root $__path", "Get-NodePilotEffectiveDestination", + "Move-Item -LiteralPath $__path -Destination $__effectiveDestination -Force", "Not a directory:" })] [InlineData("delete", "{\"operation\": \"delete\", \"path\": \"C:\\\\temp\\\\old\"}", new[] { "$__path = 'C:\\temp\\old'", "Remove-Item -LiteralPath $__path -Force -Recurse", diff --git a/tests/NodePilot.Engine.Tests/Activities/FileHashActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/FileHashActivityTests.cs index 10c5295f..fdb70a18 100644 --- a/tests/NodePilot.Engine.Tests/Activities/FileHashActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/FileHashActivityTests.cs @@ -71,6 +71,24 @@ private static string HashOutput(string hash, string algorithm) => $$""" ###NODEPILOT_FILEHASH_RESULT_END### """; + [Fact] + public async Task AllowedRoots_InjectsAuthoritativeTargetSideGuard() + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\data", + }).Build(); + + await CreateActivity(config).ExecuteAsync( + Ctx(), + Cfg("{\"path\":\"C:\\\\data\\\\file.txt\"}"), + CancellationToken.None); + + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__npPath)"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); + } + [Fact] public async Task MissingPath_Throws() { diff --git a/tests/NodePilot.Engine.Tests/Activities/FileOperationActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/FileOperationActivityTests.cs index cfe31a40..f1b5fb13 100644 --- a/tests/NodePilot.Engine.Tests/Activities/FileOperationActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/FileOperationActivityTests.cs @@ -67,8 +67,47 @@ private FileOperationActivity CreateActivity(IConfiguration? cfg = null) private StepExecutionContext Ctx() => new() { WorkflowExecutionId = Guid.NewGuid(), StepId = "step-1", TargetMachineId = _machineId, CredentialId = _credentialId }; + private StepExecutionContext LocalCtx() + { + var machineId = Guid.NewGuid(); + _db.ManagedMachines.Add(new ManagedMachine + { + Id = machineId, + Name = "Local " + machineId.ToString("N"), + Hostname = "localhost", + WinRmPort = 5985, + IsReachable = true, + }); + _db.SaveChanges(); + return new StepExecutionContext + { + WorkflowExecutionId = Guid.NewGuid(), + StepId = "local-file-operation", + TargetMachineId = machineId, + }; + } + private static JsonElement Cfg(string json) => JsonDocument.Parse(json).RootElement; + [Fact] + public async Task AllowedRoots_InjectsAuthoritativeTargetSideGuard() + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\data", + }).Build(); + + await CreateActivity(config).ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"exists\",\"path\":\"C:\\\\data\\\\file.txt\"}"), + CancellationToken.None); + + _capturedScript.Should().Contain("function Assert-NodePilotAllowedPath"); + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__path)"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); + } + // ---- Error cases ---- [Fact] @@ -250,14 +289,14 @@ await activity.ExecuteAsync(Ctx(), _capturedScript.Should().Contain("'C:\\O''Brian''s files.txt'"); } - // ---- Leaf assertion is emitted for destructive ops ---- + // ---- Link-local leaf assertion is emitted for destructive ops ---- [Theory] [InlineData("delete")] [InlineData("copy")] [InlineData("move")] [InlineData("rename")] - public async Task DestructiveOps_EmitLeafAssertion(string op) + public async Task DestructiveOps_EmitLinkLocalLeafAssertion(string op) { var activity = CreateActivity(); _capturedScript = null; @@ -268,10 +307,129 @@ public async Task DestructiveOps_EmitLeafAssertion(string op) _ => $"{{\"operation\": \"{op}\", \"path\": \"C:\\\\f.txt\"}}", }; await activity.ExecuteAsync(Ctx(), Cfg(json), CancellationToken.None); - _capturedScript.Should().Contain("-PathType Leaf"); + _capturedScript.Should().Contain("Get-NodePilotPathAttributes -Path $__path"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); _capturedScript.Should().Contain("Not a file:"); } + [Theory] + [InlineData("copy")] + [InlineData("move")] + public async Task TransferOps_ValidateEffectiveDestinationLeaf(string operation) + { + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg($"{{\"operation\":\"{operation}\",\"path\":\"C:\\\\source.txt\",\"destination\":\"C:\\\\destination\"}}"), + CancellationToken.None); + + _capturedScript.Should().Contain("Get-NodePilotEffectiveDestination"); + _capturedScript.Should().Contain( + "Assert-NodePilotAllowedPath -Candidate $__effectiveDestination"); + } + + [Fact] + public async Task Rename_ValidatesTargetBeforeLinkLocalExistenceProbe() + { + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"rename\",\"path\":\"C:\\\\source.txt\",\"newName\":\"renamed.txt\"}"), + CancellationToken.None); + + var targetGuard = _capturedScript!.IndexOf( + "Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target'", + StringComparison.Ordinal); + var existenceProbe = _capturedScript.IndexOf( + "Get-NodePilotPathAttributes -Path $__target", + StringComparison.Ordinal); + targetGuard.Should().BeGreaterThan(-1); + existenceProbe.Should().BeGreaterThan(targetGuard); + } + + [WindowsFact] + public async Task Copy_ToExistingDirectoryRejectsReparseEffectiveDestinationLeaf() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-file-copy-link-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "payload.txt"); + var destinationDirectory = Path.Combine(stage, "destination"); + var outside = Path.Combine(stage, "outside.txt"); + var effectiveDestination = Path.Combine(destinationDirectory, Path.GetFileName(source)); + Directory.CreateDirectory(destinationDirectory); + await File.WriteAllTextAsync(source, "source"); + await File.WriteAllTextAsync(outside, "outside-must-not-change"); + + try + { + try + { + File.CreateSymbolicLink(effectiveDestination, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "copy", + path = source, + destination = destinationDirectory, + })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + (await File.ReadAllTextAsync(outside)).Should().Be("outside-must-not-change"); + } + finally + { + DeleteReparsePointOnly(effectiveDestination); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Copy_ToExistingDirectoryUsesEffectiveDestinationLeaf() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-file-copy-safe-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "payload.txt"); + var destinationDirectory = Path.Combine(stage, "destination"); + Directory.CreateDirectory(destinationDirectory); + await File.WriteAllTextAsync(source, "safe-copy"); + + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "copy", + path = source, + destination = destinationDirectory, + })), + CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorOutput); + (await File.ReadAllTextAsync(Path.Combine(destinationDirectory, "payload.txt"))) + .Should().Be("safe-copy"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + // ---- Security: path traversal ---- [Fact] @@ -403,4 +561,19 @@ public async Task RemoteFailureWithStructuredError_IsPropagated() result.Success.Should().BeFalse(); result.ErrorOutput.Should().Contain("Not a file:"); } + + private static void DeleteReparsePointOnly(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) == 0) + return; + if ((attributes & FileAttributes.Directory) != 0) + Directory.Delete(path); + else + File.Delete(path); + } + catch { } + } } diff --git a/tests/NodePilot.Engine.Tests/Activities/FolderOperationActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/FolderOperationActivityTests.cs index b3fbf5fd..b7261baa 100644 --- a/tests/NodePilot.Engine.Tests/Activities/FolderOperationActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/FolderOperationActivityTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -68,6 +70,26 @@ private FolderOperationActivity CreateActivity(IConfiguration? cfg = null) private StepExecutionContext Ctx() => new() { WorkflowExecutionId = Guid.NewGuid(), StepId = "step-1", TargetMachineId = _machineId, CredentialId = _credentialId }; + private StepExecutionContext LocalCtx() + { + var machineId = Guid.NewGuid(); + _db.ManagedMachines.Add(new ManagedMachine + { + Id = machineId, + Name = "Local " + machineId.ToString("N"), + Hostname = "localhost", + WinRmPort = 5985, + IsReachable = true, + }); + _db.SaveChanges(); + return new StepExecutionContext + { + WorkflowExecutionId = Guid.NewGuid(), + StepId = "local-folder-operation", + TargetMachineId = machineId, + }; + } + private static JsonElement Cfg(string json) => JsonDocument.Parse(json).RootElement; // ---- Error cases ---- @@ -207,7 +229,7 @@ await activity.ExecuteAsync(Ctx(), _capturedScript.Should().Contain("'C:\\O''Brian''s files'"); } - // ---- Container assertion is emitted for destructive ops ---- + // ---- Link-local container assertion is emitted for destructive ops ---- [Theory] [InlineData("delete")] @@ -215,7 +237,7 @@ await activity.ExecuteAsync(Ctx(), [InlineData("copy")] [InlineData("move")] [InlineData("rename")] - public async Task DestructiveOps_EmitContainerAssertion(string op) + public async Task DestructiveOps_EmitLinkLocalContainerAssertion(string op) { var activity = CreateActivity(); _capturedScript = null; @@ -226,10 +248,272 @@ public async Task DestructiveOps_EmitContainerAssertion(string op) _ => $"{{\"operation\": \"{op}\", \"path\": \"C:\\\\dir\"}}", }; await activity.ExecuteAsync(Ctx(), Cfg(json), CancellationToken.None); - _capturedScript.Should().Contain("-PathType Container"); + _capturedScript.Should().Contain("Get-NodePilotPathAttributes -Path $__path"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); _capturedScript.Should().Contain("Not a directory:"); } + [Theory] + [InlineData("copy")] + [InlineData("move")] + public async Task TransferOps_ValidateEffectiveDestinationRoot(string operation) + { + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg($"{{\"operation\":\"{operation}\",\"path\":\"C:\\\\source\",\"destination\":\"C:\\\\destination\"}}"), + CancellationToken.None); + + _capturedScript.Should().Contain("Get-NodePilotEffectiveDestination"); + _capturedScript.Should().Contain( + "Assert-NodePilotAllowedPath -Candidate $__effectiveDestination"); + } + + [Fact] + public async Task Rename_ValidatesTargetBeforeLinkLocalExistenceProbe() + { + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"rename\",\"path\":\"C:\\\\source\",\"newName\":\"renamed\"}"), + CancellationToken.None); + + var targetGuard = _capturedScript!.IndexOf( + "Assert-NodePilotAllowedPath -Candidate $__target -Label 'rename target'", + StringComparison.Ordinal); + var existenceProbe = _capturedScript.IndexOf( + "Get-NodePilotPathAttributes -Path $__target", + StringComparison.Ordinal); + targetGuard.Should().BeGreaterThan(-1); + existenceProbe.Should().BeGreaterThan(targetGuard); + } + + [Fact] + public async Task Copy_EmitsControlledNoFollowTreeWalk() + { + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"copy\",\"path\":\"C:\\\\source\",\"destination\":\"C:\\\\destination\"}"), + CancellationToken.None); + + _capturedScript.Should().Contain("[System.IO.Directory]::EnumerateFileSystemEntries"); + _capturedScript.Should().Contain("[System.IO.File]::Copy"); + _capturedScript.Should().NotContain("Copy-Item -LiteralPath $__path"); + } + + [WindowsFact] + public async Task Copy_RejectsNestedSourceReparsePointWithoutReadingOutsideTree() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-folder-copy-link-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "destination"); + var outside = Path.Combine(stage, "outside"); + var sourceLink = Path.Combine(source, "nested-link"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(source, "safe.txt"), "safe"); + await File.WriteAllTextAsync(Path.Combine(outside, "secret.txt"), "must-not-copy"); + + try + { + try + { + Directory.CreateSymbolicLink(sourceLink, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "copy", path = source, destination })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + File.Exists(Path.Combine(destination, "nested-link", "secret.txt")).Should().BeFalse(); + } + finally + { + DeleteReparsePointOnly(sourceLink); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Copy_RejectsReparseEffectiveDestinationRoot() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-folder-copy-destination-link-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "source"); + var destinationParent = Path.Combine(stage, "destination-parent"); + var effectiveDestination = Path.Combine(destinationParent, Path.GetFileName(source)); + var outside = Path.Combine(stage, "outside"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(destinationParent); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(source, "payload.txt"), "must-not-copy"); + + try + { + try + { + Directory.CreateSymbolicLink(effectiveDestination, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "copy", + path = source, + destination = destinationParent, + })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + File.Exists(Path.Combine(outside, "payload.txt")).Should().BeFalse(); + } + finally + { + DeleteReparsePointOnly(effectiveDestination); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Copy_RecursivelyCopiesReparseFreeTree() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-folder-copy-safe-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "destination"); + Directory.CreateDirectory(Path.Combine(source, "nested")); + await File.WriteAllTextAsync(Path.Combine(source, "nested", "payload.txt"), "safe-copy"); + + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "copy", path = source, destination })), + CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorOutput); + (await File.ReadAllTextAsync(Path.Combine(destination, "nested", "payload.txt"))) + .Should().Be("safe-copy"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Copy_WindowsPowerShell51CopiesSafeTree() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-folder-copy-winps51-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "source"); + var destinationParent = Path.Combine(stage, "destination-parent"); + Directory.CreateDirectory(Path.Combine(source, "nested")); + Directory.CreateDirectory(destinationParent); + await File.WriteAllTextAsync(Path.Combine(source, "nested", "payload.txt"), "winps51-safe"); + + try + { + _capturedScript = null; + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "copy", + path = source, + destination = destinationParent, + })), + CancellationToken.None); + + var (exitCode, stdout, stderr) = await RunWithWindowsPowerShell51( + stage, + "folder-copy-safe.ps1", + _capturedScript!); + + exitCode.Should().Be(0, $"stdout: {stdout}{Environment.NewLine}stderr: {stderr}"); + stdout.Should().Contain("\"ok\":true"); + (await File.ReadAllTextAsync(Path.Combine( + destinationParent, + "source", + "nested", + "payload.txt"))) + .Should().Be("winps51-safe"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Copy_WindowsPowerShell51RejectsNestedReparsePoint() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-folder-copy-winps51-link-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "destination"); + var outside = Path.Combine(stage, "outside"); + var sourceLink = Path.Combine(source, "nested-link"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "secret.txt"), "must-not-copy"); + + try + { + try + { + Directory.CreateSymbolicLink(sourceLink, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + _capturedScript = null; + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg(JsonSerializer.Serialize(new { operation = "copy", path = source, destination })), + CancellationToken.None); + + var (exitCode, stdout, stderr) = await RunWithWindowsPowerShell51( + stage, + "folder-copy-link.ps1", + _capturedScript!); + + exitCode.Should().Be(0, $"stdout: {stdout}{Environment.NewLine}stderr: {stderr}"); + stdout.Should().Contain("\"ok\":false"); + stdout.Should().Contain("reparse point"); + File.Exists(Path.Combine(destination, "nested-link", "secret.txt")).Should().BeFalse(); + } + finally + { + DeleteReparsePointOnly(sourceLink); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + [Fact] public async Task Create_DoesNotAssertContainer() { @@ -375,4 +659,53 @@ public async Task RemoteFailureWithStructuredError_IsPropagated() result.Success.Should().BeFalse(); result.ErrorOutput.Should().Contain("Not a directory:"); } + + private static void DeleteReparsePointOnly(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) == 0) + return; + if ((attributes & FileAttributes.Directory) != 0) + Directory.Delete(path); + else + File.Delete(path); + } + catch { } + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunWithWindowsPowerShell51( + string stage, + string scriptName, + string script) + { + var scriptPath = Path.Combine(stage, scriptName); + await File.WriteAllTextAsync(scriptPath, script, Encoding.Unicode); + var executable = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + using var process = Process.Start(new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + ArgumentList = + { + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + }, + })!; + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + return (process.ExitCode, await stdoutTask, await stderrTask); + } } diff --git a/tests/NodePilot.Engine.Tests/Activities/JsonQueryActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/JsonQueryActivityTests.cs index 9cb70ea0..58161050 100644 --- a/tests/NodePilot.Engine.Tests/Activities/JsonQueryActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/JsonQueryActivityTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using NodePilot.Core.Interfaces; using NodePilot.Engine.Activities; +using NodePilot.Engine.Tests.Helpers; using Xunit; namespace NodePilot.Engine.Tests.Activities; @@ -137,4 +138,45 @@ public async Task ExecuteAsync_FileSource_NonexistentPath_ReturnsFailure() result.Success.Should().BeFalse(); result.ErrorOutput.Should().Contain("not found"); } + + [WindowsFact] + public async Task ExecuteAsync_FileSourceWithoutInjectedConfigRejectsDirectorySymlink() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-json-link-" + Guid.NewGuid().ToString("N")); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(stage, "link"); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "payload.json"), "{\"value\":1}"); + try + { + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var result = await new JsonQueryActivity().ExecuteAsync( + Ctx(), + Cfg(new { source = "file", path = Path.Combine(link, "payload.json"), jsonPath = "$.value" }), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + private static void DeleteDirectoryLink(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + } } diff --git a/tests/NodePilot.Engine.Tests/Activities/SqlActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/SqlActivityTests.cs index 90ba348b..d7fce5ee 100644 --- a/tests/NodePilot.Engine.Tests/Activities/SqlActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/SqlActivityTests.cs @@ -297,7 +297,7 @@ public void Builder_Postgres_BuildsExpectedConnectionString() database = "analytics", username = "reader", password = "secret;1", - sslMode = "Require", + sslMode = "VerifyFull", }); err.Should().BeNull(); @@ -307,16 +307,14 @@ public void Builder_Postgres_BuildsExpectedConnectionString() b.Database.Should().Be("analytics"); b.Username.Should().Be("reader"); b.Password.Should().Be("secret;1"); - b.SslMode.Should().Be(Npgsql.SslMode.Require); + b.SslMode.Should().Be(Npgsql.SslMode.VerifyFull); } [Fact] - public void Builder_Postgres_DefaultSslMode_IsRequire() + public void Builder_Postgres_DefaultSslMode_IsVerifyFull() { - // M-4 (security audit 2026-05-15): when the author does not specify sslMode the builder - // must default to Require, NOT Npgsql's built-in Prefer. Prefer silently downgrades to a - // plaintext connection when the server doesn't offer TLS, exposing DB credentials to a - // MITM. The secure default forces an encrypted channel. + // Encryption without authentication is not enough: VerifyFull validates both the CA and + // hostname, so a MITM cannot terminate TLS and collect the database credential. var (cs, err) = BuildViaActivity(new { provider = "postgres", @@ -327,14 +325,14 @@ public void Builder_Postgres_DefaultSslMode_IsRequire() }); err.Should().BeNull(); - new Npgsql.NpgsqlConnectionStringBuilder(cs).SslMode.Should().Be(Npgsql.SslMode.Require); + new Npgsql.NpgsqlConnectionStringBuilder(cs).SslMode.Should().Be(Npgsql.SslMode.VerifyFull); } [Fact] public void Builder_Postgres_ExplicitSslModeDisable_IsHonoured() { - // The secure default is overridable: an operator on a trusted local socket can still opt - // into plaintext by setting sslMode explicitly. Only the *implicit* default changed. + // Local development remains possible, but only when the configured host itself is a + // literal loopback value (not a hostname which happens to resolve there). var (cs, err) = BuildViaActivity(new { provider = "postgres", @@ -347,6 +345,106 @@ public void Builder_Postgres_ExplicitSslModeDisable_IsHonoured() new Npgsql.NpgsqlConnectionStringBuilder(cs).SslMode.Should().Be(Npgsql.SslMode.Disable); } + [Theory] + [InlineData("Disable")] + [InlineData("Allow")] + [InlineData("Prefer")] + [InlineData("Require")] + [InlineData("VerifyCA")] + public void Builder_Postgres_RemoteWeakSslMode_IsRejected(string sslMode) + { + var (cs, err) = BuildViaActivity(new + { + provider = "postgres", + host = "pg01.example.com", + database = "analytics", + sslMode, + }); + + cs.Should().BeNull(); + err.Should().Contain("SSL Mode=VerifyFull"); + } + + [Fact] + public void Builder_Postgres_InvalidSslMode_IsRejectedInsteadOfFallingBack() + { + var (cs, err) = BuildViaActivity(new + { + provider = "postgres", + host = "pg01.example.com", + sslMode = "DefinitelySecure", + }); + + cs.Should().BeNull(); + err.Should().Contain("sslMode").And.Contain("invalid"); + } + + [Fact] + public void RawPostgresConnection_DefaultsToVerifyFull() + { + var (cs, err) = BuildViaActivity(new + { + provider = "postgres", + connectionString = "Host=pg01.example.com;Database=analytics;Username=reader;Password=secret", + }); + + err.Should().BeNull(); + new Npgsql.NpgsqlConnectionStringBuilder(cs).SslMode.Should().Be(Npgsql.SslMode.VerifyFull); + } + + [Fact] + public void RawPostgresConnection_RemoteTrustServerCertificate_IsRejected() + { + var (cs, err) = BuildViaActivity(new + { + provider = "postgres", + connectionString = + "Host=pg01.example.com;Database=analytics;SSL Mode=VerifyFull;Trust Server Certificate=true", + }); + + cs.Should().BeNull(); + err.Should().Contain("Trust Server Certificate=false"); + } + + [Fact] + public void NamedPostgresConnection_RemoteWeakSslMode_IsRejected() + { + var act = new SqlActivity(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SqlActivity:ConnectionStrings:weak"] = + "Host=pg01.example.com;Database=analytics;SSL Mode=Require", + }) + .Build()); + var cfg = JsonDocument.Parse("""{"connectionRef":"weak","provider":"postgres","query":"SELECT 1"}""") + .RootElement; + + var (cs, err) = InvokeResolveConnectionString(act, cfg, "postgres"); + + cs.Should().BeNull(); + err.Should().Contain("SSL Mode=VerifyFull"); + } + + [Fact] + public void NamedPostgresConnection_RemoteTrustServerCertificate_IsRejected() + { + var act = new SqlActivity(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SqlActivity:ConnectionStrings:trust-any"] = + "Host=pg01.example.com;Database=analytics;SSL Mode=VerifyFull;Trust Server Certificate=true", + }) + .Build()); + var cfg = JsonDocument.Parse( + """{"connectionRef":"trust-any","provider":"postgres","query":"SELECT 1"}""") + .RootElement; + + var (cs, err) = InvokeResolveConnectionString(act, cfg, "postgres"); + + cs.Should().BeNull(); + err.Should().Contain("Trust Server Certificate=false"); + } + [Fact] public void Builder_Postgres_MissingHost_Rejects() { diff --git a/tests/NodePilot.Engine.Tests/Activities/TextFileEditActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/TextFileEditActivityTests.cs index 81ab85b2..e73e6608 100644 --- a/tests/NodePilot.Engine.Tests/Activities/TextFileEditActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/TextFileEditActivityTests.cs @@ -75,6 +75,24 @@ private StepExecutionContext Ctx() private static JsonElement Cfg(string json) => JsonDocument.Parse(json).RootElement; + [Fact] + public async Task AllowedRoots_InjectsTargetGuardForFileAndBackupPath() + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\data", + }).Build(); + + await CreateActivity(config).ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"append\",\"path\":\"C:\\\\data\\\\file.txt\",\"content\":\"x\",\"backupSuffix\":\".bak\"}"), + CancellationToken.None); + + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__path)"); + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__path + $__backupSuffix)"); + } + // ---- Error cases: config validation ---- [Fact] diff --git a/tests/NodePilot.Engine.Tests/Activities/XmlQueryActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/XmlQueryActivityTests.cs index a77d9f8e..6bf5e5d8 100644 --- a/tests/NodePilot.Engine.Tests/Activities/XmlQueryActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/XmlQueryActivityTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using NodePilot.Core.Interfaces; using NodePilot.Engine.Activities; +using NodePilot.Engine.Tests.Helpers; using Xunit; namespace NodePilot.Engine.Tests.Activities; @@ -112,6 +113,47 @@ public async Task ExecuteAsync_FileSource_ReadsFileAndQueries() } } + [WindowsFact] + public async Task ExecuteAsync_FileSourceWithoutInjectedConfigRejectsDirectorySymlink() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-xml-link-" + Guid.NewGuid().ToString("N")); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(stage, "link"); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "payload.xml"), ""); + try + { + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var result = await new XmlQueryActivity().ExecuteAsync( + Ctx(), + Cfg(new { source = "file", path = Path.Combine(link, "payload.xml"), xpath = "/root" }), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + private static void DeleteDirectoryLink(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + } + [Fact] public async Task ExecuteAsync_FileSource_RejectsOversizedFileBeforeRead() { diff --git a/tests/NodePilot.Engine.Tests/Activities/ZipOperationActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/ZipOperationActivityTests.cs index 1edbae9d..7a9e3963 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ZipOperationActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ZipOperationActivityTests.cs @@ -1,3 +1,6 @@ +using System.IO.Compression; +using System.Diagnostics; +using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -63,6 +66,26 @@ private ZipOperationActivity CreateActivity(IConfiguration? cfg = null) private StepExecutionContext Ctx() => new() { WorkflowExecutionId = Guid.NewGuid(), StepId = "step-1", TargetMachineId = _machineId, CredentialId = _credentialId }; + private StepExecutionContext LocalCtx() + { + var machineId = Guid.NewGuid(); + _db.ManagedMachines.Add(new ManagedMachine + { + Id = machineId, + Name = "Local " + machineId.ToString("N"), + Hostname = "localhost", + WinRmPort = 5985, + IsReachable = true, + }); + _db.SaveChanges(); + return new StepExecutionContext + { + WorkflowExecutionId = Guid.NewGuid(), + StepId = "local-extract", + TargetMachineId = machineId, + }; + } + private static JsonElement Cfg(string json) => JsonDocument.Parse(json).RootElement; private static string ZipOutput(string operation, string destination, string sizeBytes) => $$""" @@ -82,9 +105,9 @@ public async Task MissingOperation_DefaultsToCompress() await CreateActivity().ExecuteAsync(Ctx(), Cfg("{\"source\":\"C:\\\\src\",\"destination\":\"C:\\\\out.zip\"}"), CancellationToken.None); - // BuildCompressScript writes to the destination, so capturing the script proves the - // compress branch was taken (extract would read from source). - _capturedScript.Should().Contain("Compress-Archive"); + // The compress branch builds an explicit, validated manifest and writes ZipArchive + // entries itself; the extract branch opens ZipArchiveMode.Read. + _capturedScript.Should().Contain("ZipArchiveMode]::Create"); } [Fact] @@ -94,7 +117,7 @@ public async Task EmptyOperation_DefaultsToCompress() await CreateActivity().ExecuteAsync(Ctx(), Cfg("{\"operation\":\"\",\"source\":\"C:\\\\src\",\"destination\":\"C:\\\\out.zip\"}"), CancellationToken.None); - _capturedScript.Should().Contain("Compress-Archive"); + _capturedScript.Should().Contain("ZipArchiveMode]::Create"); } [Fact] @@ -134,14 +157,269 @@ public async Task CompressBuildsExpectedScript() await act.ExecuteAsync(Ctx(), Cfg("{\"operation\":\"compress\",\"source\":\"C:\\\\logs\\\\*.log\",\"destination\":\"C:\\\\out.zip\",\"force\":true,\"compressionLevel\":\"Fastest\"}"), CancellationToken.None); - _capturedScript.Should().Contain("Compress-Archive"); - _capturedScript.Should().Contain("-CompressionLevel Fastest"); - _capturedScript.Should().Contain("-Force"); + _capturedScript.Should().NotContain("Compress-Archive"); + _capturedScript.Should().Contain("CompressionLevel]::Fastest"); + _capturedScript.Should().Contain("$__npForce = $true"); _capturedScript.Should().Contain("###NODEPILOT_ZIP_RESULT_START###"); - // Source for compress uses -Path (glob expansion); destination uses literal. - _capturedScript.Should().Contain("-Path 'C:\\logs\\*.log'"); + // Wildcards are expanded only by a top-level .NET directory enumeration. No + // PowerShell-provider wildcard expansion or second recursive archive walk remains. + _capturedScript.Should().Contain("$__npSource = 'C:\\logs\\*.log'"); + _capturedScript.Should().Contain("[System.IO.Directory]::EnumerateFileSystemEntries("); + _capturedScript.Should().Contain("$__npManifest"); + _capturedScript.Should().Contain("FileMode]::CreateNew"); _capturedScript.Should().Contain("$__npDestination = 'C:\\out.zip'"); - _capturedScript.Should().Contain("-DestinationPath $__npDestination"); + } + + [Fact] + public async Task Compress_WithAllowedRoots_InjectsTargetSideGuardForBothPaths() + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\data", + }).Build(); + + await CreateActivity(config).ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"compress\",\"source\":\"C:\\\\data\\\\*.log\",\"destination\":\"C:\\\\data\\\\out.zip\"}"), + CancellationToken.None); + + _capturedScript.Should().Contain("function Assert-NodePilotAllowedPath"); + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__npSource)"); + _capturedScript.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__npDestination)"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); + } + + [WindowsFact] + public async Task Compress_ExpandsSafeWildcardAndWritesArchive() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-compress-" + Guid.NewGuid().ToString("N")); + var sourceDir = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "out.zip"); + Directory.CreateDirectory(sourceDir); + await File.WriteAllTextAsync(Path.Combine(sourceDir, "payload.txt"), "safe-payload"); + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = stage, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "compress", + source = Path.Combine(sourceDir, "*.txt"), + destination, + })), + CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorOutput); + using var archive = ZipFile.OpenRead(destination); + archive.Entries.Select(entry => entry.FullName).Should().Contain("payload.txt"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Compress_RejectsWildcardSelectedJunction() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-wildcard-link-" + Guid.NewGuid().ToString("N")); + var allowed = Path.Combine(stage, "allowed"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(allowed, "link"); + var destination = Path.Combine(allowed, "out.zip"); + Directory.CreateDirectory(allowed); + Directory.CreateDirectory(outside); + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = allowed, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "compress", + source = Path.Combine(allowed, "*"), + destination, + })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + File.Exists(destination).Should().BeFalse(); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Compress_RejectsNestedJunctionBeforeArchiveCreation() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-nested-link-" + Guid.NewGuid().ToString("N")); + var allowed = Path.Combine(stage, "allowed"); + var source = Path.Combine(allowed, "source"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(source, "nested-link"); + var destination = Path.Combine(allowed, "out.zip"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(outside); + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = allowed, + }).Build(); + var result = await CreateActivity(config).ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "compress", source, destination })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + File.Exists(destination).Should().BeFalse(); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [Fact] + public async Task Compress_RejectsWildcardInDirectorySegmentBeforeScriptRuns() + { + var act = CreateActivity(); + Func call = () => act.ExecuteAsync( + Ctx(), + Cfg("{\"operation\":\"compress\",\"source\":\"C:\\\\data\\\\*\\\\payload.txt\",\"destination\":\"C:\\\\out.zip\"}"), + CancellationToken.None); + + await call.Should().ThrowAsync() + .WithMessage("*only in the final path segment*"); + _capturedScript.Should().BeNull(); + } + + [WindowsFact] + public async Task Compress_TreatsBracketsLiterallyDuringLeafWildcardExpansion() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-brackets-" + Guid.NewGuid().ToString("N")); + var sourceDir = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "out.zip"); + Directory.CreateDirectory(sourceDir); + await File.WriteAllTextAsync(Path.Combine(sourceDir, "payload[1].txt"), "bracket-literal"); + await File.WriteAllTextAsync(Path.Combine(sourceDir, "payload1.txt"), "must-not-match"); + try + { + var result = await CreateActivity().ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "compress", + source = Path.Combine(sourceDir, "payload[1]*"), + destination, + })), + CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorOutput); + using var archive = ZipFile.OpenRead(destination); + archive.Entries.Select(entry => entry.FullName).Should().Equal("payload[1].txt"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Compress_WildcardScriptRunsUnderWindowsPowerShell51WithoutAllowedRoots() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-winps51-" + Guid.NewGuid().ToString("N")); + var sourceDir = Path.Combine(stage, "source"); + var destination = Path.Combine(stage, "out.zip"); + Directory.CreateDirectory(sourceDir); + await File.WriteAllTextAsync(Path.Combine(sourceDir, "payload.txt"), "winps51"); + try + { + _capturedScript = null; + await CreateActivity().ExecuteAsync( + Ctx(), + Cfg(JsonSerializer.Serialize(new + { + operation = "compress", + source = Path.Combine(sourceDir, "*.txt"), + destination, + })), + CancellationToken.None); + + _capturedScript.Should().NotBeNull(); + _capturedScript.Should().Contain("$__npEnforceAllowedRoots = $false"); + var scriptPath = Path.Combine(stage, "compress-winps51.ps1"); + await File.WriteAllTextAsync(scriptPath, _capturedScript!, Encoding.Unicode); + var executable = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + using var process = Process.Start(new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + ArgumentList = + { + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + }, + })!; + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + var stdout = await stdoutTask; + var stderr = await stderrTask; + + process.ExitCode.Should().Be(0, $"stdout: {stdout}{Environment.NewLine}stderr: {stderr}"); + using var archive = ZipFile.OpenRead(destination); + archive.Entries.Select(entry => entry.FullName).Should().Equal("payload.txt"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } } [Fact] @@ -151,20 +429,128 @@ public async Task ExtractBuildsExpectedScript() await act.ExecuteAsync(Ctx(), Cfg("{\"operation\":\"extract\",\"source\":\"C:\\\\in.zip\",\"destination\":\"C:\\\\out\"}"), CancellationToken.None); - // A2 hardening: source is bound to a variable so the Zip-Slip pre-scan can - // re-use it. We assert on the assignment + the Expand-Archive invocation that - // uses it, instead of the old literal-inline form. - _capturedScript.Should().Contain("Expand-Archive"); + // Extract entry-by-entry. A separate pre-scan followed by Expand-Archive would + // re-open every output path and leave a junction-swap window between the two walks. + _capturedScript.Should().NotContain("Expand-Archive"); _capturedScript.Should().Contain("$__npSource = 'C:\\in.zip'"); - _capturedScript.Should().Contain("-LiteralPath $__npSource"); _capturedScript.Should().Contain("$__npDestination = 'C:\\out'"); - _capturedScript.Should().Contain("-DestinationPath $__npDestination"); - // A2: Zip-Slip pre-scan is now baked into the extract path. _capturedScript.Should().Contain("Zip-Slip blocked"); - // -Force on Expand-Archive itself is gated by config.force (default false here), - // so the cmdlet invocation should NOT carry the flag — even though the pre-scan - // helper uses `-Force` on a New-Item call to materialise the destination dir. - _capturedScript.Should().NotContain("Expand-Archive -LiteralPath $__npSource -DestinationPath $__npDestination -Force"); + _capturedScript.Should().Contain("FileMode]::CreateNew"); + _capturedScript.Should().Contain("FileAttributes]::ReparsePoint"); + _capturedScript.Should().Contain("$__npForce = $false"); + } + + [WindowsFact] + public async Task Extract_WritesRegularEntryWithHardenedExtractor() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-safe-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "input.zip"); + var destination = Path.Combine(stage, "out"); + Directory.CreateDirectory(stage); + try + { + using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) + using (var writer = new StreamWriter(archive.CreateEntry("nested/payload.txt").Open())) + await writer.WriteAsync("safe-payload"); + + var result = await CreateActivity().ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "extract", source, destination })), + CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorOutput); + (await File.ReadAllTextAsync(Path.Combine(destination, "nested", "payload.txt"))) + .Should().Be("safe-payload"); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Extract_RejectsZipSlipEntryWithoutWritingOutsideDestination() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-slip-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "input.zip"); + var destination = Path.Combine(stage, "out"); + var escaped = Path.Combine(stage, "escaped.txt"); + Directory.CreateDirectory(stage); + try + { + using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) + using (var writer = new StreamWriter(archive.CreateEntry("../escaped.txt").Open())) + await writer.WriteAsync("must-not-escape"); + + var result = await CreateActivity().ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "extract", source, destination })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("Zip-Slip blocked"); + File.Exists(escaped).Should().BeFalse(); + } + finally + { + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task Extract_RejectsEntryWhoseExistingParentIsJunction() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-zip-junction-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(stage, "input.zip"); + var destination = Path.Combine(stage, "out"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(destination, "link"); + Directory.CreateDirectory(destination); + Directory.CreateDirectory(outside); + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; // Windows host without the symlink-development privilege. + } + + using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) + using (var writer = new StreamWriter(archive.CreateEntry("link/new/escaped.txt").Open())) + await writer.WriteAsync("must-not-escape"); + + var result = await CreateActivity().ExecuteAsync( + LocalCtx(), + Cfg(JsonSerializer.Serialize(new { operation = "extract", source, destination })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + Directory.Exists(Path.Combine(outside, "new")).Should().BeFalse( + "validation must happen before Directory.CreateDirectory can follow the junction"); + } + finally + { + try + { + DeleteDirectoryLink(link); + } + catch { } + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + private static void DeleteDirectoryLink(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } } [Fact] diff --git a/tests/NodePilot.Engine.Tests/Security/PathGuardTests.cs b/tests/NodePilot.Engine.Tests/Security/PathGuardTests.cs index e5363c18..b946f758 100644 --- a/tests/NodePilot.Engine.Tests/Security/PathGuardTests.cs +++ b/tests/NodePilot.Engine.Tests/Security/PathGuardTests.cs @@ -153,7 +153,7 @@ public void AllowedRoots_PathOutsideRoot_Rejected() } [WindowsFact] - public void AllowedRoots_ResolvesDirectorySymlinkBeforeRootComparison() + public void AllowedRoots_RejectsDirectorySymlinkWithoutResolvingItsTarget() { var baseDir = Path.Combine(Path.GetTempPath(), "nodepilot-pathguard-" + Guid.NewGuid().ToString("N")); var allowed = Path.Combine(baseDir, "allowed"); @@ -179,12 +179,140 @@ public void AllowedRoots_ResolvesDirectorySymlinkBeforeRootComparison() }).Build(); Action act = () => PathGuard.Validate(cfg, Path.Combine(link, "file.txt")); - act.Should().Throw().WithMessage("*AllowedRoots*"); + act.Should().Throw().WithMessage("*reparse point*"); } finally { - if (Directory.Exists(baseDir)) - Directory.Delete(baseDir, recursive: true); + DeleteDirectoryLink(link); + try { Directory.Delete(baseDir, recursive: true); } catch { } } } + + [WindowsFact] + public void EmptyAllowedRoots_StillRejectsExistingDirectorySymlink() + { + var baseDir = Path.Combine(Path.GetTempPath(), "nodepilot-pathguard-empty-" + Guid.NewGuid().ToString("N")); + var outside = Path.Combine(baseDir, "outside"); + var link = Path.Combine(baseDir, "link"); + Directory.CreateDirectory(outside); + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + Action act = () => PathGuard.Validate(Cfg(), Path.Combine(link, "payload.txt")); + act.Should().Throw().WithMessage("*reparse point*"); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(baseDir, recursive: true); } catch { } + } + } + + [WindowsFact] + public void DanglingDirectorySymlink_IsRejectedLinkLocally() + { + var baseDir = Path.Combine(Path.GetTempPath(), "nodepilot-pathguard-dangling-" + Guid.NewGuid().ToString("N")); + var missingTarget = Path.Combine(baseDir, "missing-target"); + var link = Path.Combine(baseDir, "link"); + Directory.CreateDirectory(baseDir); + try + { + try + { + Directory.CreateSymbolicLink(link, missingTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + Action act = () => PathGuard.Validate(Cfg(), Path.Combine(link, "payload.txt")); + act.Should().Throw().WithMessage("*reparse point*"); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(baseDir, recursive: true); } catch { } + } + } + + [Fact] + public void AllowedRoots_HigherPriorityProviderReplacesLowerArrayAtomically() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\allowed", + ["FileSystemOperation:AllowedRoots:1"] = "C:\\must-be-revoked", + }) + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\allowed", + }) + .Build(); + + Action act = () => PathGuard.Validate(config, "C:\\must-be-revoked\\secret.txt"); + act.Should().Throw().WithMessage("*AllowedRoots*"); + } + + [Fact] + public void AllowedRoots_HigherPriorityEmptyArrayClearsLowerProviderRoots() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\allowed", + }) + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots"] = null, + }) + .Build(); + + var roots = PathGuard.ReadConfiguredRoots( + config, + "FileSystemOperation:AllowedRoots", + out var configured); + + configured.Should().BeTrue(); + roots.Should().BeEmpty(); + Action act = () => PathGuard.Validate(config, "C:\\outside\\file.txt"); + act.Should().NotThrow("an explicit [] preserves the documented no-containment contract"); + } + + [Fact] + public void AllowedRoots_SparseHighestPriorityArrayFailsClosed() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = "C:\\allowed", + }) + .AddInMemoryCollection(new Dictionary + { + ["FileSystemOperation:AllowedRoots:1"] = "C:\\other", + }) + .Build(); + + Action act = () => PathGuard.Validate(config, "C:\\allowed\\file.txt"); + act.Should().Throw().WithMessage("*sparse*"); + } + + private static void DeleteDirectoryLink(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + } } diff --git a/tests/NodePilot.Engine.Tests/Security/TargetPathGuardScriptTests.cs b/tests/NodePilot.Engine.Tests/Security/TargetPathGuardScriptTests.cs new file mode 100644 index 00000000..00ddee60 --- /dev/null +++ b/tests/NodePilot.Engine.Tests/Security/TargetPathGuardScriptTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using NodePilot.Engine.PowerShell; +using NodePilot.Engine.Security; +using NodePilot.Engine.Tests.Helpers; +using Xunit; + +namespace NodePilot.Engine.Tests.Security; + +public sealed class TargetPathGuardScriptTests +{ + private static readonly RunspaceExecutionEngine Engine = + new(NullLogger.Instance, 1, 2); + + [Fact] + public void GeneratedGuard_WithNoAllowedRootsStillEmitsLinkLocalReparseCheck() + { + var config = new ConfigurationBuilder().Build(); + + var guard = TargetPathGuardScript.Build(config, ("$candidate", "path")); + + guard.Should().Contain("function Assert-NodePilotAllowedPath"); + guard.Should().Contain("[System.IO.File]::GetAttributes"); + guard.Should().Contain("$__npEnforceAllowedRoots = $false"); + guard.Should().NotContain("Test-Path -LiteralPath"); + guard.Should().NotContain("Get-Item -LiteralPath"); + } + + [WindowsFact] + public async Task GeneratedGuard_RejectsCandidateTraversingTargetSideReparsePoint() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-target-guard-" + Guid.NewGuid().ToString("N")); + var allowed = Path.Combine(stage, "allowed"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(allowed, "link"); + Directory.CreateDirectory(allowed); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "payload.txt"), "outside"); + + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; // Windows host without the symlink-development privilege. + } + + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = allowed, + }).Build(); + var guard = TargetPathGuardScript.Build(config, ("$candidate", "path")); + var result = await Engine.ExecuteAsync( + new PowerShellExecutionRequest + { + ScriptText = $$""" + $candidate = {{PowerShellOperation.Literal(Path.Combine(link, "payload.txt"))}} + {{guard}} + Write-Output 'guard-bypassed' + """, + Timeout = TimeSpan.FromSeconds(30), + }, + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("traverses reparse point"); + result.Output.Should().NotContain("guard-bypassed"); + } + finally + { + DeleteLinkOnly(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task GeneratedGuard_WithNoAllowedRootsRejectsDanglingTargetSideReparsePoint() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-target-dangling-" + Guid.NewGuid().ToString("N")); + var link = Path.Combine(stage, "link"); + var missingTarget = Path.Combine(stage, "missing-target"); + Directory.CreateDirectory(stage); + + try + { + try + { + Directory.CreateSymbolicLink(link, missingTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var guard = TargetPathGuardScript.Build( + new ConfigurationBuilder().Build(), + ("$candidate", "path")); + var result = await Engine.ExecuteAsync( + new PowerShellExecutionRequest + { + ScriptText = $$""" + $candidate = {{PowerShellOperation.Literal(Path.Combine(link, "payload.txt"))}} + {{guard}} + Write-Output 'guard-bypassed' + """, + Timeout = TimeSpan.FromSeconds(30), + }, + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Contain("traverses reparse point"); + result.Output.Should().NotContain("guard-bypassed"); + } + finally + { + DeleteLinkOnly(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task GeneratedGuard_AllowsReparseFreeCandidateInsideExistingRoot() + { + var allowed = Path.Combine(Path.GetTempPath(), "nodepilot-target-guard-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(allowed); + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = allowed, + }).Build(); + var guard = TargetPathGuardScript.Build(config, ("$candidate", "path")); + var result = await Engine.ExecuteAsync( + new PowerShellExecutionRequest + { + ScriptText = $$""" + $candidate = {{PowerShellOperation.Literal(Path.Combine(allowed, "future", "payload.txt"))}} + {{guard}} + Write-Output 'allowed' + """, + Timeout = TimeSpan.FromSeconds(30), + }, + CancellationToken.None); + + result.Success.Should().BeTrue(result.Error); + result.Output.Should().Contain("allowed"); + } + finally + { + try { Directory.Delete(allowed, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task GeneratedGuard_TreatsVolumeRootAsContainingItsChildren() + { + var candidate = Path.Combine(Path.GetTempPath(), "nodepilot-root-guard-" + Guid.NewGuid().ToString("N")); + var volumeRoot = Path.GetPathRoot(candidate)!; + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = volumeRoot, + }).Build(); + var guard = TargetPathGuardScript.Build(config, ("$candidate", "path")); + var result = await Engine.ExecuteAsync( + new PowerShellExecutionRequest + { + ScriptText = $$""" + $candidate = {{PowerShellOperation.Literal(candidate)}} + {{guard}} + Write-Output 'allowed' + """, + Timeout = TimeSpan.FromSeconds(30), + }, + CancellationToken.None); + + result.Success.Should().BeTrue(result.Error); + result.Output.Should().Contain("allowed"); + } + + private static void DeleteLinkOnly(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + } +} diff --git a/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerActivityTests.cs b/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerActivityTests.cs index 7a2e9cfc..9458f3bf 100644 --- a/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerActivityTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using NodePilot.Core.Interfaces; using NodePilot.Engine.Triggers; +using NodePilot.Engine.Tests.Helpers; using Xunit; namespace NodePilot.Engine.Tests.Triggers; @@ -104,4 +105,87 @@ public async Task Execute_ManualScan_DirectoryDoesNotExist_Fails() [Fact] public void ActivityType_IsFileWatcherTrigger() => new FileWatcherTrigger().ActivityType.Should().Be("fileWatcherTrigger"); + + [Theory] + [InlineData(@"\\?\C:\Windows\System32")] + [InlineData(@"//?/C:/Windows/System32")] + [InlineData(@"\\.\C:\Windows\System32")] + [InlineData(@"\??\C:\Windows\System32")] + public async Task Execute_ManualScanRejectsWindowsDeviceNamespace(string directory) + { + if (!OperatingSystem.IsWindows()) return; + + var result = await new FileWatcherTrigger().ExecuteAsync( + new StepExecutionContext(), + Cfg(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("device namespace"); + } + + [WindowsFact] + public async Task Execute_ManualScanRejectsLocalAdministrativeShareSystemAlias() + { + var result = await new FileWatcherTrigger().ExecuteAsync( + new StepExecutionContext(), + Cfg(JsonSerializer.Serialize(new + { + directory = @"\\localhost\c$\Windows\System32", + })), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("system path"); + } + + [WindowsFact] + public async Task Execute_ManualRecursiveScanRejectsNestedJunctionWithoutEnumeratingTarget() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-fw-manual-link-" + Guid.NewGuid().ToString("N")); + var watched = Path.Combine(stage, "watched"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(watched, "nested-link"); + Directory.CreateDirectory(watched); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "outside-secret.log"), "secret"); + try + { + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var json = JsonSerializer.Serialize(new + { + directory = watched, + filter = "*.log", + includeSubdirectories = true, + }); + var result = await new FileWatcherTrigger().ExecuteAsync( + new StepExecutionContext(), + Cfg(json), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorOutput.Should().Contain("reparse point"); + result.Output.Should().NotContain("outside-secret.log"); + } + finally + { + DeleteDirectoryLink(link); + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + private static void DeleteDirectoryLink(string link) + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + } } diff --git a/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerSourceTests.cs b/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerSourceTests.cs index 14b51f1f..12094844 100644 --- a/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerSourceTests.cs +++ b/tests/NodePilot.Engine.Tests/Triggers/FileWatcherTriggerSourceTests.cs @@ -1,8 +1,12 @@ using System.ComponentModel; +using System.Net; +using System.Net.NetworkInformation; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using NodePilot.Engine.Security; +using NodePilot.Engine.Tests.Helpers; using NodePilot.Scheduler; using NodePilot.Scheduler.Sources; using Xunit; @@ -79,6 +83,283 @@ await act.Should().ThrowAsync() .WithMessage("*system path*"); } + [Theory] + [InlineData(@"C:\PROGRA~1")] + [InlineData(@"C:\PROGRA~2")] + public async Task StartAsync_Throws_WhenHardBlockedPathUsesExistingDosShortName(string directory) + { + if (!OperatingSystem.IsWindows() || !Directory.Exists(directory)) return; + + // On supported Windows/.NET, GetFullPath expands the existing 8.3 alias before the + // hard-block comparison. Pin that behavior because FileSystemWatcher itself accepts + // these aliases and a lexical-only comparison would be bypassable. + Path.GetFullPath(directory).Should().NotContain("~"); + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*"); + } + + [Theory] + [InlineData(@"\\?\C:\Windows\System32")] + [InlineData(@"//?/C:/Windows/System32")] + [InlineData(@"\\.\C:\Windows\System32")] + [InlineData(@"\??\C:\Windows\System32")] + public async Task StartAsync_Throws_WhenDirectoryUsesWindowsDeviceNamespace(string directory) + { + if (!OperatingSystem.IsWindows()) return; + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*device namespace*"); + } + + [WindowsFact] + public async Task StartAsync_Throws_WhenSystemPathUsesLocalAdministrativeShareAliases() + { + var systemDirectory = Path.GetFullPath(Environment.SystemDirectory); + var driveRoot = Path.GetPathRoot(systemDirectory); + if (string.IsNullOrWhiteSpace(driveRoot) || driveRoot.Length < 2 || driveRoot[1] != ':') + return; + var relative = Path.GetRelativePath(driveRoot, systemDirectory); + var aliases = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "localhost", + "127.0.0.1", + "127.0.0.2", + "[::1]", + "--1.ipv6-literal.net", + "0--1.ipv6-literal.net", + "0-0-0-0-0-0-0-1.ipv6-literal.net", + "--ffff-127.0.0.1.ipv6-literal.net", + "0-0-0-0-0-ffff-127.0.0.1.ipv6-literal.net", + Environment.MachineName, + Dns.GetHostName(), + }; + var properties = IPGlobalProperties.GetIPGlobalProperties(); + if (!string.IsNullOrWhiteSpace(properties.HostName) && + !string.IsNullOrWhiteSpace(properties.DomainName)) + aliases.Add($"{properties.HostName}.{properties.DomainName}"); + + foreach (var alias in aliases.Where(alias => !string.IsNullOrWhiteSpace(alias))) + { + var directory = $@"\\{alias}\{char.ToLowerInvariant(driveRoot[0])}$\{relative}"; + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(systemDirectory); + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*", $"alias '{alias}' names the local system drive"); + } + } + + [WindowsFact] + public async Task StartAsync_Throws_WhenActualSystemDirectoryUsesLocalDriveAdminShare() + { + var systemDirectory = Path.GetFullPath(Environment.SystemDirectory); + var driveRoot = Path.GetPathRoot(systemDirectory); + if (string.IsNullOrWhiteSpace(driveRoot) || driveRoot.Length < 2 || driveRoot[1] != ':') + return; + + var relative = Path.GetRelativePath(driveRoot, systemDirectory); + var directory = $@"\\localhost\{char.ToLowerInvariant(driveRoot[0])}$\{relative}"; + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(systemDirectory); + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*"); + } + + [Theory] + [InlineData(@"\\localhost\ADMIN$\..\System32")] + [InlineData(@"\\localhost\ADMIN$\..\..\System32")] + public async Task StartAsync_Throws_WhenAdminShareParentSegmentsClampAtShareRoot(string directory) + { + if (!OperatingSystem.IsWindows()) return; + + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().BeEquivalentTo(Path.GetFullPath(Environment.SystemDirectory)); + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*"); + } + + [WindowsFact] + public async Task StartAsync_Throws_WhenDriveShareParentSegmentClampsAtShareRoot() + { + var systemDirectory = Path.GetFullPath(Environment.SystemDirectory); + var driveRoot = Path.GetPathRoot(systemDirectory); + if (string.IsNullOrWhiteSpace(driveRoot) || driveRoot.Length < 2 || driveRoot[1] != ':') + return; + + var relative = Path.GetRelativePath(driveRoot, systemDirectory); + var directory = $@"\\localhost\{char.ToLowerInvariant(driveRoot[0])}$\..\{relative}"; + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(systemDirectory); + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*"); + } + + [Theory] + [InlineData(@"\\localhost\ADMIN$.")] + [InlineData(@"\\localhost\ADMIN$ ")] + public async Task StartAsync_Throws_WhenAdminShareRootHasAcceptedTrailingAlias(string directory) + { + if (!OperatingSystem.IsWindows()) return; + + var expectedSystemRoot = Path.GetDirectoryName(Environment.SystemDirectory)!; + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(Path.GetFullPath(expectedSystemRoot)); + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*system path*"); + } + + [Theory] + [InlineData(@"\\localhost\c$.")] + [InlineData(@"\\localhost\c$ ")] + public void LocalDriveAdminShareRoot_NormalizesAcceptedTrailingAlias(string directory) + { + if (!OperatingSystem.IsWindows()) return; + + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(Path.GetFullPath(@"C:\")); + } + + [WindowsFact] + public async Task StartAsync_RejectsUnmappedLocalNamedShareEvenWhenConfiguredAsAllowedRoot() + { + const string directory = @"\\localhost\Logs"; + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + WithAllowedRoots(directory)); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new { directory })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*local UNC share*cannot be mapped safely*"); + } + + [WindowsFact] + public async Task StartAsync_RejectsDriveRootThatContainsProtectedSystemTrees() + { + var driveRoot = Path.GetPathRoot(Environment.SystemDirectory); + if (string.IsNullOrWhiteSpace(driveRoot)) return; + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx(JsonSerializer.Serialize(new + { + directory = driveRoot, + includeSubdirectories = true, + })), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*intersects a system path*"); + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + public void LocalAdminShare_WithRepeatedServerShareSeparators_MapsForPolicy(int separators) + { + if (!OperatingSystem.IsWindows()) return; + + var systemDirectory = Path.GetFullPath(Environment.SystemDirectory); + var driveRoot = Path.GetPathRoot(systemDirectory); + if (string.IsNullOrWhiteSpace(driveRoot) || driveRoot.Length < 2 || driveRoot[1] != ':') + return; + var relative = Path.GetRelativePath(driveRoot, systemDirectory); + var directory = $@"\\localhost{new string('\\', separators)}{char.ToLowerInvariant(driveRoot[0])}$\{relative}"; + + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + directory, + rejectUnmappedLocalShare: true) + .Should().Be(systemDirectory); + } + + [WindowsFact] + public void LocalAdminShare_MapsToWindowsDirectoryForPolicy() + { + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + @"\\localhost\ADMIN$\System32", + rejectUnmappedLocalShare: true) + .Should().BeEquivalentTo(Path.GetFullPath(Environment.SystemDirectory)); + } + + [WindowsFact] + public void RemoteAdministrativeUncShare_RemainsRemoteForPolicy() + { + const string remote = @"\\nodepilot-remote.example.invalid\c$\Windows\System32"; + + FileWatcherPathGuard.CanonicalizeLocalAdministrativeShareForPolicy( + remote, + rejectUnmappedLocalShare: true) + .Should().Be(remote, "remote UNC shares remain a supported FileWatcher target"); + } + [Fact] public async Task StartAsync_AllowsSystemPath_WhenAllowSystemPathsConfigSet() { @@ -133,6 +414,115 @@ await act.Should().ThrowAsync() } } + [WindowsFact] + public async Task StartAsync_Throws_WhenAllowedPathResolvesThroughJunctionOutsideRoot() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-fw-junction-" + Guid.NewGuid().ToString("N")); + var allowed = Path.Combine(stage, "allowed"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(allowed, "link"); + Directory.CreateDirectory(allowed); + Directory.CreateDirectory(outside); + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; // Windows host without the symlink-development privilege. + } + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + WithAllowedRoots(allowed)); + var act = () => src.StartAsync( + Ctx($$"""{"directory":"{{Esc(link)}}"}"""), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*reparse point*"); + } + finally + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [WindowsFact] + public async Task StartAsync_WithRecursiveWatchRejectsNestedJunction() + { + var stage = Path.Combine(Path.GetTempPath(), "nodepilot-fw-subtree-link-" + Guid.NewGuid().ToString("N")); + var watched = Path.Combine(stage, "watched"); + var outside = Path.Combine(stage, "outside"); + var link = Path.Combine(watched, "nested-link"); + Directory.CreateDirectory(watched); + Directory.CreateDirectory(outside); + try + { + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + EmptyConfig()); + var act = () => src.StartAsync( + Ctx($$"""{"directory":"{{Esc(watched)}}","includeSubdirectories":true}"""), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*watched tree contains reparse point*"); + } + finally + { + try + { + if ((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(link); + } + catch { } + try { Directory.Delete(stage, recursive: true); } catch { } + } + } + + [Fact] + public async Task AllowedRoots_HigherPriorityProviderRevokesLowerArrayEntry() + { + using var allowed = new TempDirectory(); + using var revoked = new TempDirectory(); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Trigger:FileWatcher:AllowedRoots:0"] = allowed.Path, + ["Trigger:FileWatcher:AllowedRoots:1"] = revoked.Path, + }) + .AddInMemoryCollection(new Dictionary + { + ["Trigger:FileWatcher:AllowedRoots:0"] = allowed.Path, + }) + .Build(); + var src = new FileWatcherTriggerSource( + NullLogger.Instance, + config); + + var act = () => src.StartAsync( + Ctx($$"""{"directory":"{{Esc(revoked.Path)}}"}"""), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*not within any configured Trigger:FileWatcher:AllowedRoots*"); + } + [Fact] public async Task StartAsync_Throws_WhenDirectoryDoesNotExist() {