From 2cc137275a1bc2b3090f700fa4363e11b849bdf6 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:44:31 -0400 Subject: [PATCH 1/9] Add source organization script --- .github/scripts/organize_source.py | 338 +++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 .github/scripts/organize_source.py diff --git a/.github/scripts/organize_source.py b/.github/scripts/organize_source.py new file mode 100644 index 0000000..4bbc60d --- /dev/null +++ b/.github/scripts/organize_source.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path("MagicSettings") +PROJECTS = { + ROOT / "MagicSettings": "runtime", + ROOT / "MagicSettings.Server": "server", + ROOT / "MagicSettings.Tests": "tests", +} + +TYPE_RE = re.compile( + r"^\s*(?:(?:public|internal|protected|private)\s+)" + r"(?:(?:sealed|static|abstract|partial|readonly|ref)\s+)*" + r"(?:record(?:\s+(?:class|struct))?|class|interface|enum|struct)\s+" + r"([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +NAMESPACE_RE = re.compile(r"^namespace\s+([A-Za-z_][A-Za-z0-9_.]*)\s*$", re.MULTILINE) + + +def sanitize_csharp(text: str) -> str: + """Mask comments and literals while preserving offsets and newlines.""" + out = list(text) + i = 0 + state = "normal" + raw_quotes = 0 + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if state == "normal": + if ch == "/" and nxt == "/": + out[i] = out[i + 1] = " " + i += 2 + state = "line_comment" + continue + if ch == "/" and nxt == "*": + out[i] = out[i + 1] = " " + i += 2 + state = "block_comment" + continue + quote_run = 0 + if ch == '"': + while i + quote_run < len(text) and text[i + quote_run] == '"': + quote_run += 1 + if quote_run >= 3: + raw_quotes = quote_run + for j in range(quote_run): + out[i + j] = " " + i += quote_run + state = "raw_string" + continue + if ch == "@" and nxt == '"': + out[i] = out[i + 1] = " " + i += 2 + state = "verbatim_string" + continue + if ch == "$" and nxt == '"': + out[i] = out[i + 1] = " " + i += 2 + state = "string" + continue + if ch in "$@" and nxt in "$@" and i + 2 < len(text) and text[i + 2] == '"': + out[i] = out[i + 1] = out[i + 2] = " " + i += 3 + state = "verbatim_string" + continue + if ch == '"': + out[i] = " " + i += 1 + state = "string" + continue + if ch == "'": + out[i] = " " + i += 1 + state = "char" + continue + i += 1 + continue + if state == "line_comment": + if ch == "\n": + state = "normal" + else: + out[i] = " " + i += 1 + continue + if state == "block_comment": + if ch == "*" and nxt == "/": + out[i] = out[i + 1] = " " + i += 2 + state = "normal" + else: + if ch != "\n": + out[i] = " " + i += 1 + continue + if state in {"string", "char"}: + terminator = '"' if state == "string" else "'" + if ch == "\\": + out[i] = " " + if i + 1 < len(text): + if text[i + 1] != "\n": + out[i + 1] = " " + i += 2 + else: + i += 1 + elif ch == terminator: + out[i] = " " + i += 1 + state = "normal" + else: + if ch != "\n": + out[i] = " " + i += 1 + continue + if state == "verbatim_string": + if ch == '"' and nxt == '"': + out[i] = out[i + 1] = " " + i += 2 + elif ch == '"': + out[i] = " " + i += 1 + state = "normal" + else: + if ch != "\n": + out[i] = " " + i += 1 + continue + if state == "raw_string": + run = 0 + if ch == '"': + while i + run < len(text) and text[i + run] == '"': + run += 1 + if run >= raw_quotes: + for j in range(raw_quotes): + out[i + j] = " " + i += raw_quotes + state = "normal" + else: + if ch != "\n": + out[i] = " " + i += 1 + return "".join(out) + + +def matching_brace(masked: str, opening: int) -> int: + depth = 0 + for index in range(opening, len(masked)): + if masked[index] == "{": + depth += 1 + elif masked[index] == "}": + depth -= 1 + if depth == 0: + return index + raise RuntimeError(f"No matching brace at offset {opening}") + + +def declaration_starts(body: str, masked: str) -> list[tuple[int, str]]: + starts: list[tuple[int, str]] = [] + depth = 0 + position = 0 + line_start = 0 + while line_start <= len(body): + while position < line_start: + if masked[position] == "{": + depth += 1 + elif masked[position] == "}": + depth -= 1 + position += 1 + line_end = body.find("\n", line_start) + if line_end < 0: + line_end = len(body) + if depth == 0: + match = TYPE_RE.match(body[line_start:line_end]) + if match: + starts.append((line_start, match.group(1))) + if line_end == len(body): + break + line_start = line_end + 1 + return starts + + +def include_leading_metadata(body: str, start: int, floor: int) -> int: + lines = body[:start].splitlines(keepends=True) + cursor = start + while lines: + line = lines[-1] + stripped = line.strip() + if not stripped or stripped.startswith("///") or stripped.startswith("//") or stripped.startswith("["): + candidate = cursor - len(line) + if candidate < floor: + break + cursor = candidate + lines.pop() + else: + break + return cursor + + +def runtime_folder(name: str) -> str: + if name.startswith("I") and len(name) > 1 and name[1].isupper(): + return "Abstractions" + if name.endswith("Attribute"): + return "Metadata" + if "Secret" in name: + return "Secrets" + if "Migration" in name: + return "Migrations" + if "Identity" in name or "Authentication" in name or name in {"MagicHash", "MagicNodeAuthenticationHandler", "MagicHttpClientBuilderExtensions"}: + return "Security" + if "ControlPlane" in name or name in {"MagicManifestBuilder", "HttpMagicControlPlaneTransport"}: + return "ControlPlane" + if ( + name in { + "MagicFailureAction", "MagicArrayMergePolicy", "MagicSettingsFailurePolicy", + "MagicSettingsOptions", "MagicSettingsEnvironmentResolver", "MagicSettingsPathResolver", + "MagicIdentityPathResolver", "MagicSettingsConfigurationSource", + "MagicSettingsConfigurationProvider", "MagicSettingsDocumentResult", + "MagicSettingsDocumentStore", "MagicJsonPath", "MagicJsonFlattener", + "MagicEnvironmentOverrides", + } + or "Configuration" in name + or name.endswith("Options") + or name.endswith("PathResolver") + ): + return "Configuration" + if "Initialization" in name or "Runtime" in name or "HostedService" in name or "CommandLine" in name: + return "Runtime" + return "Internal" + + +def server_folder(name: str) -> str: + if name.startswith("I") and len(name) > 1 and name[1].isupper(): + return "Abstractions" + if name.startswith("InMemory"): + return "InMemory" + if "Secret" in name: + return "Secrets" + if "Proof" in name or "AspNet" in name: + return "Authentication" + if "Credential" in name: + return "Credentials" + if "Sync" in name or "RemoteRecord" in name: + return "Synchronization" + return "Internal" + + +def test_folder(name: str) -> str: + if name in {"TemporaryDirectory", "TestSettings", "TestApplication", "TestDatabase", "TestControlPlane", "TestControlPlaneSection"}: + return "Infrastructure" + if any(token in name for token in ("Credential", "Enrollment", "Identity", "Proof", "Secret")): + return "Security" + if "Server" in name: + return "Server" + return "Configuration" + + +def folder_for(kind: str, name: str) -> str: + if kind == "runtime": + return runtime_folder(name) + if kind == "server": + return server_folder(name) + return test_folder(name) + + +created: list[Path] = [] +deleted: list[Path] = [] +seen: set[Path] = set() + +for project, kind in PROJECTS.items(): + source_files = sorted(project.glob("Source.*.cs")) + if not source_files: + raise RuntimeError(f"No consolidated source files found in {project}") + + for source in source_files: + text = source.read_text(encoding="utf-8") + masked = sanitize_csharp(text) + namespace_matches = list(NAMESPACE_RE.finditer(masked)) + if not namespace_matches: + raise RuntimeError(f"No block namespace found in {source}") + + preamble = text[: namespace_matches[0].start()] + preamble = "\n".join( + line + for line in preamble.splitlines() + if not line.startswith("// Consolidated source file.") and line.strip() != "global using Xunit;" + ).strip() + + for namespace_match in namespace_matches: + namespace = namespace_match.group(1) + opening = masked.find("{", namespace_match.end()) + closing = matching_brace(masked, opening) + body = text[opening + 1 : closing] + body_masked = masked[opening + 1 : closing] + starts = declaration_starts(body, body_masked) + if not starts: + raise RuntimeError(f"Namespace {namespace} in {source} has no declarations") + + adjusted: list[tuple[int, str]] = [] + floor = 0 + for start, name in starts: + adjusted.append((include_leading_metadata(body, start, floor), name)) + floor = start + + for index, (start, name) in enumerate(adjusted): + end = adjusted[index + 1][0] if index + 1 < len(adjusted) else len(body) + declaration = body[start:end].strip() + folder = folder_for(kind, name) + destination = project / folder / f"{name}.cs" + if destination in seen or destination.exists(): + raise RuntimeError(f"Duplicate destination {destination}") + seen.add(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + parts = ([preamble] if preamble else []) + [f"namespace {namespace};", declaration] + destination.write_text("\n\n".join(parts).rstrip() + "\n", encoding="utf-8") + created.append(destination) + + source.unlink() + deleted.append(source) + + scaffold = project / "UnitTest1.cs" + if scaffold.exists(): + scaffold.unlink() + deleted.append(scaffold) + +(ROOT / "MagicSettings.Tests" / "GlobalUsings.cs").write_text("global using Xunit;\n", encoding="utf-8") +created.append(ROOT / "MagicSettings.Tests" / "GlobalUsings.cs") + +remaining = list(ROOT.rglob("Source.*.cs")) +if remaining: + raise RuntimeError(f"Consolidated files remain: {remaining}") + +print(f"Created {len(created)} focused source files and removed {len(deleted)} consolidated/scaffold files.") +for path in sorted(created): + print(f" + {path}") +for path in sorted(deleted): + print(f" - {path}") From 7135cb9bc8835a4899a638ec9506acd263fbc040 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:44:36 -0400 Subject: [PATCH 2/9] Run source organization --- .github/workflows/organize-source.yml | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/organize-source.yml diff --git a/.github/workflows/organize-source.yml b/.github/workflows/organize-source.yml new file mode 100644 index 0000000..ffb4294 --- /dev/null +++ b/.github/workflows/organize-source.yml @@ -0,0 +1,37 @@ +name: Organize source files + +on: + push: + branches: [ agent/organize-source-files ] + +permissions: + contents: write + +jobs: + organize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/organize-source-files + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Split consolidated files + run: python3 .github/scripts/organize_source.py + - name: Remove one-shot automation + run: rm .github/scripts/organize_source.py .github/workflows/organize-source.yml + - name: Restore + run: dotnet restore MagicSettings/MagicSettings.sln + - name: Build + run: dotnet build MagicSettings/MagicSettings.sln --configuration Release --no-restore + - name: Test + run: dotnet test MagicSettings/MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal" + - name: Commit organized tree + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Organize source files by feature" + git push origin HEAD:agent/organize-source-files From d3069bab89139b27b378b983f51c4d75d626e91e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:45:05 +0000 Subject: [PATCH 3/9] Organize source files by feature --- .github/scripts/organize_source.py | 338 -------------- .github/workflows/organize-source.yml | 37 -- .../Abstractions/IMagicCredentialRegistry.cs | 13 + .../IMagicNodeRemoteRecordStore.cs | 13 + .../Abstractions/IMagicReplayCache.cs | 12 + .../Abstractions/IMagicSecretResolver.cs | 15 + .../MagicAspNetProofVerificationExtensions.cs | 62 +++ .../Authentication/MagicNodeProofVerifier.cs | 185 ++++++++ .../MagicCredentialAdministrationService.cs | 43 ++ .../MagicCredentialRotationService.cs | 59 +++ .../Credentials/MagicRegisteredCredential.cs | 14 + .../InMemoryMagicCredentialRegistry.cs | 21 + .../InMemoryMagicNodeRemoteRecordStore.cs | 21 + .../InMemory/InMemoryMagicReplayCache.cs | 26 ++ .../Secrets/MagicSecretService.cs | 48 ++ .../MagicSettings.Server/Source.01.cs | 436 ------------------ .../Synchronization/MagicNodeRemoteRecord.cs | 17 + .../MagicSettingsSyncService.cs} | 40 +- .../Configuration/EndpointBootstrapTests.cs | 73 +++ .../Configuration/InitializationTests.cs | 139 ++++++ .../Configuration/MigrationTests.cs | 74 +++ .../MagicSettings.Tests/GlobalUsings.cs | 1 + .../Infrastructure/TemporaryDirectory.cs | 32 ++ .../Infrastructure/TestApplication.cs | 13 + .../Infrastructure/TestControlPlane.cs | 12 + .../Infrastructure/TestControlPlaneSection.cs | 12 + .../Infrastructure/TestDatabase.cs | 14 + .../Infrastructure/TestSettings.cs | 15 + .../Security/CredentialAdministrationTests.cs | 30 ++ .../Security/CredentialRotationTests.cs | 57 +++ .../Security/EnrollmentTests.cs | 56 +++ .../Security/IdentityTests.cs | 57 +++ .../Security/ProofTests.cs | 47 ++ .../Security/SecretTests.cs | 37 ++ .../Server/ServerSafetyTests.cs | 38 ++ .../MagicSettings.Tests/Source.01.cs | 256 ---------- .../MagicSettings.Tests/Source.02.cs | 378 --------------- .../IMagicControlPlaneEndpointResolver.cs | 28 ++ .../IMagicControlPlaneTransport.cs | 29 ++ .../Abstractions/IMagicNodeAuthenticator.cs | 26 ++ .../Abstractions/IMagicNodeIdentityManager.cs | 28 ++ .../Abstractions/IMagicNodeIdentityStore.cs | 27 ++ .../Abstractions/IMagicSecretProvider.cs | 25 + .../Abstractions/IMagicSecretTransport.cs | 29 ++ .../Abstractions/IMagicSettings.cs | 28 ++ .../IMagicSettingsControlPlane.cs | 29 ++ .../Abstractions/IMagicSettingsMigration.cs | 27 ++ .../IMagicSettingsSourceProvider.cs | 27 ++ .../Abstractions/IMagicSettingsValidator.cs | 25 + .../Configuration/MagicArrayMergePolicy.cs | 28 ++ .../MagicEnvironmentOverrides.cs | 40 ++ .../Configuration/MagicFailureAction.cs | 27 ++ .../Configuration/MagicJsonFlattener.cs | 71 +++ .../Configuration/MagicJsonPath.cs | 124 +++++ .../MagicSettingsConfigurationProvider.cs | 29 ++ .../MagicSettingsConfigurationSource.cs | 27 ++ .../MagicSettingsDocumentResult.cs | 22 + .../MagicSettingsDocumentStore.cs} | 169 +------ .../MagicSettingsEnvironmentResolver.cs | 35 ++ .../MagicSettingsFailurePolicy.cs | 27 ++ .../Configuration/MagicSettingsOptions.cs | 56 +++ .../MagicSettingsPathResolver.cs | 45 ++ .../HttpMagicControlPlaneTransport.cs | 142 ++++++ .../MagicControlPlaneBootstrapOptions.cs | 31 ++ .../MagicControlPlaneEndpointResolver.cs | 80 ++++ .../ControlPlane/MagicControlPlaneOptions.cs | 28 ++ .../ControlPlane/MagicManifestBuilder.cs | 87 ++++ .../MagicSettingsControlPlane.cs} | 131 +----- .../Internal/MagicSettingExplanation.cs | 27 ++ .../Internal/MagicSettingSourceValue.cs | 22 + .../Internal/MagicSettingsChangedEventArgs.cs | 22 + .../Metadata/MagicRemoteOverrideAttribute.cs | 27 ++ .../Metadata/MagicSensitiveAttribute.cs | 25 + .../MagicSettingDescriptionAttribute.cs | 27 ++ .../Migrations/MagicMigrationContext.cs | 95 ++++ .../Migrations/MagicMigrationRunner.cs | 59 +++ .../Runtime/MagicSettingsCommandLine.cs | 34 ++ .../Runtime/MagicSettingsHostedService.cs | 126 +++++ .../MagicSettingsInitializationExtensions.cs | 103 +++++ .../MagicSettingsInitializationResult.cs | 22 + .../MagicSettingsRuntime.cs} | 84 +--- .../MagicSettingsRuntimeRegistration.cs | 22 + .../MagicControlPlaneSecretProvider.cs | 82 ++++ .../MagicSettings/Secrets/MagicSecretLease.cs | 33 ++ .../Security/FileMagicNodeIdentityStore.cs | 72 +++ .../MagicSettings/Security/MagicHash.cs | 26 ++ .../MagicHttpClientBuilderExtensions.cs | 30 ++ .../Security/MagicIdentityCryptography.cs | 100 ++++ .../Security/MagicIdentityPathResolver.cs | 37 ++ .../MagicNodeAuthenticationHandler.cs | 63 +++ .../Security/MagicNodeIdentityManager.cs | 159 +++++++ .../Security/MagicStoredNodeIdentity.cs | 27 ++ MagicSettings/MagicSettings/Source.01.cs | 163 ------- MagicSettings/MagicSettings/Source.04.cs | 423 ----------------- MagicSettings/MagicSettings/Source.05.cs | 414 ----------------- MagicSettings/MagicSettings/Source.06.cs | 161 ------- 96 files changed, 3727 insertions(+), 3026 deletions(-) delete mode 100644 .github/scripts/organize_source.py delete mode 100644 .github/workflows/organize-source.yml create mode 100644 MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs create mode 100644 MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs create mode 100644 MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs create mode 100644 MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs create mode 100644 MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs create mode 100644 MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs create mode 100644 MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs create mode 100644 MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs create mode 100644 MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs create mode 100644 MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs create mode 100644 MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs create mode 100644 MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs create mode 100644 MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs delete mode 100644 MagicSettings/MagicSettings.Server/Source.01.cs create mode 100644 MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs rename MagicSettings/MagicSettings.Server/{Source.02.cs => Synchronization/MagicSettingsSyncService.cs} (79%) create mode 100644 MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/GlobalUsings.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs create mode 100644 MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/ProofTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Security/SecretTests.cs create mode 100644 MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs delete mode 100644 MagicSettings/MagicSettings.Tests/Source.01.cs delete mode 100644 MagicSettings/MagicSettings.Tests/Source.02.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs create mode 100644 MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs rename MagicSettings/MagicSettings/{Source.03.cs => Configuration/MagicSettingsDocumentStore.cs} (53%) create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs create mode 100644 MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs create mode 100644 MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs create mode 100644 MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs create mode 100644 MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs create mode 100644 MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs create mode 100644 MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs rename MagicSettings/MagicSettings/{Source.02.cs => ControlPlane/MagicSettingsControlPlane.cs} (58%) create mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs create mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs create mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs create mode 100644 MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs create mode 100644 MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs create mode 100644 MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs create mode 100644 MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs create mode 100644 MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs rename MagicSettings/MagicSettings/{Source.07.cs => Runtime/MagicSettingsRuntime.cs} (80%) create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs create mode 100644 MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs create mode 100644 MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs create mode 100644 MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicHash.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs create mode 100644 MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs delete mode 100644 MagicSettings/MagicSettings/Source.01.cs delete mode 100644 MagicSettings/MagicSettings/Source.04.cs delete mode 100644 MagicSettings/MagicSettings/Source.05.cs delete mode 100644 MagicSettings/MagicSettings/Source.06.cs diff --git a/.github/scripts/organize_source.py b/.github/scripts/organize_source.py deleted file mode 100644 index 4bbc60d..0000000 --- a/.github/scripts/organize_source.py +++ /dev/null @@ -1,338 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -ROOT = Path("MagicSettings") -PROJECTS = { - ROOT / "MagicSettings": "runtime", - ROOT / "MagicSettings.Server": "server", - ROOT / "MagicSettings.Tests": "tests", -} - -TYPE_RE = re.compile( - r"^\s*(?:(?:public|internal|protected|private)\s+)" - r"(?:(?:sealed|static|abstract|partial|readonly|ref)\s+)*" - r"(?:record(?:\s+(?:class|struct))?|class|interface|enum|struct)\s+" - r"([A-Za-z_][A-Za-z0-9_]*)", - re.MULTILINE, -) -NAMESPACE_RE = re.compile(r"^namespace\s+([A-Za-z_][A-Za-z0-9_.]*)\s*$", re.MULTILINE) - - -def sanitize_csharp(text: str) -> str: - """Mask comments and literals while preserving offsets and newlines.""" - out = list(text) - i = 0 - state = "normal" - raw_quotes = 0 - while i < len(text): - ch = text[i] - nxt = text[i + 1] if i + 1 < len(text) else "" - if state == "normal": - if ch == "/" and nxt == "/": - out[i] = out[i + 1] = " " - i += 2 - state = "line_comment" - continue - if ch == "/" and nxt == "*": - out[i] = out[i + 1] = " " - i += 2 - state = "block_comment" - continue - quote_run = 0 - if ch == '"': - while i + quote_run < len(text) and text[i + quote_run] == '"': - quote_run += 1 - if quote_run >= 3: - raw_quotes = quote_run - for j in range(quote_run): - out[i + j] = " " - i += quote_run - state = "raw_string" - continue - if ch == "@" and nxt == '"': - out[i] = out[i + 1] = " " - i += 2 - state = "verbatim_string" - continue - if ch == "$" and nxt == '"': - out[i] = out[i + 1] = " " - i += 2 - state = "string" - continue - if ch in "$@" and nxt in "$@" and i + 2 < len(text) and text[i + 2] == '"': - out[i] = out[i + 1] = out[i + 2] = " " - i += 3 - state = "verbatim_string" - continue - if ch == '"': - out[i] = " " - i += 1 - state = "string" - continue - if ch == "'": - out[i] = " " - i += 1 - state = "char" - continue - i += 1 - continue - if state == "line_comment": - if ch == "\n": - state = "normal" - else: - out[i] = " " - i += 1 - continue - if state == "block_comment": - if ch == "*" and nxt == "/": - out[i] = out[i + 1] = " " - i += 2 - state = "normal" - else: - if ch != "\n": - out[i] = " " - i += 1 - continue - if state in {"string", "char"}: - terminator = '"' if state == "string" else "'" - if ch == "\\": - out[i] = " " - if i + 1 < len(text): - if text[i + 1] != "\n": - out[i + 1] = " " - i += 2 - else: - i += 1 - elif ch == terminator: - out[i] = " " - i += 1 - state = "normal" - else: - if ch != "\n": - out[i] = " " - i += 1 - continue - if state == "verbatim_string": - if ch == '"' and nxt == '"': - out[i] = out[i + 1] = " " - i += 2 - elif ch == '"': - out[i] = " " - i += 1 - state = "normal" - else: - if ch != "\n": - out[i] = " " - i += 1 - continue - if state == "raw_string": - run = 0 - if ch == '"': - while i + run < len(text) and text[i + run] == '"': - run += 1 - if run >= raw_quotes: - for j in range(raw_quotes): - out[i + j] = " " - i += raw_quotes - state = "normal" - else: - if ch != "\n": - out[i] = " " - i += 1 - return "".join(out) - - -def matching_brace(masked: str, opening: int) -> int: - depth = 0 - for index in range(opening, len(masked)): - if masked[index] == "{": - depth += 1 - elif masked[index] == "}": - depth -= 1 - if depth == 0: - return index - raise RuntimeError(f"No matching brace at offset {opening}") - - -def declaration_starts(body: str, masked: str) -> list[tuple[int, str]]: - starts: list[tuple[int, str]] = [] - depth = 0 - position = 0 - line_start = 0 - while line_start <= len(body): - while position < line_start: - if masked[position] == "{": - depth += 1 - elif masked[position] == "}": - depth -= 1 - position += 1 - line_end = body.find("\n", line_start) - if line_end < 0: - line_end = len(body) - if depth == 0: - match = TYPE_RE.match(body[line_start:line_end]) - if match: - starts.append((line_start, match.group(1))) - if line_end == len(body): - break - line_start = line_end + 1 - return starts - - -def include_leading_metadata(body: str, start: int, floor: int) -> int: - lines = body[:start].splitlines(keepends=True) - cursor = start - while lines: - line = lines[-1] - stripped = line.strip() - if not stripped or stripped.startswith("///") or stripped.startswith("//") or stripped.startswith("["): - candidate = cursor - len(line) - if candidate < floor: - break - cursor = candidate - lines.pop() - else: - break - return cursor - - -def runtime_folder(name: str) -> str: - if name.startswith("I") and len(name) > 1 and name[1].isupper(): - return "Abstractions" - if name.endswith("Attribute"): - return "Metadata" - if "Secret" in name: - return "Secrets" - if "Migration" in name: - return "Migrations" - if "Identity" in name or "Authentication" in name or name in {"MagicHash", "MagicNodeAuthenticationHandler", "MagicHttpClientBuilderExtensions"}: - return "Security" - if "ControlPlane" in name or name in {"MagicManifestBuilder", "HttpMagicControlPlaneTransport"}: - return "ControlPlane" - if ( - name in { - "MagicFailureAction", "MagicArrayMergePolicy", "MagicSettingsFailurePolicy", - "MagicSettingsOptions", "MagicSettingsEnvironmentResolver", "MagicSettingsPathResolver", - "MagicIdentityPathResolver", "MagicSettingsConfigurationSource", - "MagicSettingsConfigurationProvider", "MagicSettingsDocumentResult", - "MagicSettingsDocumentStore", "MagicJsonPath", "MagicJsonFlattener", - "MagicEnvironmentOverrides", - } - or "Configuration" in name - or name.endswith("Options") - or name.endswith("PathResolver") - ): - return "Configuration" - if "Initialization" in name or "Runtime" in name or "HostedService" in name or "CommandLine" in name: - return "Runtime" - return "Internal" - - -def server_folder(name: str) -> str: - if name.startswith("I") and len(name) > 1 and name[1].isupper(): - return "Abstractions" - if name.startswith("InMemory"): - return "InMemory" - if "Secret" in name: - return "Secrets" - if "Proof" in name or "AspNet" in name: - return "Authentication" - if "Credential" in name: - return "Credentials" - if "Sync" in name or "RemoteRecord" in name: - return "Synchronization" - return "Internal" - - -def test_folder(name: str) -> str: - if name in {"TemporaryDirectory", "TestSettings", "TestApplication", "TestDatabase", "TestControlPlane", "TestControlPlaneSection"}: - return "Infrastructure" - if any(token in name for token in ("Credential", "Enrollment", "Identity", "Proof", "Secret")): - return "Security" - if "Server" in name: - return "Server" - return "Configuration" - - -def folder_for(kind: str, name: str) -> str: - if kind == "runtime": - return runtime_folder(name) - if kind == "server": - return server_folder(name) - return test_folder(name) - - -created: list[Path] = [] -deleted: list[Path] = [] -seen: set[Path] = set() - -for project, kind in PROJECTS.items(): - source_files = sorted(project.glob("Source.*.cs")) - if not source_files: - raise RuntimeError(f"No consolidated source files found in {project}") - - for source in source_files: - text = source.read_text(encoding="utf-8") - masked = sanitize_csharp(text) - namespace_matches = list(NAMESPACE_RE.finditer(masked)) - if not namespace_matches: - raise RuntimeError(f"No block namespace found in {source}") - - preamble = text[: namespace_matches[0].start()] - preamble = "\n".join( - line - for line in preamble.splitlines() - if not line.startswith("// Consolidated source file.") and line.strip() != "global using Xunit;" - ).strip() - - for namespace_match in namespace_matches: - namespace = namespace_match.group(1) - opening = masked.find("{", namespace_match.end()) - closing = matching_brace(masked, opening) - body = text[opening + 1 : closing] - body_masked = masked[opening + 1 : closing] - starts = declaration_starts(body, body_masked) - if not starts: - raise RuntimeError(f"Namespace {namespace} in {source} has no declarations") - - adjusted: list[tuple[int, str]] = [] - floor = 0 - for start, name in starts: - adjusted.append((include_leading_metadata(body, start, floor), name)) - floor = start - - for index, (start, name) in enumerate(adjusted): - end = adjusted[index + 1][0] if index + 1 < len(adjusted) else len(body) - declaration = body[start:end].strip() - folder = folder_for(kind, name) - destination = project / folder / f"{name}.cs" - if destination in seen or destination.exists(): - raise RuntimeError(f"Duplicate destination {destination}") - seen.add(destination) - destination.parent.mkdir(parents=True, exist_ok=True) - parts = ([preamble] if preamble else []) + [f"namespace {namespace};", declaration] - destination.write_text("\n\n".join(parts).rstrip() + "\n", encoding="utf-8") - created.append(destination) - - source.unlink() - deleted.append(source) - - scaffold = project / "UnitTest1.cs" - if scaffold.exists(): - scaffold.unlink() - deleted.append(scaffold) - -(ROOT / "MagicSettings.Tests" / "GlobalUsings.cs").write_text("global using Xunit;\n", encoding="utf-8") -created.append(ROOT / "MagicSettings.Tests" / "GlobalUsings.cs") - -remaining = list(ROOT.rglob("Source.*.cs")) -if remaining: - raise RuntimeError(f"Consolidated files remain: {remaining}") - -print(f"Created {len(created)} focused source files and removed {len(deleted)} consolidated/scaffold files.") -for path in sorted(created): - print(f" + {path}") -for path in sorted(deleted): - print(f" - {path}") diff --git a/.github/workflows/organize-source.yml b/.github/workflows/organize-source.yml deleted file mode 100644 index ffb4294..0000000 --- a/.github/workflows/organize-source.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Organize source files - -on: - push: - branches: [ agent/organize-source-files ] - -permissions: - contents: write - -jobs: - organize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/organize-source-files - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - name: Split consolidated files - run: python3 .github/scripts/organize_source.py - - name: Remove one-shot automation - run: rm .github/scripts/organize_source.py .github/workflows/organize-source.yml - - name: Restore - run: dotnet restore MagicSettings/MagicSettings.sln - - name: Build - run: dotnet build MagicSettings/MagicSettings.sln --configuration Release --no-restore - - name: Test - run: dotnet test MagicSettings/MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal" - - name: Commit organized tree - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Organize source files by feature" - git push origin HEAD:agent/organize-source-files diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs new file mode 100644 index 0000000..06f9307 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public interface IMagicCredentialRegistry +{ + ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default); + ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs new file mode 100644 index 0000000..4efdf04 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public interface IMagicNodeRemoteRecordStore +{ + ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default); + ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs new file mode 100644 index 0000000..1536227 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs @@ -0,0 +1,12 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public interface IMagicReplayCache +{ + ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs new file mode 100644 index 0000000..03006ef --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public interface IMagicSecretResolver +{ + ValueTask ResolveAsync( + Guid nodeId, + string name, + CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs b/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs new file mode 100644 index 0000000..1b23e92 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public static class MagicAspNetProofVerificationExtensions +{ + public static async ValueTask VerifyHttpRequestAsync( + this MagicNodeProofVerifier verifier, + HttpRequest request, + string expectedAudience, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(verifier); + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedAudience); + + if (!request.Headers.TryGetValue("Authorization", out var authorization)) + { + return MagicProofVerificationResult.Invalid("The MagicNode authorization header is missing."); + } + + var header = authorization.ToString(); + const string prefix = "MagicNode "; + if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return MagicProofVerificationResult.Invalid("The authorization scheme is not MagicNode."); + } + + MagicAuthenticationProof proof; + try + { + proof = MagicNodeProofCodec.Decode(header[prefix.Length..].Trim()); + } + catch (Exception exception) when (exception is FormatException or System.Text.Json.JsonException) + { + return MagicProofVerificationResult.Invalid("The MagicNode authorization proof is malformed."); + } + + request.EnableBuffering(); + string bodyHash; + if (request.ContentLength is null or 0) + { + bodyHash = Convert.ToHexString(SHA256.HashData(Array.Empty())).ToLowerInvariant(); + } + else + { + await using var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer, cancellationToken); + request.Body.Position = 0; + bodyHash = Convert.ToHexString(SHA256.HashData(buffer.ToArray())).ToLowerInvariant(); + } + + var uri = new Uri($"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}{request.QueryString}"); + return await verifier.VerifyAsync( + new(proof, expectedAudience, request.Method, uri, bodyHash, DateTimeOffset.UtcNow), + cancellationToken); + } +} diff --git a/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs b/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs new file mode 100644 index 0000000..fb218c7 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs @@ -0,0 +1,185 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class MagicNodeProofVerifier +{ + private readonly IMagicCredentialRegistry _credentials; + private readonly IMagicReplayCache _replayCache; + private readonly TimeSpan _allowedClockSkew; + + public MagicNodeProofVerifier(IMagicCredentialRegistry credentials, IMagicReplayCache replayCache, TimeSpan? allowedClockSkew = null) + { + _credentials = credentials; + _replayCache = replayCache; + _allowedClockSkew = allowedClockSkew ?? TimeSpan.FromSeconds(30); + } + + public async ValueTask VerifyAsync(MagicProofVerificationRequest request, CancellationToken cancellationToken = default) + { + var structural = ValidateRequest(request); + if (!structural.IsValid) + { + return structural; + } + + var proof = request.Proof; + var credential = await _credentials.FindAsync(proof.NodeId, proof.CredentialId, cancellationToken); + if (credential is null) + { + return MagicProofVerificationResult.Invalid("The credential is unknown."); + } + + if (credential.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) + { + return MagicProofVerificationResult.Invalid($"The credential is {credential.Status}."); + } + + return await VerifySignatureAndReplayAsync(credential.PublicKey, request, cancellationToken); + } + + public async ValueTask VerifyEnrollmentAsync( + MagicNodeIdentityDescriptor identity, + MagicProofVerificationRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + var structural = ValidateRequest(request); + if (!structural.IsValid) + { + return structural; + } + + if (request.Proof.NodeId != identity.NodeId || request.Proof.CredentialId != identity.CredentialId) + { + return MagicProofVerificationResult.Invalid("The proof does not match the supplied node identity."); + } + + if (identity.CredentialKind != MagicCredentialKind.EcdsaP256 + || !string.Equals(identity.SignatureAlgorithm, "ECDSA_P256_SHA256_P1363", StringComparison.Ordinal)) + { + return MagicProofVerificationResult.Invalid("The supplied credential algorithm is unsupported."); + } + + string fingerprint; + try + { + fingerprint = Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(identity.PublicKey))).ToLowerInvariant(); + } + catch (FormatException) + { + return MagicProofVerificationResult.Invalid("The supplied public key is malformed."); + } + + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(fingerprint), + Encoding.ASCII.GetBytes(identity.Fingerprint.ToLowerInvariant()))) + { + return MagicProofVerificationResult.Invalid("The supplied public-key fingerprint is invalid."); + } + + return await VerifySignatureAndReplayAsync(identity.PublicKey, request, cancellationToken); + } + + public static bool VerifyContinuity(MagicIdentityContinuityProof proof) + { + var canonical = string.Join("\n", + "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", + proof.PreviousIdentity.NodeId.ToString("D"), + proof.PreviousIdentity.CredentialId.ToString("D"), + proof.NewIdentity.CredentialId.ToString("D"), + proof.NewIdentity.PublicKey, + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + return VerifySignature(proof.PreviousIdentity.PublicKey, canonical, proof.Signature) + && proof.PreviousIdentity.NodeId == proof.NewIdentity.NodeId; + } + + private MagicProofVerificationResult ValidateRequest(MagicProofVerificationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var proof = request.Proof; + if (!string.Equals(proof.Version, "MAGICSETTINGS-PROOF-V1", StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("Unsupported proof version."); + if (!string.Equals(proof.Audience, request.ExpectedAudience, StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("The proof audience does not match this API."); + if (!string.Equals(proof.Method, request.Method, StringComparison.OrdinalIgnoreCase)) + return MagicProofVerificationResult.Invalid("The HTTP method does not match the signed proof."); + if (!string.Equals(proof.Target, NormalizeTarget(request.Uri), StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("The request target does not match the signed proof."); + if (!string.Equals(proof.BodySha256, request.BodySha256, StringComparison.OrdinalIgnoreCase)) + return MagicProofVerificationResult.Invalid("The request body hash does not match the signed proof."); + if (proof.IssuedUtc - _allowedClockSkew > request.NowUtc) + return MagicProofVerificationResult.Invalid("The proof was issued in the future."); + if (proof.ExpiresUtc + _allowedClockSkew < request.NowUtc) + return MagicProofVerificationResult.Invalid("The proof has expired."); + if (proof.ExpiresUtc <= proof.IssuedUtc || proof.ExpiresUtc - proof.IssuedUtc > TimeSpan.FromMinutes(5)) + return MagicProofVerificationResult.Invalid("The proof lifetime is invalid."); + if (string.IsNullOrWhiteSpace(proof.Nonce)) + return MagicProofVerificationResult.Invalid("The proof nonce is missing."); + return MagicProofVerificationResult.Valid; + } + + private async ValueTask VerifySignatureAndReplayAsync( + string publicKey, + MagicProofVerificationRequest request, + CancellationToken cancellationToken) + { + var proof = request.Proof; + if (!VerifySignature(publicKey, Canonicalize(proof), proof.Signature)) + { + return MagicProofVerificationResult.Invalid("The proof signature is invalid."); + } + + if (!await _replayCache.TryUseAsync(proof.CredentialId, proof.Nonce, proof.ExpiresUtc, cancellationToken)) + { + return MagicProofVerificationResult.Invalid("The proof nonce has already been used."); + } + + return MagicProofVerificationResult.Valid; + } + + private static bool VerifySignature(string publicKey, string canonical, string signature) + { + try + { + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); + return key.VerifyData( + Encoding.UTF8.GetBytes(canonical), + Convert.FromBase64String(signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + return false; + } + catch (FormatException) + { + return false; + } + } + + private static string Canonicalize(MagicAuthenticationProof proof) + => string.Join("\n", + proof.Version, + proof.NodeId.ToString("D"), + proof.CredentialId.ToString("D"), + proof.Audience, + proof.Method.ToUpperInvariant(), + proof.Target, + proof.BodySha256.ToLowerInvariant(), + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + + private static string NormalizeTarget(Uri uri) + => uri.IsAbsoluteUri + ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) + : uri.OriginalString; +} diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs new file mode 100644 index 0000000..c4c51ff --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +/// +/// Storage-agnostic credential lifecycle helpers for control-plane implementations. +/// +public sealed class MagicCredentialAdministrationService +{ + private readonly IMagicCredentialRegistry _credentials; + + public MagicCredentialAdministrationService(IMagicCredentialRegistry credentials) => _credentials = credentials; + + public async ValueTask SetStatusAsync( + Guid nodeId, + Guid credentialId, + MagicCredentialStatus status, + CancellationToken cancellationToken = default) + { + var credential = await _credentials.FindAsync(nodeId, credentialId, cancellationToken); + if (credential is null) + { + return false; + } + + await _credentials.UpsertAsync(credential with + { + Status = status, + UpdatedUtc = DateTimeOffset.UtcNow + }, cancellationToken); + return true; + } + + public ValueTask ApproveAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Approved, cancellationToken); + + public ValueTask RevokeAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Revoked, cancellationToken); +} diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs new file mode 100644 index 0000000..321cbd1 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class MagicCredentialRotationService +{ + private readonly IMagicCredentialRegistry _credentials; + + public MagicCredentialRotationService(IMagicCredentialRegistry credentials) => _credentials = credentials; + + public async ValueTask ApplyAsync( + MagicIdentityContinuityProof continuity, + bool autoApproveNewCredential, + CancellationToken cancellationToken = default) + { + if (!MagicNodeProofVerifier.VerifyContinuity(continuity)) + { + return MagicProofVerificationResult.Invalid("The identity continuity proof is invalid."); + } + + var previous = await _credentials.FindAsync( + continuity.PreviousIdentity.NodeId, + continuity.PreviousIdentity.CredentialId, + cancellationToken); + if (previous is null) + { + return MagicProofVerificationResult.Invalid("The previous credential is unknown."); + } + + if (previous.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) + { + return MagicProofVerificationResult.Invalid("The previous credential is not authorized to rotate."); + } + + if (!string.Equals(previous.PublicKey, continuity.PreviousIdentity.PublicKey, StringComparison.Ordinal)) + { + return MagicProofVerificationResult.Invalid("The continuity proof does not match the registered previous key."); + } + + await _credentials.UpsertAsync(previous with + { + Status = MagicCredentialStatus.Retiring, + UpdatedUtc = DateTimeOffset.UtcNow + }, cancellationToken); + + await _credentials.UpsertAsync(new( + continuity.NewIdentity.NodeId, + continuity.NewIdentity.CredentialId, + continuity.NewIdentity.PublicKey, + autoApproveNewCredential ? MagicCredentialStatus.Approved : MagicCredentialStatus.Pending, + DateTimeOffset.UtcNow), cancellationToken); + + return MagicProofVerificationResult.Valid; + } +} diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs new file mode 100644 index 0000000..cc1a42b --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs @@ -0,0 +1,14 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed record MagicRegisteredCredential( + Guid NodeId, + Guid CredentialId, + string PublicKey, + MagicCredentialStatus Status, + DateTimeOffset UpdatedUtc); diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs new file mode 100644 index 0000000..340c827 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class InMemoryMagicCredentialRegistry : IMagicCredentialRegistry +{ + private readonly ConcurrentDictionary<(Guid NodeId, Guid CredentialId), MagicRegisteredCredential> _credentials = new(); + + public ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_credentials.TryGetValue((nodeId, credentialId), out var credential) ? credential : null); + + public ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default) + { + _credentials[(credential.NodeId, credential.CredentialId)] = credential; + return ValueTask.CompletedTask; + } +} diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs new file mode 100644 index 0000000..bec6faf --- /dev/null +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class InMemoryMagicNodeRemoteRecordStore : IMagicNodeRemoteRecordStore +{ + private readonly ConcurrentDictionary<(Guid NodeId, string ApplicationId), MagicNodeRemoteRecord> _records = new(); + + public ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_records.TryGetValue((nodeId, applicationId), out var record) ? record : null); + + public ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default) + { + _records[(record.NodeId, record.ApplicationId)] = record; + return ValueTask.CompletedTask; + } +} diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs new file mode 100644 index 0000000..7cd8b95 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class InMemoryMagicReplayCache : IMagicReplayCache +{ + private readonly ConcurrentDictionary _nonces = new(StringComparer.Ordinal); + + public ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _nonces) + { + if (pair.Value <= now) + { + _nonces.TryRemove(pair.Key, out _); + } + } + + return ValueTask.FromResult(_nonces.TryAdd($"{credentialId:D}:{nonce}", expiresUtc)); + } +} diff --git a/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs b/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs new file mode 100644 index 0000000..a67ccf9 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed class MagicSecretService +{ + private readonly MagicNodeProofVerifier _proofVerifier; + private readonly IMagicSecretResolver _resolver; + + public MagicSecretService(MagicNodeProofVerifier proofVerifier, IMagicSecretResolver resolver) + { + _proofVerifier = proofVerifier; + _resolver = resolver; + } + + public async ValueTask ResolveAsync( + MagicSecretRequest request, + string authorityAudience, + Uri requestUri, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.NodeId != request.Proof.NodeId || request.CredentialId != request.Proof.CredentialId) + { + throw new UnauthorizedAccessException("The secret request identity does not match its proof."); + } + + var verification = await _proofVerifier.VerifyAsync( + new( + request.Proof, + authorityAudience, + "POST", + requestUri, + MagicSecretProof.ComputeBodySha256(request.Name), + DateTimeOffset.UtcNow), + cancellationToken); + if (!verification.IsValid) + { + throw new UnauthorizedAccessException(verification.Error); + } + + return await _resolver.ResolveAsync(request.NodeId, request.Name, cancellationToken); + } +} diff --git a/MagicSettings/MagicSettings.Server/Source.01.cs b/MagicSettings/MagicSettings.Server/Source.01.cs deleted file mode 100644 index e896af6..0000000 --- a/MagicSettings/MagicSettings.Server/Source.01.cs +++ /dev/null @@ -1,436 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - -namespace MagicSettings.Server -{ -public static class MagicAspNetProofVerificationExtensions -{ - public static async ValueTask VerifyHttpRequestAsync( - this MagicNodeProofVerifier verifier, - HttpRequest request, - string expectedAudience, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(verifier); - ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrWhiteSpace(expectedAudience); - - if (!request.Headers.TryGetValue("Authorization", out var authorization)) - { - return MagicProofVerificationResult.Invalid("The MagicNode authorization header is missing."); - } - - var header = authorization.ToString(); - const string prefix = "MagicNode "; - if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return MagicProofVerificationResult.Invalid("The authorization scheme is not MagicNode."); - } - - MagicAuthenticationProof proof; - try - { - proof = MagicNodeProofCodec.Decode(header[prefix.Length..].Trim()); - } - catch (Exception exception) when (exception is FormatException or System.Text.Json.JsonException) - { - return MagicProofVerificationResult.Invalid("The MagicNode authorization proof is malformed."); - } - - request.EnableBuffering(); - string bodyHash; - if (request.ContentLength is null or 0) - { - bodyHash = Convert.ToHexString(SHA256.HashData(Array.Empty())).ToLowerInvariant(); - } - else - { - await using var buffer = new MemoryStream(); - await request.Body.CopyToAsync(buffer, cancellationToken); - request.Body.Position = 0; - bodyHash = Convert.ToHexString(SHA256.HashData(buffer.ToArray())).ToLowerInvariant(); - } - - var uri = new Uri($"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}{request.QueryString}"); - return await verifier.VerifyAsync( - new(proof, expectedAudience, request.Method, uri, bodyHash, DateTimeOffset.UtcNow), - cancellationToken); - } -} -} -namespace MagicSettings.Server -{ -/// -/// Storage-agnostic credential lifecycle helpers for control-plane implementations. -/// -public sealed class MagicCredentialAdministrationService -{ - private readonly IMagicCredentialRegistry _credentials; - - public MagicCredentialAdministrationService(IMagicCredentialRegistry credentials) => _credentials = credentials; - - public async ValueTask SetStatusAsync( - Guid nodeId, - Guid credentialId, - MagicCredentialStatus status, - CancellationToken cancellationToken = default) - { - var credential = await _credentials.FindAsync(nodeId, credentialId, cancellationToken); - if (credential is null) - { - return false; - } - - await _credentials.UpsertAsync(credential with - { - Status = status, - UpdatedUtc = DateTimeOffset.UtcNow - }, cancellationToken); - return true; - } - - public ValueTask ApproveAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) - => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Approved, cancellationToken); - - public ValueTask RevokeAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) - => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Revoked, cancellationToken); -} -} -namespace MagicSettings.Server -{ -public sealed class MagicCredentialRotationService -{ - private readonly IMagicCredentialRegistry _credentials; - - public MagicCredentialRotationService(IMagicCredentialRegistry credentials) => _credentials = credentials; - - public async ValueTask ApplyAsync( - MagicIdentityContinuityProof continuity, - bool autoApproveNewCredential, - CancellationToken cancellationToken = default) - { - if (!MagicNodeProofVerifier.VerifyContinuity(continuity)) - { - return MagicProofVerificationResult.Invalid("The identity continuity proof is invalid."); - } - - var previous = await _credentials.FindAsync( - continuity.PreviousIdentity.NodeId, - continuity.PreviousIdentity.CredentialId, - cancellationToken); - if (previous is null) - { - return MagicProofVerificationResult.Invalid("The previous credential is unknown."); - } - - if (previous.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) - { - return MagicProofVerificationResult.Invalid("The previous credential is not authorized to rotate."); - } - - if (!string.Equals(previous.PublicKey, continuity.PreviousIdentity.PublicKey, StringComparison.Ordinal)) - { - return MagicProofVerificationResult.Invalid("The continuity proof does not match the registered previous key."); - } - - await _credentials.UpsertAsync(previous with - { - Status = MagicCredentialStatus.Retiring, - UpdatedUtc = DateTimeOffset.UtcNow - }, cancellationToken); - - await _credentials.UpsertAsync(new( - continuity.NewIdentity.NodeId, - continuity.NewIdentity.CredentialId, - continuity.NewIdentity.PublicKey, - autoApproveNewCredential ? MagicCredentialStatus.Approved : MagicCredentialStatus.Pending, - DateTimeOffset.UtcNow), cancellationToken); - - return MagicProofVerificationResult.Valid; - } -} -} -namespace MagicSettings.Server -{ -public sealed class InMemoryMagicCredentialRegistry : IMagicCredentialRegistry -{ - private readonly ConcurrentDictionary<(Guid NodeId, Guid CredentialId), MagicRegisteredCredential> _credentials = new(); - - public ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) - => ValueTask.FromResult(_credentials.TryGetValue((nodeId, credentialId), out var credential) ? credential : null); - - public ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default) - { - _credentials[(credential.NodeId, credential.CredentialId)] = credential; - return ValueTask.CompletedTask; - } -} - -public sealed class InMemoryMagicReplayCache : IMagicReplayCache -{ - private readonly ConcurrentDictionary _nonces = new(StringComparer.Ordinal); - - public ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default) - { - var now = DateTimeOffset.UtcNow; - foreach (var pair in _nonces) - { - if (pair.Value <= now) - { - _nonces.TryRemove(pair.Key, out _); - } - } - - return ValueTask.FromResult(_nonces.TryAdd($"{credentialId:D}:{nonce}", expiresUtc)); - } -} - -public sealed class InMemoryMagicNodeRemoteRecordStore : IMagicNodeRemoteRecordStore -{ - private readonly ConcurrentDictionary<(Guid NodeId, string ApplicationId), MagicNodeRemoteRecord> _records = new(); - - public ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default) - => ValueTask.FromResult(_records.TryGetValue((nodeId, applicationId), out var record) ? record : null); - - public ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default) - { - _records[(record.NodeId, record.ApplicationId)] = record; - return ValueTask.CompletedTask; - } -} -} -namespace MagicSettings.Server -{ -public sealed class MagicNodeProofVerifier -{ - private readonly IMagicCredentialRegistry _credentials; - private readonly IMagicReplayCache _replayCache; - private readonly TimeSpan _allowedClockSkew; - - public MagicNodeProofVerifier(IMagicCredentialRegistry credentials, IMagicReplayCache replayCache, TimeSpan? allowedClockSkew = null) - { - _credentials = credentials; - _replayCache = replayCache; - _allowedClockSkew = allowedClockSkew ?? TimeSpan.FromSeconds(30); - } - - public async ValueTask VerifyAsync(MagicProofVerificationRequest request, CancellationToken cancellationToken = default) - { - var structural = ValidateRequest(request); - if (!structural.IsValid) - { - return structural; - } - - var proof = request.Proof; - var credential = await _credentials.FindAsync(proof.NodeId, proof.CredentialId, cancellationToken); - if (credential is null) - { - return MagicProofVerificationResult.Invalid("The credential is unknown."); - } - - if (credential.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) - { - return MagicProofVerificationResult.Invalid($"The credential is {credential.Status}."); - } - - return await VerifySignatureAndReplayAsync(credential.PublicKey, request, cancellationToken); - } - - public async ValueTask VerifyEnrollmentAsync( - MagicNodeIdentityDescriptor identity, - MagicProofVerificationRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(identity); - var structural = ValidateRequest(request); - if (!structural.IsValid) - { - return structural; - } - - if (request.Proof.NodeId != identity.NodeId || request.Proof.CredentialId != identity.CredentialId) - { - return MagicProofVerificationResult.Invalid("The proof does not match the supplied node identity."); - } - - if (identity.CredentialKind != MagicCredentialKind.EcdsaP256 - || !string.Equals(identity.SignatureAlgorithm, "ECDSA_P256_SHA256_P1363", StringComparison.Ordinal)) - { - return MagicProofVerificationResult.Invalid("The supplied credential algorithm is unsupported."); - } - - string fingerprint; - try - { - fingerprint = Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(identity.PublicKey))).ToLowerInvariant(); - } - catch (FormatException) - { - return MagicProofVerificationResult.Invalid("The supplied public key is malformed."); - } - - if (!CryptographicOperations.FixedTimeEquals( - Encoding.ASCII.GetBytes(fingerprint), - Encoding.ASCII.GetBytes(identity.Fingerprint.ToLowerInvariant()))) - { - return MagicProofVerificationResult.Invalid("The supplied public-key fingerprint is invalid."); - } - - return await VerifySignatureAndReplayAsync(identity.PublicKey, request, cancellationToken); - } - - public static bool VerifyContinuity(MagicIdentityContinuityProof proof) - { - var canonical = string.Join("\n", - "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", - proof.PreviousIdentity.NodeId.ToString("D"), - proof.PreviousIdentity.CredentialId.ToString("D"), - proof.NewIdentity.CredentialId.ToString("D"), - proof.NewIdentity.PublicKey, - proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - proof.Nonce); - return VerifySignature(proof.PreviousIdentity.PublicKey, canonical, proof.Signature) - && proof.PreviousIdentity.NodeId == proof.NewIdentity.NodeId; - } - - private MagicProofVerificationResult ValidateRequest(MagicProofVerificationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - var proof = request.Proof; - if (!string.Equals(proof.Version, "MAGICSETTINGS-PROOF-V1", StringComparison.Ordinal)) - return MagicProofVerificationResult.Invalid("Unsupported proof version."); - if (!string.Equals(proof.Audience, request.ExpectedAudience, StringComparison.Ordinal)) - return MagicProofVerificationResult.Invalid("The proof audience does not match this API."); - if (!string.Equals(proof.Method, request.Method, StringComparison.OrdinalIgnoreCase)) - return MagicProofVerificationResult.Invalid("The HTTP method does not match the signed proof."); - if (!string.Equals(proof.Target, NormalizeTarget(request.Uri), StringComparison.Ordinal)) - return MagicProofVerificationResult.Invalid("The request target does not match the signed proof."); - if (!string.Equals(proof.BodySha256, request.BodySha256, StringComparison.OrdinalIgnoreCase)) - return MagicProofVerificationResult.Invalid("The request body hash does not match the signed proof."); - if (proof.IssuedUtc - _allowedClockSkew > request.NowUtc) - return MagicProofVerificationResult.Invalid("The proof was issued in the future."); - if (proof.ExpiresUtc + _allowedClockSkew < request.NowUtc) - return MagicProofVerificationResult.Invalid("The proof has expired."); - if (proof.ExpiresUtc <= proof.IssuedUtc || proof.ExpiresUtc - proof.IssuedUtc > TimeSpan.FromMinutes(5)) - return MagicProofVerificationResult.Invalid("The proof lifetime is invalid."); - if (string.IsNullOrWhiteSpace(proof.Nonce)) - return MagicProofVerificationResult.Invalid("The proof nonce is missing."); - return MagicProofVerificationResult.Valid; - } - - private async ValueTask VerifySignatureAndReplayAsync( - string publicKey, - MagicProofVerificationRequest request, - CancellationToken cancellationToken) - { - var proof = request.Proof; - if (!VerifySignature(publicKey, Canonicalize(proof), proof.Signature)) - { - return MagicProofVerificationResult.Invalid("The proof signature is invalid."); - } - - if (!await _replayCache.TryUseAsync(proof.CredentialId, proof.Nonce, proof.ExpiresUtc, cancellationToken)) - { - return MagicProofVerificationResult.Invalid("The proof nonce has already been used."); - } - - return MagicProofVerificationResult.Valid; - } - - private static bool VerifySignature(string publicKey, string canonical, string signature) - { - try - { - using var key = ECDsa.Create(); - key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); - return key.VerifyData( - Encoding.UTF8.GetBytes(canonical), - Convert.FromBase64String(signature), - HashAlgorithmName.SHA256, - DSASignatureFormat.IeeeP1363FixedFieldConcatenation); - } - catch (CryptographicException) - { - return false; - } - catch (FormatException) - { - return false; - } - } - - private static string Canonicalize(MagicAuthenticationProof proof) - => string.Join("\n", - proof.Version, - proof.NodeId.ToString("D"), - proof.CredentialId.ToString("D"), - proof.Audience, - proof.Method.ToUpperInvariant(), - proof.Target, - proof.BodySha256.ToLowerInvariant(), - proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - proof.Nonce); - - private static string NormalizeTarget(Uri uri) - => uri.IsAbsoluteUri - ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) - : uri.OriginalString; -} -} -namespace MagicSettings.Server -{ -public interface IMagicSecretResolver -{ - ValueTask ResolveAsync( - Guid nodeId, - string name, - CancellationToken cancellationToken = default); -} - -public sealed class MagicSecretService -{ - private readonly MagicNodeProofVerifier _proofVerifier; - private readonly IMagicSecretResolver _resolver; - - public MagicSecretService(MagicNodeProofVerifier proofVerifier, IMagicSecretResolver resolver) - { - _proofVerifier = proofVerifier; - _resolver = resolver; - } - - public async ValueTask ResolveAsync( - MagicSecretRequest request, - string authorityAudience, - Uri requestUri, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - if (request.NodeId != request.Proof.NodeId || request.CredentialId != request.Proof.CredentialId) - { - throw new UnauthorizedAccessException("The secret request identity does not match its proof."); - } - - var verification = await _proofVerifier.VerifyAsync( - new( - request.Proof, - authorityAudience, - "POST", - requestUri, - MagicSecretProof.ComputeBodySha256(request.Name), - DateTimeOffset.UtcNow), - cancellationToken); - if (!verification.IsValid) - { - throw new UnauthorizedAccessException(verification.Error); - } - - return await _resolver.ResolveAsync(request.NodeId, request.Name, cancellationToken); - } -} -} diff --git a/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs b/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs new file mode 100644 index 0000000..3edd1f2 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server; + +public sealed record MagicNodeRemoteRecord( + Guid NodeId, + string ApplicationId, + int SchemaVersion, + string SchemaFingerprint, + long Revision, + MagicRemoteSnapshot Snapshot, + IReadOnlyList PendingReviewItems, + DateTimeOffset LastContactUtc); diff --git a/MagicSettings/MagicSettings.Server/Source.02.cs b/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs similarity index 79% rename from MagicSettings/MagicSettings.Server/Source.02.cs rename to MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs index a937d12..ccf85d0 100644 --- a/MagicSettings/MagicSettings.Server/Source.02.cs +++ b/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs @@ -1,48 +1,11 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. using System.Security.Cryptography; using MagicSettings.Share; using Microsoft.AspNetCore.Http; using System.Collections.Concurrent; using System.Text; -namespace MagicSettings.Server -{ -public sealed record MagicRegisteredCredential( - Guid NodeId, - Guid CredentialId, - string PublicKey, - MagicCredentialStatus Status, - DateTimeOffset UpdatedUtc); - -public interface IMagicCredentialRegistry -{ - ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default); - ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default); -} - -public interface IMagicReplayCache -{ - ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default); -} +namespace MagicSettings.Server; -public sealed record MagicNodeRemoteRecord( - Guid NodeId, - string ApplicationId, - int SchemaVersion, - string SchemaFingerprint, - long Revision, - MagicRemoteSnapshot Snapshot, - IReadOnlyList PendingReviewItems, - DateTimeOffset LastContactUtc); - -public interface IMagicNodeRemoteRecordStore -{ - ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default); - ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default); -} -} -namespace MagicSettings.Server -{ public sealed class MagicSettingsSyncService { private readonly IMagicCredentialRegistry _credentials; @@ -161,4 +124,3 @@ await _credentials.UpsertAsync( return new(MagicControlPlaneState.Active, snapshot); } } -} diff --git a/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs new file mode 100644 index 0000000..d22d8d9 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs @@ -0,0 +1,73 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class EndpointBootstrapTests +{ + [Fact] + public void Resolver_UsesEnvironmentBeforePersistentAndFallback() + { + const string variable = "MAGICSETTINGS_TEST_CONTROL_PLANE"; + var previous = Environment.GetEnvironmentVariable(variable); + try + { + Environment.SetEnvironmentVariable(variable, "https://environment.example/"); + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.EnvironmentVariableName = variable; + options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); + var document = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://file.example/"}}}""")!.AsObject(); + + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, document); + + Assert.Equal(MagicControlPlaneEndpointSource.EnvironmentVariable, resolved.Source); + Assert.Equal("https://environment.example/", resolved.Endpoint!.AbsoluteUri); + } + finally + { + Environment.SetEnvironmentVariable(variable, previous); + } + } + + [Fact] + public void Resolver_UsesPersistentFileWithoutConsultingRemoteState() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.EnvironmentVariableName = "MAGICSETTINGS_TEST_MISSING_ENDPOINT"; + options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; + var local = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://local.example/"}}}""")!.AsObject(); + + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, local); + + Assert.Equal(MagicControlPlaneEndpointSource.PersistentSettings, resolved.Source); + Assert.Equal("https://local.example/", resolved.Endpoint!.AbsoluteUri); + } + + [Fact] + public void Resolver_RejectsNonLoopbackHttpUnlessExplicitlyAllowed() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("http://control.example/"); + + Assert.Throws(() => + new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject())); + + options.ControlPlane.Bootstrap.AllowInsecureHttp = true; + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject()); + Assert.Equal("http://control.example/", resolved.Endpoint!.AbsoluteUri); + } + + [Fact] + public void RuntimeOverride_IsHighestPriority() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject(), new Uri("https://runtime.example/")); + Assert.Equal(MagicControlPlaneEndpointSource.RuntimeOverride, resolved.Source); + Assert.Equal("https://runtime.example/", resolved.Endpoint!.AbsoluteUri); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs new file mode 100644 index 0000000..41af14a --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs @@ -0,0 +1,139 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class InitializationTests +{ + [Fact] + public async Task Initialization_GeneratesFileAndPreservesExistingValuesWhileAddingDefaults() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "template-host"; + options.Template.Database.Port = 5432; + }); + + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + document["Database"]!["Host"] = "operator-host"; + document["Database"]!.AsObject().Remove("Port"); + await File.WriteAllTextAsync(path, document.ToJsonString(new() { WriteIndented = true })); + + var secondBuilder = new HostApplicationBuilder(); + await secondBuilder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "new-template-host"; + options.Template.Database.Port = 7777; + }); + + var reconciled = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("operator-host", reconciled["Database"]!["Host"]!.GetValue()); + Assert.Equal(7777, reconciled["Database"]!["Port"]!.GetValue()); + } + + [Fact] + public async Task RemoteSnapshot_OverridesEnvironmentAndFileWithoutPersisting() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var transport = new FakeTransport("Database:Host", MagicRemoteValue.From("remote-host")); + var previous = Environment.GetEnvironmentVariable("MagicSettings__Database__Host"); + Environment.SetEnvironmentVariable("MagicSettings__Database__Host", "environment-host"); + try + { + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "file-host"; + options.ControlPlaneTransport = transport; + }); + await using var provider = builder.Services.BuildServiceProvider(); + var controlPlane = provider.GetRequiredService(); + await controlPlane.ConfigureAsync(new Uri("https://control.example/"), MagicControlPlaneTrust.SystemTls("Control.Api")); + var settings = provider.GetRequiredService>(); + + Assert.Equal("remote-host", settings.Current.Database.Host); + var file = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("file-host", file["Database"]!["Host"]!.GetValue()); + } + finally + { + Environment.SetEnvironmentVariable("MagicSettings__Database__Host", previous); + } + } + + [Fact] + public async Task RemoteSnapshot_CannotOverrideControlPlaneBootstrapEndpoint() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var transport = new FakeTransport( + "MagicSettings:ControlPlane:Endpoint", + MagicRemoteValue.From("https://attacker.example/")); + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.MagicSettings.ControlPlane.Endpoint = "https://trusted-local.example/"; + options.ControlPlaneTransport = transport; + }); + await using var provider = builder.Services.BuildServiceProvider(); + await provider.GetRequiredService().ConfigureAsync( + new Uri("https://control.example/"), + MagicControlPlaneTrust.SystemTls("Control.Api")); + + var settings = provider.GetRequiredService>(); + Assert.Equal("https://trusted-local.example/", settings.Current.MagicSettings.ControlPlane.Endpoint); + } + + [Fact] + public async Task DevelopmentRequiresExplicitArrayPolicy() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var first = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); + await first.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Environment = "Development"; + options.Template.Origins = ["https://one.example"]; + }); + + var second = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); + await Assert.ThrowsAsync(async () => + await second.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Environment = "Development"; + options.Template.Origins = ["https://one.example", "https://two.example"]; + })); + } + + private sealed class FakeTransport : IMagicControlPlaneTransport + { + private readonly string _path; + private readonly MagicRemoteValue _value; + public FakeTransport(string path, MagicRemoteValue value) + { + _path = path; + _value = value; + } + + public ValueTask SynchronizeAsync(Uri endpoint, MagicControlPlaneTrust trust, MagicSettingsSyncRequest request, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new MagicSettingsSyncResponse( + MagicControlPlaneState.Active, + new MagicRemoteSnapshot(1, DateTimeOffset.UtcNow, new Dictionary + { + [_path] = _value + }))); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs new file mode 100644 index 0000000..d4889a8 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs @@ -0,0 +1,74 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class MigrationTests +{ + [Fact] + public async Task SequentialMigrationRenamesAndTransformsWithoutDeletingRemoteHistory() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + await File.WriteAllTextAsync(path, """ + { + "Database": { + "Server": "old-host", + "Port": "7443" + }, + "$magicSettings": { + "schemaVersion": 1 + } + } + """); + + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.SchemaVersion = 2; + options.Migrations.Add(new VersionOneToTwo()); + }); + + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("old-host", document["Database"]!["Host"]!.GetValue()); + Assert.Equal(7443, document["Database"]!["Port"]!.GetValue()); + Assert.Equal(2, document["$magicSettings"]!["schemaVersion"]!.GetValue()); + } + + [Fact] + public async Task MissingMigrationFailsStartupInEveryEnvironment() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + await File.WriteAllTextAsync(path, """{"$magicSettings":{"schemaVersion":1}}"""); + var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); + + await Assert.ThrowsAsync(async () => + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.SchemaVersion = 2; + })); + } + + private sealed class VersionOneToTwo : IMagicSettingsMigration + { + public int FromVersion => 1; + public int ToVersion => 2; + + public void Apply(JsonObject document, MagicMigrationContext context) + { + context.Rename(document, "Database:Server", "Database:Host"); + context.Transform( + document, + "Database:Port", + node => JsonValue.Create(int.Parse(node!.GetValue(), System.Globalization.CultureInfo.InvariantCulture)), + "Convert port from string to integer.", + remoteSafeProjection: false); + } + } +} diff --git a/MagicSettings/MagicSettings.Tests/GlobalUsings.cs b/MagicSettings/MagicSettings.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs new file mode 100644 index 0000000..927b57a --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs @@ -0,0 +1,32 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "MagicSettings.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs new file mode 100644 index 0000000..a7016e4 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs @@ -0,0 +1,13 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class TestApplication +{ + public string Name { get; set; } = "Test"; + public bool FeatureEnabled { get; set; } +} diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs new file mode 100644 index 0000000..8382bd6 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs @@ -0,0 +1,12 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class TestControlPlane +{ + public TestControlPlaneSection ControlPlane { get; set; } = new(); +} diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs new file mode 100644 index 0000000..5fdbe7f --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs @@ -0,0 +1,12 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class TestControlPlaneSection +{ + public string? Endpoint { get; set; } +} diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs new file mode 100644 index 0000000..4a43c6b --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs @@ -0,0 +1,14 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class TestDatabase +{ + public string Host { get; set; } = "localhost"; + public string Password { get; set; } = "change-me"; + public int Port { get; set; } = 5432; +} diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs new file mode 100644 index 0000000..8992455 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs @@ -0,0 +1,15 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class TestSettings +{ + public TestApplication Application { get; set; } = new(); + public TestDatabase Database { get; set; } = new(); + public TestControlPlane MagicSettings { get; set; } = new(); + public List Origins { get; set; } = new(); +} diff --git a/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs b/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs new file mode 100644 index 0000000..fef5336 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs @@ -0,0 +1,30 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class CredentialAdministrationTests +{ + [Fact] + public async Task RevokedCredentialCanNoLongerAuthenticate() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var administration = new MagicCredentialAdministrationService(registry); + Assert.True(await administration.RevokeAsync(identity.NodeId, identity.CredentialId)); + + var uri = new Uri("https://api.example/resource"); + var proof = await manager.CreateProofAsync(new("Resource.Api", "GET", uri, MagicHash.EmptySha256)); + var result = await new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()).VerifyAsync( + new(proof, "Resource.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + + Assert.False(result.IsValid); + Assert.Equal("The credential is Revoked.", result.Error); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs b/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs new file mode 100644 index 0000000..8f9bb52 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs @@ -0,0 +1,57 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class CredentialRotationTests +{ + [Fact] + public async Task ServerCanApproveRotatedCredentialWithoutReceivingPrivateKey() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var initial = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var rotation = await manager.RotateAsync("scheduled"); + + var result = await new MagicCredentialRotationService(registry).ApplyAsync(rotation.ContinuityProof!, autoApproveNewCredential: true); + var oldCredential = await registry.FindAsync(initial.NodeId, initial.CredentialId); + var newCredential = await registry.FindAsync(rotation.Current.NodeId, rotation.Current.CredentialId); + + Assert.True(result.IsValid); + Assert.Equal(MagicCredentialStatus.Retiring, oldCredential!.Status); + Assert.Equal(MagicCredentialStatus.Approved, newCredential!.Status); + } + [Fact] + public async Task SyncCanCarryContinuityProofAndAutoApproveRotatedCredential() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var initial = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var change = await manager.RotateAsync("scheduled"); + var records = new InMemoryMagicNodeRemoteRecordStore(); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var service = new MagicSettingsSyncService(registry, records, verifier, autoApproveRotatedCredentials: true); + var uri = new Uri("https://control.example/magicsettings/sync"); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(change.Current, manifest, 0, null, change.ContinuityProof); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync( + new(change.Current, proof, manifest, 0, null, change.ContinuityProof), + "Control.Api", + uri); + + Assert.Equal(MagicControlPlaneState.Active, response.State); + Assert.Equal( + MagicCredentialStatus.Approved, + (await registry.FindAsync(change.Current.NodeId, change.Current.CredentialId))!.Status); + } + +} diff --git a/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs b/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs new file mode 100644 index 0000000..17a3d5b --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs @@ -0,0 +1,56 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class EnrollmentTests +{ + [Fact] + public async Task UnknownNodeMustProvePossessionBeforeBeingRegisteredAsPending() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + var service = new MagicSettingsSyncService( + registry, + new InMemoryMagicNodeRemoteRecordStore(), + new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); + var uri = new Uri("https://control.example/magicsettings/sync"); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, null); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0), "Control.Api", uri); + var registered = await registry.FindAsync(identity.NodeId, identity.CredentialId); + + Assert.Equal(MagicControlPlaneState.PendingApproval, response.State); + Assert.Equal(MagicCredentialStatus.Pending, registered!.Status); + } + + [Fact] + public async Task UnknownNodeWithTamperedManifestIsRejected() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + var service = new MagicSettingsSyncService( + registry, + new InMemoryMagicNodeRemoteRecordStore(), + new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); + var uri = new Uri("https://control.example/magicsettings/sync"); + var signedManifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint-a", []); + var sentManifest = signedManifest with { SchemaFingerprint = "fingerprint-b" }; + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, signedManifest, 0, null); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, sentManifest, 0), "Control.Api", uri); + + Assert.Equal(MagicControlPlaneState.Faulted, response.State); + Assert.Null(await registry.FindAsync(identity.NodeId, identity.CredentialId)); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs b/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs new file mode 100644 index 0000000..ce42761 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs @@ -0,0 +1,57 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class IdentityTests +{ + [Fact] + public async Task Identity_IsStableAcrossManagerInstances() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "identity.json"); + var first = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); + var firstIdentity = await first.GetCurrentAsync(); + var second = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); + var secondIdentity = await second.GetCurrentAsync(); + + Assert.Equal(firstIdentity.NodeId, secondIdentity.NodeId); + Assert.Equal(firstIdentity.CredentialId, secondIdentity.CredentialId); + Assert.Equal(firstIdentity.Fingerprint, secondIdentity.Fingerprint); + } + + [Fact] + public async Task Rotation_KeepsNodeAndProducesVerifiableContinuityProof() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var before = await manager.GetCurrentAsync(); + + var change = await manager.RotateAsync("scheduled rotation"); + + Assert.Equal(MagicIdentityChangeKind.Rotated, change.Kind); + Assert.Equal(before.NodeId, change.Current.NodeId); + Assert.NotEqual(before.CredentialId, change.Current.CredentialId); + Assert.NotNull(change.ContinuityProof); + Assert.True(MagicNodeProofVerifier.VerifyContinuity(change.ContinuityProof!)); + } + + [Fact] + public async Task Reset_ChangesNodeAndRequiresExplicitConfirmation() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var before = await manager.GetCurrentAsync(); + + await Assert.ThrowsAsync(async () => + await manager.ResetAsync(new("test", false))); + + var change = await manager.ResetAsync(new("compromised", true)); + Assert.Equal(MagicIdentityChangeKind.Reset, change.Kind); + Assert.NotEqual(before.NodeId, change.Current.NodeId); + Assert.Null(change.ContinuityProof); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs b/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs new file mode 100644 index 0000000..4ea2fbd --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs @@ -0,0 +1,47 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class ProofTests +{ + [Fact] + public async Task Proof_IsAudienceMethodTargetAndBodyBound_AndCannotReplay() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var uri = new Uri("https://api.example/v1/items?x=1"); + var bodyHash = MagicHash.Sha256Hex("hello"u8); + var proof = await manager.CreateProofAsync(new("Items.Api", "POST", uri, bodyHash)); + var request = new MagicProofVerificationRequest(proof, "Items.Api", "POST", uri, bodyHash, DateTimeOffset.UtcNow); + + Assert.True((await verifier.VerifyAsync(request)).IsValid); + Assert.Equal("The proof nonce has already been used.", (await verifier.VerifyAsync(request)).Error); + } + + [Fact] + public async Task Proof_RejectsWrongAudienceBeforeNonceConsumption() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var uri = new Uri("https://api.example/v1/items"); + var proof = await manager.CreateProofAsync(new("Items.Api", "GET", uri, MagicHash.EmptySha256)); + + var wrong = await verifier.VerifyAsync(new(proof, "Admin.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + var correct = await verifier.VerifyAsync(new(proof, "Items.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + + Assert.False(wrong.IsValid); + Assert.True(correct.IsValid); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs b/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs new file mode 100644 index 0000000..e2bfde4 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs @@ -0,0 +1,37 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class SecretTests +{ + [Fact] + public async Task SecretRequestUsesFreshBoundNodeProof() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var resolver = new FakeSecretResolver(); + var service = new MagicSecretService(verifier, resolver); + var uri = new Uri("https://control.example/magicsettings/secrets/resolve"); + const string name = "Database:Password"; + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, MagicSecretProof.ComputeBodySha256(name))); + + var response = await service.ResolveAsync(new(identity.NodeId, identity.CredentialId, name, proof), "Control.Api", uri); + + Assert.True(response.Found); + Assert.Equal("secret-value", response.Value); + } + + private sealed class FakeSecretResolver : IMagicSecretResolver + { + public ValueTask ResolveAsync(Guid nodeId, string name, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new MagicSecretResponse(true, "secret-value")); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs b/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs new file mode 100644 index 0000000..ef0db9d --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs @@ -0,0 +1,38 @@ +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests; + +public sealed class ServerSafetyTests +{ + [Fact] + public async Task SyncService_RetainsDestructiveMigrationAsManualReview() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var records = new InMemoryMagicNodeRemoteRecordStore(); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var service = new MagicSettingsSyncService(registry, records, verifier); + var uri = new Uri("https://control.example/magicsettings/sync"); + var report = new MagicSettingsMigrationReport(1, 2, [], + [ + new("Secrets:Old", "remove", MagicMigrationReviewSeverity.Destructive, "Client no longer uses this secret.") + ]); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 2, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, report); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0, report), "Control.Api", uri); + var stored = await records.GetAsync(identity.NodeId, "TestApp"); + + Assert.Equal(MagicControlPlaneState.Active, response.State); + Assert.Single(stored!.PendingReviewItems); + Assert.Equal("Secrets:Old", stored.PendingReviewItems[0].Path); + } +} diff --git a/MagicSettings/MagicSettings.Tests/Source.01.cs b/MagicSettings/MagicSettings.Tests/Source.01.cs deleted file mode 100644 index d5fc0ec..0000000 --- a/MagicSettings/MagicSettings.Tests/Source.01.cs +++ /dev/null @@ -1,256 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -global using Xunit; -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace MagicSettings.Tests -{ -public sealed class CredentialAdministrationTests -{ - [Fact] - public async Task RevokedCredentialCanNoLongerAuthenticate() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var administration = new MagicCredentialAdministrationService(registry); - Assert.True(await administration.RevokeAsync(identity.NodeId, identity.CredentialId)); - - var uri = new Uri("https://api.example/resource"); - var proof = await manager.CreateProofAsync(new("Resource.Api", "GET", uri, MagicHash.EmptySha256)); - var result = await new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()).VerifyAsync( - new(proof, "Resource.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); - - Assert.False(result.IsValid); - Assert.Equal("The credential is Revoked.", result.Error); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class CredentialRotationTests -{ - [Fact] - public async Task ServerCanApproveRotatedCredentialWithoutReceivingPrivateKey() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var initial = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var rotation = await manager.RotateAsync("scheduled"); - - var result = await new MagicCredentialRotationService(registry).ApplyAsync(rotation.ContinuityProof!, autoApproveNewCredential: true); - var oldCredential = await registry.FindAsync(initial.NodeId, initial.CredentialId); - var newCredential = await registry.FindAsync(rotation.Current.NodeId, rotation.Current.CredentialId); - - Assert.True(result.IsValid); - Assert.Equal(MagicCredentialStatus.Retiring, oldCredential!.Status); - Assert.Equal(MagicCredentialStatus.Approved, newCredential!.Status); - } - [Fact] - public async Task SyncCanCarryContinuityProofAndAutoApproveRotatedCredential() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var initial = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var change = await manager.RotateAsync("scheduled"); - var records = new InMemoryMagicNodeRemoteRecordStore(); - var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); - var service = new MagicSettingsSyncService(registry, records, verifier, autoApproveRotatedCredentials: true); - var uri = new Uri("https://control.example/magicsettings/sync"); - var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); - var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(change.Current, manifest, 0, null, change.ContinuityProof); - var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); - - var response = await service.SynchronizeAsync( - new(change.Current, proof, manifest, 0, null, change.ContinuityProof), - "Control.Api", - uri); - - Assert.Equal(MagicControlPlaneState.Active, response.State); - Assert.Equal( - MagicCredentialStatus.Approved, - (await registry.FindAsync(change.Current.NodeId, change.Current.CredentialId))!.Status); - } - -} -} -namespace MagicSettings.Tests -{ -public sealed class EndpointBootstrapTests -{ - [Fact] - public void Resolver_UsesEnvironmentBeforePersistentAndFallback() - { - const string variable = "MAGICSETTINGS_TEST_CONTROL_PLANE"; - var previous = Environment.GetEnvironmentVariable(variable); - try - { - Environment.SetEnvironmentVariable(variable, "https://environment.example/"); - var options = new MagicSettingsOptions(); - options.ControlPlane.Bootstrap.EnvironmentVariableName = variable; - options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; - options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); - var document = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://file.example/"}}}""")!.AsObject(); - - var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, document); - - Assert.Equal(MagicControlPlaneEndpointSource.EnvironmentVariable, resolved.Source); - Assert.Equal("https://environment.example/", resolved.Endpoint!.AbsoluteUri); - } - finally - { - Environment.SetEnvironmentVariable(variable, previous); - } - } - - [Fact] - public void Resolver_UsesPersistentFileWithoutConsultingRemoteState() - { - var options = new MagicSettingsOptions(); - options.ControlPlane.Bootstrap.EnvironmentVariableName = "MAGICSETTINGS_TEST_MISSING_ENDPOINT"; - options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; - var local = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://local.example/"}}}""")!.AsObject(); - - var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, local); - - Assert.Equal(MagicControlPlaneEndpointSource.PersistentSettings, resolved.Source); - Assert.Equal("https://local.example/", resolved.Endpoint!.AbsoluteUri); - } - - [Fact] - public void Resolver_RejectsNonLoopbackHttpUnlessExplicitlyAllowed() - { - var options = new MagicSettingsOptions(); - options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("http://control.example/"); - - Assert.Throws(() => - new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject())); - - options.ControlPlane.Bootstrap.AllowInsecureHttp = true; - var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject()); - Assert.Equal("http://control.example/", resolved.Endpoint!.AbsoluteUri); - } - - [Fact] - public void RuntimeOverride_IsHighestPriority() - { - var options = new MagicSettingsOptions(); - options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); - var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject(), new Uri("https://runtime.example/")); - Assert.Equal(MagicControlPlaneEndpointSource.RuntimeOverride, resolved.Source); - Assert.Equal("https://runtime.example/", resolved.Endpoint!.AbsoluteUri); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class EnrollmentTests -{ - [Fact] - public async Task UnknownNodeMustProvePossessionBeforeBeingRegisteredAsPending() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - var service = new MagicSettingsSyncService( - registry, - new InMemoryMagicNodeRemoteRecordStore(), - new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); - var uri = new Uri("https://control.example/magicsettings/sync"); - var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); - var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, null); - var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); - - var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0), "Control.Api", uri); - var registered = await registry.FindAsync(identity.NodeId, identity.CredentialId); - - Assert.Equal(MagicControlPlaneState.PendingApproval, response.State); - Assert.Equal(MagicCredentialStatus.Pending, registered!.Status); - } - - [Fact] - public async Task UnknownNodeWithTamperedManifestIsRejected() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - var service = new MagicSettingsSyncService( - registry, - new InMemoryMagicNodeRemoteRecordStore(), - new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); - var uri = new Uri("https://control.example/magicsettings/sync"); - var signedManifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint-a", []); - var sentManifest = signedManifest with { SchemaFingerprint = "fingerprint-b" }; - var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, signedManifest, 0, null); - var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); - - var response = await service.SynchronizeAsync(new(identity, proof, sentManifest, 0), "Control.Api", uri); - - Assert.Equal(MagicControlPlaneState.Faulted, response.State); - Assert.Null(await registry.FindAsync(identity.NodeId, identity.CredentialId)); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class IdentityTests -{ - [Fact] - public async Task Identity_IsStableAcrossManagerInstances() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "identity.json"); - var first = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); - var firstIdentity = await first.GetCurrentAsync(); - var second = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); - var secondIdentity = await second.GetCurrentAsync(); - - Assert.Equal(firstIdentity.NodeId, secondIdentity.NodeId); - Assert.Equal(firstIdentity.CredentialId, secondIdentity.CredentialId); - Assert.Equal(firstIdentity.Fingerprint, secondIdentity.Fingerprint); - } - - [Fact] - public async Task Rotation_KeepsNodeAndProducesVerifiableContinuityProof() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var before = await manager.GetCurrentAsync(); - - var change = await manager.RotateAsync("scheduled rotation"); - - Assert.Equal(MagicIdentityChangeKind.Rotated, change.Kind); - Assert.Equal(before.NodeId, change.Current.NodeId); - Assert.NotEqual(before.CredentialId, change.Current.CredentialId); - Assert.NotNull(change.ContinuityProof); - Assert.True(MagicNodeProofVerifier.VerifyContinuity(change.ContinuityProof!)); - } - - [Fact] - public async Task Reset_ChangesNodeAndRequiresExplicitConfirmation() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var before = await manager.GetCurrentAsync(); - - await Assert.ThrowsAsync(async () => - await manager.ResetAsync(new("test", false))); - - var change = await manager.ResetAsync(new("compromised", true)); - Assert.Equal(MagicIdentityChangeKind.Reset, change.Kind); - Assert.NotEqual(before.NodeId, change.Current.NodeId); - Assert.Null(change.ContinuityProof); - } -} -} diff --git a/MagicSettings/MagicSettings.Tests/Source.02.cs b/MagicSettings/MagicSettings.Tests/Source.02.cs deleted file mode 100644 index fc75ce7..0000000 --- a/MagicSettings/MagicSettings.Tests/Source.02.cs +++ /dev/null @@ -1,378 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -global using Xunit; -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace MagicSettings.Tests -{ -public sealed class InitializationTests -{ - [Fact] - public async Task Initialization_GeneratesFileAndPreservesExistingValuesWhileAddingDefaults() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - var builder = new HostApplicationBuilder(); - await builder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Template.Database.Host = "template-host"; - options.Template.Database.Port = 5432; - }); - - var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); - document["Database"]!["Host"] = "operator-host"; - document["Database"]!.AsObject().Remove("Port"); - await File.WriteAllTextAsync(path, document.ToJsonString(new() { WriteIndented = true })); - - var secondBuilder = new HostApplicationBuilder(); - await secondBuilder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Template.Database.Host = "new-template-host"; - options.Template.Database.Port = 7777; - }); - - var reconciled = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); - Assert.Equal("operator-host", reconciled["Database"]!["Host"]!.GetValue()); - Assert.Equal(7777, reconciled["Database"]!["Port"]!.GetValue()); - } - - [Fact] - public async Task RemoteSnapshot_OverridesEnvironmentAndFileWithoutPersisting() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - var transport = new FakeTransport("Database:Host", MagicRemoteValue.From("remote-host")); - var previous = Environment.GetEnvironmentVariable("MagicSettings__Database__Host"); - Environment.SetEnvironmentVariable("MagicSettings__Database__Host", "environment-host"); - try - { - var builder = new HostApplicationBuilder(); - await builder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Template.Database.Host = "file-host"; - options.ControlPlaneTransport = transport; - }); - await using var provider = builder.Services.BuildServiceProvider(); - var controlPlane = provider.GetRequiredService(); - await controlPlane.ConfigureAsync(new Uri("https://control.example/"), MagicControlPlaneTrust.SystemTls("Control.Api")); - var settings = provider.GetRequiredService>(); - - Assert.Equal("remote-host", settings.Current.Database.Host); - var file = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); - Assert.Equal("file-host", file["Database"]!["Host"]!.GetValue()); - } - finally - { - Environment.SetEnvironmentVariable("MagicSettings__Database__Host", previous); - } - } - - [Fact] - public async Task RemoteSnapshot_CannotOverrideControlPlaneBootstrapEndpoint() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - var transport = new FakeTransport( - "MagicSettings:ControlPlane:Endpoint", - MagicRemoteValue.From("https://attacker.example/")); - var builder = new HostApplicationBuilder(); - await builder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Template.MagicSettings.ControlPlane.Endpoint = "https://trusted-local.example/"; - options.ControlPlaneTransport = transport; - }); - await using var provider = builder.Services.BuildServiceProvider(); - await provider.GetRequiredService().ConfigureAsync( - new Uri("https://control.example/"), - MagicControlPlaneTrust.SystemTls("Control.Api")); - - var settings = provider.GetRequiredService>(); - Assert.Equal("https://trusted-local.example/", settings.Current.MagicSettings.ControlPlane.Endpoint); - } - - [Fact] - public async Task DevelopmentRequiresExplicitArrayPolicy() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - var first = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); - await first.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Environment = "Development"; - options.Template.Origins = ["https://one.example"]; - }); - - var second = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); - await Assert.ThrowsAsync(async () => - await second.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.Environment = "Development"; - options.Template.Origins = ["https://one.example", "https://two.example"]; - })); - } - - private sealed class FakeTransport : IMagicControlPlaneTransport - { - private readonly string _path; - private readonly MagicRemoteValue _value; - public FakeTransport(string path, MagicRemoteValue value) - { - _path = path; - _value = value; - } - - public ValueTask SynchronizeAsync(Uri endpoint, MagicControlPlaneTrust trust, MagicSettingsSyncRequest request, CancellationToken cancellationToken = default) - => ValueTask.FromResult(new MagicSettingsSyncResponse( - MagicControlPlaneState.Active, - new MagicRemoteSnapshot(1, DateTimeOffset.UtcNow, new Dictionary - { - [_path] = _value - }))); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class MigrationTests -{ - [Fact] - public async Task SequentialMigrationRenamesAndTransformsWithoutDeletingRemoteHistory() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - await File.WriteAllTextAsync(path, """ - { - "Database": { - "Server": "old-host", - "Port": "7443" - }, - "$magicSettings": { - "schemaVersion": 1 - } - } - """); - - var builder = new HostApplicationBuilder(); - await builder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.SchemaVersion = 2; - options.Migrations.Add(new VersionOneToTwo()); - }); - - var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); - Assert.Equal("old-host", document["Database"]!["Host"]!.GetValue()); - Assert.Equal(7443, document["Database"]!["Port"]!.GetValue()); - Assert.Equal(2, document["$magicSettings"]!["schemaVersion"]!.GetValue()); - } - - [Fact] - public async Task MissingMigrationFailsStartupInEveryEnvironment() - { - using var directory = new TemporaryDirectory(); - var path = Path.Combine(directory.Path, "settings.json"); - await File.WriteAllTextAsync(path, """{"$magicSettings":{"schemaVersion":1}}"""); - var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); - - await Assert.ThrowsAsync(async () => - await builder.AddMagicSettingsAsync(configure: options => - { - options.Path = path; - options.SchemaVersion = 2; - })); - } - - private sealed class VersionOneToTwo : IMagicSettingsMigration - { - public int FromVersion => 1; - public int ToVersion => 2; - - public void Apply(JsonObject document, MagicMigrationContext context) - { - context.Rename(document, "Database:Server", "Database:Host"); - context.Transform( - document, - "Database:Port", - node => JsonValue.Create(int.Parse(node!.GetValue(), System.Globalization.CultureInfo.InvariantCulture)), - "Convert port from string to integer.", - remoteSafeProjection: false); - } - } -} -} -namespace MagicSettings.Tests -{ -public sealed class ProofTests -{ - [Fact] - public async Task Proof_IsAudienceMethodTargetAndBodyBound_AndCannotReplay() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); - var uri = new Uri("https://api.example/v1/items?x=1"); - var bodyHash = MagicHash.Sha256Hex("hello"u8); - var proof = await manager.CreateProofAsync(new("Items.Api", "POST", uri, bodyHash)); - var request = new MagicProofVerificationRequest(proof, "Items.Api", "POST", uri, bodyHash, DateTimeOffset.UtcNow); - - Assert.True((await verifier.VerifyAsync(request)).IsValid); - Assert.Equal("The proof nonce has already been used.", (await verifier.VerifyAsync(request)).Error); - } - - [Fact] - public async Task Proof_RejectsWrongAudienceBeforeNonceConsumption() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); - var uri = new Uri("https://api.example/v1/items"); - var proof = await manager.CreateProofAsync(new("Items.Api", "GET", uri, MagicHash.EmptySha256)); - - var wrong = await verifier.VerifyAsync(new(proof, "Admin.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); - var correct = await verifier.VerifyAsync(new(proof, "Items.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); - - Assert.False(wrong.IsValid); - Assert.True(correct.IsValid); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class SecretTests -{ - [Fact] - public async Task SecretRequestUsesFreshBoundNodeProof() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); - var resolver = new FakeSecretResolver(); - var service = new MagicSecretService(verifier, resolver); - var uri = new Uri("https://control.example/magicsettings/secrets/resolve"); - const string name = "Database:Password"; - var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, MagicSecretProof.ComputeBodySha256(name))); - - var response = await service.ResolveAsync(new(identity.NodeId, identity.CredentialId, name, proof), "Control.Api", uri); - - Assert.True(response.Found); - Assert.Equal("secret-value", response.Value); - } - - private sealed class FakeSecretResolver : IMagicSecretResolver - { - public ValueTask ResolveAsync(Guid nodeId, string name, CancellationToken cancellationToken = default) - => ValueTask.FromResult(new MagicSecretResponse(true, "secret-value")); - } -} -} -namespace MagicSettings.Tests -{ -public sealed class ServerSafetyTests -{ - [Fact] - public async Task SyncService_RetainsDestructiveMigrationAsManualReview() - { - using var directory = new TemporaryDirectory(); - var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); - var identity = await manager.GetCurrentAsync(); - var registry = new InMemoryMagicCredentialRegistry(); - await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); - var records = new InMemoryMagicNodeRemoteRecordStore(); - var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); - var service = new MagicSettingsSyncService(registry, records, verifier); - var uri = new Uri("https://control.example/magicsettings/sync"); - var report = new MagicSettingsMigrationReport(1, 2, [], - [ - new("Secrets:Old", "remove", MagicMigrationReviewSeverity.Destructive, "Client no longer uses this secret.") - ]); - var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 2, "fingerprint", []); - var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, report); - var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); - - var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0, report), "Control.Api", uri); - var stored = await records.GetAsync(identity.NodeId, "TestApp"); - - Assert.Equal(MagicControlPlaneState.Active, response.State); - Assert.Single(stored!.PendingReviewItems); - Assert.Equal("Secrets:Old", stored.PendingReviewItems[0].Path); - } -} -} -namespace MagicSettings.Tests -{ -internal sealed class TemporaryDirectory : IDisposable -{ - public TemporaryDirectory() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "MagicSettings.Tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - try - { - Directory.Delete(Path, recursive: true); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - } -} -} -namespace MagicSettings.Tests -{ -public sealed class TestSettings -{ - public TestApplication Application { get; set; } = new(); - public TestDatabase Database { get; set; } = new(); - public TestControlPlane MagicSettings { get; set; } = new(); - public List Origins { get; set; } = new(); -} - -public sealed class TestApplication -{ - public string Name { get; set; } = "Test"; - public bool FeatureEnabled { get; set; } -} - -public sealed class TestDatabase -{ - public string Host { get; set; } = "localhost"; - public string Password { get; set; } = "change-me"; - public int Port { get; set; } = 5432; -} - -public sealed class TestControlPlane -{ - public TestControlPlaneSection ControlPlane { get; set; } = new(); -} - -public sealed class TestControlPlaneSection -{ - public string? Endpoint { get; set; } -} -} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs new file mode 100644 index 0000000..f9de491 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicControlPlaneEndpointResolver +{ + MagicResolvedControlPlaneEndpoint Resolve( + MagicSettingsOptions options, + JsonObject persistentDocument, + Uri? runtimeOverride = null) where TSettings : class, new(); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs new file mode 100644 index 0000000..bf5a224 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicControlPlaneTransport +{ + ValueTask SynchronizeAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSettingsSyncRequest request, + CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs new file mode 100644 index 0000000..2133ac6 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicNodeAuthenticator +{ + ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default); + ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs new file mode 100644 index 0000000..c76e2a9 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicNodeIdentityManager +{ + event EventHandler? IdentityChanged; + ValueTask GetCurrentAsync(CancellationToken cancellationToken = default); + ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default); + ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs new file mode 100644 index 0000000..40f7be8 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicNodeIdentityStore +{ + ValueTask LoadAsync(CancellationToken cancellationToken = default); + ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default); + ValueTask DeleteAsync(CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs new file mode 100644 index 0000000..f04293c --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSecretProvider +{ + ValueTask> GetAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs new file mode 100644 index 0000000..9798a09 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSecretTransport +{ + ValueTask ResolveSecretAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSecretRequest request, + CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs new file mode 100644 index 0000000..b55220b --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSettings where TSettings : class +{ + TSettings Current { get; } + long Revision { get; } + event EventHandler>? Changed; + MagicSettingExplanation Explain(string path); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs new file mode 100644 index 0000000..24af4f5 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSettingsControlPlane +{ + MagicControlPlaneState State { get; } + MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; } + ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default); + ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default); + ValueTask RefreshAsync(CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs new file mode 100644 index 0000000..27fb82d --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSettingsMigration +{ + int FromVersion { get; } + int ToVersion { get; } + void Apply(JsonObject document, MagicMigrationContext context); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs new file mode 100644 index 0000000..aacbed1 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSettingsSourceProvider +{ + string Name { get; } + int Priority { get; } + ValueTask> LoadAsync(CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs new file mode 100644 index 0000000..85c4799 --- /dev/null +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public interface IMagicSettingsValidator where TSettings : class +{ + ValueTask> ValidateAsync(TSettings settings, CancellationToken cancellationToken = default); +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs b/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs new file mode 100644 index 0000000..fee5254 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public enum MagicArrayMergePolicy +{ + PreserveExisting, + ReplaceWithTemplate, + AppendMissing, + Union +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs b/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs new file mode 100644 index 0000000..231d3ef --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicEnvironmentOverrides +{ + public static Dictionary Read(string prefix) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is not string name || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var path = name[prefix.Length..].Replace("__", ":", StringComparison.Ordinal); + result[path] = entry.Value?.ToString(); + } + + return result; + } +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs b/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs new file mode 100644 index 0000000..a166015 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public enum MagicFailureAction +{ + StopStartup, + WarnAndContinue, + KeepLastKnownGood +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs b/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs new file mode 100644 index 0000000..bbd796a --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicJsonFlattener +{ + public static Dictionary Flatten(JsonObject root) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + Visit(root, null, result); + return result; + } + + private static void Visit(JsonNode? node, string? path, IDictionary result) + { + switch (node) + { + case JsonObject obj: + foreach (var pair in obj) + { + if (pair.Key == "$magicSettings") + { + continue; + } + + Visit(pair.Value, path is null ? pair.Key : $"{path}:{pair.Key}", result); + } + break; + case JsonArray array: + for (var index = 0; index < array.Count; index++) + { + Visit(array[index], $"{path}:{index}", result); + } + break; + case JsonValue value when path is not null: + result[path] = ToConfigurationString(value); + break; + case null when path is not null: + result[path] = null; + break; + } + } + + private static string? ToConfigurationString(JsonValue value) + { + if (value.TryGetValue(out var text)) return text; + if (value.TryGetValue(out var boolean)) return boolean ? "true" : "false"; + if (value.TryGetValue(out var integer)) return integer.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var longInteger)) return longInteger.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var decimalNumber)) return decimalNumber.ToString(CultureInfo.InvariantCulture); + return value.ToJsonString(); + } +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs b/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs new file mode 100644 index 0000000..5e6a506 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicJsonPath +{ + public static bool TryGet(JsonObject root, string path, out JsonNode? value) + { + value = root; + foreach (var segment in Split(path)) + { + if (value is not JsonObject obj || !TryGetProperty(obj, segment, out _, out value)) + { + value = null; + return false; + } + } + + return true; + } + + public static void Set(JsonObject root, string path, JsonNode? value) + { + var segments = Split(path); + if (segments.Length == 0) + { + throw new ArgumentException("A setting path is required.", nameof(path)); + } + + var current = root; + for (var index = 0; index < segments.Length - 1; index++) + { + var segment = segments[index]; + if (!TryGetProperty(current, segment, out var actualName, out var existing) || existing is not JsonObject child) + { + child = new JsonObject(); + current[actualName ?? segment] = child; + } + + current = child; + } + + if (TryGetProperty(current, segments[^1], out var finalName, out _)) + { + current[finalName!] = value?.DeepClone(); + } + else + { + current[segments[^1]] = value?.DeepClone(); + } + } + + public static bool TryTake(JsonObject root, string path, out JsonNode? value) + { + value = null; + var segments = Split(path); + if (segments.Length == 0) + { + return false; + } + + var current = root; + for (var index = 0; index < segments.Length - 1; index++) + { + if (!TryGetProperty(current, segments[index], out _, out var existing) || existing is not JsonObject child) + { + return false; + } + + current = child; + } + + if (!TryGetProperty(current, segments[^1], out var actualName, out value)) + { + return false; + } + + current.Remove(actualName!); + return true; + } + + public static bool TryGetProperty(JsonObject obj, string name, out string? actualName, out JsonNode? value) + { + if (obj.TryGetPropertyValue(name, out value)) + { + actualName = name; + return true; + } + + foreach (var pair in obj) + { + if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + actualName = pair.Key; + value = pair.Value; + return true; + } + } + + actualName = null; + value = null; + return false; + } + + public static string[] Split(string path) + => path.Split(new[] { ':', '.' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs new file mode 100644 index 0000000..2ebb147 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed class MagicSettingsConfigurationProvider : ConfigurationProvider +{ + public void Publish(IReadOnlyDictionary values) + { + Data = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + OnReload(); + } +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs new file mode 100644 index 0000000..c88c6fe --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed class MagicSettingsConfigurationSource : IConfigurationSource +{ + private readonly MagicSettingsConfigurationProvider _provider; + public MagicSettingsConfigurationSource(MagicSettingsConfigurationProvider provider) => _provider = provider; + public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider; +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs new file mode 100644 index 0000000..d5f5707 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed record MagicSettingsDocumentResult(JsonObject Document, MagicSettingsMigrationReport? MigrationReport, bool Changed); diff --git a/MagicSettings/MagicSettings/Source.03.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs similarity index 53% rename from MagicSettings/MagicSettings/Source.03.cs rename to MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs index 4751f52..fbf5604 100644 --- a/MagicSettings/MagicSettings/Source.03.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs @@ -1,4 +1,3 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. using System.Text.Json.Nodes; using MagicSettings.Share; using System.Reflection; @@ -18,9 +17,7 @@ using System.Globalization; using System.ComponentModel.DataAnnotations; -namespace MagicSettings -{ -internal sealed record MagicSettingsDocumentResult(JsonObject Document, MagicSettingsMigrationReport? MigrationReport, bool Changed); +namespace MagicSettings; internal static class MagicSettingsDocumentStore { @@ -179,167 +176,3 @@ private static bool IsDevelopment(string environment) || environment.Equals("Local", StringComparison.OrdinalIgnoreCase) || environment.Equals("Test", StringComparison.OrdinalIgnoreCase); } -} -namespace MagicSettings -{ -internal sealed class MagicSettingsHostedService : BackgroundService where TSettings : class, new() -{ - private readonly MagicSettingsOptions _options; - private readonly MagicSettingsRuntime _runtime; - private readonly MagicSettingsControlPlane _controlPlane; - private readonly ILogger> _logger; - private readonly string _settingsPath; - private DateTime _lastWriteUtc; - private DateTimeOffset _nextRemotePoll; - - public MagicSettingsHostedService( - MagicSettingsOptions options, - MagicSettingsRuntime runtime, - MagicSettingsControlPlane controlPlane, - ILogger> logger, - string settingsPath) - { - _options = options; - _runtime = runtime; - _controlPlane = controlPlane; - _logger = logger; - _settingsPath = settingsPath; - _lastWriteUtc = File.Exists(settingsPath) ? File.GetLastWriteTimeUtc(settingsPath) : DateTime.MinValue; - _nextRemotePoll = DateTimeOffset.UtcNow.Add(options.ControlPlane.PollInterval); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - await _controlPlane.BootstrapAsync(stoppingToken); - } - catch (Exception exception) - { - _logger.LogError(exception, "MagicSettings could not establish the bootstrap control-plane connection. Local configuration remains active."); - } - - using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)); - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - _runtime.RefreshRemoteExpirations(DateTimeOffset.UtcNow); - if (_options.ReloadOnChange) - { - await CheckPersistentFileAsync(stoppingToken); - } - - if (DateTimeOffset.UtcNow >= _nextRemotePoll) - { - await PollRemoteAsync(stoppingToken); - _nextRemotePoll = DateTimeOffset.UtcNow.Add(JitteredPollInterval()); - } - } - } - - private async ValueTask CheckPersistentFileAsync(CancellationToken cancellationToken) - { - if (!File.Exists(_settingsPath)) - { - return; - } - - var currentWrite = File.GetLastWriteTimeUtc(_settingsPath); - if (currentWrite <= _lastWriteUtc) - { - return; - } - - await Task.Delay(_options.ReloadDebounce, cancellationToken); - try - { - await _runtime.ReloadPersistentAsync(cancellationToken); - var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken); - await _controlPlane.ReevaluatePersistentEndpointAsync(document, cancellationToken); - _lastWriteUtc = currentWrite; - } - catch (Exception exception) - { - _logger.LogError(exception, "MagicSettings rejected an invalid runtime reload and retained the last known good snapshot."); - } - } - - private async ValueTask PollRemoteAsync(CancellationToken cancellationToken) - { - try - { - await _controlPlane.RefreshAsync(cancellationToken); - } - catch (Exception exception) - { - _logger.LogWarning(exception, "MagicSettings could not refresh remote overrides. The last known good remote snapshot remains active."); - } - } - - private TimeSpan JitteredPollInterval() - { - var jitter = _options.ControlPlane.PollJitter; - if (jitter <= TimeSpan.Zero) - { - return _options.ControlPlane.PollInterval; - } - - var milliseconds = Random.Shared.NextDouble() * jitter.TotalMilliseconds * 2 - jitter.TotalMilliseconds; - return _options.ControlPlane.PollInterval + TimeSpan.FromMilliseconds(milliseconds); - } -} -} -namespace MagicSettings -{ -public sealed class MagicNodeAuthenticationHandler : DelegatingHandler -{ - private readonly IMagicNodeAuthenticator _authenticator; - private readonly string _audience; - - public MagicNodeAuthenticationHandler(IMagicNodeAuthenticator authenticator, string audience) - { - _authenticator = authenticator; - _audience = audience; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request.RequestUri); - var body = request.Content is null - ? Array.Empty() - : await request.Content.ReadAsByteArrayAsync(cancellationToken); - - if (request.Content is not null) - { - var replacement = new ByteArrayContent(body); - foreach (var header in request.Content.Headers) - { - replacement.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - request.Content = replacement; - } - - var proof = await _authenticator.CreateProofAsync( - new( - _audience, - request.Method.Method, - request.RequestUri, - MagicHash.Sha256Hex(body)), - cancellationToken); - - request.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(proof)); - request.Headers.TryAddWithoutValidation("X-Magic-Node-Id", proof.NodeId.ToString("D")); - request.Headers.TryAddWithoutValidation("X-Magic-Credential-Id", proof.CredentialId.ToString("D")); - return await base.SendAsync(request, cancellationToken); - } -} - -public static class MagicHttpClientBuilderExtensions -{ - public static IHttpClientBuilder AddMagicNodeAuthentication(this IHttpClientBuilder builder, string audience) - { - ArgumentException.ThrowIfNullOrWhiteSpace(audience); - return builder.AddHttpMessageHandler(serviceProvider => - new MagicNodeAuthenticationHandler(serviceProvider.GetRequiredService(), audience)); - } -} -} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs new file mode 100644 index 0000000..16b39ba --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicSettingsEnvironmentResolver +{ + public static string Resolve(string? explicitEnvironment = null, string? hostEnvironment = null) + => FirstNonEmpty( + explicitEnvironment, + Environment.GetEnvironmentVariable("MAGICSETTINGS_ENVIRONMENT"), + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + hostEnvironment, + "Production")!; + + private static string? FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)); +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs new file mode 100644 index 0000000..6643f98 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicSettingsFailurePolicy +{ + public bool StrictDevelopmentMode { get; set; } = true; + public MagicFailureAction AmbiguousArrayInProduction { get; set; } = MagicFailureAction.WarnAndContinue; + public MagicFailureAction RuntimeReloadFailure { get; set; } = MagicFailureAction.KeepLastKnownGood; +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs new file mode 100644 index 0000000..63ffbfe --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicSettingsOptions where TSettings : class, new() +{ + public string ApplicationId { get; set; } = typeof(TSettings).Assembly.GetName().Name ?? typeof(TSettings).Name; + public string ApplicationVersion { get; set; } = typeof(TSettings).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + public int SchemaVersion { get; set; } = 1; + public TSettings Template { get; set; } = new(); + public string? Path { get; set; } + public string FileName { get; set; } = "appsettings.json"; + public string EnvironmentOverridePrefix { get; set; } = "MagicSettings__"; + public string Environment { get; set; } = string.Empty; + public bool ReloadOnChange { get; set; } = true; + public TimeSpan ReloadDebounce { get; set; } = TimeSpan.FromMilliseconds(350); + public bool PreserveUnknownProperties { get; set; } = true; + public string IdentityFileName { get; set; } = ".magicsettings.identity.json"; + public string? IdentityPath { get; set; } + public JsonSerializerOptions Json { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; + public MagicSettingsFailurePolicy Failures { get; } = new(); + public MagicControlPlaneOptions ControlPlane { get; } = new(); + public IDictionary> EnvironmentProfiles { get; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + public IList Migrations { get; } = new List(); + public IList> Validators { get; } = new List>(); + public IList Providers { get; } = new List(); + public IDictionary ArrayPolicies { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public ISet SensitivePaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public IMagicControlPlaneTransport? ControlPlaneTransport { get; set; } + public IMagicControlPlaneEndpointResolver? ControlPlaneEndpointResolver { get; set; } + public IMagicNodeIdentityStore? IdentityStore { get; set; } + + public void ConfigureEnvironment(string environment, Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environment); + ArgumentNullException.ThrowIfNull(configure); + EnvironmentProfiles[environment] = configure; + } +} diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs new file mode 100644 index 0000000..f9016d1 --- /dev/null +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicSettingsPathResolver +{ + public static string Resolve(MagicSettingsOptions options) where TSettings : class, new() + { + var configured = options.Path; + if (string.IsNullOrWhiteSpace(configured)) + { + configured = Environment.GetEnvironmentVariable("MAGICSETTINGS_PATH"); + } + + if (string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.FileName)); + } + + var fullPath = Path.GetFullPath(configured); + if (Path.HasExtension(fullPath) && string.Equals(Path.GetExtension(fullPath), ".json", StringComparison.OrdinalIgnoreCase)) + { + return fullPath; + } + + return Path.Combine(fullPath, options.FileName); + } +} diff --git a/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs b/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs new file mode 100644 index 0000000..2c0bc38 --- /dev/null +++ b/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs @@ -0,0 +1,142 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class HttpMagicControlPlaneTransport : IMagicControlPlaneTransport, IMagicSecretTransport, IDisposable +{ + private readonly HttpClient _systemTlsClient; + private readonly ConcurrentDictionary _pinnedClients = new(StringComparer.OrdinalIgnoreCase); + private readonly JsonSerializerOptions _json; + + public HttpMagicControlPlaneTransport(TimeSpan? timeout = null, JsonSerializerOptions? json = null) + { + _systemTlsClient = CreateClient(certificateValidator: null, timeout); + _json = json ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + } + + public async ValueTask SynchronizeAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSettingsSyncRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(trust); + ArgumentNullException.ThrowIfNull(request); + + var client = SelectClient(trust); + + var syncUri = new Uri(endpoint, "magicsettings/sync"); + using var message = new HttpRequestMessage(HttpMethod.Post, syncUri) + { + Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") + }; + message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) + ?? throw new InvalidOperationException("The control plane returned an empty synchronization response."); + } + + + public async ValueTask ResolveSecretAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSecretRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(trust); + ArgumentNullException.ThrowIfNull(request); + + var client = SelectClient(trust); + var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); + using var message = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") + }; + message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) + ?? throw new InvalidOperationException("The control plane returned an empty secret response."); + } + + public void Dispose() + { + _systemTlsClient.Dispose(); + foreach (var client in _pinnedClients.Values) + { + client.Dispose(); + } + } + + private HttpClient SelectClient(MagicControlPlaneTrust trust) + => trust.Mode switch + { + MagicControlPlaneTrustMode.SystemTls => _systemTlsClient, + MagicControlPlaneTrustMode.PinnedPublicKey => GetPinnedClient(trust), + _ => throw new ArgumentOutOfRangeException(nameof(trust), trust.Mode, "Unsupported control-plane trust mode.") + }; + + private HttpClient GetPinnedClient(MagicControlPlaneTrust trust) + { + var expected = NormalizeFingerprint(trust.PinnedPublicKeyFingerprint + ?? throw new InvalidOperationException("PinnedPublicKey trust requires a public-key fingerprint.")); + return _pinnedClients.GetOrAdd(expected, fingerprint => CreateClient( + (_, certificate, _, _) => certificate is not null && FixedTimeEquals(PublicKeyFingerprint(certificate), fingerprint), + _systemTlsClient.Timeout)); + } + + private static HttpClient CreateClient( + Func? certificateValidator, + TimeSpan? timeout) + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false + }; + if (certificateValidator is not null) + { + handler.ServerCertificateCustomValidationCallback = certificateValidator; + } + + return new HttpClient(handler, disposeHandler: true) + { + Timeout = timeout ?? TimeSpan.FromSeconds(30) + }; + } + + private static string PublicKeyFingerprint(X509Certificate2 certificate) + => Convert.ToHexString(SHA256.HashData(certificate.GetPublicKey())).ToLowerInvariant(); + + private static string NormalizeFingerprint(string value) + => value.Replace(":", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant(); + + private static bool FixedTimeEquals(string actual, string expected) + { + var actualBytes = Encoding.ASCII.GetBytes(actual); + var expectedBytes = Encoding.ASCII.GetBytes(expected); + return actualBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); + } +} diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs new file mode 100644 index 0000000..8e95535 --- /dev/null +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicControlPlaneBootstrapOptions +{ + public string EnvironmentVariableName { get; set; } = "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT"; + public string PersistentSettingPath { get; set; } = "MagicSettings:ControlPlane:Endpoint"; + public Uri? CodeFallbackEndpoint { get; set; } + public MagicControlPlaneTrust? Trust { get; set; } + public bool ConnectOnStartup { get; set; } + public bool WatchPersistentEndpoint { get; set; } = true; + public bool AllowInsecureHttp { get; set; } +} diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs new file mode 100644 index 0000000..343c634 --- /dev/null +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicControlPlaneEndpointResolver : IMagicControlPlaneEndpointResolver +{ + public MagicResolvedControlPlaneEndpoint Resolve( + MagicSettingsOptions options, + JsonObject persistentDocument, + Uri? runtimeOverride = null) where TSettings : class, new() + { + if (runtimeOverride is not null) + { + return new(Validate(runtimeOverride, "runtime override", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.RuntimeOverride); + } + + var environmentValue = Environment.GetEnvironmentVariable(options.ControlPlane.Bootstrap.EnvironmentVariableName); + if (!string.IsNullOrWhiteSpace(environmentValue)) + { + return new(ParseAbsolute(environmentValue, options.ControlPlane.Bootstrap.EnvironmentVariableName, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.EnvironmentVariable); + } + + if (MagicJsonPath.TryGet(persistentDocument, options.ControlPlane.Bootstrap.PersistentSettingPath, out var node) + && node is JsonValue value + && value.TryGetValue(out var persistentValue) + && !string.IsNullOrWhiteSpace(persistentValue)) + { + return new(ParseAbsolute(persistentValue, options.ControlPlane.Bootstrap.PersistentSettingPath, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.PersistentSettings); + } + + return options.ControlPlane.Bootstrap.CodeFallbackEndpoint is { } fallback + ? new(Validate(fallback, "code fallback", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.CodeFallback) + : MagicResolvedControlPlaneEndpoint.None; + } + + private static Uri ParseAbsolute(string value, string source, bool allowInsecureHttp) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must be an absolute URI."); + } + + return Validate(endpoint, source, allowInsecureHttp); + } + + internal static Uri Validate(Uri endpoint, string source, bool allowInsecureHttp) + { + if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTP or HTTPS."); + } + + if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !allowInsecureHttp + && !endpoint.IsLoopback) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTPS unless insecure HTTP is explicitly enabled or the endpoint is loopback."); + } + + return endpoint; + } +} diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs new file mode 100644 index 0000000..47a3317 --- /dev/null +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicControlPlaneOptions +{ + public MagicControlPlaneBootstrapOptions Bootstrap { get; } = new(); + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(90); + public TimeSpan PollJitter { get; set; } = TimeSpan.FromSeconds(30); + public bool KeepLastKnownGoodDuringOutage { get; set; } = true; +} diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs b/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs new file mode 100644 index 0000000..00c4ba6 --- /dev/null +++ b/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicManifestBuilder +{ + public static MagicSettingsSchemaManifest Build(MagicSettingsOptions options) where TSettings : class, new() + { + var discovered = new List(); + Visit(typeof(TSettings), string.Empty, discovered, new HashSet()); + var entries = discovered + .Select(item => item with + { + Sensitive = item.Sensitive || options.SensitivePaths.Contains(item.Path), + RemoteOverrideAllowed = item.RemoteOverrideAllowed && !string.Equals( + item.Path, + options.ControlPlane.Bootstrap.PersistentSettingPath, + StringComparison.OrdinalIgnoreCase) + }) + .ToList(); + var canonical = string.Join("\n", entries.OrderBy(static item => item.Path, StringComparer.Ordinal).Select(static item => $"{item.Path}|{item.Type}|{item.Nullable}|{item.Sensitive}|{item.RemoteOverrideAllowed}")); + var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + return new(options.ApplicationId, options.ApplicationVersion, options.SchemaVersion, fingerprint, entries); + } + + private static void Visit(Type type, string path, ICollection entries, ISet stack) + { + if (!stack.Add(type)) + { + return; + } + + try + { + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static property => property.CanRead)) + { + var propertyPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}:{property.Name}"; + var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var sensitive = property.IsDefined(typeof(MagicSensitiveAttribute), inherit: true); + var remoteAllowed = property.GetCustomAttribute(inherit: true)?.Allowed ?? true; + var description = property.GetCustomAttribute(inherit: true)?.Description; + if (IsLeaf(propertyType)) + { + entries.Add(new( + propertyPath, + propertyType.FullName ?? propertyType.Name, + Nullable.GetUnderlyingType(property.PropertyType) is not null || !property.PropertyType.IsValueType, + sensitive, + remoteAllowed, + description)); + } + else if (!typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) || propertyType == typeof(string)) + { + Visit(propertyType, propertyPath, entries, stack); + } + else + { + entries.Add(new(propertyPath, propertyType.FullName ?? propertyType.Name, true, sensitive, remoteAllowed, description)); + } + } + } + finally + { + stack.Remove(type); + } + } + + private static bool IsLeaf(Type type) + => type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(Guid) || type == typeof(Uri) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan); +} diff --git a/MagicSettings/MagicSettings/Source.02.cs b/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs similarity index 58% rename from MagicSettings/MagicSettings/Source.02.cs rename to MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs index a916a75..2d9e13d 100644 --- a/MagicSettings/MagicSettings/Source.02.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs @@ -1,4 +1,3 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. using System.Text.Json.Nodes; using MagicSettings.Share; using System.Reflection; @@ -18,67 +17,7 @@ using System.Globalization; using System.ComponentModel.DataAnnotations; -namespace MagicSettings -{ -public sealed class MagicControlPlaneEndpointResolver : IMagicControlPlaneEndpointResolver -{ - public MagicResolvedControlPlaneEndpoint Resolve( - MagicSettingsOptions options, - JsonObject persistentDocument, - Uri? runtimeOverride = null) where TSettings : class, new() - { - if (runtimeOverride is not null) - { - return new(Validate(runtimeOverride, "runtime override", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.RuntimeOverride); - } - - var environmentValue = Environment.GetEnvironmentVariable(options.ControlPlane.Bootstrap.EnvironmentVariableName); - if (!string.IsNullOrWhiteSpace(environmentValue)) - { - return new(ParseAbsolute(environmentValue, options.ControlPlane.Bootstrap.EnvironmentVariableName, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.EnvironmentVariable); - } - - if (MagicJsonPath.TryGet(persistentDocument, options.ControlPlane.Bootstrap.PersistentSettingPath, out var node) - && node is JsonValue value - && value.TryGetValue(out var persistentValue) - && !string.IsNullOrWhiteSpace(persistentValue)) - { - return new(ParseAbsolute(persistentValue, options.ControlPlane.Bootstrap.PersistentSettingPath, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.PersistentSettings); - } - - return options.ControlPlane.Bootstrap.CodeFallbackEndpoint is { } fallback - ? new(Validate(fallback, "code fallback", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.CodeFallback) - : MagicResolvedControlPlaneEndpoint.None; - } - - private static Uri ParseAbsolute(string value, string source, bool allowInsecureHttp) - { - if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint)) - { - throw new InvalidOperationException($"Control-plane endpoint from '{source}' must be an absolute URI."); - } - - return Validate(endpoint, source, allowInsecureHttp); - } - - internal static Uri Validate(Uri endpoint, string source, bool allowInsecureHttp) - { - if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) - && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTP or HTTPS."); - } - - if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) - && !allowInsecureHttp - && !endpoint.IsLoopback) - { - throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTPS unless insecure HTTP is explicitly enabled or the endpoint is loopback."); - } - - return endpoint; - } -} +namespace MagicSettings; internal sealed class MagicSettingsControlPlane : IMagicSettingsControlPlane where TSettings : class, new() { @@ -309,71 +248,3 @@ private void OnIdentityChanged(object? sender, MagicIdentityChange change) State = MagicControlPlaneState.PendingApproval; } } - -internal static class MagicManifestBuilder -{ - public static MagicSettingsSchemaManifest Build(MagicSettingsOptions options) where TSettings : class, new() - { - var discovered = new List(); - Visit(typeof(TSettings), string.Empty, discovered, new HashSet()); - var entries = discovered - .Select(item => item with - { - Sensitive = item.Sensitive || options.SensitivePaths.Contains(item.Path), - RemoteOverrideAllowed = item.RemoteOverrideAllowed && !string.Equals( - item.Path, - options.ControlPlane.Bootstrap.PersistentSettingPath, - StringComparison.OrdinalIgnoreCase) - }) - .ToList(); - var canonical = string.Join("\n", entries.OrderBy(static item => item.Path, StringComparer.Ordinal).Select(static item => $"{item.Path}|{item.Type}|{item.Nullable}|{item.Sensitive}|{item.RemoteOverrideAllowed}")); - var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); - return new(options.ApplicationId, options.ApplicationVersion, options.SchemaVersion, fingerprint, entries); - } - - private static void Visit(Type type, string path, ICollection entries, ISet stack) - { - if (!stack.Add(type)) - { - return; - } - - try - { - foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static property => property.CanRead)) - { - var propertyPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}:{property.Name}"; - var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - var sensitive = property.IsDefined(typeof(MagicSensitiveAttribute), inherit: true); - var remoteAllowed = property.GetCustomAttribute(inherit: true)?.Allowed ?? true; - var description = property.GetCustomAttribute(inherit: true)?.Description; - if (IsLeaf(propertyType)) - { - entries.Add(new( - propertyPath, - propertyType.FullName ?? propertyType.Name, - Nullable.GetUnderlyingType(property.PropertyType) is not null || !property.PropertyType.IsValueType, - sensitive, - remoteAllowed, - description)); - } - else if (!typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) || propertyType == typeof(string)) - { - Visit(propertyType, propertyPath, entries, stack); - } - else - { - entries.Add(new(propertyPath, propertyType.FullName ?? propertyType.Name, true, sensitive, remoteAllowed, description)); - } - } - } - finally - { - stack.Remove(type); - } - } - - private static bool IsLeaf(Type type) - => type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(Guid) || type == typeof(Uri) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan); -} -} diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs b/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs new file mode 100644 index 0000000..5c9edb3 --- /dev/null +++ b/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicSettingExplanation( + string Path, + string? EffectiveValue, + string EffectiveSource, + IReadOnlyList Sources, + bool IsSensitive); diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs b/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs new file mode 100644 index 0000000..b5320a4 --- /dev/null +++ b/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value); diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs b/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs new file mode 100644 index 0000000..8526a59 --- /dev/null +++ b/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class; diff --git a/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs new file mode 100644 index 0000000..e1e99d9 --- /dev/null +++ b/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicRemoteOverrideAttribute : Attribute +{ + public MagicRemoteOverrideAttribute(bool allowed = true) => Allowed = allowed; + public bool Allowed { get; } +} diff --git a/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs new file mode 100644 index 0000000..0a98ec7 --- /dev/null +++ b/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicSensitiveAttribute : Attribute +{ +} diff --git a/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs new file mode 100644 index 0000000..34f8693 --- /dev/null +++ b/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicSettingDescriptionAttribute : Attribute +{ + public MagicSettingDescriptionAttribute(string description) => Description = description; + public string Description { get; } +} diff --git a/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs b/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs new file mode 100644 index 0000000..2937b8c --- /dev/null +++ b/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicMigrationContext +{ + private readonly List _safeOperations = new(); + private readonly List _reviewItems = new(); + + public IReadOnlyList SafeOperations => _safeOperations; + public IReadOnlyList ReviewItems => _reviewItems; + + public void RecordSafe(string operation) => _safeOperations.Add(operation); + + public void RequireReview(string path, string operation, string reason, string? proposedPath = null, MagicMigrationReviewSeverity severity = MagicMigrationReviewSeverity.Warning) + => _reviewItems.Add(new(path, operation, severity, reason, proposedPath)); + + public void Rename(JsonObject document, string from, string to, bool remoteSafeProjection = true) + { + if (!MagicJsonPath.TryTake(document, from, out var value)) + { + return; + } + + if (MagicJsonPath.TryGet(document, to, out _)) + { + throw new InvalidOperationException($"Cannot rename '{from}' to '{to}' because the destination already exists."); + } + + MagicJsonPath.Set(document, to, value); + RecordSafe($"rename:{from}->{to}"); + if (!remoteSafeProjection) + { + RequireReview(from, "rename", "The remote record should retain the original value until reviewed.", to); + } + } + + public void Transform( + JsonObject document, + string path, + Func transform, + string description, + bool remoteSafeProjection = false) + { + ArgumentNullException.ThrowIfNull(transform); + if (!MagicJsonPath.TryGet(document, path, out var current)) + { + return; + } + + MagicJsonPath.Set(document, path, transform(current?.DeepClone())); + RecordSafe($"transform:{path}:{description}"); + if (!remoteSafeProjection) + { + RequireReview(path, "transform", description); + } + } + + public void SetIfMissing(JsonObject document, string path, JsonNode? value) + { + if (MagicJsonPath.TryGet(document, path, out _)) + { + return; + } + + MagicJsonPath.Set(document, path, value); + RecordSafe($"set-if-missing:{path}"); + } + + public void Remove(JsonObject document, string path, string reason) + { + if (MagicJsonPath.TryTake(document, path, out _)) + { + RecordSafe($"local-remove:{path}"); + RequireReview(path, "remove", reason, severity: MagicMigrationReviewSeverity.Destructive); + } + } +} diff --git a/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs b/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs new file mode 100644 index 0000000..66f683f --- /dev/null +++ b/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicMigrationRunner +{ + public static MagicSettingsMigrationReport? Run(JsonObject document, int currentVersion, int targetVersion, IEnumerable migrations) + { + if (currentVersion > targetVersion) + { + throw new InvalidOperationException($"Downgrading settings from schema {currentVersion} to {targetVersion} is not supported."); + } + + if (currentVersion == targetVersion) + { + return null; + } + + var ordered = migrations.OrderBy(static migration => migration.FromVersion).ToList(); + var context = new MagicMigrationContext(); + var version = currentVersion; + while (version < targetVersion) + { + var migration = ordered.SingleOrDefault(item => item.FromVersion == version) + ?? throw new InvalidOperationException($"No migration exists from schema version {version}."); + if (migration.ToVersion <= version) + { + throw new InvalidOperationException($"Migration {migration.GetType().Name} does not advance the schema version."); + } + + migration.Apply(document, context); + version = migration.ToVersion; + } + + if (version != targetVersion) + { + throw new InvalidOperationException($"Migration chain ended at schema version {version}, expected {targetVersion}."); + } + + return new(currentVersion, targetVersion, context.SafeOperations.ToArray(), context.ReviewItems.ToArray()); + } +} diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs new file mode 100644 index 0000000..512b6ba --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed record MagicSettingsCommandLine(bool GenerateOnly, bool ForceGenerate, bool ValidateOnly, bool PrintPath) +{ + public static MagicSettingsCommandLine Parse(IEnumerable args) + { + var set = args.ToHashSet(StringComparer.OrdinalIgnoreCase); + var force = set.Contains("--magic-settings-force-generate"); + return new( + set.Contains("--magic-settings-generate") || force, + force, + set.Contains("--magic-settings-validate"), + set.Contains("--magic-settings-print-path")); + } +} diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs new file mode 100644 index 0000000..5a27bb0 --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed class MagicSettingsHostedService : BackgroundService where TSettings : class, new() +{ + private readonly MagicSettingsOptions _options; + private readonly MagicSettingsRuntime _runtime; + private readonly MagicSettingsControlPlane _controlPlane; + private readonly ILogger> _logger; + private readonly string _settingsPath; + private DateTime _lastWriteUtc; + private DateTimeOffset _nextRemotePoll; + + public MagicSettingsHostedService( + MagicSettingsOptions options, + MagicSettingsRuntime runtime, + MagicSettingsControlPlane controlPlane, + ILogger> logger, + string settingsPath) + { + _options = options; + _runtime = runtime; + _controlPlane = controlPlane; + _logger = logger; + _settingsPath = settingsPath; + _lastWriteUtc = File.Exists(settingsPath) ? File.GetLastWriteTimeUtc(settingsPath) : DateTime.MinValue; + _nextRemotePoll = DateTimeOffset.UtcNow.Add(options.ControlPlane.PollInterval); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await _controlPlane.BootstrapAsync(stoppingToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "MagicSettings could not establish the bootstrap control-plane connection. Local configuration remains active."); + } + + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _runtime.RefreshRemoteExpirations(DateTimeOffset.UtcNow); + if (_options.ReloadOnChange) + { + await CheckPersistentFileAsync(stoppingToken); + } + + if (DateTimeOffset.UtcNow >= _nextRemotePoll) + { + await PollRemoteAsync(stoppingToken); + _nextRemotePoll = DateTimeOffset.UtcNow.Add(JitteredPollInterval()); + } + } + } + + private async ValueTask CheckPersistentFileAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_settingsPath)) + { + return; + } + + var currentWrite = File.GetLastWriteTimeUtc(_settingsPath); + if (currentWrite <= _lastWriteUtc) + { + return; + } + + await Task.Delay(_options.ReloadDebounce, cancellationToken); + try + { + await _runtime.ReloadPersistentAsync(cancellationToken); + var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken); + await _controlPlane.ReevaluatePersistentEndpointAsync(document, cancellationToken); + _lastWriteUtc = currentWrite; + } + catch (Exception exception) + { + _logger.LogError(exception, "MagicSettings rejected an invalid runtime reload and retained the last known good snapshot."); + } + } + + private async ValueTask PollRemoteAsync(CancellationToken cancellationToken) + { + try + { + await _controlPlane.RefreshAsync(cancellationToken); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "MagicSettings could not refresh remote overrides. The last known good remote snapshot remains active."); + } + } + + private TimeSpan JitteredPollInterval() + { + var jitter = _options.ControlPlane.PollJitter; + if (jitter <= TimeSpan.Zero) + { + return _options.ControlPlane.PollInterval; + } + + var milliseconds = Random.Shared.NextDouble() * jitter.TotalMilliseconds * 2 - jitter.TotalMilliseconds; + return _options.ControlPlane.PollInterval + TimeSpan.FromMilliseconds(milliseconds); + } +} diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs new file mode 100644 index 0000000..471bca8 --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs @@ -0,0 +1,103 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicSettingsInitializationExtensions +{ + public static async ValueTask AddMagicSettingsAsync( + this IHostApplicationBuilder builder, + string[]? args = null, + Action>? configure = null, + CancellationToken cancellationToken = default) + where TSettings : class, new() + { + ArgumentNullException.ThrowIfNull(builder); + var options = new MagicSettingsOptions(); + configure?.Invoke(options); + options.Environment = MagicSettingsEnvironmentResolver.Resolve(options.Environment, builder.Environment.EnvironmentName); + if (options.EnvironmentProfiles.TryGetValue(options.Environment, out var environmentProfile)) + { + environmentProfile(options.Template); + } + + var command = MagicSettingsCommandLine.Parse(args ?? Array.Empty()); + var path = MagicSettingsPathResolver.Resolve(options); + var documentResult = await MagicSettingsDocumentStore.LoadAndReconcileAsync( + path, + options, + command.ForceGenerate, + cancellationToken); + + if (command.PrintPath) + { + Console.Out.WriteLine(path); + } + + var identityPath = MagicIdentityPathResolver.Resolve(path, options.IdentityPath, options.IdentityFileName); + var identityStore = options.IdentityStore ?? new FileMagicNodeIdentityStore(identityPath); + var identityManager = new MagicNodeIdentityManager(identityStore); + await identityManager.GetCurrentAsync(cancellationToken); + + var configurationProvider = new MagicSettingsConfigurationProvider(); + var runtime = new MagicSettingsRuntime(options, configurationProvider, path); + await runtime.InitializeAsync(documentResult.Document, cancellationToken); + + var endpointResolver = options.ControlPlaneEndpointResolver ?? new MagicControlPlaneEndpointResolver(); + var transport = options.ControlPlaneTransport ?? new HttpMagicControlPlaneTransport(); + var controlPlane = new MagicSettingsControlPlane( + options, + runtime, + identityManager, + identityManager, + transport, + endpointResolver, + documentResult.Document, + documentResult.MigrationReport); + + builder.Configuration.Add(new MagicSettingsConfigurationSource(configurationProvider)); + builder.Services.AddSingleton(options); + builder.Services.AddSingleton>(runtime); + builder.Services.AddSingleton(runtime); + builder.Services.AddSingleton(identityManager); + builder.Services.AddSingleton(identityManager); + builder.Services.AddSingleton(controlPlane); + builder.Services.AddSingleton(controlPlane); + builder.Services.AddSingleton(endpointResolver); + builder.Services.AddSingleton(transport); + if (transport is IMagicSecretTransport secretTransport) + { + builder.Services.AddSingleton(secretTransport); + builder.Services.AddSingleton( + new MagicControlPlaneSecretProvider(controlPlane, identityManager, secretTransport, options)); + } + builder.Services.AddSingleton(new MagicSettingsRuntimeRegistration(path)); + builder.Services.AddHostedService(serviceProvider => + new MagicSettingsHostedService( + options, + runtime, + controlPlane, + serviceProvider.GetRequiredService>>(), + path)); + builder.Services.AddOptions().Bind(builder.Configuration); + + var shouldExit = command.GenerateOnly || command.ValidateOnly || command.PrintPath; + return new(shouldExit, 0, path, options.Environment); + } +} diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs new file mode 100644 index 0000000..25b90e8 --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicSettingsInitializationResult(bool ShouldExit, int ExitCode, string SettingsPath, string Environment); diff --git a/MagicSettings/MagicSettings/Source.07.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs similarity index 80% rename from MagicSettings/MagicSettings/Source.07.cs rename to MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs index ba79bd8..63335a0 100644 --- a/MagicSettings/MagicSettings/Source.07.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs @@ -1,4 +1,3 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. using System.Text.Json.Nodes; using MagicSettings.Share; using System.Reflection; @@ -18,23 +17,7 @@ using System.Globalization; using System.ComponentModel.DataAnnotations; -namespace MagicSettings -{ -internal sealed class MagicSettingsConfigurationSource : IConfigurationSource -{ - private readonly MagicSettingsConfigurationProvider _provider; - public MagicSettingsConfigurationSource(MagicSettingsConfigurationProvider provider) => _provider = provider; - public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider; -} - -internal sealed class MagicSettingsConfigurationProvider : ConfigurationProvider -{ - public void Publish(IReadOnlyDictionary values) - { - Data = new Dictionary(values, StringComparer.OrdinalIgnoreCase); - OnReload(); - } -} +namespace MagicSettings; internal sealed class MagicSettingsRuntime : IMagicSettings where TSettings : class, new() { @@ -338,68 +321,3 @@ private static MagicSettingSourceValue SourceValue(string source, IReadOnlyDicti _ => element.GetRawText() }; } -} -namespace MagicSettings -{ -internal sealed class MagicControlPlaneSecretProvider : IMagicSecretProvider where TSettings : class, new() -{ - private readonly MagicSettingsControlPlane _controlPlane; - private readonly IMagicNodeAuthenticator _authenticator; - private readonly IMagicSecretTransport _transport; - private readonly JsonSerializerOptions _json; - - public MagicControlPlaneSecretProvider( - MagicSettingsControlPlane controlPlane, - IMagicNodeAuthenticator authenticator, - IMagicSecretTransport transport, - MagicSettingsOptions options) - { - _controlPlane = controlPlane; - _authenticator = authenticator; - _transport = transport; - _json = options.Json; - } - - public async ValueTask> GetAsync(string name, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - if (!_controlPlane.TryGetActiveConnection(out var endpoint, out var trust)) - { - throw new InvalidOperationException("The MagicSettings control plane is not active."); - } - - var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); - var proof = await _authenticator.CreateProofAsync( - new(trust.AuthorityId, "POST", requestUri, MagicSecretProof.ComputeBodySha256(name)), - cancellationToken); - var identity = await _authenticator.GetCurrentIdentityAsync(cancellationToken); - var response = await _transport.ResolveSecretAsync( - endpoint, - trust, - new(identity.NodeId, identity.CredentialId, name, proof), - cancellationToken); - - if (!response.Found) - { - throw new KeyNotFoundException($"MagicSettings secret '{name}' was not found."); - } - - object? value; - if (typeof(T) == typeof(string)) - { - value = response.Value; - } - else - { - value = response.Value is null ? default(T) : JsonSerializer.Deserialize(response.Value, _json); - } - - if (value is null) - { - throw new InvalidOperationException($"MagicSettings secret '{name}' could not be converted to {typeof(T).FullName}."); - } - - return new MagicSecretLease((T)value, response.ExpiresUtc); - } -} -} diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs new file mode 100644 index 0000000..daf98c2 --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicSettingsRuntimeRegistration(string SettingsPath); diff --git a/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs b/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs new file mode 100644 index 0000000..5fdb0b4 --- /dev/null +++ b/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal sealed class MagicControlPlaneSecretProvider : IMagicSecretProvider where TSettings : class, new() +{ + private readonly MagicSettingsControlPlane _controlPlane; + private readonly IMagicNodeAuthenticator _authenticator; + private readonly IMagicSecretTransport _transport; + private readonly JsonSerializerOptions _json; + + public MagicControlPlaneSecretProvider( + MagicSettingsControlPlane controlPlane, + IMagicNodeAuthenticator authenticator, + IMagicSecretTransport transport, + MagicSettingsOptions options) + { + _controlPlane = controlPlane; + _authenticator = authenticator; + _transport = transport; + _json = options.Json; + } + + public async ValueTask> GetAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (!_controlPlane.TryGetActiveConnection(out var endpoint, out var trust)) + { + throw new InvalidOperationException("The MagicSettings control plane is not active."); + } + + var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); + var proof = await _authenticator.CreateProofAsync( + new(trust.AuthorityId, "POST", requestUri, MagicSecretProof.ComputeBodySha256(name)), + cancellationToken); + var identity = await _authenticator.GetCurrentIdentityAsync(cancellationToken); + var response = await _transport.ResolveSecretAsync( + endpoint, + trust, + new(identity.NodeId, identity.CredentialId, name, proof), + cancellationToken); + + if (!response.Found) + { + throw new KeyNotFoundException($"MagicSettings secret '{name}' was not found."); + } + + object? value; + if (typeof(T) == typeof(string)) + { + value = response.Value; + } + else + { + value = response.Value is null ? default(T) : JsonSerializer.Deserialize(response.Value, _json); + } + + if (value is null) + { + throw new InvalidOperationException($"MagicSettings secret '{name}' could not be converted to {typeof(T).FullName}."); + } + + return new MagicSecretLease((T)value, response.ExpiresUtc); + } +} diff --git a/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs b/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs new file mode 100644 index 0000000..48f18fb --- /dev/null +++ b/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicSecretLease : IAsyncDisposable +{ + public MagicSecretLease(T value, DateTimeOffset? expiresUtc = null) + { + Value = value; + ExpiresUtc = expiresUtc; + } + + public T Value { get; } + public DateTimeOffset? ExpiresUtc { get; } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs b/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs new file mode 100644 index 0000000..e831fb2 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class FileMagicNodeIdentityStore : IMagicNodeIdentityStore +{ + private readonly string _path; + private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public FileMagicNodeIdentityStore(string path) => _path = Path.GetFullPath(path); + + public async ValueTask LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return null; + } + + await using var stream = File.OpenRead(_path); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken); + } + + public async ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory); + var temporary = $"{_path}.{Guid.NewGuid():N}.tmp"; + await using (var stream = File.Create(temporary)) + { + await JsonSerializer.SerializeAsync(stream, identity, _json, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + TryRestrictPermissions(temporary); + File.Move(temporary, _path, overwrite: true); + TryRestrictPermissions(_path); + } + + public ValueTask DeleteAsync(CancellationToken cancellationToken = default) + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + + return ValueTask.CompletedTask; + } + + private static void TryRestrictPermissions(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } +} diff --git a/MagicSettings/MagicSettings/Security/MagicHash.cs b/MagicSettings/MagicSettings/Security/MagicHash.cs new file mode 100644 index 0000000..cece779 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicHash.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicHash +{ + public static string Sha256Hex(ReadOnlySpan value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant(); + public static string EmptySha256 { get; } = Sha256Hex(Array.Empty()); +} diff --git a/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs b/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs new file mode 100644 index 0000000..9d26ca9 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicHttpClientBuilderExtensions +{ + public static IHttpClientBuilder AddMagicNodeAuthentication(this IHttpClientBuilder builder, string audience) + { + ArgumentException.ThrowIfNullOrWhiteSpace(audience); + return builder.AddHttpMessageHandler(serviceProvider => + new MagicNodeAuthenticationHandler(serviceProvider.GetRequiredService(), audience)); + } +} diff --git a/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs b/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs new file mode 100644 index 0000000..8e49a31 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs @@ -0,0 +1,100 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +internal static class MagicIdentityCryptography +{ + public const string ProofVersion = "MAGICSETTINGS-PROOF-V1"; + public const string SignatureAlgorithm = "ECDSA_P256_SHA256_P1363"; + + public static MagicStoredNodeIdentity Create(Guid? nodeId = null) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + return new( + nodeId ?? Guid.NewGuid(), + Guid.NewGuid(), + Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()), + Convert.ToBase64String(key.ExportPkcs8PrivateKey()), + DateTimeOffset.UtcNow); + } + + public static MagicNodeIdentityDescriptor Describe(MagicStoredNodeIdentity identity) + => new( + identity.NodeId, + identity.CredentialId, + MagicCredentialKind.EcdsaP256, + SignatureAlgorithm, + identity.PublicKey, + Fingerprint(identity.PublicKey), + identity.CreatedUtc); + + public static string Sign(MagicStoredNodeIdentity identity, string canonical) + { + using var key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(Convert.FromBase64String(identity.PrivateKey), out _); + var signature = key.SignData( + Encoding.UTF8.GetBytes(canonical), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return Convert.ToBase64String(signature); + } + + public static bool Verify(string publicKey, string canonical, string signature) + { + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); + return key.VerifyData( + Encoding.UTF8.GetBytes(canonical), + Convert.FromBase64String(signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + + public static string Canonicalize(MagicAuthenticationProof proof) + => string.Join('\n', + proof.Version, + proof.NodeId.ToString("D"), + proof.CredentialId.ToString("D"), + proof.Audience, + proof.Method.ToUpperInvariant(), + proof.Target, + proof.BodySha256.ToLowerInvariant(), + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + + public static string CanonicalizeContinuity(MagicNodeIdentityDescriptor previous, MagicNodeIdentityDescriptor current, DateTimeOffset issuedUtc, string nonce) + => string.Join('\n', + "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", + previous.NodeId.ToString("D"), + previous.CredentialId.ToString("D"), + current.CredentialId.ToString("D"), + current.PublicKey, + issuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + nonce); + + public static string NormalizeTarget(Uri uri) + => uri.IsAbsoluteUri + ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) + : uri.OriginalString; + + public static string Fingerprint(string publicKey) + => Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant(); +} diff --git a/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs b/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs new file mode 100644 index 0000000..3f532cf --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public static class MagicIdentityPathResolver +{ + public static string Resolve(string settingsPath, string? configuredPath, string fileName) + { + var configured = string.IsNullOrWhiteSpace(configuredPath) + ? Environment.GetEnvironmentVariable("MAGICSETTINGS_IDENTITY_PATH") + : configuredPath; + if (string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsPath) ?? AppContext.BaseDirectory, fileName)); + } + + var fullPath = Path.GetFullPath(configured); + return Path.HasExtension(fullPath) ? fullPath : Path.Combine(fullPath, fileName); + } +} diff --git a/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs b/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs new file mode 100644 index 0000000..439d291 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicNodeAuthenticationHandler : DelegatingHandler +{ + private readonly IMagicNodeAuthenticator _authenticator; + private readonly string _audience; + + public MagicNodeAuthenticationHandler(IMagicNodeAuthenticator authenticator, string audience) + { + _authenticator = authenticator; + _audience = audience; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.RequestUri); + var body = request.Content is null + ? Array.Empty() + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + + if (request.Content is not null) + { + var replacement = new ByteArrayContent(body); + foreach (var header in request.Content.Headers) + { + replacement.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + request.Content = replacement; + } + + var proof = await _authenticator.CreateProofAsync( + new( + _audience, + request.Method.Method, + request.RequestUri, + MagicHash.Sha256Hex(body)), + cancellationToken); + + request.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(proof)); + request.Headers.TryAddWithoutValidation("X-Magic-Node-Id", proof.NodeId.ToString("D")); + request.Headers.TryAddWithoutValidation("X-Magic-Credential-Id", proof.CredentialId.ToString("D")); + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs b/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs new file mode 100644 index 0000000..d0dd0d7 --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs @@ -0,0 +1,159 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed class MagicNodeIdentityManager : IMagicNodeIdentityManager, IMagicNodeAuthenticator +{ + private readonly IMagicNodeIdentityStore _store; + private readonly SemaphoreSlim _gate = new(1, 1); + private MagicStoredNodeIdentity? _current; + + public MagicNodeIdentityManager(IMagicNodeIdentityStore store) => _store = store; + + public event EventHandler? IdentityChanged; + + public async ValueTask GetCurrentAsync(CancellationToken cancellationToken = default) + => MagicIdentityCryptography.Describe(await GetStoredAsync(cancellationToken)); + + public ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default) + => GetCurrentAsync(cancellationToken); + + public async ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.Audience); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Method); + ArgumentNullException.ThrowIfNull(request.Uri); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BodySha256); + + var identity = await GetStoredAsync(cancellationToken); + var issued = DateTimeOffset.UtcNow; + var validFor = request.ValidFor ?? TimeSpan.FromMinutes(1); + if (validFor <= TimeSpan.Zero || validFor > TimeSpan.FromMinutes(5)) + { + throw new ArgumentOutOfRangeException(nameof(request), "Authentication proofs must be valid for more than zero and no longer than five minutes."); + } + + var unsigned = new MagicAuthenticationProof( + MagicIdentityCryptography.ProofVersion, + identity.NodeId, + identity.CredentialId, + request.Audience, + request.Method.ToUpperInvariant(), + MagicIdentityCryptography.NormalizeTarget(request.Uri), + request.BodySha256.ToLowerInvariant(), + issued, + issued.Add(validFor), + MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)), + string.Empty); + + return unsigned with { Signature = MagicIdentityCryptography.Sign(identity, MagicIdentityCryptography.Canonicalize(unsigned)) }; + } + + public async ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + await _gate.WaitAsync(cancellationToken); + try + { + var previous = await LoadOrCreateUnderLockAsync(cancellationToken); + var next = MagicIdentityCryptography.Create(previous.NodeId); + var previousDescriptor = MagicIdentityCryptography.Describe(previous); + var nextDescriptor = MagicIdentityCryptography.Describe(next); + var issued = DateTimeOffset.UtcNow; + var nonce = MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)); + var canonical = MagicIdentityCryptography.CanonicalizeContinuity(previousDescriptor, nextDescriptor, issued, nonce); + var continuity = new MagicIdentityContinuityProof(previousDescriptor, nextDescriptor, issued, nonce, MagicIdentityCryptography.Sign(previous, canonical)); + await _store.SaveAsync(next, cancellationToken); + _current = next; + var change = new MagicIdentityChange(MagicIdentityChangeKind.Rotated, nextDescriptor, previousDescriptor, continuity, reason); + IdentityChanged?.Invoke(this, change); + return change; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!request.ConfirmDestructiveReset) + { + throw new InvalidOperationException("Identity reset is destructive and requires ConfirmDestructiveReset=true."); + } + + await _gate.WaitAsync(cancellationToken); + try + { + var previous = await _store.LoadAsync(cancellationToken); + var next = MagicIdentityCryptography.Create(); + await _store.SaveAsync(next, cancellationToken); + _current = next; + var change = new MagicIdentityChange( + MagicIdentityChangeKind.Reset, + MagicIdentityCryptography.Describe(next), + previous is null ? null : MagicIdentityCryptography.Describe(previous), + null, + request.Reason); + IdentityChanged?.Invoke(this, change); + return change; + } + finally + { + _gate.Release(); + } + } + + private async ValueTask GetStoredAsync(CancellationToken cancellationToken) + { + if (_current is not null) + { + return _current; + } + + await _gate.WaitAsync(cancellationToken); + try + { + return await LoadOrCreateUnderLockAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + } + + private async ValueTask LoadOrCreateUnderLockAsync(CancellationToken cancellationToken) + { + if (_current is not null) + { + return _current; + } + + _current = await _store.LoadAsync(cancellationToken); + if (_current is null) + { + _current = MagicIdentityCryptography.Create(); + await _store.SaveAsync(_current, cancellationToken); + } + + return _current; + } +} diff --git a/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs b/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs new file mode 100644 index 0000000..1d0468f --- /dev/null +++ b/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings; + +public sealed record MagicStoredNodeIdentity( + Guid NodeId, + Guid CredentialId, + string PublicKey, + string PrivateKey, + DateTimeOffset CreatedUtc); diff --git a/MagicSettings/MagicSettings/Source.01.cs b/MagicSettings/MagicSettings/Source.01.cs deleted file mode 100644 index 73223f4..0000000 --- a/MagicSettings/MagicSettings/Source.01.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings -{ -public interface IMagicSettings where TSettings : class -{ - TSettings Current { get; } - long Revision { get; } - event EventHandler>? Changed; - MagicSettingExplanation Explain(string path); -} - -public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class; - -public sealed record MagicSettingExplanation( - string Path, - string? EffectiveValue, - string EffectiveSource, - IReadOnlyList Sources, - bool IsSensitive); - -public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value); - -public interface IMagicSettingsValidator where TSettings : class -{ - ValueTask> ValidateAsync(TSettings settings, CancellationToken cancellationToken = default); -} - -public interface IMagicSettingsSourceProvider -{ - string Name { get; } - int Priority { get; } - ValueTask> LoadAsync(CancellationToken cancellationToken = default); -} - -public interface IMagicSettingsMigration -{ - int FromVersion { get; } - int ToVersion { get; } - void Apply(JsonObject document, MagicMigrationContext context); -} - -public interface IMagicSecretProvider -{ - ValueTask> GetAsync(string name, CancellationToken cancellationToken = default); -} - -public sealed class MagicSecretLease : IAsyncDisposable -{ - public MagicSecretLease(T value, DateTimeOffset? expiresUtc = null) - { - Value = value; - ExpiresUtc = expiresUtc; - } - - public T Value { get; } - public DateTimeOffset? ExpiresUtc { get; } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} - -public interface IMagicControlPlaneEndpointResolver -{ - MagicResolvedControlPlaneEndpoint Resolve( - MagicSettingsOptions options, - JsonObject persistentDocument, - Uri? runtimeOverride = null) where TSettings : class, new(); -} - -public interface IMagicSettingsControlPlane -{ - MagicControlPlaneState State { get; } - MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; } - ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default); - ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default); - ValueTask RefreshAsync(CancellationToken cancellationToken = default); -} - -public interface IMagicControlPlaneTransport -{ - ValueTask SynchronizeAsync( - Uri endpoint, - MagicControlPlaneTrust trust, - MagicSettingsSyncRequest request, - CancellationToken cancellationToken = default); -} - - -public interface IMagicSecretTransport -{ - ValueTask ResolveSecretAsync( - Uri endpoint, - MagicControlPlaneTrust trust, - MagicSecretRequest request, - CancellationToken cancellationToken = default); -} - -public interface IMagicNodeAuthenticator -{ - ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default); - ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default); -} - -public interface IMagicNodeIdentityManager -{ - event EventHandler? IdentityChanged; - ValueTask GetCurrentAsync(CancellationToken cancellationToken = default); - ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default); - ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default); -} - -public interface IMagicNodeIdentityStore -{ - ValueTask LoadAsync(CancellationToken cancellationToken = default); - ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default); - ValueTask DeleteAsync(CancellationToken cancellationToken = default); -} - -public sealed record MagicStoredNodeIdentity( - Guid NodeId, - Guid CredentialId, - string PublicKey, - string PrivateKey, - DateTimeOffset CreatedUtc); -} -namespace MagicSettings -{ -[AttributeUsage(AttributeTargets.Property)] -public sealed class MagicSensitiveAttribute : Attribute -{ -} - -[AttributeUsage(AttributeTargets.Property)] -public sealed class MagicRemoteOverrideAttribute : Attribute -{ - public MagicRemoteOverrideAttribute(bool allowed = true) => Allowed = allowed; - public bool Allowed { get; } -} - -[AttributeUsage(AttributeTargets.Property)] -public sealed class MagicSettingDescriptionAttribute : Attribute -{ - public MagicSettingDescriptionAttribute(string description) => Description = description; - public string Description { get; } -} -} diff --git a/MagicSettings/MagicSettings/Source.04.cs b/MagicSettings/MagicSettings/Source.04.cs deleted file mode 100644 index 897ebf7..0000000 --- a/MagicSettings/MagicSettings/Source.04.cs +++ /dev/null @@ -1,423 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings -{ -public sealed class HttpMagicControlPlaneTransport : IMagicControlPlaneTransport, IMagicSecretTransport, IDisposable -{ - private readonly HttpClient _systemTlsClient; - private readonly ConcurrentDictionary _pinnedClients = new(StringComparer.OrdinalIgnoreCase); - private readonly JsonSerializerOptions _json; - - public HttpMagicControlPlaneTransport(TimeSpan? timeout = null, JsonSerializerOptions? json = null) - { - _systemTlsClient = CreateClient(certificateValidator: null, timeout); - _json = json ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); - } - - public async ValueTask SynchronizeAsync( - Uri endpoint, - MagicControlPlaneTrust trust, - MagicSettingsSyncRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(trust); - ArgumentNullException.ThrowIfNull(request); - - var client = SelectClient(trust); - - var syncUri = new Uri(endpoint, "magicsettings/sync"); - using var message = new HttpRequestMessage(HttpMethod.Post, syncUri) - { - Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") - }; - message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) - ?? throw new InvalidOperationException("The control plane returned an empty synchronization response."); - } - - - public async ValueTask ResolveSecretAsync( - Uri endpoint, - MagicControlPlaneTrust trust, - MagicSecretRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(trust); - ArgumentNullException.ThrowIfNull(request); - - var client = SelectClient(trust); - var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); - using var message = new HttpRequestMessage(HttpMethod.Post, requestUri) - { - Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") - }; - message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) - ?? throw new InvalidOperationException("The control plane returned an empty secret response."); - } - - public void Dispose() - { - _systemTlsClient.Dispose(); - foreach (var client in _pinnedClients.Values) - { - client.Dispose(); - } - } - - private HttpClient SelectClient(MagicControlPlaneTrust trust) - => trust.Mode switch - { - MagicControlPlaneTrustMode.SystemTls => _systemTlsClient, - MagicControlPlaneTrustMode.PinnedPublicKey => GetPinnedClient(trust), - _ => throw new ArgumentOutOfRangeException(nameof(trust), trust.Mode, "Unsupported control-plane trust mode.") - }; - - private HttpClient GetPinnedClient(MagicControlPlaneTrust trust) - { - var expected = NormalizeFingerprint(trust.PinnedPublicKeyFingerprint - ?? throw new InvalidOperationException("PinnedPublicKey trust requires a public-key fingerprint.")); - return _pinnedClients.GetOrAdd(expected, fingerprint => CreateClient( - (_, certificate, _, _) => certificate is not null && FixedTimeEquals(PublicKeyFingerprint(certificate), fingerprint), - _systemTlsClient.Timeout)); - } - - private static HttpClient CreateClient( - Func? certificateValidator, - TimeSpan? timeout) - { - var handler = new HttpClientHandler - { - AllowAutoRedirect = false - }; - if (certificateValidator is not null) - { - handler.ServerCertificateCustomValidationCallback = certificateValidator; - } - - return new HttpClient(handler, disposeHandler: true) - { - Timeout = timeout ?? TimeSpan.FromSeconds(30) - }; - } - - private static string PublicKeyFingerprint(X509Certificate2 certificate) - => Convert.ToHexString(SHA256.HashData(certificate.GetPublicKey())).ToLowerInvariant(); - - private static string NormalizeFingerprint(string value) - => value.Replace(":", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant(); - - private static bool FixedTimeEquals(string actual, string expected) - { - var actualBytes = Encoding.ASCII.GetBytes(actual); - var expectedBytes = Encoding.ASCII.GetBytes(expected); - return actualBytes.Length == expectedBytes.Length - && CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); - } -} -} -namespace MagicSettings -{ -public sealed class FileMagicNodeIdentityStore : IMagicNodeIdentityStore -{ - private readonly string _path; - private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - - public FileMagicNodeIdentityStore(string path) => _path = Path.GetFullPath(path); - - public async ValueTask LoadAsync(CancellationToken cancellationToken = default) - { - if (!File.Exists(_path)) - { - return null; - } - - await using var stream = File.OpenRead(_path); - return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken); - } - - public async ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default) - { - Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory); - var temporary = $"{_path}.{Guid.NewGuid():N}.tmp"; - await using (var stream = File.Create(temporary)) - { - await JsonSerializer.SerializeAsync(stream, identity, _json, cancellationToken); - await stream.FlushAsync(cancellationToken); - } - - TryRestrictPermissions(temporary); - File.Move(temporary, _path, overwrite: true); - TryRestrictPermissions(_path); - } - - public ValueTask DeleteAsync(CancellationToken cancellationToken = default) - { - if (File.Exists(_path)) - { - File.Delete(_path); - } - - return ValueTask.CompletedTask; - } - - private static void TryRestrictPermissions(string path) - { - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } - } -} - -internal static class MagicIdentityCryptography -{ - public const string ProofVersion = "MAGICSETTINGS-PROOF-V1"; - public const string SignatureAlgorithm = "ECDSA_P256_SHA256_P1363"; - - public static MagicStoredNodeIdentity Create(Guid? nodeId = null) - { - using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); - return new( - nodeId ?? Guid.NewGuid(), - Guid.NewGuid(), - Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()), - Convert.ToBase64String(key.ExportPkcs8PrivateKey()), - DateTimeOffset.UtcNow); - } - - public static MagicNodeIdentityDescriptor Describe(MagicStoredNodeIdentity identity) - => new( - identity.NodeId, - identity.CredentialId, - MagicCredentialKind.EcdsaP256, - SignatureAlgorithm, - identity.PublicKey, - Fingerprint(identity.PublicKey), - identity.CreatedUtc); - - public static string Sign(MagicStoredNodeIdentity identity, string canonical) - { - using var key = ECDsa.Create(); - key.ImportPkcs8PrivateKey(Convert.FromBase64String(identity.PrivateKey), out _); - var signature = key.SignData( - Encoding.UTF8.GetBytes(canonical), - HashAlgorithmName.SHA256, - DSASignatureFormat.IeeeP1363FixedFieldConcatenation); - return Convert.ToBase64String(signature); - } - - public static bool Verify(string publicKey, string canonical, string signature) - { - using var key = ECDsa.Create(); - key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); - return key.VerifyData( - Encoding.UTF8.GetBytes(canonical), - Convert.FromBase64String(signature), - HashAlgorithmName.SHA256, - DSASignatureFormat.IeeeP1363FixedFieldConcatenation); - } - - public static string Canonicalize(MagicAuthenticationProof proof) - => string.Join('\n', - proof.Version, - proof.NodeId.ToString("D"), - proof.CredentialId.ToString("D"), - proof.Audience, - proof.Method.ToUpperInvariant(), - proof.Target, - proof.BodySha256.ToLowerInvariant(), - proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - proof.Nonce); - - public static string CanonicalizeContinuity(MagicNodeIdentityDescriptor previous, MagicNodeIdentityDescriptor current, DateTimeOffset issuedUtc, string nonce) - => string.Join('\n', - "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", - previous.NodeId.ToString("D"), - previous.CredentialId.ToString("D"), - current.CredentialId.ToString("D"), - current.PublicKey, - issuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), - nonce); - - public static string NormalizeTarget(Uri uri) - => uri.IsAbsoluteUri - ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) - : uri.OriginalString; - - public static string Fingerprint(string publicKey) - => Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant(); -} - -public sealed class MagicNodeIdentityManager : IMagicNodeIdentityManager, IMagicNodeAuthenticator -{ - private readonly IMagicNodeIdentityStore _store; - private readonly SemaphoreSlim _gate = new(1, 1); - private MagicStoredNodeIdentity? _current; - - public MagicNodeIdentityManager(IMagicNodeIdentityStore store) => _store = store; - - public event EventHandler? IdentityChanged; - - public async ValueTask GetCurrentAsync(CancellationToken cancellationToken = default) - => MagicIdentityCryptography.Describe(await GetStoredAsync(cancellationToken)); - - public ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default) - => GetCurrentAsync(cancellationToken); - - public async ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(request.Audience); - ArgumentException.ThrowIfNullOrWhiteSpace(request.Method); - ArgumentNullException.ThrowIfNull(request.Uri); - ArgumentException.ThrowIfNullOrWhiteSpace(request.BodySha256); - - var identity = await GetStoredAsync(cancellationToken); - var issued = DateTimeOffset.UtcNow; - var validFor = request.ValidFor ?? TimeSpan.FromMinutes(1); - if (validFor <= TimeSpan.Zero || validFor > TimeSpan.FromMinutes(5)) - { - throw new ArgumentOutOfRangeException(nameof(request), "Authentication proofs must be valid for more than zero and no longer than five minutes."); - } - - var unsigned = new MagicAuthenticationProof( - MagicIdentityCryptography.ProofVersion, - identity.NodeId, - identity.CredentialId, - request.Audience, - request.Method.ToUpperInvariant(), - MagicIdentityCryptography.NormalizeTarget(request.Uri), - request.BodySha256.ToLowerInvariant(), - issued, - issued.Add(validFor), - MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)), - string.Empty); - - return unsigned with { Signature = MagicIdentityCryptography.Sign(identity, MagicIdentityCryptography.Canonicalize(unsigned)) }; - } - - public async ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(reason); - await _gate.WaitAsync(cancellationToken); - try - { - var previous = await LoadOrCreateUnderLockAsync(cancellationToken); - var next = MagicIdentityCryptography.Create(previous.NodeId); - var previousDescriptor = MagicIdentityCryptography.Describe(previous); - var nextDescriptor = MagicIdentityCryptography.Describe(next); - var issued = DateTimeOffset.UtcNow; - var nonce = MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)); - var canonical = MagicIdentityCryptography.CanonicalizeContinuity(previousDescriptor, nextDescriptor, issued, nonce); - var continuity = new MagicIdentityContinuityProof(previousDescriptor, nextDescriptor, issued, nonce, MagicIdentityCryptography.Sign(previous, canonical)); - await _store.SaveAsync(next, cancellationToken); - _current = next; - var change = new MagicIdentityChange(MagicIdentityChangeKind.Rotated, nextDescriptor, previousDescriptor, continuity, reason); - IdentityChanged?.Invoke(this, change); - return change; - } - finally - { - _gate.Release(); - } - } - - public async ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - if (!request.ConfirmDestructiveReset) - { - throw new InvalidOperationException("Identity reset is destructive and requires ConfirmDestructiveReset=true."); - } - - await _gate.WaitAsync(cancellationToken); - try - { - var previous = await _store.LoadAsync(cancellationToken); - var next = MagicIdentityCryptography.Create(); - await _store.SaveAsync(next, cancellationToken); - _current = next; - var change = new MagicIdentityChange( - MagicIdentityChangeKind.Reset, - MagicIdentityCryptography.Describe(next), - previous is null ? null : MagicIdentityCryptography.Describe(previous), - null, - request.Reason); - IdentityChanged?.Invoke(this, change); - return change; - } - finally - { - _gate.Release(); - } - } - - private async ValueTask GetStoredAsync(CancellationToken cancellationToken) - { - if (_current is not null) - { - return _current; - } - - await _gate.WaitAsync(cancellationToken); - try - { - return await LoadOrCreateUnderLockAsync(cancellationToken); - } - finally - { - _gate.Release(); - } - } - - private async ValueTask LoadOrCreateUnderLockAsync(CancellationToken cancellationToken) - { - if (_current is not null) - { - return _current; - } - - _current = await _store.LoadAsync(cancellationToken); - if (_current is null) - { - _current = MagicIdentityCryptography.Create(); - await _store.SaveAsync(_current, cancellationToken); - } - - return _current; - } -} - -public static class MagicHash -{ - public static string Sha256Hex(ReadOnlySpan value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant(); - public static string EmptySha256 { get; } = Sha256Hex(Array.Empty()); -} -} diff --git a/MagicSettings/MagicSettings/Source.05.cs b/MagicSettings/MagicSettings/Source.05.cs deleted file mode 100644 index de23aef..0000000 --- a/MagicSettings/MagicSettings/Source.05.cs +++ /dev/null @@ -1,414 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings -{ -public static class MagicSettingsInitializationExtensions -{ - public static async ValueTask AddMagicSettingsAsync( - this IHostApplicationBuilder builder, - string[]? args = null, - Action>? configure = null, - CancellationToken cancellationToken = default) - where TSettings : class, new() - { - ArgumentNullException.ThrowIfNull(builder); - var options = new MagicSettingsOptions(); - configure?.Invoke(options); - options.Environment = MagicSettingsEnvironmentResolver.Resolve(options.Environment, builder.Environment.EnvironmentName); - if (options.EnvironmentProfiles.TryGetValue(options.Environment, out var environmentProfile)) - { - environmentProfile(options.Template); - } - - var command = MagicSettingsCommandLine.Parse(args ?? Array.Empty()); - var path = MagicSettingsPathResolver.Resolve(options); - var documentResult = await MagicSettingsDocumentStore.LoadAndReconcileAsync( - path, - options, - command.ForceGenerate, - cancellationToken); - - if (command.PrintPath) - { - Console.Out.WriteLine(path); - } - - var identityPath = MagicIdentityPathResolver.Resolve(path, options.IdentityPath, options.IdentityFileName); - var identityStore = options.IdentityStore ?? new FileMagicNodeIdentityStore(identityPath); - var identityManager = new MagicNodeIdentityManager(identityStore); - await identityManager.GetCurrentAsync(cancellationToken); - - var configurationProvider = new MagicSettingsConfigurationProvider(); - var runtime = new MagicSettingsRuntime(options, configurationProvider, path); - await runtime.InitializeAsync(documentResult.Document, cancellationToken); - - var endpointResolver = options.ControlPlaneEndpointResolver ?? new MagicControlPlaneEndpointResolver(); - var transport = options.ControlPlaneTransport ?? new HttpMagicControlPlaneTransport(); - var controlPlane = new MagicSettingsControlPlane( - options, - runtime, - identityManager, - identityManager, - transport, - endpointResolver, - documentResult.Document, - documentResult.MigrationReport); - - builder.Configuration.Add(new MagicSettingsConfigurationSource(configurationProvider)); - builder.Services.AddSingleton(options); - builder.Services.AddSingleton>(runtime); - builder.Services.AddSingleton(runtime); - builder.Services.AddSingleton(identityManager); - builder.Services.AddSingleton(identityManager); - builder.Services.AddSingleton(controlPlane); - builder.Services.AddSingleton(controlPlane); - builder.Services.AddSingleton(endpointResolver); - builder.Services.AddSingleton(transport); - if (transport is IMagicSecretTransport secretTransport) - { - builder.Services.AddSingleton(secretTransport); - builder.Services.AddSingleton( - new MagicControlPlaneSecretProvider(controlPlane, identityManager, secretTransport, options)); - } - builder.Services.AddSingleton(new MagicSettingsRuntimeRegistration(path)); - builder.Services.AddHostedService(serviceProvider => - new MagicSettingsHostedService( - options, - runtime, - controlPlane, - serviceProvider.GetRequiredService>>(), - path)); - builder.Services.AddOptions().Bind(builder.Configuration); - - var shouldExit = command.GenerateOnly || command.ValidateOnly || command.PrintPath; - return new(shouldExit, 0, path, options.Environment); - } -} - -public sealed record MagicSettingsRuntimeRegistration(string SettingsPath); - -internal sealed record MagicSettingsCommandLine(bool GenerateOnly, bool ForceGenerate, bool ValidateOnly, bool PrintPath) -{ - public static MagicSettingsCommandLine Parse(IEnumerable args) - { - var set = args.ToHashSet(StringComparer.OrdinalIgnoreCase); - var force = set.Contains("--magic-settings-force-generate"); - return new( - set.Contains("--magic-settings-generate") || force, - force, - set.Contains("--magic-settings-validate"), - set.Contains("--magic-settings-print-path")); - } -} -} -namespace MagicSettings -{ -internal static class MagicJsonPath -{ - public static bool TryGet(JsonObject root, string path, out JsonNode? value) - { - value = root; - foreach (var segment in Split(path)) - { - if (value is not JsonObject obj || !TryGetProperty(obj, segment, out _, out value)) - { - value = null; - return false; - } - } - - return true; - } - - public static void Set(JsonObject root, string path, JsonNode? value) - { - var segments = Split(path); - if (segments.Length == 0) - { - throw new ArgumentException("A setting path is required.", nameof(path)); - } - - var current = root; - for (var index = 0; index < segments.Length - 1; index++) - { - var segment = segments[index]; - if (!TryGetProperty(current, segment, out var actualName, out var existing) || existing is not JsonObject child) - { - child = new JsonObject(); - current[actualName ?? segment] = child; - } - - current = child; - } - - if (TryGetProperty(current, segments[^1], out var finalName, out _)) - { - current[finalName!] = value?.DeepClone(); - } - else - { - current[segments[^1]] = value?.DeepClone(); - } - } - - public static bool TryTake(JsonObject root, string path, out JsonNode? value) - { - value = null; - var segments = Split(path); - if (segments.Length == 0) - { - return false; - } - - var current = root; - for (var index = 0; index < segments.Length - 1; index++) - { - if (!TryGetProperty(current, segments[index], out _, out var existing) || existing is not JsonObject child) - { - return false; - } - - current = child; - } - - if (!TryGetProperty(current, segments[^1], out var actualName, out value)) - { - return false; - } - - current.Remove(actualName!); - return true; - } - - public static bool TryGetProperty(JsonObject obj, string name, out string? actualName, out JsonNode? value) - { - if (obj.TryGetPropertyValue(name, out value)) - { - actualName = name; - return true; - } - - foreach (var pair in obj) - { - if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) - { - actualName = pair.Key; - value = pair.Value; - return true; - } - } - - actualName = null; - value = null; - return false; - } - - public static string[] Split(string path) - => path.Split(new[] { ':', '.' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -} - -internal static class MagicJsonFlattener -{ - public static Dictionary Flatten(JsonObject root) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - Visit(root, null, result); - return result; - } - - private static void Visit(JsonNode? node, string? path, IDictionary result) - { - switch (node) - { - case JsonObject obj: - foreach (var pair in obj) - { - if (pair.Key == "$magicSettings") - { - continue; - } - - Visit(pair.Value, path is null ? pair.Key : $"{path}:{pair.Key}", result); - } - break; - case JsonArray array: - for (var index = 0; index < array.Count; index++) - { - Visit(array[index], $"{path}:{index}", result); - } - break; - case JsonValue value when path is not null: - result[path] = ToConfigurationString(value); - break; - case null when path is not null: - result[path] = null; - break; - } - } - - private static string? ToConfigurationString(JsonValue value) - { - if (value.TryGetValue(out var text)) return text; - if (value.TryGetValue(out var boolean)) return boolean ? "true" : "false"; - if (value.TryGetValue(out var integer)) return integer.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var longInteger)) return longInteger.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var decimalNumber)) return decimalNumber.ToString(CultureInfo.InvariantCulture); - return value.ToJsonString(); - } -} - -internal static class MagicEnvironmentOverrides -{ - public static Dictionary Read(string prefix) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) - { - if (entry.Key is not string name || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var path = name[prefix.Length..].Replace("__", ":", StringComparison.Ordinal); - result[path] = entry.Value?.ToString(); - } - - return result; - } -} -} -namespace MagicSettings -{ -public sealed class MagicMigrationContext -{ - private readonly List _safeOperations = new(); - private readonly List _reviewItems = new(); - - public IReadOnlyList SafeOperations => _safeOperations; - public IReadOnlyList ReviewItems => _reviewItems; - - public void RecordSafe(string operation) => _safeOperations.Add(operation); - - public void RequireReview(string path, string operation, string reason, string? proposedPath = null, MagicMigrationReviewSeverity severity = MagicMigrationReviewSeverity.Warning) - => _reviewItems.Add(new(path, operation, severity, reason, proposedPath)); - - public void Rename(JsonObject document, string from, string to, bool remoteSafeProjection = true) - { - if (!MagicJsonPath.TryTake(document, from, out var value)) - { - return; - } - - if (MagicJsonPath.TryGet(document, to, out _)) - { - throw new InvalidOperationException($"Cannot rename '{from}' to '{to}' because the destination already exists."); - } - - MagicJsonPath.Set(document, to, value); - RecordSafe($"rename:{from}->{to}"); - if (!remoteSafeProjection) - { - RequireReview(from, "rename", "The remote record should retain the original value until reviewed.", to); - } - } - - public void Transform( - JsonObject document, - string path, - Func transform, - string description, - bool remoteSafeProjection = false) - { - ArgumentNullException.ThrowIfNull(transform); - if (!MagicJsonPath.TryGet(document, path, out var current)) - { - return; - } - - MagicJsonPath.Set(document, path, transform(current?.DeepClone())); - RecordSafe($"transform:{path}:{description}"); - if (!remoteSafeProjection) - { - RequireReview(path, "transform", description); - } - } - - public void SetIfMissing(JsonObject document, string path, JsonNode? value) - { - if (MagicJsonPath.TryGet(document, path, out _)) - { - return; - } - - MagicJsonPath.Set(document, path, value); - RecordSafe($"set-if-missing:{path}"); - } - - public void Remove(JsonObject document, string path, string reason) - { - if (MagicJsonPath.TryTake(document, path, out _)) - { - RecordSafe($"local-remove:{path}"); - RequireReview(path, "remove", reason, severity: MagicMigrationReviewSeverity.Destructive); - } - } -} - -internal static class MagicMigrationRunner -{ - public static MagicSettingsMigrationReport? Run(JsonObject document, int currentVersion, int targetVersion, IEnumerable migrations) - { - if (currentVersion > targetVersion) - { - throw new InvalidOperationException($"Downgrading settings from schema {currentVersion} to {targetVersion} is not supported."); - } - - if (currentVersion == targetVersion) - { - return null; - } - - var ordered = migrations.OrderBy(static migration => migration.FromVersion).ToList(); - var context = new MagicMigrationContext(); - var version = currentVersion; - while (version < targetVersion) - { - var migration = ordered.SingleOrDefault(item => item.FromVersion == version) - ?? throw new InvalidOperationException($"No migration exists from schema version {version}."); - if (migration.ToVersion <= version) - { - throw new InvalidOperationException($"Migration {migration.GetType().Name} does not advance the schema version."); - } - - migration.Apply(document, context); - version = migration.ToVersion; - } - - if (version != targetVersion) - { - throw new InvalidOperationException($"Migration chain ended at schema version {version}, expected {targetVersion}."); - } - - return new(currentVersion, targetVersion, context.SafeOperations.ToArray(), context.ReviewItems.ToArray()); - } -} -} diff --git a/MagicSettings/MagicSettings/Source.06.cs b/MagicSettings/MagicSettings/Source.06.cs deleted file mode 100644 index 5cfe2f9..0000000 --- a/MagicSettings/MagicSettings/Source.06.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Consolidated source file. See repository history and wiki for subsystem boundaries. -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings -{ -public enum MagicFailureAction -{ - StopStartup, - WarnAndContinue, - KeepLastKnownGood -} - -public enum MagicArrayMergePolicy -{ - PreserveExisting, - ReplaceWithTemplate, - AppendMissing, - Union -} - -public sealed class MagicSettingsFailurePolicy -{ - public bool StrictDevelopmentMode { get; set; } = true; - public MagicFailureAction AmbiguousArrayInProduction { get; set; } = MagicFailureAction.WarnAndContinue; - public MagicFailureAction RuntimeReloadFailure { get; set; } = MagicFailureAction.KeepLastKnownGood; -} - -public sealed class MagicControlPlaneBootstrapOptions -{ - public string EnvironmentVariableName { get; set; } = "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT"; - public string PersistentSettingPath { get; set; } = "MagicSettings:ControlPlane:Endpoint"; - public Uri? CodeFallbackEndpoint { get; set; } - public MagicControlPlaneTrust? Trust { get; set; } - public bool ConnectOnStartup { get; set; } - public bool WatchPersistentEndpoint { get; set; } = true; - public bool AllowInsecureHttp { get; set; } -} - -public sealed class MagicControlPlaneOptions -{ - public MagicControlPlaneBootstrapOptions Bootstrap { get; } = new(); - public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(90); - public TimeSpan PollJitter { get; set; } = TimeSpan.FromSeconds(30); - public bool KeepLastKnownGoodDuringOutage { get; set; } = true; -} - -public sealed class MagicSettingsOptions where TSettings : class, new() -{ - public string ApplicationId { get; set; } = typeof(TSettings).Assembly.GetName().Name ?? typeof(TSettings).Name; - public string ApplicationVersion { get; set; } = typeof(TSettings).Assembly.GetName().Version?.ToString() ?? "0.0.0"; - public int SchemaVersion { get; set; } = 1; - public TSettings Template { get; set; } = new(); - public string? Path { get; set; } - public string FileName { get; set; } = "appsettings.json"; - public string EnvironmentOverridePrefix { get; set; } = "MagicSettings__"; - public string Environment { get; set; } = string.Empty; - public bool ReloadOnChange { get; set; } = true; - public TimeSpan ReloadDebounce { get; set; } = TimeSpan.FromMilliseconds(350); - public bool PreserveUnknownProperties { get; set; } = true; - public string IdentityFileName { get; set; } = ".magicsettings.identity.json"; - public string? IdentityPath { get; set; } - public JsonSerializerOptions Json { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; - public MagicSettingsFailurePolicy Failures { get; } = new(); - public MagicControlPlaneOptions ControlPlane { get; } = new(); - public IDictionary> EnvironmentProfiles { get; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); - public IList Migrations { get; } = new List(); - public IList> Validators { get; } = new List>(); - public IList Providers { get; } = new List(); - public IDictionary ArrayPolicies { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - public ISet SensitivePaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); - public IMagicControlPlaneTransport? ControlPlaneTransport { get; set; } - public IMagicControlPlaneEndpointResolver? ControlPlaneEndpointResolver { get; set; } - public IMagicNodeIdentityStore? IdentityStore { get; set; } - - public void ConfigureEnvironment(string environment, Action configure) - { - ArgumentException.ThrowIfNullOrWhiteSpace(environment); - ArgumentNullException.ThrowIfNull(configure); - EnvironmentProfiles[environment] = configure; - } -} - -public sealed record MagicSettingsInitializationResult(bool ShouldExit, int ExitCode, string SettingsPath, string Environment); -} -namespace MagicSettings -{ -public static class MagicSettingsEnvironmentResolver -{ - public static string Resolve(string? explicitEnvironment = null, string? hostEnvironment = null) - => FirstNonEmpty( - explicitEnvironment, - Environment.GetEnvironmentVariable("MAGICSETTINGS_ENVIRONMENT"), - Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), - hostEnvironment, - "Production")!; - - private static string? FirstNonEmpty(params string?[] values) - => values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)); -} - -public static class MagicSettingsPathResolver -{ - public static string Resolve(MagicSettingsOptions options) where TSettings : class, new() - { - var configured = options.Path; - if (string.IsNullOrWhiteSpace(configured)) - { - configured = Environment.GetEnvironmentVariable("MAGICSETTINGS_PATH"); - } - - if (string.IsNullOrWhiteSpace(configured)) - { - return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.FileName)); - } - - var fullPath = Path.GetFullPath(configured); - if (Path.HasExtension(fullPath) && string.Equals(Path.GetExtension(fullPath), ".json", StringComparison.OrdinalIgnoreCase)) - { - return fullPath; - } - - return Path.Combine(fullPath, options.FileName); - } -} - - -public static class MagicIdentityPathResolver -{ - public static string Resolve(string settingsPath, string? configuredPath, string fileName) - { - var configured = string.IsNullOrWhiteSpace(configuredPath) - ? Environment.GetEnvironmentVariable("MAGICSETTINGS_IDENTITY_PATH") - : configuredPath; - if (string.IsNullOrWhiteSpace(configured)) - { - return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsPath) ?? AppContext.BaseDirectory, fileName)); - } - - var fullPath = Path.GetFullPath(configured); - return Path.HasExtension(fullPath) ? fullPath : Path.Combine(fullPath, fileName); - } -} -} From 88e67c4650ef2e15a9e16d56de823c87943312fa Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:45:53 -0400 Subject: [PATCH 4/9] Prepare organized source cleanup --- .github/scripts/clean_organized_source.py | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/scripts/clean_organized_source.py diff --git a/.github/scripts/clean_organized_source.py b/.github/scripts/clean_organized_source.py new file mode 100644 index 0000000..f4ff2c4 --- /dev/null +++ b/.github/scripts/clean_organized_source.py @@ -0,0 +1,88 @@ +from pathlib import Path +import shutil + +root = Path("MagicSettings") +runtime = root / "MagicSettings" +server = root / "MagicSettings.Server" +tests = root / "MagicSettings.Tests" + + +def remove_file_usings(project: Path) -> None: + for path in project.rglob("*.cs"): + if path.name == "GlobalUsings.cs": + continue + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + namespace_index = next( + (index for index, line in enumerate(lines) if line.startswith("namespace ")), + None, + ) + if namespace_index is None: + raise RuntimeError(f"No namespace declaration found in {path}") + path.write_text("\n".join(lines[namespace_index:]).rstrip() + "\n", encoding="utf-8") + + +remove_file_usings(runtime) +remove_file_usings(server) +remove_file_usings(tests) + +(runtime / "GlobalUsings.cs").write_text( + """global using System.Collections; +global using System.Collections.Concurrent; +global using System.ComponentModel.DataAnnotations; +global using System.Globalization; +global using System.Net.Http.Headers; +global using System.Net.Security; +global using System.Reflection; +global using System.Security.Cryptography; +global using System.Security.Cryptography.X509Certificates; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using MagicSettings.Share; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +""", + encoding="utf-8", +) + +(server / "GlobalUsings.cs").write_text( + """global using System.Collections.Concurrent; +global using System.Security.Cryptography; +global using System.Text; +global using MagicSettings.Share; +global using Microsoft.AspNetCore.Http; +""", + encoding="utf-8", +) + +(tests / "GlobalUsings.cs").write_text( + """global using System.Text.Json.Nodes; +global using MagicSettings.Server; +global using MagicSettings.Share; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Xunit; +""", + encoding="utf-8", +) + +moves = { + runtime / "Internal" / "MagicSettingExplanation.cs": runtime / "Diagnostics" / "MagicSettingExplanation.cs", + runtime / "Internal" / "MagicSettingSourceValue.cs": runtime / "Diagnostics" / "MagicSettingSourceValue.cs", + runtime / "Internal" / "MagicSettingsChangedEventArgs.cs": runtime / "Runtime" / "MagicSettingsChangedEventArgs.cs", +} +for source, destination in moves.items(): + if not source.exists(): + raise RuntimeError(f"Expected source file does not exist: {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(source, destination) + +internal = runtime / "Internal" +if internal.exists(): + internal.rmdir() + +print("Centralized project usings and corrected feature placement.") From 84ea14f84fdae8eeba2320d4c2333644c3c67cb0 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:46:00 -0400 Subject: [PATCH 5/9] Run organized source cleanup --- .github/workflows/clean-organized-source.yml | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/clean-organized-source.yml diff --git a/.github/workflows/clean-organized-source.yml b/.github/workflows/clean-organized-source.yml new file mode 100644 index 0000000..a12951b --- /dev/null +++ b/.github/workflows/clean-organized-source.yml @@ -0,0 +1,37 @@ +name: Clean organized source + +on: + push: + branches: [ agent/organize-source-files ] + +permissions: + contents: write + +jobs: + clean: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/organize-source-files + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Clean generated files + run: python3 .github/scripts/clean_organized_source.py + - name: Remove one-shot automation + run: rm .github/scripts/clean_organized_source.py .github/workflows/clean-organized-source.yml + - name: Restore + run: dotnet restore MagicSettings/MagicSettings.sln + - name: Build + run: dotnet build MagicSettings/MagicSettings.sln --configuration Release --no-restore + - name: Test + run: dotnet test MagicSettings/MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal" + - name: Commit cleanup + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Clean organized source layout" + git push origin HEAD:agent/organize-source-files From b8591a449d0f48d24a27442b5f466b67c9da9f98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:46:33 +0000 Subject: [PATCH 6/9] Clean organized source layout --- .github/scripts/clean_organized_source.py | 88 ------------------- .github/workflows/clean-organized-source.yml | 37 -------- .../Abstractions/IMagicCredentialRegistry.cs | 6 -- .../IMagicNodeRemoteRecordStore.cs | 6 -- .../Abstractions/IMagicReplayCache.cs | 6 -- .../Abstractions/IMagicSecretResolver.cs | 6 -- .../MagicAspNetProofVerificationExtensions.cs | 6 -- .../Authentication/MagicNodeProofVerifier.cs | 6 -- .../MagicCredentialAdministrationService.cs | 6 -- .../MagicCredentialRotationService.cs | 6 -- .../Credentials/MagicRegisteredCredential.cs | 6 -- .../MagicSettings.Server/GlobalUsings.cs | 5 ++ .../InMemoryMagicCredentialRegistry.cs | 6 -- .../InMemoryMagicNodeRemoteRecordStore.cs | 6 -- .../InMemory/InMemoryMagicReplayCache.cs | 6 -- .../Secrets/MagicSecretService.cs | 6 -- .../Synchronization/MagicNodeRemoteRecord.cs | 6 -- .../MagicSettingsSyncService.cs | 6 -- .../Configuration/EndpointBootstrapTests.cs | 6 -- .../Configuration/InitializationTests.cs | 6 -- .../Configuration/MigrationTests.cs | 6 -- .../MagicSettings.Tests/GlobalUsings.cs | 5 ++ .../Infrastructure/TemporaryDirectory.cs | 6 -- .../Infrastructure/TestApplication.cs | 6 -- .../Infrastructure/TestControlPlane.cs | 6 -- .../Infrastructure/TestControlPlaneSection.cs | 6 -- .../Infrastructure/TestDatabase.cs | 6 -- .../Infrastructure/TestSettings.cs | 6 -- .../Security/CredentialAdministrationTests.cs | 6 -- .../Security/CredentialRotationTests.cs | 6 -- .../Security/EnrollmentTests.cs | 6 -- .../Security/IdentityTests.cs | 6 -- .../Security/ProofTests.cs | 6 -- .../Security/SecretTests.cs | 6 -- .../Server/ServerSafetyTests.cs | 6 -- .../IMagicControlPlaneEndpointResolver.cs | 19 ---- .../IMagicControlPlaneTransport.cs | 19 ---- .../Abstractions/IMagicNodeAuthenticator.cs | 19 ---- .../Abstractions/IMagicNodeIdentityManager.cs | 19 ---- .../Abstractions/IMagicNodeIdentityStore.cs | 19 ---- .../Abstractions/IMagicSecretProvider.cs | 19 ---- .../Abstractions/IMagicSecretTransport.cs | 19 ---- .../Abstractions/IMagicSettings.cs | 19 ---- .../IMagicSettingsControlPlane.cs | 19 ---- .../Abstractions/IMagicSettingsMigration.cs | 19 ---- .../IMagicSettingsSourceProvider.cs | 19 ---- .../Abstractions/IMagicSettingsValidator.cs | 19 ---- .../Configuration/MagicArrayMergePolicy.cs | 19 ---- .../MagicEnvironmentOverrides.cs | 19 ---- .../Configuration/MagicFailureAction.cs | 19 ---- .../Configuration/MagicJsonFlattener.cs | 19 ---- .../Configuration/MagicJsonPath.cs | 19 ---- .../MagicSettingsConfigurationProvider.cs | 19 ---- .../MagicSettingsConfigurationSource.cs | 19 ---- .../MagicSettingsDocumentResult.cs | 19 ---- .../MagicSettingsDocumentStore.cs | 19 ---- .../MagicSettingsEnvironmentResolver.cs | 19 ---- .../MagicSettingsFailurePolicy.cs | 19 ---- .../Configuration/MagicSettingsOptions.cs | 19 ---- .../MagicSettingsPathResolver.cs | 19 ---- .../HttpMagicControlPlaneTransport.cs | 19 ---- .../MagicControlPlaneBootstrapOptions.cs | 19 ---- .../MagicControlPlaneEndpointResolver.cs | 19 ---- .../ControlPlane/MagicControlPlaneOptions.cs | 19 ---- .../ControlPlane/MagicManifestBuilder.cs | 19 ---- .../ControlPlane/MagicSettingsControlPlane.cs | 19 ---- .../Diagnostics/MagicSettingExplanation.cs | 8 ++ .../Diagnostics/MagicSettingSourceValue.cs | 3 + MagicSettings/MagicSettings/GlobalUsings.cs | 18 ++++ .../Internal/MagicSettingExplanation.cs | 27 ------ .../Internal/MagicSettingSourceValue.cs | 22 ----- .../Internal/MagicSettingsChangedEventArgs.cs | 22 ----- .../Metadata/MagicRemoteOverrideAttribute.cs | 19 ---- .../Metadata/MagicSensitiveAttribute.cs | 19 ---- .../MagicSettingDescriptionAttribute.cs | 19 ---- .../Migrations/MagicMigrationContext.cs | 19 ---- .../Migrations/MagicMigrationRunner.cs | 19 ---- .../Runtime/MagicSettingsChangedEventArgs.cs | 3 + .../Runtime/MagicSettingsCommandLine.cs | 19 ---- .../Runtime/MagicSettingsHostedService.cs | 19 ---- .../MagicSettingsInitializationExtensions.cs | 19 ---- .../MagicSettingsInitializationResult.cs | 19 ---- .../Runtime/MagicSettingsRuntime.cs | 19 ---- .../MagicSettingsRuntimeRegistration.cs | 19 ---- .../MagicControlPlaneSecretProvider.cs | 19 ---- .../MagicSettings/Secrets/MagicSecretLease.cs | 19 ---- .../Security/FileMagicNodeIdentityStore.cs | 19 ---- .../MagicSettings/Security/MagicHash.cs | 19 ---- .../MagicHttpClientBuilderExtensions.cs | 19 ---- .../Security/MagicIdentityCryptography.cs | 19 ---- .../Security/MagicIdentityPathResolver.cs | 19 ---- .../MagicNodeAuthenticationHandler.cs | 19 ---- .../Security/MagicNodeIdentityManager.cs | 19 ---- .../Security/MagicStoredNodeIdentity.cs | 19 ---- 94 files changed, 42 insertions(+), 1370 deletions(-) delete mode 100644 .github/scripts/clean_organized_source.py delete mode 100644 .github/workflows/clean-organized-source.yml create mode 100644 MagicSettings/MagicSettings.Server/GlobalUsings.cs create mode 100644 MagicSettings/MagicSettings/Diagnostics/MagicSettingExplanation.cs create mode 100644 MagicSettings/MagicSettings/Diagnostics/MagicSettingSourceValue.cs create mode 100644 MagicSettings/MagicSettings/GlobalUsings.cs delete mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs delete mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs delete mode 100644 MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs create mode 100644 MagicSettings/MagicSettings/Runtime/MagicSettingsChangedEventArgs.cs diff --git a/.github/scripts/clean_organized_source.py b/.github/scripts/clean_organized_source.py deleted file mode 100644 index f4ff2c4..0000000 --- a/.github/scripts/clean_organized_source.py +++ /dev/null @@ -1,88 +0,0 @@ -from pathlib import Path -import shutil - -root = Path("MagicSettings") -runtime = root / "MagicSettings" -server = root / "MagicSettings.Server" -tests = root / "MagicSettings.Tests" - - -def remove_file_usings(project: Path) -> None: - for path in project.rglob("*.cs"): - if path.name == "GlobalUsings.cs": - continue - text = path.read_text(encoding="utf-8") - lines = text.splitlines() - namespace_index = next( - (index for index, line in enumerate(lines) if line.startswith("namespace ")), - None, - ) - if namespace_index is None: - raise RuntimeError(f"No namespace declaration found in {path}") - path.write_text("\n".join(lines[namespace_index:]).rstrip() + "\n", encoding="utf-8") - - -remove_file_usings(runtime) -remove_file_usings(server) -remove_file_usings(tests) - -(runtime / "GlobalUsings.cs").write_text( - """global using System.Collections; -global using System.Collections.Concurrent; -global using System.ComponentModel.DataAnnotations; -global using System.Globalization; -global using System.Net.Http.Headers; -global using System.Net.Security; -global using System.Reflection; -global using System.Security.Cryptography; -global using System.Security.Cryptography.X509Certificates; -global using System.Text; -global using System.Text.Json; -global using System.Text.Json.Nodes; -global using MagicSettings.Share; -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Options; -""", - encoding="utf-8", -) - -(server / "GlobalUsings.cs").write_text( - """global using System.Collections.Concurrent; -global using System.Security.Cryptography; -global using System.Text; -global using MagicSettings.Share; -global using Microsoft.AspNetCore.Http; -""", - encoding="utf-8", -) - -(tests / "GlobalUsings.cs").write_text( - """global using System.Text.Json.Nodes; -global using MagicSettings.Server; -global using MagicSettings.Share; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Hosting; -global using Xunit; -""", - encoding="utf-8", -) - -moves = { - runtime / "Internal" / "MagicSettingExplanation.cs": runtime / "Diagnostics" / "MagicSettingExplanation.cs", - runtime / "Internal" / "MagicSettingSourceValue.cs": runtime / "Diagnostics" / "MagicSettingSourceValue.cs", - runtime / "Internal" / "MagicSettingsChangedEventArgs.cs": runtime / "Runtime" / "MagicSettingsChangedEventArgs.cs", -} -for source, destination in moves.items(): - if not source.exists(): - raise RuntimeError(f"Expected source file does not exist: {source}") - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(source, destination) - -internal = runtime / "Internal" -if internal.exists(): - internal.rmdir() - -print("Centralized project usings and corrected feature placement.") diff --git a/.github/workflows/clean-organized-source.yml b/.github/workflows/clean-organized-source.yml deleted file mode 100644 index a12951b..0000000 --- a/.github/workflows/clean-organized-source.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Clean organized source - -on: - push: - branches: [ agent/organize-source-files ] - -permissions: - contents: write - -jobs: - clean: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/organize-source-files - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - name: Clean generated files - run: python3 .github/scripts/clean_organized_source.py - - name: Remove one-shot automation - run: rm .github/scripts/clean_organized_source.py .github/workflows/clean-organized-source.yml - - name: Restore - run: dotnet restore MagicSettings/MagicSettings.sln - - name: Build - run: dotnet build MagicSettings/MagicSettings.sln --configuration Release --no-restore - - name: Test - run: dotnet test MagicSettings/MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal" - - name: Commit cleanup - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Clean organized source layout" - git push origin HEAD:agent/organize-source-files diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs index 06f9307..c3ae763 100644 --- a/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicCredentialRegistry.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public interface IMagicCredentialRegistry diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs index 4efdf04..a188024 100644 --- a/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicNodeRemoteRecordStore.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public interface IMagicNodeRemoteRecordStore diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs index 1536227..8b853af 100644 --- a/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicReplayCache.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public interface IMagicReplayCache diff --git a/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs b/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs index 03006ef..210c4c8 100644 --- a/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs +++ b/MagicSettings/MagicSettings.Server/Abstractions/IMagicSecretResolver.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public interface IMagicSecretResolver diff --git a/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs b/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs index 1b23e92..6d8e193 100644 --- a/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs +++ b/MagicSettings/MagicSettings.Server/Authentication/MagicAspNetProofVerificationExtensions.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public static class MagicAspNetProofVerificationExtensions diff --git a/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs b/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs index fb218c7..a1722cf 100644 --- a/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs +++ b/MagicSettings/MagicSettings.Server/Authentication/MagicNodeProofVerifier.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class MagicNodeProofVerifier diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs index c4c51ff..26b2e02 100644 --- a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialAdministrationService.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; /// diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs index 321cbd1..6943693 100644 --- a/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicCredentialRotationService.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class MagicCredentialRotationService diff --git a/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs b/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs index cc1a42b..d9a4aac 100644 --- a/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs +++ b/MagicSettings/MagicSettings.Server/Credentials/MagicRegisteredCredential.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed record MagicRegisteredCredential( diff --git a/MagicSettings/MagicSettings.Server/GlobalUsings.cs b/MagicSettings/MagicSettings.Server/GlobalUsings.cs new file mode 100644 index 0000000..1a32b04 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Collections.Concurrent; +global using System.Security.Cryptography; +global using System.Text; +global using MagicSettings.Share; +global using Microsoft.AspNetCore.Http; diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs index 340c827..62ea0a2 100644 --- a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicCredentialRegistry.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class InMemoryMagicCredentialRegistry : IMagicCredentialRegistry diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs index bec6faf..b79e9b0 100644 --- a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicNodeRemoteRecordStore.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class InMemoryMagicNodeRemoteRecordStore : IMagicNodeRemoteRecordStore diff --git a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs index 7cd8b95..de6b2e3 100644 --- a/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs +++ b/MagicSettings/MagicSettings.Server/InMemory/InMemoryMagicReplayCache.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class InMemoryMagicReplayCache : IMagicReplayCache diff --git a/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs b/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs index a67ccf9..fcbe780 100644 --- a/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs +++ b/MagicSettings/MagicSettings.Server/Secrets/MagicSecretService.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class MagicSecretService diff --git a/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs b/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs index 3edd1f2..3735153 100644 --- a/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs +++ b/MagicSettings/MagicSettings.Server/Synchronization/MagicNodeRemoteRecord.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed record MagicNodeRemoteRecord( diff --git a/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs b/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs index ccf85d0..3ed498b 100644 --- a/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs +++ b/MagicSettings/MagicSettings.Server/Synchronization/MagicSettingsSyncService.cs @@ -1,9 +1,3 @@ -using System.Security.Cryptography; -using MagicSettings.Share; -using Microsoft.AspNetCore.Http; -using System.Collections.Concurrent; -using System.Text; - namespace MagicSettings.Server; public sealed class MagicSettingsSyncService diff --git a/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs index d22d8d9..3e2560a 100644 --- a/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs +++ b/MagicSettings/MagicSettings.Tests/Configuration/EndpointBootstrapTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class EndpointBootstrapTests diff --git a/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs index 41af14a..a1212a2 100644 --- a/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs +++ b/MagicSettings/MagicSettings.Tests/Configuration/InitializationTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class InitializationTests diff --git a/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs b/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs index d4889a8..8dc1868 100644 --- a/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs +++ b/MagicSettings/MagicSettings.Tests/Configuration/MigrationTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class MigrationTests diff --git a/MagicSettings/MagicSettings.Tests/GlobalUsings.cs b/MagicSettings/MagicSettings.Tests/GlobalUsings.cs index c802f44..0214af7 100644 --- a/MagicSettings/MagicSettings.Tests/GlobalUsings.cs +++ b/MagicSettings/MagicSettings.Tests/GlobalUsings.cs @@ -1 +1,6 @@ +global using System.Text.Json.Nodes; +global using MagicSettings.Server; +global using MagicSettings.Share; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; global using Xunit; diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs index 927b57a..61eea38 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TemporaryDirectory.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; internal sealed class TemporaryDirectory : IDisposable diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs index a7016e4..582b555 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestApplication.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class TestApplication diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs index 8382bd6..b9eff95 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlane.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class TestControlPlane diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs index 5fdbe7f..26aa26f 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestControlPlaneSection.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class TestControlPlaneSection diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs index 4a43c6b..6f684ee 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestDatabase.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class TestDatabase diff --git a/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs b/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs index 8992455..80d2f83 100644 --- a/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs +++ b/MagicSettings/MagicSettings.Tests/Infrastructure/TestSettings.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class TestSettings diff --git a/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs b/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs index fef5336..a04fcdf 100644 --- a/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/CredentialAdministrationTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class CredentialAdministrationTests diff --git a/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs b/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs index 8f9bb52..5aa4620 100644 --- a/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/CredentialRotationTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class CredentialRotationTests diff --git a/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs b/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs index 17a3d5b..2d15286 100644 --- a/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/EnrollmentTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class EnrollmentTests diff --git a/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs b/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs index ce42761..3b7eb5a 100644 --- a/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/IdentityTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class IdentityTests diff --git a/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs b/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs index 4ea2fbd..b2a3cd1 100644 --- a/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/ProofTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class ProofTests diff --git a/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs b/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs index e2bfde4..5676231 100644 --- a/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs +++ b/MagicSettings/MagicSettings.Tests/Security/SecretTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class SecretTests diff --git a/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs b/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs index ef0db9d..845d822 100644 --- a/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs +++ b/MagicSettings/MagicSettings.Tests/Server/ServerSafetyTests.cs @@ -1,9 +1,3 @@ -using MagicSettings.Server; -using MagicSettings.Share; -using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - namespace MagicSettings.Tests; public sealed class ServerSafetyTests diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs index f9de491..392433d 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneEndpointResolver.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicControlPlaneEndpointResolver diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs index bf5a224..59db80a 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicControlPlaneTransport.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicControlPlaneTransport diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs index 2133ac6..05a956b 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeAuthenticator.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicNodeAuthenticator diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs index c76e2a9..9784b2f 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityManager.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicNodeIdentityManager diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs index 40f7be8..ff7bb02 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicNodeIdentityStore.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicNodeIdentityStore diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs index f04293c..e86a495 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSecretProvider.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSecretProvider diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs index 9798a09..429d9b3 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSecretTransport.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSecretTransport diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs index b55220b..a36c489 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettings.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSettings where TSettings : class diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs index 24af4f5..1945253 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsControlPlane.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSettingsControlPlane diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs index 27fb82d..3eb41f6 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsMigration.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSettingsMigration diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs index aacbed1..557e8b5 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsSourceProvider.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSettingsSourceProvider diff --git a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs index 85c4799..40a5418 100644 --- a/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs +++ b/MagicSettings/MagicSettings/Abstractions/IMagicSettingsValidator.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public interface IMagicSettingsValidator where TSettings : class diff --git a/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs b/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs index fee5254..5ad9c18 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicArrayMergePolicy.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public enum MagicArrayMergePolicy diff --git a/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs b/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs index 231d3ef..e5a8a7e 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicEnvironmentOverrides.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicEnvironmentOverrides diff --git a/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs b/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs index a166015..4542874 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicFailureAction.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public enum MagicFailureAction diff --git a/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs b/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs index bbd796a..5d5162b 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicJsonFlattener.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicJsonFlattener diff --git a/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs b/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs index 5e6a506..213e0eb 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicJsonPath.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicJsonPath diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs index 2ebb147..4082366 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationProvider.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicSettingsConfigurationProvider : ConfigurationProvider diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs index c88c6fe..87096c7 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsConfigurationSource.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicSettingsConfigurationSource : IConfigurationSource diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs index d5f5707..973fcdc 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentResult.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed record MagicSettingsDocumentResult(JsonObject Document, MagicSettingsMigrationReport? MigrationReport, bool Changed); diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs index fbf5604..9ca6ae8 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsDocumentStore.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicSettingsDocumentStore diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs index 16b39ba..0c63693 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsEnvironmentResolver.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicSettingsEnvironmentResolver diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs index 6643f98..0c1e891 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsFailurePolicy.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicSettingsFailurePolicy diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs index 63ffbfe..8f433eb 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsOptions.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicSettingsOptions where TSettings : class, new() diff --git a/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs b/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs index f9016d1..f2877f1 100644 --- a/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs +++ b/MagicSettings/MagicSettings/Configuration/MagicSettingsPathResolver.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicSettingsPathResolver diff --git a/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs b/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs index 2c0bc38..353398f 100644 --- a/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs +++ b/MagicSettings/MagicSettings/ControlPlane/HttpMagicControlPlaneTransport.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class HttpMagicControlPlaneTransport : IMagicControlPlaneTransport, IMagicSecretTransport, IDisposable diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs index 8e95535..bf12a7a 100644 --- a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneBootstrapOptions.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicControlPlaneBootstrapOptions diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs index 343c634..644dd66 100644 --- a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneEndpointResolver.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicControlPlaneEndpointResolver : IMagicControlPlaneEndpointResolver diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs index 47a3317..4b64932 100644 --- a/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicControlPlaneOptions.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicControlPlaneOptions diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs b/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs index 00c4ba6..1191e0e 100644 --- a/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicManifestBuilder.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicManifestBuilder diff --git a/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs b/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs index 2d9e13d..bccd192 100644 --- a/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs +++ b/MagicSettings/MagicSettings/ControlPlane/MagicSettingsControlPlane.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicSettingsControlPlane : IMagicSettingsControlPlane where TSettings : class, new() diff --git a/MagicSettings/MagicSettings/Diagnostics/MagicSettingExplanation.cs b/MagicSettings/MagicSettings/Diagnostics/MagicSettingExplanation.cs new file mode 100644 index 0000000..6f20307 --- /dev/null +++ b/MagicSettings/MagicSettings/Diagnostics/MagicSettingExplanation.cs @@ -0,0 +1,8 @@ +namespace MagicSettings; + +public sealed record MagicSettingExplanation( + string Path, + string? EffectiveValue, + string EffectiveSource, + IReadOnlyList Sources, + bool IsSensitive); diff --git a/MagicSettings/MagicSettings/Diagnostics/MagicSettingSourceValue.cs b/MagicSettings/MagicSettings/Diagnostics/MagicSettingSourceValue.cs new file mode 100644 index 0000000..c352aba --- /dev/null +++ b/MagicSettings/MagicSettings/Diagnostics/MagicSettingSourceValue.cs @@ -0,0 +1,3 @@ +namespace MagicSettings; + +public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value); diff --git a/MagicSettings/MagicSettings/GlobalUsings.cs b/MagicSettings/MagicSettings/GlobalUsings.cs new file mode 100644 index 0000000..071e24f --- /dev/null +++ b/MagicSettings/MagicSettings/GlobalUsings.cs @@ -0,0 +1,18 @@ +global using System.Collections; +global using System.Collections.Concurrent; +global using System.ComponentModel.DataAnnotations; +global using System.Globalization; +global using System.Net.Http.Headers; +global using System.Net.Security; +global using System.Reflection; +global using System.Security.Cryptography; +global using System.Security.Cryptography.X509Certificates; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using MagicSettings.Share; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs b/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs deleted file mode 100644 index 5c9edb3..0000000 --- a/MagicSettings/MagicSettings/Internal/MagicSettingExplanation.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings; - -public sealed record MagicSettingExplanation( - string Path, - string? EffectiveValue, - string EffectiveSource, - IReadOnlyList Sources, - bool IsSensitive); diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs b/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs deleted file mode 100644 index b5320a4..0000000 --- a/MagicSettings/MagicSettings/Internal/MagicSettingSourceValue.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings; - -public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value); diff --git a/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs b/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs deleted file mode 100644 index 8526a59..0000000 --- a/MagicSettings/MagicSettings/Internal/MagicSettingsChangedEventArgs.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - -namespace MagicSettings; - -public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class; diff --git a/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs index e1e99d9..38b5f0b 100644 --- a/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs +++ b/MagicSettings/MagicSettings/Metadata/MagicRemoteOverrideAttribute.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; [AttributeUsage(AttributeTargets.Property)] diff --git a/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs index 0a98ec7..de65022 100644 --- a/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs +++ b/MagicSettings/MagicSettings/Metadata/MagicSensitiveAttribute.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; [AttributeUsage(AttributeTargets.Property)] diff --git a/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs b/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs index 34f8693..d07b1ea 100644 --- a/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs +++ b/MagicSettings/MagicSettings/Metadata/MagicSettingDescriptionAttribute.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; [AttributeUsage(AttributeTargets.Property)] diff --git a/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs b/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs index 2937b8c..a8a227f 100644 --- a/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs +++ b/MagicSettings/MagicSettings/Migrations/MagicMigrationContext.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicMigrationContext diff --git a/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs b/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs index 66f683f..5348e53 100644 --- a/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs +++ b/MagicSettings/MagicSettings/Migrations/MagicMigrationRunner.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicMigrationRunner diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsChangedEventArgs.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsChangedEventArgs.cs new file mode 100644 index 0000000..1592dd7 --- /dev/null +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsChangedEventArgs.cs @@ -0,0 +1,3 @@ +namespace MagicSettings; + +public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class; diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs index 512b6ba..5ab8e01 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsCommandLine.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed record MagicSettingsCommandLine(bool GenerateOnly, bool ForceGenerate, bool ValidateOnly, bool PrintPath) diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs index 5a27bb0..fa4884c 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsHostedService.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicSettingsHostedService : BackgroundService where TSettings : class, new() diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs index 471bca8..5b128c7 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationExtensions.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicSettingsInitializationExtensions diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs index 25b90e8..a130064 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsInitializationResult.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed record MagicSettingsInitializationResult(bool ShouldExit, int ExitCode, string SettingsPath, string Environment); diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs index 63335a0..0611efd 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntime.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicSettingsRuntime : IMagicSettings where TSettings : class, new() diff --git a/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs index daf98c2..c28fbfa 100644 --- a/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs +++ b/MagicSettings/MagicSettings/Runtime/MagicSettingsRuntimeRegistration.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed record MagicSettingsRuntimeRegistration(string SettingsPath); diff --git a/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs b/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs index 5fdb0b4..7865a26 100644 --- a/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs +++ b/MagicSettings/MagicSettings/Secrets/MagicControlPlaneSecretProvider.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal sealed class MagicControlPlaneSecretProvider : IMagicSecretProvider where TSettings : class, new() diff --git a/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs b/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs index 48f18fb..b1c026c 100644 --- a/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs +++ b/MagicSettings/MagicSettings/Secrets/MagicSecretLease.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicSecretLease : IAsyncDisposable diff --git a/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs b/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs index e831fb2..2a7ce76 100644 --- a/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs +++ b/MagicSettings/MagicSettings/Security/FileMagicNodeIdentityStore.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class FileMagicNodeIdentityStore : IMagicNodeIdentityStore diff --git a/MagicSettings/MagicSettings/Security/MagicHash.cs b/MagicSettings/MagicSettings/Security/MagicHash.cs index cece779..d3d9721 100644 --- a/MagicSettings/MagicSettings/Security/MagicHash.cs +++ b/MagicSettings/MagicSettings/Security/MagicHash.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicHash diff --git a/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs b/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs index 9d26ca9..cef0239 100644 --- a/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs +++ b/MagicSettings/MagicSettings/Security/MagicHttpClientBuilderExtensions.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicHttpClientBuilderExtensions diff --git a/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs b/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs index 8e49a31..0d52183 100644 --- a/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs +++ b/MagicSettings/MagicSettings/Security/MagicIdentityCryptography.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; internal static class MagicIdentityCryptography diff --git a/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs b/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs index 3f532cf..6944d50 100644 --- a/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs +++ b/MagicSettings/MagicSettings/Security/MagicIdentityPathResolver.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public static class MagicIdentityPathResolver diff --git a/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs b/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs index 439d291..48418fe 100644 --- a/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs +++ b/MagicSettings/MagicSettings/Security/MagicNodeAuthenticationHandler.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicNodeAuthenticationHandler : DelegatingHandler diff --git a/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs b/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs index d0dd0d7..3edb38b 100644 --- a/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs +++ b/MagicSettings/MagicSettings/Security/MagicNodeIdentityManager.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed class MagicNodeIdentityManager : IMagicNodeIdentityManager, IMagicNodeAuthenticator diff --git a/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs b/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs index 1d0468f..b018732 100644 --- a/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs +++ b/MagicSettings/MagicSettings/Security/MagicStoredNodeIdentity.cs @@ -1,22 +1,3 @@ -using System.Text.Json.Nodes; -using MagicSettings.Share; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using System.Collections; -using System.Globalization; -using System.ComponentModel.DataAnnotations; - namespace MagicSettings; public sealed record MagicStoredNodeIdentity( From ee6c0a13e5c12a7982063d5039332ce222ab6eaa Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:47:05 -0400 Subject: [PATCH 7/9] Document source layout --- wiki/source-layout.md | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 wiki/source-layout.md diff --git a/wiki/source-layout.md b/wiki/source-layout.md new file mode 100644 index 0000000..40a3d08 --- /dev/null +++ b/wiki/source-layout.md @@ -0,0 +1,55 @@ +# Source layout + +MagicSettings uses one focused top-level type per source file. File names match the primary type they contain, and feature folders describe the responsibility of that type. Avoid reintroducing numbered catch-all files such as `Source.01.cs`. + +## `MagicSettings` + +| Folder | Responsibility | +|---|---| +| `Abstractions` | Public interfaces and extension contracts consumed or implemented by applications. | +| `Configuration` | Persistent document handling, path/environment resolution, configuration providers, merge policies, and options. | +| `ControlPlane` | Endpoint resolution, synchronization orchestration, schema manifests, trust, and HTTP transport. | +| `Diagnostics` | Public value-provenance and explanation models. | +| `Metadata` | Attributes used to describe settings and remote-override behavior. | +| `Migrations` | Migration context and sequential migration execution. | +| `Runtime` | Host initialization, hosted reload/polling services, runtime snapshots, and command-line behavior. | +| `Security` | Node identity storage, signing, request authentication, hashing, rotation, and HTTP authentication helpers. | +| `Secrets` | Explicit asynchronous secret retrieval and secret leases. | + +Project-wide imports live in `GlobalUsings.cs`; individual files should not carry a copied wall of unrelated imports. + +## `MagicSettings.Share` + +Shared protocol contracts remain grouped by protocol concern rather than split into one file per tiny record. These files define configuration, control-plane, proof, secret, security, and synchronization wire contracts used by both client and server packages. + +## `MagicSettings.Server` + +| Folder | Responsibility | +|---|---| +| `Abstractions` | Persistence and secret-resolution interfaces supplied by a control-plane host. | +| `Authentication` | HTTP proof extraction, signature verification, request binding, and replay defense. | +| `Credentials` | Approval, revocation, registration, and rotation lifecycle services. | +| `InMemory` | Test/development implementations of registries, record stores, and replay caches. | +| `Secrets` | Authenticated server-side secret resolution. | +| `Synchronization` | Node synchronization requests, remote records, and migration-review retention. | + +## `MagicSettings.Tests` + +Tests are grouped by the behavior they verify: + +- `Configuration` for generation, precedence, endpoint bootstrap, arrays, and migrations. +- `Security` for enrollment, identity, credentials, proofs, rotation, revocation, and secrets. +- `Server` for server-side retention and safety behavior. +- `Infrastructure` for test-only settings models and temporary-directory helpers. + +## Placement rules + +When adding code: + +1. Create a file named after its primary top-level type. +2. Place it in the narrowest existing feature folder. +3. Add a new feature folder only when the responsibility is genuinely distinct. +4. Keep protocol DTOs in `MagicSettings.Share` when both client and server must understand them. +5. Keep storage implementations out of the core runtime; depend on abstractions instead. +6. Keep tests beside the feature category they validate, not in numbered aggregate files. +7. Preserve public namespaces when moving files. Folder structure is for navigation and does not require namespace churn. From a1b0e682e9a0842a6467931cf6e796ad1ca57a1e Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:47:14 -0400 Subject: [PATCH 8/9] Link source layout guide --- wiki/README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/wiki/README.md b/wiki/README.md index 613535a..4d3d3f6 100644 --- a/wiki/README.md +++ b/wiki/README.md @@ -10,15 +10,16 @@ MagicSettings is intentionally split into three packages: 1. [API reference](api-reference.md) 2. [AI agent usage guide](agent-usage.md) -3. [Getting started](getting-started.md) -4. [Architecture](configuration.md) -5. [Configuration precedence](configuration.md) -6. [Paths and environments](configuration.md) -7. [Reconciliation and arrays](configuration.md) -8. [Migrations](migrations.md) -9. [Providers](configuration.md) -10. [Control-plane integration](control-plane.md) -11. [Secrets](secrets-and-security.md) -12. [Diagnostics](configuration.md) -13. [Security boundaries](secrets-and-security.md) -14. [Testing](testing.md) +3. [Source layout](source-layout.md) +4. [Getting started](getting-started.md) +5. [Architecture](configuration.md) +6. [Configuration precedence](configuration.md) +7. [Paths and environments](configuration.md) +8. [Reconciliation and arrays](configuration.md) +9. [Migrations](migrations.md) +10. [Providers](configuration.md) +11. [Control-plane integration](control-plane.md) +12. [Secrets](secrets-and-security.md) +13. [Diagnostics](configuration.md) +14. [Security boundaries](secrets-and-security.md) +15. [Testing](testing.md) From aab4a1fd6db7c22ba253d5e923d6be477d2b15c2 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:47:36 -0400 Subject: [PATCH 9/9] Link source layout guide --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ce85fb9..9c65647 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ The default HTTP transport signs the secret name and request target. It never wr - [API reference](wiki/api-reference.md) - [AI agent usage guide](wiki/agent-usage.md) +- [Source layout](wiki/source-layout.md) - [Getting started](wiki/getting-started.md) - [Architecture and ownership](wiki/configuration.md) - [Configuration precedence](wiki/configuration.md)