diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 3f650a81..3678d646 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -166,6 +166,7 @@ jobs: permissions: contents: read packages: write + actions: read strategy: fail-fast: false matrix: @@ -183,6 +184,7 @@ jobs: env: VERSION: ${{ needs.verify.outputs.version }} REVISION: ${{ needs.verify.outputs.revision }} + RELEASE_BUILD_RUN_ID: ${{ needs.verify.outputs.run_id }} NETRATEL_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/source steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -193,7 +195,7 @@ jobs: with: ref: ${{ needs.verify.outputs.tag }} path: source - - name: Build, scan, and push approved image + - name: Build, scan, and journal approved image before push shell: bash run: | set -euo pipefail @@ -210,11 +212,65 @@ jobs: --version "$VERSION" --revision "$REVISION" bash tools/ci/scan-public-image.sh \ --image "$tag" --version "$VERSION" --revision "$REVISION" + mkdir -p "$RUNNER_TEMP/image-journal" "$RUNNER_TEMP/review-inputs" + gh run download "$RELEASE_BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "netratel-${VERSION}-linux-x64" \ + --name "netratel-client-${VERSION}-linux-x64" \ + --name "netratel-client-${VERSION}-win-x64" \ + --name "netratel-client-${VERSION}-osx-arm64" \ + --dir "$RUNNER_TEMP/review-inputs" + python3 "$GITHUB_WORKSPACE/tooling/tools/ci/promote-release.py" receipt \ + --inputs "$RUNNER_TEMP/review-inputs" \ + --output "$RUNNER_TEMP/image-journal/original-input-receipt.json" \ + --run-id "$RELEASE_BUILD_RUN_ID" + local_config="$(docker image inspect "$tag" --format '{{.Id}}')" + python3 "$GITHUB_WORKSPACE/tooling/tools/ci/promote-release.py" image-journal \ + --inputs "$RUNNER_TEMP/review-inputs" \ + --receipt "$RUNNER_TEMP/image-journal/original-input-receipt.json" \ + --output "$RUNNER_TEMP/image-journal/prepared.json" \ + --component "$component" --phase prepared --local-image-config "$local_config" + - name: Preserve authenticated prepared image journal before registry write + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-image-${{ matrix.component }}-prepared-attempt-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/image-journal/prepared.json + ${{ runner.temp }}/image-journal/original-input-receipt.json + if-no-files-found: error + retention-days: 14 + - name: Push approved image and journal exact published digest + shell: bash + run: | + set -euo pipefail + cd "$NETRATEL_RELEASE_SOURCE_ROOT" + test "$(git rev-parse HEAD)" = "$REVISION" + component='${{ matrix.component }}' + tag="ghcr.io/bostontechnologies/netratel-${component}:${VERSION}" + # Recheck immediately before the write; a prepared journal does not + # authorize replacing or repairing an existing remote image tag. + python3 "$GITHUB_WORKSPACE/tooling/tools/ci/promote-release.py" registry-check \ + --version "$VERSION" --component "$component" docker push "$tag" digest="$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | tr -d '"')" [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] printf '%s\n' "ghcr.io/bostontechnologies/netratel-${component}@${digest}" \ > "$RUNNER_TEMP/${component}.digest" + cp "$RUNNER_TEMP/${component}.digest" "$RUNNER_TEMP/image-journal/${component}.digest" + local_config="$(docker image inspect "$tag" --format '{{.Id}}')" + python3 "$GITHUB_WORKSPACE/tooling/tools/ci/promote-release.py" image-journal \ + --inputs "$RUNNER_TEMP/review-inputs" \ + --receipt "$RUNNER_TEMP/image-journal/original-input-receipt.json" \ + --output "$RUNNER_TEMP/image-journal/digest-recorded.json" \ + --component "$component" --phase digest-recorded \ + --local-image-config "$local_config" --image-digest "$(cat "$RUNNER_TEMP/${component}.digest")" + - name: Preserve image phase evidence after success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-image-${{ matrix.component }}-result-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/image-journal + if-no-files-found: error + retention-days: 14 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: promoted-${{ matrix.component }}-digest @@ -304,6 +360,16 @@ jobs: --output "$RUNNER_TEMP/promoted-release" \ --state "$RUNNER_TEMP/promotion-state.json" (cd "$RUNNER_TEMP/promoted-release" && sha256sum -c SHA256SUMS) + - name: Preserve exact completed bytes before public asset publication + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-finish-prepared-attempt-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/release-receipt.json + ${{ runner.temp }}/promotion-state.json + ${{ runner.temp }}/promoted-release + if-no-files-found: error + retention-days: 14 - name: Attach and verify public release assets shell: bash run: | @@ -316,3 +382,14 @@ jobs: gh release download "v${VERSION}" --repo "$GITHUB_REPOSITORY" \ --dir "$RUNNER_TEMP/public-downloads" (cd "$RUNNER_TEMP/public-downloads" && sha256sum -c SHA256SUMS) + - name: Preserve exact Finish receipt state and staged bytes after success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-finish-attempt-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/release-receipt.json + ${{ runner.temp }}/promotion-state.json + ${{ runner.temp }}/promoted-release + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index 1fbd2ff7..8f225cce 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ normal operation. - **CLI**, **stdio MCP**, and **HTTP MCP** provide separately authorized automation interfaces. - **Client** is the native agent installed on managed machines. +- **RatelDesk connector** provides tenant-scoped protected configuration and + preview for monitoring flows; see [Monitoring flows](docs/FLOWS.md). ## Self-hosting @@ -60,12 +62,18 @@ test with: ```sh dotnet restore NetRatel.sln dotnet build NetRatel.sln --configuration Release --no-restore -dotnet test NetRatel.sln --configuration Release --no-build +bash tools/ci/run-fast-regressions.sh TestResults/fast ``` `tools/ci/verify-product-version.sh` verifies that all first-party projects evaluate to the release-manifest version. +Required CI runs fast unit, component and focused API/PostgreSQL regressions +after one restore/build. Full service-link pairs, physical incidents, native +client execution, browser acceptance and deployment/upgrade checks are opt-in +through the manual integration workflow. See [release validation](docs/RELEASES.md) +for selecting one functional suite and interpreting pending owner acceptance. + ## Contributing and security See [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), diff --git a/docs/RATELDESK-CONNECTOR.md b/docs/RATELDESK-CONNECTOR.md new file mode 100644 index 00000000..9c1f9b50 --- /dev/null +++ b/docs/RATELDESK-CONNECTOR.md @@ -0,0 +1,140 @@ +# RatelDesk incident connector + +The connector sends an incident to a saved, tenant-owned RatelDesk destination. +Delivery requires the authenticated `rateldesk.incident-create.v1` receiver, +its registered NetRatel producer namespace, and the exact approved organization +and requester customer. Saving settings or completing OAuth setup alone does +not enable delivery. Release interoperability evidence is recorded separately. + +## Setup + +1. Open `/flows/connectors` and choose a tenant for which you have current + connector-management access. Load approved connections and producer. This + displays the existing persisted Flow producer and the installation identity + separately; their GUIDs may differ. +2. For reciprocal connections, approve the existing Flow producer before + incident pairing. Adoption also requires current instance integration + management and an interactive human account. It cannot replace an existing + different producer or change a previous attempt. Review reciprocal + connections through the shared connection panel and select an approved + incident connection for the same tenant. +3. Alternatively, select registered API credential mode. A RatelDesk human + administrator registers the displayed Flow producer against the dedicated + API-purpose credential and the explicit organization/customer. Store its + `rdk_` bearer through the protected credential field. It is never an OAuth + client secret. A replacement API credential needs an approved binding to the + existing RatelDesk namespace; revocation of the predecessor is separate. +4. Save the exact API base, including any configured path base, and mapping. + Reciprocal selection supplies its approved base and organization/customer; + assignment, categories, and severity-to-priority remain explicit. +5. Run the authenticated receiver check. It reads capabilities and validates + the exact source and target without creating an incident. A failed or + interrupted new check invalidates the preceding readiness observation. The + catalog also rechecks current local owner, source, profile and deployment + policy and treats observations older than two minutes as unavailable. + +The connection supplies managed credentials from the existing approved profile. +Rotate or revoke them in reciprocal connection management. API credential +rotation updates only the protected credential revision; neither mode copies +secrets into a Flow graph, prepared action, receipt, browser DTO, or audit. +Changing mode, peer, mapping or other semantics advances the connector revision +and cannot retarget queued work. A mode change discards the prior manual secret. + +## Deployment restrictions and transport bounds + +Optional `RatelDesk:AllowedOrigins` retains exact root HTTPS origin restrictions. +`RatelDesk:AllowedApiBases` additionally restricts exact API/path bases. +`RestrictToConfiguredPeers=true` denies traffic when both restrictions are empty. +Otherwise an empty optional restriction permits the current durable, +owner-approved connector or approved link, rather than discovery alone. +Manual private HTTP requires `RatelDesk:AllowPrivateHttp`; managed mode follows +existing service-link/service-identity private HTTP policy. Browser input and +remote metadata cannot change those permissions or TLS trust. + +```json +{ + "RatelDesk": { + "AllowedOrigins": ["https://helpdesk.example"], + "AllowedApiBases": ["https://helpdesk.example/support"], + "RestrictToConfiguredPeers": true + } +} +``` + +Every operation validates the captured API identity and fixed route. DNS and +all resolved IPv4/IPv6 addresses are checked before each new owning socket; +policy is rechecked after DNS and connection. Redirects, proxies, cookies, +automatic transport retries and socket pooling are disabled. TLS retains its +normal hostname and certificate validation. Bounds remain ten seconds per +HTTP operation within the original twenty-second Flow execution budget, +32 KiB requests, 128 KiB responses, eight global/two tenant/one connector +operations, and bounded 1–300 second Retry-After scheduling. No HTTP occurs +inside a Flow database transaction. + +## Durable delivery and recovery + +Read-only preparation captures receiver/source/namespace, semantic connection +and mapping revisions, fixed endpoints, receiver guarantees, exact wire body, +receiver fingerprint, and wire key. Credentials are freshly resolved for this +captured target on each operation; secret-only rotation cannot change the +snapshot. Target validation is not required to read an already accepted +receipt after category deactivation or assignee changes. + +The historical Flow local key remains unchanged. New wire keys use a +SHA-256 domain-separated projection of source, occurrence, event, immutable +Flow version and node GUIDs. Previously durable conforming wire keys remain +exact. A new key is never used to escape uncertainty or fingerprint conflict. +Frozen receiver fingerprint normalization is separate from the preserved V1 +Flow semantic fingerprint. V1 prepared records and their immutable hashes are +not rewritten; new receiver evidence is an additive V2 sidecar saved atomically +with initial preparation under the existing lease fence. + +Before HTTP, the existing Flow/Monitoring/current-authority guards admit the +operation and persist `MayHaveCommitted`. A verified 201 create, 200 replay or +200 lookup must match the complete immutable receipt, top-level IDs, captured +namespace/key/fingerprint/map and relative Location. Missing, malformed, +redirected, oversized, canceled or transport-failed responses cannot erase a +possible remote commit. Recovered work looks up the same key first. Only a +verified safe same-key/body replay with current authority and the original +horizon can POST again. A 404 lookup does not prove that an in-flight commit +cannot succeed; a 410 never permits a new creation. + +At most five POST attempts are admitted. An ambiguous fifth attempt can receive +one durably consumed read-only reconciliation turn, without a sixth POST or a +new action identity. Original run budgets and the 24-hour automatic age survive +restart. Exhaustion with an unconfirmed send remains `DeliveryUnknown`, and +uncertain history is retained rather than pruned as ordinary failure. Receipt +persistence rechecks current tenant authority and the exact lease; a stale +worker cannot finish another worker's action. Flow disablement or suppression +still prevents a new POST while an authorized read-only lookup may recover an +original receipt. External navigation remains absent until a real Web route is +verified; the accepted API Location and original receipt are retained privately. + +## Tenant API and Web behavior + +Under `/api/v2/tenants/{tenantId}/connectors/rateldesk`: + +- `GET` and `GET /{id}` return current configuration/readiness summaries. +- `GET /setup` resolves the existing Flow producer and eligible tenant links. +- `POST /setup/source` takes only `expectedIdentityRevision` and adopts that + existing producer through the foundation human-administrator operation. +- `PUT /{id}` saves configuration and explicit authentication reference using + `expectedRevision`; zero creates a new connector. +- `POST /{id}/credential` rotates only API-purpose credentials using + `expectedCredentialRevision`; managed rotation uses connection management. +- `POST /{id}/connection-test` performs authenticated capability/target reads. +- `POST /{id}/dry-run` returns an inert sanitized preview and sends no incident. + +Every route requires current connector-management permission for the exact +tenant before reading configuration. Adoption additionally requires interactive +account and instance integration-management authority. Dispatch retains the +original configuring principal/credential and current SecretUse checks; service +transport authority never supplies a human user FK. Cookie-backed mutations +retain `X-NetRatel-Account-Request: 1`; responses use no-store. + +The Web client retains its 30-second operation and streamed 1 MiB response bound. +Dirty drafts, pending-write guards, stable new connector identity, late-response +fences, tenant changes and password clearing remain. The 256-record tenant +admission uses its existing transaction lock and optimistic row CAS. The UI +shows connection status and receiver delivery readiness separately; no hidden +test-incident action is exposed. diff --git a/docs/contracts/rateldesk-receiver-proposal.v1.json b/docs/contracts/rateldesk-receiver-proposal.v1.json new file mode 100644 index 00000000..96cfc3aa --- /dev/null +++ b/docs/contracts/rateldesk-receiver-proposal.v1.json @@ -0,0 +1,20 @@ +{ + "schema": "netratel.rateldesk.receiver.proposal.v1", + "status": "proposal-only-not-implemented-or-negotiated", + "reviewedReceiverCommit": "9a9a8abd2578dc31d1ec500dd3893478f8fa2749", + "normalCreateEndpoint": "/api/v1/incidents/", + "requiresSeparateReceiverScope": true, + "requiredGuarantees": [ + "Authenticated API-purpose credential plus current organization/customer authorization before receipt disclosure", + "Authorized source-instance namespace plus stable action key and canonical semantic payload fingerprint", + "Same source/key/fingerprint returns original incident ID and tracking ID", + "Same source/key with different semantic fingerprint fails without another side effect", + "Incident, receipt and deterministic confirmation outbox effect commit atomically", + "Concurrent replay, response loss after commit and process restart preserve one incident and one confirmation enqueue", + "A read-only versioned capability and target-validation response explicitly establishes these guarantees", + "Authenticated receipt reconciliation handles an ambiguous result without changing the action key" + ], + "productionCapabilityEndpoint": null, + "productionIdempotencyHeaders": null, + "automaticDeliveryEnabled": false +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ApiFactory.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ApiFactory.cs index a8f804b3..a547c6d7 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ApiFactory.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ApiFactory.cs @@ -125,6 +125,11 @@ async ValueTask IAsyncDisposable.DisposeAsync() } } + // Physical tests copy only ready disposable configuration into private startup + // files. Returning this dictionary neither starts an API host nor seeds runtime authority. + internal IReadOnlyDictionary PhysicalRuntimeSettings() => + _settings.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + public ApiFactory CreateRuntimeSibling(IReadOnlyDictionary overrides) { ArgumentNullException.ThrowIfNull(overrides); diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/CommittedIncidentResponseLoss.cs b/src/NetRatel/NetRatel.API.IntegrationTests/CommittedIncidentResponseLoss.cs new file mode 100644 index 00000000..8abed77c --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/CommittedIncidentResponseLoss.cs @@ -0,0 +1,167 @@ +// Fault injection in the actual transparent peer fixture. A loss is successful +// only after an independent committed-row read of the original production action. +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed record PhysicalReceiptIdentity(Guid SourceInstanceId, Guid NamespaceId, string Key, + string Fingerprint, string IncidentId, Guid ReceiptId, Guid ConfirmationEffectId); +internal sealed record PhysicalIncidentForwarding(Guid SourceInstanceId, string Key, string RequestBytesSha256); + +internal sealed class CommittedIncidentResponseLoss : IAsyncDisposable +{ + private readonly object sync = new(); + private readonly Action? onDispose; + private readonly string createPath; + private readonly string receiptPrefix; + private readonly Guid approvedSource; + private readonly Guid approvedNamespace; + private readonly Func> independentCommittedRead; + private readonly TaskCompletionSource committed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource restartedRecovery = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource released = new(TaskCreationOptions.RunContinuationsAsynchronously); + private PhysicalIncidentForwarding? claimed; + private bool firstClaimed; + private bool processesRestarted; + private bool disposed; + + public CommittedIncidentResponseLoss(Uri actualApprovedPeerApiBase, Guid source, Guid receiverNamespace, + Func> independentCommittedRead, + Action? onDispose = null) + { + if (!actualApprovedPeerApiBase.IsAbsoluteUri || actualApprovedPeerApiBase.UserInfo.Length != 0 || + actualApprovedPeerApiBase.Query.Length != 0 || actualApprovedPeerApiBase.Fragment.Length != 0 || + source == Guid.Empty || receiverNamespace == Guid.Empty) + throw new ArgumentException("An actual current approved peer and source/receiver namespace are required."); + // Match the exact receiver contract paths with the approved path base. + var pathBase = actualApprovedPeerApiBase.AbsolutePath.TrimEnd('/'); + createPath = pathBase + "/api/v1/incidents/"; + receiptPrefix = pathBase + "/api/v1/integrations/netratel/incident-receipts/"; + approvedSource = source; + approvedNamespace = receiverNamespace; + this.independentCommittedRead = independentCommittedRead; + this.onDispose = onDispose; + } + + public bool Matches(HttpContext context) => !context.Request.QueryString.HasValue && + (HttpMethods.IsPost(context.Request.Method) && context.Request.Path == createPath || + HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Value?.StartsWith(receiptPrefix, StringComparison.Ordinal) == true); + + public Task Committed => committed.Task; + public Task PostRestartRecovery => restartedRecovery.Task; + + // The transparent proxy has already bounded a candidate request to131072 + // bytes and forwards those exact bytes. No bearer or arbitrary JSON survives. + public async Task BeforeForwardAsync(HttpContext context, ReadOnlyMemory exactRequestBytes) + { + if (context.Request.QueryString.HasValue) return false; + var isCreate = HttpMethods.IsPost(context.Request.Method) && context.Request.Path == createPath; + var isLookup = HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Value?.StartsWith(receiptPrefix, StringComparison.Ordinal) == true; + if (!isCreate && !isLookup) return false; + var sourceValues = context.Request.Headers["X-NetRatel-Source-Instance"]; + if (sourceValues.Count != 1 || !Guid.TryParseExact(sourceValues[0], "D", out var source) || source != approvedSource) + return false; + var keyValues = context.Request.Headers["Idempotency-Key"]; + var key = isCreate ? keyValues.Count == 1 ? keyValues[0] : null : + context.Request.Path.Value![receiptPrefix.Length..]; + if (!ValidKey(key)) return false; + var observed = new PhysicalIncidentForwarding(source, key!, + Convert.ToHexStringLower(SHA256.HashData(exactRequestBytes.Span))); + bool first; + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (!firstClaimed) + { + if (!isCreate) return false; + firstClaimed = true; + claimed = observed; + first = true; + } + else + { + if (claimed!.SourceInstanceId != source || claimed.Key != key) return false; + if (isCreate && observed.RequestBytesSha256 != claimed.RequestBytesSha256) + throw new InvalidOperationException("The same actual receiver key was retried with changed body bytes."); + if (processesRestarted) restartedRecovery.TrySetResult(observed); + first = false; + } + } + if (first) return true; // HookAfterActualResponse applies only to this claim. + // Keep the original production transport deadline and cancellation; do + // not manufacture502/404, change retries, or bypass an HTTP limiter. + await released.Task.WaitAsync(context.RequestAborted); + return false; + } + + public async Task AfterActualResponseAsync(HttpContext downstream, HttpResponseMessage actualUpstream, + ReadOnlyMemory exactResponseBytes, bool isClaimedFirstCreate) + { + if (!isClaimedFirstCreate) return; + try + { + if (actualUpstream.StatusCode != HttpStatusCode.Created || exactResponseBytes.Length is <= 0 or > 131072) + throw new InvalidOperationException("The actual first receiver create did not return bounded HTTP201."); + using var response = JsonDocument.Parse(exactResponseBytes, new JsonDocumentOptions { MaxDepth = 32 }); + var receipt = response.RootElement.GetProperty("integrationReceipt"); + PhysicalIncidentForwarding request; + lock (sync) request = claimed ?? throw new InvalidOperationException("The receiver claim disappeared."); + if (receipt.GetProperty("contractVersion").GetString() != "rateldesk.incident-create.v1" || + receipt.GetProperty("sourceInstanceId").GetString() != approvedSource.ToString("D") || + receipt.GetProperty("sourceNamespaceId").GetString() != approvedNamespace.ToString("D") || + receipt.GetProperty("key").GetString() != request.Key || receipt.GetProperty("outcome").GetString() != "committed") + throw new InvalidOperationException("The actual receiver result differs from the approved action identity."); + // This delegate must use a new read-only PostgreSQL scope and the + // actual durable V2 action/receipt/incident/effects rows. No transaction + // or DB lock spans HTTP; receipt fake/seed/response-only proof is forbidden. + var proof = await independentCommittedRead(request, response.RootElement, downstream.RequestAborted); + if (proof.SourceInstanceId != approvedSource || proof.NamespaceId != approvedNamespace || + proof.Key != request.Key || proof.Fingerprint != receipt.GetProperty("fingerprint").GetString() || + proof.IncidentId != receipt.GetProperty("incidentId").GetString() || proof.ReceiptId == Guid.Empty || + proof.ConfirmationEffectId == Guid.Empty) + throw new InvalidOperationException("The independent durable receipt/incident/confirmation read did not match."); + // Complete only after the real receiver201 and independent committed + // row read. The original response has not been copied downstream. + downstream.Abort(); + committed.TrySetResult(proof); + } + catch (Exception error) + { + committed.TrySetException(new InvalidOperationException("Actual after-commit loss proof failed.", error)); + throw; // This is a failed acceptance attempt, never a successful loss. + } + } + + public void MarkActualProcessesRestarted() + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (!committed.Task.IsCompletedSuccessfully) + throw new InvalidOperationException("Actual receiver commit/loss must precede the verified process restart fence."); + processesRestarted = true; + } + } + + public void Release() => released.TrySetResult(); + private static bool ValidKey(string? value) => value is { Length: > 0 and <= 256 } && + value.All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '~' or '-'); + + public ValueTask DisposeAsync() + { + lock (sync) + { + if (disposed) return ValueTask.CompletedTask; + disposed = true; + claimed = null; + released.TrySetResult(); + committed.TrySetCanceled(); + restartedRecovery.TrySetCanceled(); + } + onDispose?.Invoke(); + return ValueTask.CompletedTask; + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskFixtureProcess.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskFixtureProcess.cs new file mode 100644 index 00000000..461d15b3 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskFixtureProcess.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +// Owns only the reviewed helper process and its private stdin/stdout. The root +// helper's scope is its new UUID directory, loop device and exact Client IDs. +internal sealed class PhysicalDiskFixtureProcess : IAsyncDisposable +{ + private readonly Process process; + private readonly Task diagnostics; + private readonly SemaphoreSlim exchange = new(1, 1); + private bool closed; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public JsonElement? Cleanup { get; private set; } + public bool OwnedMetadataRemoved { get; private set; } + + public PhysicalDiskFixtureProcess(string reviewedScript, string fixtureWorkRoot) + { + if (!OperatingSystem.IsLinux() || !Path.IsPathFullyQualified(reviewedScript) || + !File.Exists(reviewedScript) || !Path.IsPathFullyQualified(fixtureWorkRoot) || + !Directory.Exists(fixtureWorkRoot)) + throw new InvalidOperationException("The real Linux physical fixture requires its reviewed helper and owned disk-backed work root."); + var start = new ProcessStartInfo("sudo") + { + UseShellExecute = false, RedirectStandardInput = true, + RedirectStandardOutput = true, RedirectStandardError = true + }; + foreach (var argument in new[] { "-n", "python3", reviewedScript, "--work-root", fixtureWorkRoot }) + start.ArgumentList.Add(argument); + process = Process.Start(start) ?? throw new InvalidOperationException("The owned physical fixture process could not start."); + diagnostics = process.StandardError.ReadToEndAsync(); + } + + public Task ProvisionAsync(CancellationToken ct) => SendAsync("provision", new { }, ct); + + public Task StartClientAsync(string imageId, string sourceSha, string version, + string executableSha256, string trustedCaPem, string apiBaseUrl, string gatewayEndpoint, + string enrollmentCode, CancellationToken ct) => SendAsync("start", new + { + imageId, sourceSha, version, executableSha256, trustedCaPem, + apiBaseUrl, gatewayEndpoint, enrollmentCode + }, ct); + + public Task ReleaseCommandAsync(CancellationToken ct) => SendAsync("release-command", new { }, ct); + + public Task AllocateAsync(CancellationToken ct) => SendAsync("allocate", new { }, ct); + public Task RecoverAsync(CancellationToken ct) => SendAsync("recover", new { }, ct); + public Task AssertAliveAsync(CancellationToken ct) => SendAsync("alive", new { }, ct); + + private async Task SendAsync(string operation, object input, CancellationToken ct) + { + await exchange.WaitAsync(ct); + try + { + if (closed || process.HasExited) + throw new InvalidOperationException("The owned physical fixture process is unavailable."); + var id = Guid.NewGuid().ToString("N"); + var request = JsonSerializer.Serialize(new { id, operation, input }, Json); + if (Encoding.UTF8.GetByteCount(request) > 65_535) + throw new InvalidOperationException("The private physical fixture input exceeded its bound."); + // The enrollment code travels only in this private redirected pipe. + // Do not retain request bodies or include them in exceptions/logs. + await process.StandardInput.WriteLineAsync(request.AsMemory(), ct); + await process.StandardInput.FlushAsync(ct); + var line = await process.StandardOutput.ReadLineAsync(ct); + if (line is null || Encoding.UTF8.GetByteCount(line) > 65_536) + throw new InvalidOperationException("The typed physical fixture response was absent or oversized."); + try + { + using var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || root.EnumerateObject().Count() != 2 || + root.GetProperty("id").GetString() != id || + root.GetProperty("facts").ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("The typed physical fixture response did not match its request."); + return root.GetProperty("facts").Clone(); + } + catch (JsonException) + { + throw new InvalidOperationException("The typed physical fixture response was malformed."); + } + } + finally { exchange.Release(); } + } + + public async ValueTask DisposeAsync() + { + if (closed) return; + // Cleanup owns a separate bounded cancellation token so an expired + // scenario still stops its Client before normal unmount/detach. No + // producer lifetime or business protocol deadline is extended. + using var teardown = new CancellationTokenSource(TimeSpan.FromSeconds(55)); + Exception? failure = null; + try + { + if (!process.HasExited) + { + Cleanup = await SendAsync("close", new { }, teardown.Token); + foreach (var name in new[] { "ownedClientStoppedAndRemoved", "ownedMountRemoved", + "ownedLoopDetached", "ownedBackingRemoved", "privateCredentialsRemoved" }) + if (!Cleanup.Value.GetProperty(name).GetBoolean()) + throw new InvalidOperationException("Owned physical cleanup did not complete."); + if (Cleanup.Value.GetProperty("imagesRemoved").GetInt32() != 0 || + Cleanup.Value.GetProperty("fleetChanges").GetInt32() != 0) + throw new InvalidOperationException("Owned physical cleanup exceeded its scope."); + var ownership = Cleanup.Value.GetProperty("ownershipId").GetString(); + if (ownership is null || ownership.Length != 32 || !ownership.All(Uri.IsHexDigit)) + throw new InvalidOperationException("Owned cleanup receipt had no exact helper ownership identity."); + var acknowledged = await SendAsync("cleanup-ack", new { ownershipId = ownership }, teardown.Token); + OwnedMetadataRemoved = acknowledged.GetProperty("ownedMetadataRemoved").GetBoolean(); + if (!OwnedMetadataRemoved) + throw new InvalidOperationException("Consumed owned cleanup metadata was not removed."); + } + else throw new InvalidOperationException("The owned physical fixture exited without a positive cleanup receipt."); + process.StandardInput.Close(); + await process.WaitForExitAsync(teardown.Token); + if (process.ExitCode != 0) + throw new InvalidOperationException("The owned physical fixture cleanup process failed."); + } + catch (Exception error) + { + failure = error; + // EOF requests normal owned cleanup even when the response pipe or + // cancellation failed. Avoid SIGKILL before the helper can unmount. + process.StandardInput.Close(); + try { await process.WaitForExitAsync(teardown.Token); } + catch (OperationCanceledException) when (teardown.IsCancellationRequested) { } + } + finally + { + if (process.HasExited) + { + _ = await diagnostics; // Arbitrary sudo/OS diagnostics never enter public proof. + process.Dispose(); + closed = true; + } + } + if (failure is not null) + throw new InvalidOperationException("The physical fixture failed bounded owned cleanup; acceptance is incomplete.", failure); + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentAcceptance.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentAcceptance.cs new file mode 100644 index 00000000..a8a78063 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentAcceptance.cs @@ -0,0 +1,382 @@ +// Candidate physical orchestration: registered through PhysicalDiskIncidentTests. +// Runtime proof is emitted only after real product commands and owned cleanup succeed. +using System.Net; +using Xunit; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal static class PhysicalDiskIncidentAcceptance +{ + internal static void ReportProgress(Action? progress, string stage) + { + // Diagnostics must not replace the original physical operation or cleanup failure. + try { progress?.Invoke(stage); } catch { } + } + + public static async Task RunAsync(IPhysicalIncidentFixture runtime, CancellationToken outer, Action? progress = null) + { + void Mark(string stage) => ReportProgress(progress, stage); + // Product boot/source adoption/image verification happens before this + // call. The genuine Client enrolls before the ordinary pairing ceremony + // selects its newly created job target. The native + // lifetime is bounded to the existing 300-second physical-process limit. + using var budget = CancellationTokenSource.CreateLinkedTokenSource(outer); + budget.CancelAfter(TimeSpan.FromSeconds(300)); + var ct = budget.Token; + await using var ownership = runtime; // Stops containers before unmounting. + Mark("source-identity:start"); + await runtime.AssertExactCurrentSourcesAndPublishedCompanionAsync(ct); + Mark("source-identity:complete"); + Mark("empty-authority:start"); + await runtime.AssertNoFixtureSeededExecutionAuthorityAsync(ct); + Mark("empty-authority:complete"); + Mark("full-client:start"); + await runtime.StartFullProductionClientAsync(ct); + Mark("full-client:complete"); + Mark("admission:start"); + var admitted = await runtime.WaitRealFirstHeartbeatAndTelemetryAdmissionAsync(ct); + Mark("admission:complete"); + Assert.NotEqual(Guid.Empty, admitted.AgentId); + Assert.NotEqual(Guid.Empty, admitted.ConnectionId); + Assert.NotEqual(Guid.Empty, admitted.EvidenceStreamId); + Assert.True(admitted.ConnectionEpoch > 0 && admitted.CommittedOwner && admitted.ProductionSystemClock); + Assert.Equal("full-production-NetRatel.Client", admitted.Producer); + // Reuse the actual recorded-form-task HTTP ceremony with this genuinely + // enrolled AgentId; preserve native ACK/result/provider-callback proof. + Mark("recorded-task:start"); + await runtime.RunProtectedRecordedFormTaskAsync(admitted.AgentId, ct); + Mark("recorded-task:complete"); + Mark("second-replica:start"); + await runtime.StartSecondRealApiWorkerReplicaAsync(ct); + Mark("second-replica:complete"); + + Mark("baseline:start"); + var baseline = await UniqueCollectionsAsync(runtime, admitted.AgentId, after: default, + condition: _ => true, requiredSpan: TimeSpan.FromSeconds(10), ct); + Mark("baseline:complete"); + Assert.All(baseline, sample => Assert.Equal("/netratel-physical-disk", sample.Scope)); + Assert.All(baseline, sample => Assert.True(sample.FreeBytes >= 2L * 1024 * 1024 * 1024)); + Assert.True(baseline.Max(x => x.FreeBytes) - baseline.Min(x => x.FreeBytes) < 16L * 1024 * 1024); + const long physicalBytes = 256L * 1024 * 1024; + var minimum = baseline.Min(x => x.FreeBytes); + Assert.True(physicalBytes <= minimum / 10 && minimum - physicalBytes >= 2L * 1024 * 1024 * 1024); + var thresholds = new PhysicalThresholds(minimum - physicalBytes / 2, minimum - physicalBytes / 4, + HoldSeconds: 10, RecoveryHoldSeconds: 10, FreshnessSeconds: 65); + Mark("flow-configuration:start"); + var configured = await runtime.ConfigureOwnerPublishedFlowAndSelectedDiskRuleAsync(admitted.AgentId, + baseline[0].Scope, thresholds, ct); + Mark("flow-configuration:complete"); + Mark("dry-run:start"); + await runtime.ValidateAndDryRunWithoutEffectsAsync(configured, ct); + Mark("dry-run:complete"); + Mark("initial-durable-read:start"); + var before = await runtime.ReadScopedDurableProofAsync(configured, ct); + Mark("initial-durable-read:complete"); + RequireCount(before, 0); + + Mark("response-loss:start"); + await using var loss = await runtime.ArmOneActualReceiver201AfterCommitLossAsync(configured, ct); + Mark("response-loss:complete"); + Mark("first-allocation:start"); + var allocated = await runtime.AllocateOwnedVolumeAsync(physicalBytes, ct); + Mark("first-allocation:complete"); + Mark("first-breach:start"); + var firstBreach = await UniqueCollectionsAsync(runtime, admitted.AgentId, allocated.CompletedAtUtc, + sample => sample.FreeBytes < thresholds.BreachBytes, TimeSpan.FromSeconds(10), ct); + Mark("first-breach:complete"); + Mark("committed-receipt:start"); + var committed = await loss.WaitIndependentCommittedReceiptAsync(ct); + Mark("committed-receipt:complete"); + Assert.Equal(201, committed.ActualUpstreamStatus); + Assert.True(committed.TransactionCommitted && committed.ResponseAbortedAfterIndependentRead); + Assert.Equal(1, committed.IncidentRows); + Assert.Equal(1, committed.ReceiptRows); + Assert.Equal(1, committed.ConfirmationEnqueues); + Mark("ambiguous-action:start"); + var ambiguous = await runtime.WaitDurableMayHaveCommittedActionAsync(configured, ct); + Mark("ambiguous-action:complete"); + RequireCount(ambiguous, 1); + Assert.True(ambiguous.MayHaveCommitted && !ambiguous.ActionSucceeded); + Assert.Equal(committed.NamespaceId, ambiguous.Actions[0].SourceNamespaceId); + Assert.Equal(committed.Key, ambiguous.Actions[0].ReceiverKey); + Assert.Equal(committed.Fingerprint, ambiguous.Actions[0].ReceiverFingerprint); + // Loss holds only the identical action's real retry/lookup requests with + // the original RequestAborted token. It never fabricates a receiver reply. + Mark("worker-restart:start"); + await runtime.RestartRealApiWorkerReplicasWithPersistedStateAsync(ct); + Mark("worker-restart:complete"); + Mark("two-replicas:start"); + await runtime.AssertTwoActualWorkerReplicasAsync(ct); + Mark("two-replicas:complete"); + Mark("rotation:start"); + var rotation = await runtime.RotateThroughActualOwnerServiceLinkAsync(ct); + Mark("rotation:complete"); + Assert.True(rotation.AfterCredentialRevision > rotation.BeforeCredentialRevision); + Assert.Equal(rotation.BeforeSemanticRevision, rotation.AfterSemanticRevision); + Assert.Equal(rotation.BeforeSourceNamespaceId, rotation.AfterSourceNamespaceId); + Assert.Equal(ambiguous.Actions[0].SourceNamespaceId, rotation.AfterSourceNamespaceId); + Mark("same-key-recovery:start"); + await loss.ObserveRealSameKeyRecoveryAfterRestartAsync(ct); + Mark("same-key-recovery:complete"); + loss.ReleaseIdenticalRetryGate(); + Mark("verified-action:start"); + var completed = await runtime.WaitRealVerifiedReceiptAndActionSuccessAsync(configured, ct); + Mark("verified-action:complete"); + RequireCount(completed, 1); + Assert.True(completed.ActionSucceeded); + Assert.Equal(ambiguous.Actions, completed.Actions); + Assert.Equal(ambiguous.Occurrences, completed.Occurrences); + Assert.Equal(ambiguous.Incidents, completed.Incidents); + Assert.Equal(ambiguous.Receipts, completed.Receipts); + Assert.Equal(ambiguous.ConfirmationEffects, completed.ConfirmationEffects); + Mark("continued-breach:start"); + var stillBad = await UniqueCollectionsAsync(runtime, admitted.AgentId, allocated.CompletedAtUtc, + sample => sample.FreeBytes < thresholds.BreachBytes, TimeSpan.FromSeconds(10), ct); + Mark("continued-breach:complete"); + Mark("sustained-durable-read:start"); + var sustained = await runtime.ReadScopedDurableProofAsync(configured, ct); + Mark("sustained-durable-read:complete"); + RequireCount(sustained, 1); + Assert.Equal(completed.Actions, sustained.Actions); + + Mark("allocation-recovery:start"); + var recovered = await runtime.DeleteOnlyOwnedAllocationAsync(ct); + Mark("allocation-recovery:complete"); + Mark("recovery-collections:start"); + var recoverySamples = await UniqueCollectionsAsync(runtime, admitted.AgentId, recovered.CompletedAtUtc, + sample => sample.FreeBytes > thresholds.RecoveryBytes, TimeSpan.FromSeconds(10), ct); + Mark("recovery-collections:complete"); + Mark("occurrence-resolution:start"); + await runtime.WaitActualOccurrenceResolvedOnceAsync(configured, completed.Occurrences[0], ct); + Mark("occurrence-resolution:complete"); + Mark("recovery-durable-read:start"); + var recoveredProof = await runtime.ReadScopedDurableProofAsync(configured, ct); + Mark("recovery-durable-read:complete"); + RequireCount(recoveredProof, 1); + Assert.Equal(completed.Actions, recoveredProof.Actions); + Mark("second-allocation:start"); + var secondAllocation = await runtime.AllocateOwnedVolumeAsync(physicalBytes, ct); + Mark("second-allocation:complete"); + Mark("second-breach:start"); + var secondBreach = await UniqueCollectionsAsync(runtime, admitted.AgentId, secondAllocation.CompletedAtUtc, + sample => sample.FreeBytes < thresholds.BreachBytes, TimeSpan.FromSeconds(10), ct); + Mark("second-breach:complete"); + Mark("second-incident:start"); + var second = await runtime.WaitSecondActualOccurrenceAndVerifiedIncidentAsync(configured, ct); + Mark("second-incident:complete"); + RequireCount(second, 2); + Assert.Contains(completed.Actions[0], second.Actions); + Assert.Contains(completed.Occurrences[0], second.Occurrences); + Assert.All(second.Actions, action => Assert.Equal(rotation.AfterSourceNamespaceId, action.SourceNamespaceId)); + Assert.Equal(2, second.Actions.Select(x => x.ReceiverKey).Distinct().Count()); + Assert.Equal(2, second.Actions.Select(x => x.OccurrenceId).Distinct().Count()); + + // Cache the actually CURRENT successor after rotation, and prove it is + // authorized before unlink. A retired predecessor already returning401 + // would not establish that unlink caused the protected denial. + Mark("cached-authority:start"); + await using var cachedAuthorization = await runtime.CaptureActuallyIssuedBusinessAuthorizationPrivatelyAsync(ct); + Mark("cached-authority:complete"); + Assert.True(cachedAuthorization.ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(2)); + Mark("cached-authority-control:start"); + Assert.Equal(HttpStatusCode.OK, await runtime.ProtectedReceiverLookupWithCachedAuthorizationAsync( + cachedAuthorization, completed.Actions[0], ct)); + Mark("cached-authority-control:complete"); + Mark("peer-stop:start"); + await runtime.StopOnlyFixtureRatelDeskPeerAsync(ct); + Mark("peer-stop:complete"); + Mark("owner-unlink:start"); + await runtime.UnlinkThroughActualOwnerAsync(reason: "isolated physical acceptance complete", ct); + Mark("owner-unlink:complete"); + Mark("local-unlink:start"); + await runtime.AssertImmediateLocalSenderAndInboundAuthorityStoppedAsync(ct); + Mark("local-unlink:complete"); + Mark("settled-unlink:start"); + await runtime.RestoreFixtureRatelDeskAndSettleUnlinkAsync(ct); + Mark("settled-unlink:complete"); + Assert.True(cachedAuthorization.ExpiresAtUtc > DateTimeOffset.UtcNow.AddSeconds(20)); + Mark("cached-authority-denial:start"); + var deniedStatus = await runtime.ProtectedReceiverLookupWithCachedAuthorizationAsync(cachedAuthorization, + completed.Actions[0], ct); + Mark("cached-authority-denial:complete"); + var denialObservedAtUtc = DateTimeOffset.UtcNow; + Assert.Contains(deniedStatus, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + Assert.True(cachedAuthorization.ExpiresAtUtc > denialObservedAtUtc); + Mark("old-authority-stopped:start"); + await runtime.AssertNoOldAuthorizationCanIssueOrSendAsync(cachedAuthorization, ct); + Mark("old-authority-stopped:complete"); + var latestDenialObservedAtUtc = cachedAuthorization.LatestDenialObservedAtUtc + ?? throw new InvalidOperationException("The actual final cached-token denial observation is missing."); + Assert.True(latestDenialObservedAtUtc >= denialObservedAtUtc); + Assert.True(cachedAuthorization.ExpiresAtUtc > latestDenialObservedAtUtc); + var unlinkProof = new PhysicalUnlinkProof(200, (int)deniedStatus, + cachedAuthorization.ExpiresAtUtc, latestDenialObservedAtUtc, + cachedAuthorization.PositivelyControlledInboundReplicas, cachedAuthorization.ImmediateDeniedInboundReplicas, + cachedAuthorization.SettledDeniedInboundReplicas, SamePrivateAuthorizationHandleUsed: true); + Mark("terminal-durable-read:start"); + var terminal = await runtime.ReadScopedDurableProofAsync(configured, ct); + Mark("terminal-durable-read:complete"); + RequireCount(terminal, 2); + Assert.Equal(second.Actions, terminal.Actions); + // Cleanup is part of acceptance. No PASS document may claim a future + // teardown succeeded. These owned ports must dispose idempotently; + // await-using still covers failures before this successful path. + Mark("cleanup-cached-authority:start"); + await cachedAuthorization.DisposeAsync(); + Mark("cleanup-cached-authority:complete"); + Mark("cleanup-loss:start"); + await loss.DisposeAsync(); + Mark("cleanup-loss:complete"); + Mark("cleanup-runtime:start"); + await runtime.DisposeAsync(); + Mark("cleanup-runtime:complete"); + // Export only this typed schema. Native inputs, tokens, raw bodies, + // arbitrary logs and browser traces never become proof artifacts. + Mark("proof-export:start"); + await runtime.WritePrivacySafePhysicalProofAsync(admitted, configured, baseline, firstBreach, stillBad, + recoverySamples, secondBreach, allocated, recovered, rotation, committed, terminal, unlinkProof, ct); + Mark("proof-export:complete"); + } + + private static async Task UniqueCollectionsAsync(IPhysicalIncidentFixture runtime, + Guid agent, DateTimeOffset after, Func condition, TimeSpan requiredSpan, + CancellationToken ct) + { + var samples = new List(); + var seen = new Dictionary(); + while (true) + { + ct.ThrowIfCancellationRequested(); + await runtime.AssertFullProductionClientWithinFixedLifetimeAsync(ct); + var value = await runtime.ReadActualAcceptedSelectedDiskAsync(agent, ct); + Assert.True(value.TransportAccepted && value.DurableCommittedOwner && value.CollectionComplete); + Assert.NotEqual(Guid.Empty, value.CollectionId); + Assert.True(value.CollectedAtUtc <= value.FrameObservedAtUtc && value.FrameObservedAtUtc <= value.ReceivedAtUtc); + Assert.True(value.ReceivedAtUtc - value.CollectedAtUtc <= TimeSpan.FromSeconds(15)); + Assert.True(DateTimeOffset.UtcNow - value.CollectedAtUtc <= TimeSpan.FromSeconds(15)); + Assert.True(value.TotalBytes > 0 && value.FreeBytes >= 0 && value.FreeBytes <= value.TotalBytes); + if (seen.TryGetValue(value.CollectionId, out var original)) + { + // Cached copies may advance transport cursors, never counters, + // collection identity/time or the physical hold window. + Assert.Equal(original.CollectedAtUtc, value.CollectedAtUtc); + Assert.Equal(original.FreeBytes, value.FreeBytes); + Assert.Equal(original.TotalBytes, value.TotalBytes); + } + else + { + seen.Add(value.CollectionId, value); + if (value.CollectedAtUtc > after && condition(value)) + { + if (samples.Count > 0) + { + Assert.True(value.CollectedAtUtc > samples[^1].CollectedAtUtc); + Assert.Equal(samples[0].Scope, value.Scope); + } + samples.Add(value); + if (samples.Count >= 3 && value.CollectedAtUtc - samples[0].CollectedAtUtc >= requiredSpan) + return samples.ToArray(); + } + else samples.Clear(); // A real opposite sample interrupts the hold. + } + Assert.True(seen.Count <= 128, "The bounded physical sample receipt limit was exceeded."); + await Task.Delay(TimeSpan.FromMilliseconds(250), ct); + } + } + + private static void RequireCount(PhysicalDurableProof value, int count) + { + Assert.Equal(count, value.Occurrences.Length); + Assert.Equal(count, value.RaisedEvents.Length); + Assert.Equal(count, value.DispatchIntentKeys.Length); + Assert.Equal(count, value.FlowRunIds.Length); + Assert.Equal(count, value.Actions.Length); + Assert.Equal(count, value.Receipts.Length); + Assert.Equal(count, value.Incidents.Length); + Assert.Equal(count, value.ConfirmationEffects.Length); + Assert.Equal(count, value.Actions.Select(x => x.ReceiverKey).Distinct().Count()); + } +} + +// These are allowlisted proof DTOs, not alternative production authority stores. +internal sealed record PhysicalAdmission(Guid AgentId, Guid ConnectionId, ulong ConnectionEpoch, + Guid EvidenceStreamId, bool CommittedOwner, bool ProductionSystemClock, string Producer); +internal sealed record PhysicalThresholds(long BreachBytes, long RecoveryBytes, int HoldSeconds, + int RecoveryHoldSeconds, int FreshnessSeconds); +internal sealed record PhysicalRule(Guid RuleId, Guid FlowVersionId, string ConfigurationHash, + string ResourceKey, long ConfigurationRevision, Guid ConnectorId, long SemanticRevision); +internal sealed record PhysicalDiskSample(string Scope, Guid CollectionId, DateTimeOffset CollectedAtUtc, + DateTimeOffset FrameObservedAtUtc, DateTimeOffset ReceivedAtUtc, ulong TransportSequence, + long TotalBytes, long FreeBytes, bool CollectionComplete, bool TransportAccepted, bool DurableCommittedOwner); +internal sealed record PhysicalAllocation(DateTimeOffset CompletedAtUtc, long Bytes, long ActualBlocksBytes, + long SelectedFreeBefore, long SelectedFreeAfter, long HostFreeAfter); +internal sealed record PhysicalAction(Guid OccurrenceId, Guid FlowRunId, Guid FlowVersionId, string ActionKey, + string ReceiverKey, string PayloadSha256, string ReceiverFingerprint, Guid SourceNamespaceId, + Guid SourceInstanceId, long SemanticRevision); +internal sealed record PhysicalDurableProof(Guid[] Occurrences, Guid[] RaisedEvents, string[] DispatchIntentKeys, + Guid[] FlowRunIds, PhysicalAction[] Actions, Guid[] Receipts, string[] Incidents, + Guid[] ConfirmationEffects, bool MayHaveCommitted, bool ActionSucceeded); +internal sealed record PhysicalCommittedLoss(int ActualUpstreamStatus, bool TransactionCommitted, + bool ResponseAbortedAfterIndependentRead, Guid NamespaceId, string Key, string Fingerprint, + int IncidentRows, int ReceiptRows, int ConfirmationEnqueues); +internal sealed record PhysicalRotation(long BeforeCredentialRevision, long AfterCredentialRevision, + long BeforeSemanticRevision, long AfterSemanticRevision, Guid BeforeSourceNamespaceId, Guid AfterSourceNamespaceId); +internal sealed record PhysicalUnlinkProof(int PositiveControlStatusBeforeUnlink, int ProtectedDenialStatus, + DateTimeOffset CachedAuthorizationCommonExpiresAtUtc, DateTimeOffset LatestDenialObservedAtUtc, + int PositivelyControlledInboundReplicas, int ImmediateDeniedInboundReplicas, int SettledDeniedInboundReplicas, + bool SamePrivateAuthorizationHandleUsed); +internal interface IPrivateCachedPhysicalAuthorization : IAsyncDisposable +{ + DateTimeOffset ExpiresAtUtc { get; } // Minimum of both parsed expiries; never a token accessor. + DateTimeOffset? LatestDenialObservedAtUtc { get; } + int PositivelyControlledInboundReplicas { get; } + int ImmediateDeniedInboundReplicas { get; } + int SettledDeniedInboundReplicas { get; } +} +internal interface IPhysicalCommittedResponseLoss : IAsyncDisposable +{ + Task WaitIndependentCommittedReceiptAsync(CancellationToken ct); + Task ObserveRealSameKeyRecoveryAfterRestartAsync(CancellationToken ct); + void ReleaseIdenticalRetryGate(); +} + +// PhysicalIncidentFixture implements these ports with actual production processes. In particular +// no constructor/port may populate Agents, owner/evidence, FlowRunNode, incidents +// or receipts in SQL. Only genuine product commands create business authority. +internal interface IPhysicalIncidentFixture : IAsyncDisposable +{ + Task AssertExactCurrentSourcesAndPublishedCompanionAsync(CancellationToken ct); + Task AssertNoFixtureSeededExecutionAuthorityAsync(CancellationToken ct); + Task StartFullProductionClientAsync(CancellationToken ct); + Task WaitRealFirstHeartbeatAndTelemetryAdmissionAsync(CancellationToken ct); + Task RunProtectedRecordedFormTaskAsync(Guid agent, CancellationToken ct); + Task StartSecondRealApiWorkerReplicaAsync(CancellationToken ct); + Task AssertFullProductionClientWithinFixedLifetimeAsync(CancellationToken ct); + Task ReadActualAcceptedSelectedDiskAsync(Guid agent, CancellationToken ct); + Task ConfigureOwnerPublishedFlowAndSelectedDiskRuleAsync(Guid agent, string scope, PhysicalThresholds thresholds, CancellationToken ct); + Task ValidateAndDryRunWithoutEffectsAsync(PhysicalRule rule, CancellationToken ct); + Task ReadScopedDurableProofAsync(PhysicalRule rule, CancellationToken ct); + Task CaptureActuallyIssuedBusinessAuthorizationPrivatelyAsync(CancellationToken ct); + Task ArmOneActualReceiver201AfterCommitLossAsync(PhysicalRule rule, CancellationToken ct); + Task AllocateOwnedVolumeAsync(long count, CancellationToken ct); + Task DeleteOnlyOwnedAllocationAsync(CancellationToken ct); + Task WaitDurableMayHaveCommittedActionAsync(PhysicalRule rule, CancellationToken ct); + Task RestartRealApiWorkerReplicasWithPersistedStateAsync(CancellationToken ct); + Task AssertTwoActualWorkerReplicasAsync(CancellationToken ct); + Task RotateThroughActualOwnerServiceLinkAsync(CancellationToken ct); + Task WaitRealVerifiedReceiptAndActionSuccessAsync(PhysicalRule rule, CancellationToken ct); + Task WaitActualOccurrenceResolvedOnceAsync(PhysicalRule rule, Guid occurrence, CancellationToken ct); + Task WaitSecondActualOccurrenceAndVerifiedIncidentAsync(PhysicalRule rule, CancellationToken ct); + Task StopOnlyFixtureRatelDeskPeerAsync(CancellationToken ct); + Task UnlinkThroughActualOwnerAsync(string reason, CancellationToken ct); + Task AssertImmediateLocalSenderAndInboundAuthorityStoppedAsync(CancellationToken ct); + Task RestoreFixtureRatelDeskAndSettleUnlinkAsync(CancellationToken ct); + Task ProtectedReceiverLookupWithCachedAuthorizationAsync(IPrivateCachedPhysicalAuthorization cached, PhysicalAction action, CancellationToken ct); + Task AssertNoOldAuthorizationCanIssueOrSendAsync(IPrivateCachedPhysicalAuthorization cached, CancellationToken ct); + // Safe AFTER DisposeAsync: use retained allowlisted observations and the + // actual successful cleanup receipt only; never reopen a disposed provider. + Task WritePrivacySafePhysicalProofAsync(PhysicalAdmission admitted, PhysicalRule rule, PhysicalDiskSample[] baseline, + PhysicalDiskSample[] firstBreach, PhysicalDiskSample[] continuedBad, PhysicalDiskSample[] recovery, + PhysicalDiskSample[] secondBreach, PhysicalAllocation allocated, PhysicalAllocation recovered, PhysicalRotation rotation, + PhysicalCommittedLoss loss, PhysicalDurableProof terminal, PhysicalUnlinkProof unlink, CancellationToken ct); +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentTests.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentTests.cs new file mode 100644 index 00000000..f13727f9 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalDiskIncidentTests.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Reflection; +using Xunit; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +[Collection(ServiceLinkRealPeerCollection.Name)] +public sealed class PhysicalDiskIncidentTests(ITestOutputHelper output) +{ + [Fact(Timeout = 300_000)] + [Trait("Category", "hosted")] + [Trait("Category", "PhysicalDiskIncidentProof")] + public async Task Real_full_Client_disk_breach_survives_actual_worker_process_crashes_rotation_and_unlink_without_duplicates() + { + var elapsed = Stopwatch.StartNew(); + void Progress(string stage) => output.WriteLine("physical stage={0} elapsedMilliseconds={1}", stage, elapsed.ElapsedMilliseconds); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + budget.CancelAfter(TimeSpan.FromSeconds(300)); + await using var fixture = await PhysicalIncidentFixture.CreateAsync(budget.Token, Progress); + await PhysicalDiskIncidentAcceptance.RunAsync(fixture, budget.Token, Progress); + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentDurableReadProbe.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentDurableReadProbe.cs new file mode 100644 index 00000000..535b1d01 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentDurableReadProbe.cs @@ -0,0 +1,109 @@ +using System.Data; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.Monitoring; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.Monitoring; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +// Scoped to a disposable fixture's actual tenant/rule/agent/resource/Flow version. +// This probe has no mutation, seeding, dispatch or receipt-recovery operation. +internal sealed class PhysicalIncidentDurableReadProbe(IServiceProvider netRatelServices, + ServiceLinkPublishedRatelDeskPeer publishedPeer, int tenantId, Guid ruleId, Guid agentId, + string resourceKey, Guid flowVersionId) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public async Task ReadCommittedAsync( + PhysicalIncidentForwarding forwarded, JsonElement acceptedResponse, CancellationToken ct) + { + RatelDeskReceiverPreparationV2 captured; + await using (var scope = netRatelServices.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Database.IsNpgsql()) + throw new InvalidOperationException("Physical acceptance requires the actual PostgreSQL provider."); + await using var snapshot = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + await db.Database.ExecuteSqlRawAsync("SET TRANSACTION READ ONLY", ct); + var row = await db.Set().AsNoTracking().SingleAsync(x => + x.TenantId == tenantId && x.ReceiverIdempotencyKey == forwarded.Key, ct); + var action = await db.FlowActions.AsNoTracking().SingleAsync(x => + x.TenantId == tenantId && x.RunId == row.RunId && x.NodeId == row.NodeId, ct); + var run = await db.FlowRuns.AsNoTracking().SingleAsync(x => + x.TenantId == tenantId && x.Id == row.RunId && x.FlowVersionId == flowVersionId, ct); + var occurrence = await db.MonitoringOccurrences.AsNoTracking().SingleAsync(x => + x.TenantId == tenantId && x.RuleId == ruleId && x.AgentId == agentId && + x.ResourceKey == resourceKey && x.OccurrenceId == run.OccurrenceId, ct); + var dispatch = await db.MonitoringFlowOutbox.AsNoTracking().SingleAsync(x => + x.TenantId == tenantId && x.OccurrenceId == occurrence.OccurrenceId && + x.EventId == occurrence.RaisedEventId, ct); + captured = Parse(row.PreparationJson); + var prepared = Parse(action.PreparedJson); + var draft = Parse(action.DraftJson); + var canonical = MonitoringCanonicalFlowEvent.Create(Parse(dispatch.IntentJson)); + var recordedEvent = Parse(run.EventJson); + if (!row.MayHaveCommitted || row.FirstPostAttemptAtUtc is null || + row.LastPostLeaseFence != action.LeaseFence || action.Attempts < 1 || + action.Attempts > FlowLimits.MaximumActionAttempts || row.SchemaVersion != 2 || + captured.SchemaVersion != 2 || captured.Peer.SourceInstanceId != forwarded.SourceInstanceId || + captured.Peer.SourceNamespaceId == Guid.Empty || captured.Peer.LocalTenantId != tenantId || + captured.ReceiverIdempotencyKey != forwarded.Key || + captured.ReceiverIdempotencyKey != row.ReceiverIdempotencyKey || + captured.ReceiverFingerprint != row.ReceiverFingerprint || + captured.EvidenceFingerprint != row.EvidenceFingerprint || + run.EventId != occurrence.RaisedEventId || canonical is null || recordedEvent != canonical || + dispatch.FlowRunId is { } handedOffRun && handedOffRun != run.Id || + dispatch.StableFlowDispatchKey != $"monitoring:v1:{tenantId}:{run.OccurrenceId:D}:{run.EventId:D}:{flowVersionId:D}" || + captured.OriginalActionCreatedAtUtc != run.CreatedAtUtc || + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(captured.ExactCreateBodyJson))) != + forwarded.RequestBytesSha256 || + !ReceiverPreparedBinding.Valid(captured, prepared, draft, run.CreatedAtUtc, + scope.ServiceProvider.GetRequiredService())) + throw new InvalidOperationException("The sent body did not match the durable physical action and occurrence."); + // Release the independent sender snapshot before reading the other product. + // Neither application transaction is ever held across peer HTTP. + await snapshot.CommitAsync(ct); + } + var committed = await publishedPeer.ReadPhysicalIncidentCommitAsync(forwarded.SourceInstanceId, + captured.Peer.SourceNamespaceId, forwarded.Key, ct); + var receipt = acceptedResponse.GetProperty("integrationReceipt"); + if (committed.Fingerprint != captured.ReceiverFingerprint || + committed.OrganizationId != captured.Peer.OrganizationId || committed.CustomerId != captured.Peer.CustomerId || + committed.AcceptedJson != acceptedResponse.GetRawText() || + receipt.GetProperty("receiverInstanceId").GetString() != captured.Peer.ReceiverInstanceId || + receipt.GetProperty("fingerprint").GetString() != committed.Fingerprint || + receipt.GetProperty("incidentId").GetString() != committed.IncidentId || + NormalizePostgresTime(receipt.GetProperty("committedAtUtc").GetDateTimeOffset()) != committed.CommittedAtUtc) + throw new InvalidOperationException("The actual accepted response did not match the independent published receiver rows."); + return new(committed.SourceInstanceId, committed.NamespaceId, committed.Key, committed.Fingerprint, + committed.IncidentId, committed.ReceiptId, committed.ConfirmationIds.Single()); + } + + // Npgsql stores timestamptz at microsecond precision; AcceptedJson above is + // still compared byte for byte, preserving the original complete receipt. + private static DateTimeOffset NormalizePostgresTime(DateTimeOffset value) => + new(value.UtcTicks - value.UtcTicks % 10, TimeSpan.Zero); + + private static T Parse(string? raw) + { + try + { + if (string.IsNullOrEmpty(raw) || Encoding.UTF8.GetByteCount(raw) > 131_072) + throw new InvalidOperationException("The durable physical action body was absent or oversized."); + return JsonSerializer.Deserialize(raw, Json) + ?? throw new InvalidOperationException("The durable physical action body was absent."); + } + catch (JsonException) + { + throw new InvalidOperationException("The durable physical action body was malformed."); + } + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Durable.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Durable.cs new file mode 100644 index 00000000..2a742230 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Durable.cs @@ -0,0 +1,152 @@ +using System.Data; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.Monitoring; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.Monitoring; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed partial class PhysicalIncidentFixture +{ + public async Task ReadScopedDurableProofAsync(PhysicalRule rule, CancellationToken ct) + { + List occurrences; + List outbox; + List runs; + List actions; + List evidence; + List events; + var identities = new List(); + var capturedRows = new List(); + await using (var scope = pair.NetRatel.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await using var snapshot = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + await db.Database.ExecuteSqlRawAsync("SET TRANSACTION READ ONLY", ct); + occurrences = await db.MonitoringOccurrences.AsNoTracking().Where(x => x.TenantId == Tenant && + x.RuleId == rule.RuleId && x.AgentId == agent && x.ResourceKey == rule.ResourceKey).OrderBy(x => x.RaisedAtUtc).ThenBy(x => x.OccurrenceId).ToListAsync(ct); + var occurrenceIds = occurrences.Select(x => x.OccurrenceId).ToArray(); + outbox = await db.MonitoringFlowOutbox.AsNoTracking().Where(x => x.TenantId == Tenant && occurrenceIds.Contains(x.OccurrenceId)).ToListAsync(ct); + runs = await db.FlowRuns.AsNoTracking().Where(x => x.TenantId == Tenant && x.FlowVersionId == rule.FlowVersionId).ToListAsync(ct); + var runIds = runs.Select(x => x.Id).ToArray(); + actions = await db.FlowActions.AsNoTracking().Where(x => x.TenantId == Tenant && runIds.Contains(x.RunId)).ToListAsync(ct); + evidence = await db.Set().AsNoTracking().Where(x => x.TenantId == Tenant && runIds.Contains(x.RunId)).ToListAsync(ct); + events = await db.MonitoringEvents.AsNoTracking().Where(x => x.TenantId == Tenant && occurrenceIds.Contains(x.OccurrenceId)).ToListAsync(ct); + if (outbox.Count < occurrences.Count || runs.Count < occurrences.Count || actions.Count < occurrences.Count || evidence.Count < actions.Count) + throw new PhysicalProofNotReadyException(); + Require(outbox.Count == occurrences.Count && runs.Count == occurrences.Count && actions.Count == occurrences.Count && evidence.Count == actions.Count, + "The scoped occurrence/action/outbox/run/evidence counts contain extra durable effects."); + foreach (var occurrence in occurrences) + { + var dispatch = outbox.Single(x => x.OccurrenceId == occurrence.OccurrenceId && x.EventId == occurrence.RaisedEventId); + var run = runs.Single(x => x.OccurrenceId == occurrence.OccurrenceId && x.EventId == occurrence.RaisedEventId); + var action = actions.Single(x => x.RunId == run.Id); + var receiver = evidence.Single(x => x.RunId == run.Id && x.NodeId == action.NodeId); + var captured = Parse(receiver.PreparationJson); + var raised = Parse(events.Single(x => x.EventId == occurrence.RaisedEventId).EventJson); + var intent = Parse(dispatch.IntentJson); + var canonical = MonitoringCanonicalFlowEvent.Create(intent); + var prepared = Parse(action.PreparedJson); + var draft = Parse(action.DraftJson); + Require(raised.Kind == MonitoringEventKind.AlertRaised && raised.Series.RuleId == rule.RuleId && raised.Series.AgentId == agent && + raised.Series.ResourceKey == rule.ResourceKey && raised.PinnedRule.PublishedFlowVersionId == rule.FlowVersionId && + canonical is not null && Parse(run.EventJson) == canonical && + intent.PinnedEvidence == raised.Evidence && System.Text.Json.JsonSerializer.Serialize(intent.PinnedRule, json) == System.Text.Json.JsonSerializer.Serialize(raised.PinnedRule, json) && + dispatch.StableFlowDispatchKey == $"monitoring:v1:{Tenant}:{run.OccurrenceId:D}:{run.EventId:D}:{rule.FlowVersionId:D}" && + (dispatch.FlowRunId is null || dispatch.FlowRunId == run.Id) && receiver.SchemaVersion == 2 && + captured.Peer.SourceInstanceId == pair.NetRatel.SourceInstanceId && captured.Peer.SourceNamespaceId == sourceNamespace && + captured.Peer.LocalTenantId == Tenant && captured.ConnectorId == rule.ConnectorId && captured.ConnectorRevision == rule.SemanticRevision && + captured.Peer.ReceiverInstanceId == pair.RatelDesk.InstanceId.ToString("D") && + captured.ReceiverIdempotencyKey == receiver.ReceiverIdempotencyKey && captured.ReceiverFingerprint == receiver.ReceiverFingerprint && + captured.EvidenceFingerprint == receiver.EvidenceFingerprint && captured.OriginalActionCreatedAtUtc == run.CreatedAtUtc && + ReceiverPreparedBinding.Valid(captured, prepared, draft, run.CreatedAtUtc, + scope.ServiceProvider.GetRequiredService()), + "The actual durable occurrence, canonical event and immutable action identity disagree."); + if (action.Attempts == 0 || receiver.FirstPostAttemptAtUtc is null) throw new PhysicalProofNotReadyException(); + Require(action.Attempts is >= 1 and <= FlowLimits.MaximumActionAttempts && receiver.FirstPostAttemptAtUtc is not null && + receiver.AutomaticReplayUntilUtc == run.CreatedAtUtc.Add(FlowLimits.MaximumRetryAge), + "The actual durable physical action did not preserve bounded automatic replay/POST evidence."); + identities.Add(new(occurrence.OccurrenceId, run.Id, run.FlowVersionId, action.IdempotencyKey, + captured.ReceiverIdempotencyKey, Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(captured.ExactCreateBodyJson))), + captured.ReceiverFingerprint, sourceNamespace, captured.Peer.SourceInstanceId, captured.ConnectorRevision)); + capturedRows.Add(captured); + } + await snapshot.CommitAsync(ct); + } + var counts = await pair.RatelDesk.ReadPhysicalNamespaceCountsAsync(pair.NetRatel.SourceInstanceId, sourceNamespace, ct); + if (counts.Receipts < identities.Count) throw new PhysicalProofNotReadyException(); + Require(counts.Receipts == identities.Count && counts.Incidents == identities.Count && counts.Confirmations == identities.Count, + "The actual receiver namespace contains missing or duplicate incidents, receipts or confirmation enqueues."); + var receipts = new List(); var incidents = new List(); var confirmations = new List(); + for (var index = 0; index < identities.Count; index++) + { + var action = identities[index]; var captured = capturedRows[index]; + var remote = await pair.RatelDesk.ReadPhysicalIncidentCommitAsync(action.SourceInstanceId, action.SourceNamespaceId, action.ReceiverKey, ct); + Require(remote.Fingerprint == action.ReceiverFingerprint && remote.OrganizationId == captured.Peer.OrganizationId && + remote.CustomerId == captured.Peer.CustomerId, "The actual receipt changed its immutable target or fingerprint."); + var row = evidence.Single(x => x.RunId == action.FlowRunId); + if (row.FullReceiptJson is not null) + { + var stored = Parse(row.FullReceiptJson); + Require(stored.Key == action.ReceiverKey && stored.Fingerprint == action.ReceiverFingerprint && + stored.IncidentId == remote.IncidentId && stored.ExactAcceptedBodyJson == remote.AcceptedJson && + stored.SourceNamespaceId == sourceNamespace && stored.SourceInstanceId == action.SourceInstanceId && + !row.MayHaveCommitted, "The durably verified original receipt differs from the actual published receiver rows."); + } + receipts.Add(remote.ReceiptId); incidents.Add(remote.IncidentId); confirmations.Add(remote.ConfirmationIds.Single()); + } + return new(occurrences.Select(x => x.OccurrenceId).ToArray(), occurrences.Select(x => x.RaisedEventId).ToArray(), + occurrences.Select(x => outbox.Single(y => y.OccurrenceId == x.OccurrenceId).StableFlowDispatchKey).ToArray(), + identities.Select(x => x.FlowRunId).ToArray(), identities.ToArray(), receipts.ToArray(), incidents.ToArray(), confirmations.ToArray(), + evidence.Any(x => x.MayHaveCommitted), actions.Count > 0 && actions.All(x => x.Status == FlowActionStatus.Succeeded) && + evidence.All(x => x.FullReceiptJson is not null) && runs.All(x => x.Status == FlowRunStatus.Succeeded)); + } + + public Task WaitDurableMayHaveCommittedActionAsync(PhysicalRule rule, CancellationToken ct) => + WaitProofAsync(rule, x => x.Actions.Length == 1 && x.MayHaveCommitted && !x.ActionSucceeded, ct); + public Task WaitRealVerifiedReceiptAndActionSuccessAsync(PhysicalRule rule, CancellationToken ct) => + WaitProofAsync(rule, x => x.Actions.Length == 1 && !x.MayHaveCommitted && x.ActionSucceeded, ct); + public Task WaitSecondActualOccurrenceAndVerifiedIncidentAsync(PhysicalRule rule, CancellationToken ct) => + WaitProofAsync(rule, x => x.Actions.Length == 2 && !x.MayHaveCommitted && x.ActionSucceeded, ct); + + private async Task WaitProofAsync(PhysicalRule rule, Func predicate, CancellationToken ct) + { + while (true) + { + await disk.AssertAliveAsync(ct); + try { var proof = await ReadScopedDurableProofAsync(rule, ct); if (predicate(proof)) return proof; } + catch (PhysicalProofNotReadyException) { } // Only incomplete actual handoff/receipt is retryable. + await Task.Delay(250, ct); + } + } + + public async Task WaitActualOccurrenceResolvedOnceAsync(PhysicalRule rule, Guid occurrence, CancellationToken ct) + { + while (true) + { + await disk.AssertAliveAsync(ct); + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.MonitoringOccurrences.AsNoTracking().SingleAsync(x => x.TenantId == Tenant && x.OccurrenceId == occurrence && x.RuleId == rule.RuleId, ct); + var events = await db.MonitoringEvents.AsNoTracking().Where(x => x.TenantId == Tenant && x.OccurrenceId == occurrence).Select(x => x.EventJson).ToArrayAsync(ct); + var resolved = events.Select(Parse).Count(x => x.Kind == MonitoringEventKind.AlertResolved); + Require(resolved <= 1, "The same actual occurrence resolved more than once."); + if (row.EndedAtUtc is not null && resolved == 1) + { + var state = Parse(row.OccurrenceJson); + Require(state.ClosureDisposition == MonitoringClosureDisposition.Recovered, "The actual occurrence ended without sustained physical recovery."); + return; + } + await Task.Delay(250, ct); + } + } + + private sealed class PhysicalProofNotReadyException : Exception { } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Lifecycle.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Lifecycle.cs new file mode 100644 index 00000000..7f050414 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Lifecycle.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Shared.ServiceLinks; +using Xunit; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed partial class PhysicalIncidentFixture +{ + public async Task RotateThroughActualOwnerServiceLinkAsync(CancellationToken ct) + { + var before = await pair.StatusAsync(pair.NetRatel.Administrator, ct); + var readiness = await ReadReadinessAsync(ct); + var oldToken = await pair.TokenAsync(false, "rateldesk.incident-receipts.read", ct); + var oldRevision = CredentialRevision(oldToken); + oldToken = ""; + var direction = before.GrantSummary!.Grants.Single(x => x.TargetProduct == "rateldesk").DirectionId; + var requested = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, + $"/api/v1/admin/service-links/links/{before.LinkId}/rotate", new ServiceLinkAdminAction(DirectionId: direction), ct); + var rotationId = requested.Rotations.Single(x => x.DirectionId == direction).RotationId; + ServiceLinkAdminStatus after; + ServiceLinkRotationSummary rotation; + // The real background workers execute the durable offer/verify/activation/ + // switch/retirement protocol. Read-only status never seeds or drives a phase. + while (true) + { + await disk.AssertAliveAsync(ct); + await Task.Delay(TimeSpan.FromSeconds(10), ct); // Preserve sensitive-route admission headroom. + after = await pair.StatusAsync(pair.NetRatel.Administrator, ct); + rotation = after.Rotations.Single(x => x.RotationId == rotationId); + Require(after.LinkId == before.LinkId && after.LinkRevision == before.LinkRevision && after.GrantHash == before.GrantHash, + "Secret-only rotation changed the actual semantic approved link."); + if (rotation.CallerSwitchRevision is not null && rotation.SuccessorCredentialRevision > oldRevision && + rotation.ActivateDecisionId is not null && rotation.RotationState is "retiring" or "completed") break; + Require(rotation.RotationState != "aborted", "The actual owner rotation aborted."); + } + // Receipt reconciliation can proceed with the verified selected successor + // during its fixed predecessor overlap. Retirement is separately covered by + // the normal four real rotation cases; this physical lane proves queued work. + var successorToken = await pair.TokenAsync(false, "rateldesk.incident-receipts.read", ct); + var successorRevision = CredentialRevision(successorToken); successorToken = ""; + Require(successorRevision == rotation.SuccessorCredentialRevision && successorRevision > oldRevision, + "The actual selected successor did not issue the current business authorization."); + selectedCredentialRevision = successorRevision; + var current = await ReadReadinessAsync(ct); + Require(JsonSerializer.Serialize(current.Peer, json) == JsonSerializer.Serialize(readiness.Peer, json) && current.Peer.SourceNamespaceId == sourceNamespace, + "Secret-only rotation changed the prepared semantic connector or producer namespace."); + return new(oldRevision, successorRevision, before.LinkRevision, after.LinkRevision, + readiness.Peer.SourceNamespaceId, current.Peer.SourceNamespaceId); + } + + public async Task CaptureActuallyIssuedBusinessAuthorizationPrivatelyAsync(CancellationToken ct) + { + var receiver = await pair.TokenAsync(false, "rateldesk.incident-receipts.read", ct); + var inbound = await pair.TokenAsync(true, "netratel.orchestration.read", ct); + // Both cached channels must still be live at every control/denial. Only + // their conservative common expiry leaves this private token owner. + var expiry = new[] { TokenExpiry(receiver), TokenExpiry(inbound) }.Min(); + var issued = new CachedPhysicalAuthorization(receiver, inbound, expiry, CredentialRevision(receiver)); + try + { + Require(issued.Revision == selectedCredentialRevision && issued.Revision > 1, + "The cached physical receiver token was not issued for the actual selected successor."); + Require(await ReadNetRatelWithCachedAsync(issued, false, ct) == HttpStatusCode.OK && + await ReadNetRatelWithCachedAsync(issued, true, ct) == HttpStatusCode.OK, + "The actual current cached inbound token lacked a positive control on both API processes."); + cachedAuthorization = issued; + return issued; + } + catch { await issued.DisposeAsync(); throw; } + } + + public async Task ProtectedReceiverLookupWithCachedAuthorizationAsync( + IPrivateCachedPhysicalAuthorization handle, PhysicalAction action, CancellationToken ct) + { + var cached = (CachedPhysicalAuthorization)handle; + cached.RequireUnexpired(); + Require(cached.ReceiverToken is not null && action.SourceInstanceId == pair.NetRatel.SourceInstanceId && action.SourceNamespaceId == sourceNamespace, + "The actual private cached authorization or scoped lookup identity is unavailable."); + using var request = new HttpRequestMessage(HttpMethod.Get, + "/api/v1/integrations/netratel/incident-receipts/" + action.ReceiverKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cached.ReceiverToken); + request.Headers.Add("X-NetRatel-Source-Instance", action.SourceInstanceId.ToString("D")); + using var response = await pair.RatelDesk.Anonymous.SendAsync(request, ct); + cached.ObserveResponse(response.StatusCode, inboundReplica: null); + return response.StatusCode; + } + public async Task StopOnlyFixtureRatelDeskPeerAsync(CancellationToken ct) => _ = await pair.RatelDesk.StopPhysicalApiAsync(ct); + + public async Task UnlinkThroughActualOwnerAsync(string reason, CancellationToken ct) + { + Require(reason == "isolated physical acceptance complete", "The actual physical unlink reason differs."); + var result = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, + $"/api/v1/admin/service-links/links/{pair.Review.GrantSummary.LinkId}/revoke", + new ServiceLinkAdminAction(ReasonCode: "physical-acceptance-complete"), ct); + Require(!result.LocalBusinessSenderEnabled && !result.LocalInboundActive && + result.LifecycleState == "revocation_pending", "The real owner unlink did not report immediate local denial and truthful pending peer completion."); + localUnlinkVerified = true; + } + + public async Task AssertImmediateLocalSenderAndInboundAuthorityStoppedAsync(CancellationToken ct) + { + Require(localUnlinkVerified, "The actual local owner unlink has not completed."); + var current = await GetAsync(pair.NetRatel.Administrator, + $"/api/v2/tenants/{Tenant}/connectors/rateldesk/{connector:D}", ct); + Require(!current.AutomaticDeliveryAvailable, "The actual connector still permitted automatic delivery after local unlink."); + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var link = await db.Set().AsNoTracking().SingleAsync(x => x.LinkId == pair.Review.GrantSummary.LinkId, ct); + Require(!link.LocalBusinessSenderEnabled && !link.LocalInboundActive && !link.PeerRevocationAcknowledged && link.RevocationId is not null, + "The actual durable local revocation state does not disable sender and inbound authority."); + Require(cachedAuthorization is not null, "The immediate unlink proof requires the actual positively controlled cached token."); + foreach (var replica in new[] { false, true }) + { + Require(await ReadNetRatelWithCachedAsync(cachedAuthorization!, replica, ct) is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + "Immediate local unlink did not deny the same current cached inbound token on both actual API processes."); + cachedAuthorization!.ObserveImmediateReplicaDenial(replica); + } + } + + public async Task RestoreFixtureRatelDeskAndSettleUnlinkAsync(CancellationToken ct) + { + await pair.RatelDesk.RestorePhysicalApiAsync(ct); + while (true) + { + await disk.AssertAliveAsync(ct); + var local = await pair.StatusAsync(pair.NetRatel.Administrator, ct); + if (local.LifecycleState == "revoked") + { + var remote = await pair.StatusAsync(pair.RatelDesk.Administrator, ct); + Require(!local.LocalBusinessSenderEnabled && !local.LocalInboundActive && !remote.LocalBusinessSenderEnabled && + !remote.LocalInboundActive, "Actual peer unlink completion left business authority enabled."); + return; + } + Require(!local.LocalBusinessSenderEnabled && !local.LocalInboundActive, "Pending peer revocation revived local business authority."); + await Task.Delay(TimeSpan.FromSeconds(10), ct); + } + } + + public async Task AssertNoOldAuthorizationCanIssueOrSendAsync(IPrivateCachedPhysicalAuthorization handle, CancellationToken ct) + { + var cached = (CachedPhysicalAuthorization)handle; + foreach (var replica in new[] { false, true }) + { + Require(await ReadNetRatelWithCachedAsync(cached, replica, ct) is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + "The same unexpired cached inbound token revived business access on an actual API process after unlink."); + cached.ObserveSettledReplicaDenial(replica); + } + foreach (var issuer in new[] { true, false }) + { + var peer = issuer ? pair.NetRatel.Proxy : pair.RatelDesk.Proxy; + var identity = peer.ObservedClients.Values.Single(); + using var response = await (issuer ? pair.NetRatel.Anonymous : pair.RatelDesk.Anonymous).PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = identity.ClientId, + ["client_secret"] = identity.Secret, ["scope"] = issuer ? "netratel.orchestration.read" : "rateldesk.incident-receipts.read" }), ct); + Require(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest or HttpStatusCode.Forbidden, + "The revoked current service credential still issued new business authorization."); + } + // Include the final issuance probes in the safe latest denial timestamp. + // They may not let an expired cached token qualify any earlier denial. + cached.ObserveFinalDenialBoundary(); + } + + private async Task ReadNetRatelWithCachedAsync(CachedPhysicalAuthorization cached, bool replica, CancellationToken ct) + { + cached.RequireUnexpired(); + Require(cached.InboundToken is not null, "The private actual inbound authorization has been disposed."); + using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false }) + { BaseAddress = new Uri(replica ? pair.NetRatel.PhysicalReplicaBaseUrl : pair.NetRatel.BaseUrl), Timeout = TimeSpan.FromSeconds(10) }; + using var request = new HttpRequestMessage(HttpMethod.Get, "/internal/health"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cached.InboundToken); + using var response = await client.SendAsync(request, ct); + cached.ObserveResponse(response.StatusCode, replica); + return response.StatusCode; + } + private static DateTimeOffset TokenExpiry(string token) => new(new JwtSecurityTokenHandler().ReadJwtToken(token).ValidTo, TimeSpan.Zero); + private static long CredentialRevision(string token) => long.Parse(new JwtSecurityTokenHandler().ReadJwtToken(token) + .Claims.Single(x => x.Type == ServiceIdentityClaims.CredentialRevision).Value, CultureInfo.InvariantCulture); + private sealed class CachedPhysicalAuthorization(string receiverToken, string inboundToken, DateTimeOffset expiresAtUtc, + long revision) : IPrivateCachedPhysicalAuthorization + { + public string? ReceiverToken { get; private set; } = receiverToken; + public string? InboundToken { get; private set; } = inboundToken; + public DateTimeOffset ExpiresAtUtc => expiresAtUtc; + public DateTimeOffset? LatestDenialObservedAtUtc { get; private set; } + private readonly HashSet positiveReplicas = []; + private readonly HashSet immediateDeniedReplicas = []; + private readonly HashSet settledDeniedReplicas = []; + public int PositivelyControlledInboundReplicas => positiveReplicas.Count; + public int ImmediateDeniedInboundReplicas => immediateDeniedReplicas.Count; + public int SettledDeniedInboundReplicas => settledDeniedReplicas.Count; + public long Revision => revision; + public void RequireUnexpired() => Require(ReceiverToken is not null && InboundToken is not null && + ExpiresAtUtc > DateTimeOffset.UtcNow, "Both actual cached authorization channels must remain unexpired."); + public void ObserveResponse(HttpStatusCode status, bool? inboundReplica) + { + RequireUnexpired(); // Recheck after HTTP, including either actual replica. + if (status == HttpStatusCode.OK && inboundReplica.HasValue) positiveReplicas.Add(inboundReplica.Value); + if (status is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + LatestDenialObservedAtUtc = DateTimeOffset.UtcNow; + RequireUnexpired(); // The recorded observation itself must precede both expiries. + } + public void ObserveImmediateReplicaDenial(bool replica) { RequireUnexpired(); immediateDeniedReplicas.Add(replica); } + public void ObserveSettledReplicaDenial(bool replica) { RequireUnexpired(); settledDeniedReplicas.Add(replica); } + public void ObserveFinalDenialBoundary() + { + RequireUnexpired(); + Require(positiveReplicas.Count == 2 && immediateDeniedReplicas.Count == 2 && settledDeniedReplicas.Count == 2, + "Both cached inbound replica controls and both unlink denial phases must be observed."); + LatestDenialObservedAtUtc = DateTimeOffset.UtcNow; + RequireUnexpired(); + } + public ValueTask DisposeAsync() { ReceiverToken = null; InboundToken = null; return ValueTask.CompletedTask; } + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Proof.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Proof.cs new file mode 100644 index 00000000..6f618267 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.Proof.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed record PhysicalProduct(string Version, string Source, string ApiImage, string WebImage); +internal sealed record PhysicalProducer(string ImageId, string ExecutableSha256, string Version, string Source, + string Producer, int SlowCollectionSeconds, int MaximumLifetimeSeconds, string CaSha256, + bool TrustedTlsAlpnVerified, bool UnrelatedCaRejected, bool OwnedExt4Visible); +internal sealed record PhysicalVolume(long BackingBytes, long SelectedAvailableBefore, long HostAvailableBefore, + string Filesystem, bool SparseBacking, PhysicalAllocation Allocation, PhysicalAllocation Recovery, PhysicalAllocation SecondAllocation); +internal sealed record PhysicalCleanup(bool ClientStoppedAndRemoved, bool MountRemoved, bool LoopDetached, + bool BackingRemoved, bool PrivateCredentialsRemoved, bool AllApiProcessesExited, + bool BothProductFixturesDisposed, bool ApiPrivateStateRemoved, bool OwnedMetadataRemoved, int ImagesRemoved, int FleetChanges); +internal sealed record PhysicalDiskProof(string Schema, string Status, string CandidateSource, string CandidateTree, + string ReviewedSource, string ActualTestCheckout, PhysicalProduct Companion, PhysicalProducer Producer, + PhysicalAdmission Admission, PhysicalVolume Volume, PhysicalRule Rule, PhysicalRecordedTask RecordedTask, + PhysicalDiskSample[] BaselineCollections, PhysicalDiskSample[] BreachCollections, + PhysicalDiskSample[] ContinuedBreachCollections, PhysicalDiskSample[] RecoveryCollections, + PhysicalDiskSample[] SecondBreachCollections, PhysicalCommittedLoss CommittedLoss, PhysicalRotation Rotation, + PhysicalDurableProof DurableIdentities, PhysicalUnlinkProof Unlink, PhysicalApiProcess[] ApiProcesses, + PhysicalCleanup Cleanup); + +internal sealed partial class PhysicalIncidentFixture +{ + public async ValueTask DisposeAsync() + { + if (closed) return; + var ownedPair = pair; + var ownedNetRatel = ownedPair?.NetRatel; + var failures = new List(); + if (cachedAuthorization is not null) try { await cachedAuthorization.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + // Continue owned cleanup after an individual failure, but never export PASS + // unless every owned producer/process/filesystem/private-state cleanup proves. + if (responseLoss is not null) try { await responseLoss.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + if (disk is not null) try { await disk.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + if (ownedPair is not null) + { + try { await ownedPair.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + terminalProcesses = ownedNetRatel is null ? [] : ownedNetRatel.PhysicalProcesses.ToArray(); + } + try { listener.Dispose(); } catch (Exception e) { failures.Add(e); } + if (failures.Count > 0) throw new InvalidOperationException("Physical acceptance failed owned cleanup; no PASS artifact is permitted.", new AggregateException(failures)); + Require((ownedPair is null || ownedNetRatel is not null && ownedPair.OwnedCleanupComplete && ownedNetRatel.PhysicalOwnedCleanupComplete) && + (disk is null || disk.OwnedMetadataRemoved), + "Every actual product, API private state and acknowledged helper metadata owner must prove cleanup."); + closed = true; + } + + public async Task WritePrivacySafePhysicalProofAsync(PhysicalAdmission admitted, PhysicalRule rule, + PhysicalDiskSample[] baseline, PhysicalDiskSample[] firstBreach, PhysicalDiskSample[] continuedBad, + PhysicalDiskSample[] recovery, PhysicalDiskSample[] secondBreach, PhysicalAllocation allocated, + PhysicalAllocation recovered, PhysicalRotation rotation, PhysicalCommittedLoss loss, + PhysicalDurableProof terminal, PhysicalUnlinkProof unlink, CancellationToken ct) + { + var cleanup = disk.Cleanup ?? throw new InvalidOperationException("The owned physical disk helper has not produced cleanup evidence."); + Require(closed && disk.OwnedMetadataRemoved && pair.OwnedCleanupComplete && + pair.NetRatel.PhysicalOwnedCleanupComplete && terminalProcesses is { Length: 4 } && + terminalProcesses.All(x => x.ExitedAtUtc is not null) && terminalProcesses.Select(x => x.ProcessId).Distinct().Count() == 4 && + terminalProcesses.Count(x => x.Role == "primary") == 2 && terminalProcesses.Count(x => x.Role == "replica") == 2 && + recordedTask is not null && allocationCount == 2 && secondAllocation is not null && terminal.ActionSucceeded && !terminal.MayHaveCommitted && terminal.Actions.Length == 2 && + unlink.CachedAuthorizationCommonExpiresAtUtc > unlink.LatestDenialObservedAtUtc && + unlink.PositivelyControlledInboundReplicas == 2 && unlink.ImmediateDeniedInboundReplicas == 2 && + unlink.SettledDeniedInboundReplicas == 2 && unlink.SamePrivateAuthorizationHandleUsed, + "Physical proof requires actual completed acceptance and positive owned cleanup facts."); + var current = await ServiceLinkNativeArtifacts.ResolveAsync(ct); + Require(current == artifacts, "The actual source/artifacts changed before cleanup-first proof export."); + var tree = await ReadGitTreeAsync(ct); + var reviewed = Environment.GetEnvironmentVariable("NETRATEL_REVIEW_SOURCE_SHA") ?? artifacts.CandidateSourceSha; + Require(reviewed.Length == 40 && reviewed.All(Uri.IsHexDigit), "The reviewed source identity is invalid."); + var volume = provision.GetProperty("volume"); + var proof = new PhysicalDiskProof("netratel.physical-disk-incident-proof.v2", "PASS_ACTUAL_PHYSICAL_INCIDENT", + artifacts.CandidateSourceSha, tree, reviewed, artifacts.CandidateSourceSha, + new(ServiceLinkPublishedRatelDeskPeer.PublishedVersion, ServiceLinkPublishedRatelDeskPeer.PublishedSource, + ServiceLinkPublishedRatelDeskPeer.ApiImage, ServiceLinkPublishedRatelDeskPeer.WebImage), + new(client.GetProperty("clientImageId").GetString()!, client.GetProperty("clientExecutableSha256").GetString()!, + client.GetProperty("clientVersion").GetString()!, client.GetProperty("sourceSha").GetString()!, + client.GetProperty("producer").GetString()!, client.GetProperty("slowCollectionSeconds").GetInt32(), + client.GetProperty("maximumClientLifetimeSeconds").GetInt32(), client.GetProperty("caSha256").GetString()!, + client.GetProperty("trustedTlsAlpnVerified").GetBoolean(), client.GetProperty("unrelatedCaRejected").GetBoolean(), + client.GetProperty("daemonVisibleOwnedExt4Verified").GetBoolean()), admitted, + new(volume.GetProperty("backingBytes").GetInt64(), volume.GetProperty("selectedAvailable").GetInt64(), + volume.GetProperty("hostAvailable").GetInt64(), volume.GetProperty("realFilesystem").GetString()!, + volume.GetProperty("sparseBacking").GetBoolean(), allocated, recovered, secondAllocation!), rule, recordedTask!, baseline, + firstBreach, continuedBad, recovery, secondBreach, loss, rotation, terminal, unlink, terminalProcesses!, + new(cleanup.GetProperty("ownedClientStoppedAndRemoved").GetBoolean(), cleanup.GetProperty("ownedMountRemoved").GetBoolean(), + cleanup.GetProperty("ownedLoopDetached").GetBoolean(), cleanup.GetProperty("ownedBackingRemoved").GetBoolean(), + cleanup.GetProperty("privateCredentialsRemoved").GetBoolean(), true, pair.OwnedCleanupComplete, + pair.NetRatel.PhysicalOwnedCleanupComplete, disk.OwnedMetadataRemoved, cleanup.GetProperty("imagesRemoved").GetInt32(), + cleanup.GetProperty("fleetChanges").GetInt32())); + var directory = Environment.GetEnvironmentVariable("NETRATEL_PHYSICAL_DISK_EVIDENCE_DIRECTORY") + ?? Path.Combine("TestResults", "physical-disk-incident"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "disk-incident-" + Guid.NewGuid().ToString("N") + ".json"); + await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await JsonSerializer.SerializeAsync(stream, proof, new JsonSerializerOptions(json) { WriteIndented = true }, ct); + await stream.FlushAsync(ct); + } + + private static async Task ReadGitTreeAsync(CancellationToken ct) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = FindRepository(), UseShellExecute = false, + RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var argument in new[] { "rev-parse", "HEAD^{tree}" }) start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Physical proof source inspection could not start."); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(TimeSpan.FromSeconds(5)); + var output = process.StandardOutput.ReadToEndAsync(deadline.Token); var errors = process.StandardError.ReadToEndAsync(deadline.Token); + try + { + await process.WaitForExitAsync(deadline.Token); var tree = (await output).Trim(); _ = await errors; + Require(process.ExitCode == 0 && tree.Length == 40 && tree.All(Uri.IsHexDigit), "Physical proof has no actual source tree identity."); + return tree; + } + finally + { + if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } + try { _ = await output; _ = await errors; } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { } + } + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.RecordedTask.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.RecordedTask.cs new file mode 100644 index 00000000..4665b076 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.RecordedTask.cs @@ -0,0 +1,240 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Jobs; +using NetRatel.Infrastructure.Persistence; +using Xunit; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed record PhysicalRecordedTask(string ParentRequestId, string TaskId, string AutomationBindingId, + int NetRatelRequestId, string JobRunId, string StepId, string ActivityId, string SuccessWorklogId, + int CallbackStatus, string TaskTemplateId, string CorrelationId, string CommandMarker); + +internal sealed partial class PhysicalIncidentFixture +{ + private const string JobName = "Genuine production Client bounded disk fixture command"; + private const string TaskName = "Genuine bounded Client command"; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public async Task RunProtectedRecordedFormTaskAsync(Guid agentId, CancellationToken ct) + { + Require(agentId == agent, "The actual recorded form task must target the genuinely enrolled Client."); + var tenant = Tenant; + await pair.NetRatel.CreateNativeJobAndSelectTargetAsync(agentId, JobName, ct); + var definitionId = pair.NetRatel.RequestDefinitionId.ToString(CultureInfo.InvariantCulture); + using (var step = await pair.NetRatel.Administrator.PostAsJsonAsync($"/api/v1/jobs/{definitionId}/steps", new + { Ordinal = 1, Type = 0, Runner = "bash", Command = command!, ScriptId = (ulong?)null, PayloadJson = (string?)null, Enabled = true }, ct)) + Assert.True(step.IsSuccessStatusCode, $"The actual bounded job step returned HTTP {(int)step.StatusCode}."); + + await pair.ActivateAsync(ct); // Both human consents, exchanged actual credentials, verification, durable commit. + await pair.RatelDesk.WaitForPostActivationSensitiveWindowAsync(ct); + var link = pair.Review.GrantSummary.LinkId; + Assert.Equal(agent, pair.NetRatel.ResourceId); + using (var settings = await pair.RatelDesk.Administrator.GetAsync("/api/v1/admin/orchestration/", ct)) + { + Assert.Equal(HttpStatusCode.OK, settings.StatusCode); + var profile = await settings.Content.ReadFromJsonAsync(cancellationToken: ct); + Assert.True(profile.GetProperty("enabled").GetBoolean()); + Assert.Equal(pair.NetRatel.BaseUrl, profile.GetProperty("baseUrl").GetString()); + } + + var authorizedCatalog = await GetJsonAsync(pair.RatelDesk.Administrator, "/api/v1/admin/orchestration/catalog/jobs", ct); + Assert.Equal(JsonValueKind.Array, authorizedCatalog.ValueKind); + Assert.Single(authorizedCatalog.EnumerateArray()); // The genuinely enrolled target's one real owner-created definition. + + var templateId = Guid.NewGuid().ToString("D"); + var service = await PostJsonAsync(pair.RatelDesk.Administrator, "/api/v1/services/", new + { Name = "Synthetic orchestration round trip", Description = "bounded disposable proof", AllowedCustomerIds = new[] { pair.RatelDesk.CustomerId }, AllowedOrganizationIds = new[] { pair.RatelDesk.OrganizationId } }, ct); + var schema = JsonSerializer.Serialize(new + { + Fields = Array.Empty(), Tasks = new[] { new + { Id = templateId, Name = TaskName, Order = 1, Type = "automation", AutoStart = true, DependsOn = Array.Empty(), ExpectedRuntimeMinutes = 1, GraceRuntimeMinutes = 1 } } + }, Json); + var form = await PostJsonAsync(pair.RatelDesk.Administrator, "/api/v1/request-forms/", new + { + ServiceId = Id(service), Title = "Synthetic orchestration round trip", Description = "bounded disposable proof", + OrganizationId = pair.RatelDesk.OrganizationId, AllowedOrganizationIds = new[] { pair.RatelDesk.OrganizationId }, ReleaseStatus = 0, JsonSchema = schema + }, ct); + var binding = await PostJsonAsync(pair.RatelDesk.Administrator, "/api/v1/admin/orchestration/bindings", new + { + RequestFormId = Id(form), TaskTemplateId = templateId, OrchestrationRequestDefinitionId = definitionId, + OrchestrationRequestDefinitionName = JobName, OrchestrationJobDefinitionId = definitionId, OrchestrationJobDefinitionName = JobName + }, ct); + Assert.True(binding.GetProperty("enabled").GetBoolean()); + + var correlation = "physical-" + Guid.NewGuid().ToString("N"); + using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/requests/") + { + Content = JsonContent.Create(new + { + Title = "Synthetic actual automation proof", Description = "bounded disposable request", Priority = 0, + OrganizationId = pair.RatelDesk.OrganizationId, CustomerId = pair.RatelDesk.CustomerId, + ServiceId = Id(service), RequestFormId = Id(form), PayloadJson = "{}" + }) + }; + create.Headers.Add("X-Correlation-Id", correlation); + using var created = await pair.RatelDesk.Administrator.SendAsync(create, ct); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var parent = await created.Content.ReadFromJsonAsync(cancellationToken: ct); + var parentId = Id(parent); + // Actual HTTP creation generated this task and invoked published StartAsync; no task/status/stamps were seeded. + var tasks = await GetJsonAsync(pair.RatelDesk.Administrator, "/api/v1/request-tasks/?requestId=" + Uri.EscapeDataString(parentId), ct); + var generated = Assert.Single(tasks.GetProperty("items").EnumerateArray()); + Assert.Equal(TaskName, generated.GetProperty("name").GetString()); + var taskId = Id(generated); + var acknowledged = await GetJsonAsync(pair.RatelDesk.Administrator, "/api/v1/request-tasks/" + taskId, ct); + Assert.Equal(templateId, acknowledged.GetProperty("templateId").GetString()); + Assert.Equal(2, acknowledged.GetProperty("status").GetInt32()); + Assert.Equal(link, acknowledged.GetProperty("orchestrationLinkId").GetString()); + Assert.Equal(pair.NetRatel.InstanceId.ToString("D"), acknowledged.GetProperty("orchestrationPeerInstanceId").GetString()); + Assert.Equal(pair.Review.GrantSummary.ProposedLinkRevision, acknowledged.GetProperty("orchestrationLinkRevision").GetInt64()); + Assert.Equal(Id(binding), acknowledged.GetProperty("automationBindingId").GetString()); + var localRequestId = int.Parse(acknowledged.GetProperty("orchestrationExternalRequestId").GetString()!, CultureInfo.InvariantCulture); + var execution = acknowledged.GetProperty("orchestratorExecutionId").GetString()!; + Assert.Equal(execution, acknowledged.GetProperty("orchestrationExternalRunId").GetString()); + var runId = ulong.Parse(execution, CultureInfo.InvariantCulture); + + var ingest = Assert.Single(pair.NetRatel.Proxy.BusinessObservations, observation => + observation.Path == "/internal/ingest" && observation.StatusCode is >= 200 and < 300); + using (var transmitted = JsonDocument.Parse(ingest.RequestJson)) + { + Assert.Equal(parentId, transmitted.RootElement.GetProperty("RequestId").GetString()); + Assert.Equal(taskId, transmitted.RootElement.GetProperty("RequestTaskId").GetString()); + Assert.Equal(correlation, transmitted.RootElement.GetProperty("CorrelationId").GetString()); + Assert.Equal(definitionId, transmitted.RootElement.GetProperty("NetRatelJobDefinitionId").GetString()); + Assert.Equal(definitionId, transmitted.RootElement.GetProperty("NetRatelRequestDefinitionId").GetString()); + } + using (var ack = JsonDocument.Parse(ingest.ResponseJson!)) + { + Assert.Equal(localRequestId.ToString(CultureInfo.InvariantCulture), ack.RootElement.GetProperty("requestId").GetString()); + Assert.Equal(execution, ack.RootElement.GetProperty("runId").GetString()); + Assert.Equal(execution, ack.RootElement.GetProperty("executionId").GetString()); + } + + var recorded = await RecordedBindingAsync(pair, parentId, taskId, ct); + Assert.Equal(localRequestId, recorded.RequestId); Assert.Equal(execution, recorded.ExecutionId); + Assert.Equal(tenant, recorded.TenantId); Assert.Equal(agent, recorded.AgentId); + Assert.Equal(definitionId, recorded.JobDefinitionId); Assert.Equal(correlation, recorded.CorrelationId); + Assert.Equal(link, recorded.LinkId); Assert.Equal(pair.Review.GrantHash, recorded.GrantHash); + Assert.Equal(pair.RatelDesk.InstanceId.ToString("D"), recorded.PeerInstanceId); + Assert.Equal(pair.RatelDesk.OrganizationId, recorded.PeerTenantId); + Assert.Equal(pair.RatelDesk.ApiBaseUrl + "/api/v1/orchestration/provider/callback", recorded.CallbackUrl); + + await WaitAsync(async poll => + { + var current = await DetailsAsync(pair, runId, poll); + return current.Run.Status == JobRunState.Running && current.Steps.Count == 1 && current.Activities.Count == 1; + }, TimeSpan.FromSeconds(10), ct); + var beforeCompletion = await DetailsAsync(pair, runId, ct); + Assert.Equal(JobRunState.Running, beforeCompletion.Run.Status); + Assert.Equal(recorded.SourceSystem, beforeCompletion.Run.StartedBy); + Assert.Equal(agent, beforeCompletion.Run.AgentId); + Assert.Single(beforeCompletion.Steps); Assert.Single(beforeCompletion.Activities); + Require(beforeCompletion.Run.Status == JobRunState.Running, "The actual bounded command completed before its acknowledged release."); + await disk.ReleaseCommandAsync(ct); // Actual persisted ACK exists before the physical executor can finish. + + await WaitAsync(async poll => (await DetailsAsync(pair, runId, poll)).Run.Status == JobRunState.Succeeded, + TimeSpan.FromSeconds(40), ct); + var completed = await DetailsAsync(pair, runId, ct); + Assert.Equal(JobStepRunState.Succeeded, Assert.Single(completed.Steps).Status); + var activity = Assert.Single(completed.Activities); + Assert.Equal(agent, activity.AgentId); + using (var result = JsonDocument.Parse(activity.ResultJson!)) + { + Assert.Equal(0, result.RootElement.GetProperty("exitCode").GetInt32()); + Assert.Contains(commandMarker!, result.RootElement.GetProperty("stdout").EnumerateArray().Select(line => line.GetString())); + } + await WaitAsync(async poll => + { + var current = await GetJsonAsync(pair.RatelDesk.Administrator, "/api/v1/request-tasks/" + taskId, poll); + return current.GetProperty("status").GetInt32() == 3 && current.GetProperty("lastAutomationStatus").GetString() == "succeeded" && + await TerminalDeliveredAsync(pair, localRequestId, poll); + }, TimeSpan.FromSeconds(40), ct); + var finalTask = await GetJsonAsync(pair.RatelDesk.Administrator, "/api/v1/request-tasks/" + taskId, ct); + Assert.Equal(3, finalTask.GetProperty("status").GetInt32()); + Assert.Equal(6, finalTask.GetProperty("state").GetInt32()); // Published TicketState.Resolved. + Assert.NotEqual(JsonValueKind.Null, finalTask.GetProperty("completedAt").ValueKind); + Assert.Equal(link, finalTask.GetProperty("orchestrationLinkId").GetString()); + Assert.Equal(execution, finalTask.GetProperty("orchestratorExecutionId").GetString()); + Assert.Equal(localRequestId.ToString(CultureInfo.InvariantCulture), finalTask.GetProperty("orchestrationExternalRequestId").GetString()); + using (var callbackResult = JsonDocument.Parse(finalTask.GetProperty("resultJson").GetString()!)) + { + Assert.Equal("succeeded", callbackResult.RootElement.GetProperty("status").GetString()); + Assert.Equal(execution, callbackResult.RootElement.GetProperty("orchestrationRunId").GetString()); + Assert.Contains(commandMarker!, callbackResult.RootElement.GetProperty("resultJson").GetString()!, StringComparison.Ordinal); + } + var callback = Assert.Single(pair.RatelDesk.Proxy.BusinessObservations, observation => + observation.Path == "/api/v1/orchestration/provider/callback" && observation.StatusCode == 204 && + JsonSerializer.Deserialize(observation.RequestJson).GetProperty("status").GetString() == "succeeded"); + var worklogs = await GetJsonAsync(pair.RatelDesk.Administrator, $"/api/v1/requests/{parentId}/worklogs", ct); + var success = Assert.Single(worklogs.EnumerateArray(), log => + log.GetProperty("notesText").GetString()?.Contains("reported 'succeeded'", StringComparison.Ordinal) == true); + Assert.Contains(Id(binding), success.GetProperty("notesText").GetString()!, StringComparison.Ordinal); + Assert.Contains(execution, success.GetProperty("notesText").GetString()!, StringComparison.Ordinal); + Assert.Equal(JsonValueKind.Null, success.GetProperty("technicianId").ValueKind); // Machine subject is not a human FK. + var originalWorklogs = worklogs.EnumerateArray().Select(Id).Order(StringComparer.Ordinal).ToArray(); + + // Replay only the actual terminal provider body after the genuine result/receipt is complete. + var callbackToken = await pair.TokenAsync(netRatelIssuer: false, "rateldesk.orchestration.callback", ct); + using var replay = new HttpRequestMessage(HttpMethod.Post, "/api/v1/orchestration/provider/callback") + { Content = new StringContent(callback.RequestJson, Encoding.UTF8, "application/json") }; + replay.Headers.Authorization = new AuthenticationHeaderValue("Bearer", callbackToken); + replay.Headers.Add("X-Correlation-Id", correlation); + using (var replayed = await pair.RatelDesk.Anonymous.SendAsync(replay, ct)) Assert.Equal(HttpStatusCode.NoContent, replayed.StatusCode); + var unchanged = await GetJsonAsync(pair.RatelDesk.Administrator, $"/api/v1/requests/{parentId}/worklogs", ct); + Assert.Equal(originalWorklogs, unchanged.EnumerateArray().Select(Id).Order(StringComparer.Ordinal).ToArray()); + await using (var scope = pair.NetRatel.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await db.Set().CountAsync(row => row.ParentRequestId == parentId && row.RequestTaskId == taskId, ct)); + var runs = scope.ServiceProvider.GetRequiredService(); + Assert.Single(await runs.ListAsync(ct), run => run.JobId.ToString(CultureInfo.InvariantCulture) == definitionId); + } + recordedTask = new(parentId, taskId, Id(binding), localRequestId, execution, + completed.Steps.Single().Id.ToString(CultureInfo.InvariantCulture), + activity.Id.ToString(CultureInfo.InvariantCulture), Id(success), callback.StatusCode, + templateId, correlation, commandMarker!); + } + + private static async Task PostJsonAsync(HttpClient client, string path, object body, CancellationToken ct) + { + using var response = await client.PostAsJsonAsync(path, body, ct); + Assert.True(response.IsSuccessStatusCode, $"Actual human product command {path} returned HTTP {(int)response.StatusCode}."); + return await response.Content.ReadFromJsonAsync(cancellationToken: ct); + } + private static async Task GetJsonAsync(HttpClient client, string path, CancellationToken ct) + { + using var response = await client.GetAsync(path, ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadFromJsonAsync(cancellationToken: ct); + } + private static string Id(JsonElement item) => item.GetProperty("id").GetString()!; + private static async Task RecordedBindingAsync(ServiceLinkPair pair, string parent, string task, CancellationToken ct) + { + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Set() + .AsNoTracking().SingleAsync(row => row.ParentRequestId == parent && row.RequestTaskId == task, ct); + } + private static async Task DetailsAsync(ServiceLinkPair pair, ulong run, CancellationToken ct) + { + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().GetDetailsAsync(run, ct) + ?? throw new InvalidOperationException("The actual persisted job run disappeared."); + } + private static async Task TerminalDeliveredAsync(ServiceLinkPair pair, int request, CancellationToken ct) + { + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Set() + .AsNoTracking().AnyAsync(row => row.RequestId == request && row.Phase == "succeeded" && row.DeliveredAtUtc != null, ct); + } + private static async Task WaitAsync(Func> condition, TimeSpan timeout, CancellationToken ct) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(timeout); + while (!await condition(deadline.Token)) await Task.Delay(TimeSpan.FromMilliseconds(200), deadline.Token); + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.cs b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.cs new file mode 100644 index 00000000..71115f67 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/PhysicalIncidentFixture.cs @@ -0,0 +1,389 @@ +using System.Data; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Agents; +using NetRatel.Application.Flows; +using NetRatel.Application.Jobs; +using NetRatel.Application.Monitoring; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.Enrollment; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.Monitoring; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Shared.ServiceLinks; +using Xunit; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +// Real production API/worker OS processes, full Client image and immutable published +// receiver. The only application writes in this class are authenticated owner HTTP +// operations. Its separate service provider contains only read-only probes. +internal sealed partial class PhysicalIncidentFixture : IPhysicalIncidentFixture +{ + private readonly ServiceLinkNativeListener listener = new(); + private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web); + private ServiceLinkPair pair = null!; + private PhysicalDiskFixtureProcess disk = null!; + private ServiceLinkNativeArtifacts artifacts = null!; + private JsonElement provision; + private JsonElement client; + private PhysicalRule? selectedRule; + private FlowGraphDto? selectedGraph; + private Guid flowId; + private Guid agent; + private Guid sourceNamespace; + private Guid connector; + private string? command; + private string? commandMarker; + private PhysicalLoss? responseLoss; + private PhysicalRecordedTask? recordedTask; + private CachedPhysicalAuthorization? cachedAuthorization; + private long selectedCredentialRevision; + private PhysicalAllocation? secondAllocation; + private int allocationCount; + private bool closed; + private bool localUnlinkVerified; + private PhysicalApiProcess[]? terminalProcesses; + private int Tenant => int.Parse(pair.NetRatel.TenantId, CultureInfo.InvariantCulture); + public static async Task CreateAsync(CancellationToken ct, Action? progress = null) + { + void Mark(string stage) => PhysicalDiskIncidentAcceptance.ReportProgress(progress, stage); + if (!OperatingSystem.IsLinux()) throw new InvalidOperationException("The required genuine physical disk lane must run on Linux."); + var fixture = new PhysicalIncidentFixture(); + Mark("setup:start"); + try + { + Mark("artifacts:start"); + fixture.artifacts = await ServiceLinkNativeArtifacts.ResolveAsync(ct); + Mark("artifacts:complete"); + Mark("pair:start"); + fixture.pair = await ServiceLinkPair.CreateAsync(true, nativeListener: fixture.listener, + rotationPolicy: new(false, false), useSystemTime: true, physicalIncidentMode: true); + Mark("pair:complete"); + Mark("producer-adoption:start"); + await fixture.pair.NetRatel.AdoptActualPhysicalFlowProducerAsync(ct); + Mark("producer-adoption:complete"); + var repository = FindRepository(); + var helper = Path.Combine(repository, "tools", "testing", "physical-incident", "physical_disk_fixture_process.py"); + var work = Environment.GetEnvironmentVariable("NETRATEL_PHYSICAL_WORK_ROOT") + ?? throw new InvalidOperationException("The required Linux lane did not select its owned disk-backed fixture work root."); + Mark("disk-provision:start"); + fixture.disk = new PhysicalDiskFixtureProcess(helper, work); + fixture.provision = await fixture.disk.ProvisionAsync(ct); + Mark("disk-provision:complete"); + Mark("setup:complete"); + return fixture; + } + catch { Mark("setup-cleanup:start"); await fixture.DisposeAsync(); Mark("setup-cleanup:complete"); throw; } + } + + public async Task AssertExactCurrentSourcesAndPublishedCompanionAsync(CancellationToken ct) + { + var current = await ServiceLinkNativeArtifacts.ResolveAsync(ct); + Require(current == artifacts, "The physical source/artifact identity changed during setup."); + var metadata = await GetAsync(pair.RatelDesk.Anonymous, + "/api/integrations/service-link/metadata", ct); + Require(metadata.Product == "rateldesk" && metadata.ProductVersion == ServiceLinkPublishedRatelDeskPeer.PublishedVersion && + metadata.InstanceId == pair.RatelDesk.InstanceId.ToString("D"), "The actual published compatible peer identity differs."); + var candidate = await GetAsync(pair.NetRatel.Anonymous, + "/api/integrations/service-link/metadata", ct); + var expectedVersion = typeof(PhysicalIncidentFixture).Assembly.GetCustomAttributes() + .Single(x => x.Key == "ProductVersion").Value; + Require(candidate.Product == "netratel" && candidate.ProductVersion == expectedVersion && + candidate.InstanceId == pair.NetRatel.InstanceId.ToString("D"), "The actual production API process source/version identity differs."); + } + + public async Task AssertNoFixtureSeededExecutionAuthorityAsync(CancellationToken ct) + { + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Require(!await db.Agents.AnyAsync(ct) && !await db.Jobs.AnyAsync(ct) && !await db.JobRuns.AnyAsync(ct) && + !await db.Set().AnyAsync(ct) && !await db.MonitoringEvidenceStreams.AnyAsync(ct) && + !await db.MonitoringOccurrences.AnyAsync(ct) && !await db.MonitoringFlowOutbox.AnyAsync(ct) && + !await db.FlowRuns.AnyAsync(ct) && !await db.FlowActions.AnyAsync(ct) && + !await db.Set().AnyAsync(ct), "The disposable physical fixture contains seeded runtime authority or effects."); + Require(await pair.RatelDesk.CountAsync("Incidents") == 0 && await pair.RatelDesk.CountAsync("IncidentCreateReceipts") == 0, + "The published physical receiver contains a seeded incident or receipt."); + } + + public async Task StartFullProductionClientAsync(CancellationToken ct) + { + var image = Environment.GetEnvironmentVariable("NETRATEL_PHYSICAL_CLIENT_IMAGE_ID") + ?? throw new InvalidOperationException("The same-job production Client image identity is missing."); + var executable = Environment.GetEnvironmentVariable("NETRATEL_PHYSICAL_CLIENT_EXECUTABLE_SHA256") + ?? throw new InvalidOperationException("The actual same-image production Client executable identity is missing."); + var version = typeof(PhysicalIncidentFixture).Assembly.GetCustomAttributes().Single(x => x.Key == "ProductVersion").Value!; + using var response = await pair.NetRatel.Administrator.PostAsJsonAsync($"/api/v1/tenants/{Tenant}/enrollment-codes", + new { ValidForMinutes = 5, MaxUses = 1, Note = "isolated full production Client disk acceptance" }, ct); + Require(response.IsSuccessStatusCode, "The real owner enrollment-code command failed."); + var issued = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + Require(issued?.EnrollmentCode is { Length: > 0 and <= 4096 }, "The actual owner enrollment-code response was invalid."); + client = await disk.StartClientAsync(image, artifacts.CandidateSourceSha, version, executable, + listener.CaPem, pair.NetRatel.BaseUrl, listener.Endpoint, issued!.EnrollmentCode, ct); + Require(client.GetProperty("trustedTlsAlpnVerified").GetBoolean() && client.GetProperty("unrelatedCaRejected").GetBoolean() && + client.GetProperty("daemonVisibleOwnedExt4Verified").GetBoolean() && client.GetProperty("slowCollectionSeconds").GetInt32() == 5, + "The genuine Client image/owned volume/TLS/effective cadence preconditions were not proven."); + command = client.GetProperty("command").GetString(); commandMarker = client.GetProperty("commandMarker").GetString(); + Require(command is { Length: > 0 and <= 1024 } && commandMarker is { Length: > 0 and <= 128 }, "The owned bounded command gate is invalid."); + } + + public async Task WaitRealFirstHeartbeatAndTelemetryAdmissionAsync(CancellationToken ct) + { + while (true) + { + await disk.AssertAliveAsync(ct); + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var enrolled = await db.Agents.AsNoTracking().Where(x => x.TenantId == Tenant && x.CreatedBy == "enrollment").ToArrayAsync(ct); + Require(enrolled.Length <= 1, "The single genuine Client enrolled more than one Agent-ID."); + if (enrolled is [var value] && value.IsEnabled && value.Status == AgentStatus.Active && !string.IsNullOrWhiteSpace(value.PublicKeyFingerprint)) + { + agent = value.Id; + var snapshot = await ReadAcceptedTelemetryAsync(agent, ct); + return new(agent, snapshot.Owner.ConnectionId!.Value, checked((ulong)snapshot.Owner.ConnectionEpoch), + snapshot.Stream.EvidenceStreamId, true, true, "full-production-NetRatel.Client"); + } + await Task.Delay(200, ct); + } + } + + public async Task StartSecondRealApiWorkerReplicaAsync(CancellationToken ct) + { await pair.NetRatel.StartPhysicalWorkerReplicaAsync(ct); } + public Task AssertTwoActualWorkerReplicasAsync(CancellationToken ct) + { ct.ThrowIfCancellationRequested(); pair.NetRatel.AssertPhysicalWorkerProcesses(); return Task.CompletedTask; } + public Task AssertFullProductionClientWithinFixedLifetimeAsync(CancellationToken ct) => disk.AssertAliveAsync(ct); + + public async Task ReadActualAcceptedSelectedDiskAsync(Guid agentId, CancellationToken ct) + { + var accepted = await ReadAcceptedTelemetryAsync(agentId, ct); + var diskValue = accepted.Snapshot.GetProperty("disks").EnumerateArray() + .Single(x => x.GetProperty("scope").GetString() == "/netratel-physical-disk"); + Require(diskValue.GetProperty("collectionQuality").GetInt32() == 1, + "The actual selected disk collection is incomplete, unsupported or failed."); + return new("/netratel-physical-disk", diskValue.GetProperty("collectionId").GetGuid(), + diskValue.GetProperty("collectedAtUtc").GetDateTimeOffset(), accepted.Snapshot.GetProperty("observedAtUtc").GetDateTimeOffset(), + accepted.Snapshot.GetProperty("receivedAtUtc").GetDateTimeOffset(), accepted.Sequence, + checked((long)diskValue.GetProperty("totalBytes").GetUInt64()), checked((long)diskValue.GetProperty("freeBytes").GetUInt64()), true, true, true); + } + + private async Task<(JsonElement Snapshot, ulong Sequence, ClientConnectionOwnerRecord Owner, MonitoringEvidenceStreamRecord Stream)> + ReadAcceptedTelemetryAsync(Guid agentId, CancellationToken ct) + { + // A real authorized SSE envelope contains the transport epoch/sequence and + // the server's actual effective sampling policy. Do not invent a cursor from + // clock time or a cached sample. Closing each read releases its demand lease. + using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v2/agents/{Tenant}/{agentId:D}/telemetry/stream?samplePeriodMs=5000"); + using var response = await pair.NetRatel.Administrator.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + Require(response.StatusCode == HttpStatusCode.OK, "The actual owner telemetry read was denied."); + await using var body = await response.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(body); + while (true) + { + var line = await reader.ReadLineAsync(ct) ?? throw new InvalidOperationException("The actual accepted telemetry stream closed."); + Require(line.Length <= 131_072, "The actual accepted telemetry envelope exceeded its bound."); + if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue; + using var message = JsonDocument.Parse(line[6..]); + var envelope = message.RootElement; + if (!envelope.TryGetProperty("snapshot", out var sample) || sample.ValueKind == JsonValueKind.Null) continue; + Require(envelope.GetProperty("effectiveSamplePeriodMilliseconds").GetInt32() == 5000 && + sample.GetProperty("tenantId").GetInt32() == Tenant && sample.GetProperty("agentId").GetGuid() == agentId && + sample.GetProperty("isAuthoritative").GetBoolean(), "The actual admitted telemetry target/cadence/authority differs."); + var epoch = envelope.GetProperty("connectionEpoch").GetInt64(); + var sequence = envelope.GetProperty("sequence").GetUInt64(); + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + await db.Database.ExecuteSqlRawAsync("SET TRANSACTION READ ONLY", ct); + var owner = await db.Set().AsNoTracking().SingleOrDefaultAsync(x => x.TenantId == Tenant && x.AgentId == agentId, ct); + var stream = await db.MonitoringEvidenceStreams.AsNoTracking().SingleOrDefaultAsync(x => x.TenantId == Tenant && x.AgentId == agentId, ct); + var now = await db.Database.SqlQuery($"SELECT clock_timestamp() AS \"Value\"").SingleAsync(ct); + var accepted = owner is { Active: true, ConnectionId: not null } && owner.ConnectionEpoch == epoch && owner.LastHeartbeatSequence > 0 && + owner.PresenceExpiresAtUtc > now && owner.AuthenticationExpiresAtUtc > now && stream is { Active: true } && + stream.ConnectionId == owner.ConnectionId && stream.ConnectionEpoch == epoch && stream.CommittedRegistrationOrdinal > 0; + await transaction.CommitAsync(ct); + if (!accepted) continue; // The real first heartbeat/registration may still be committing. + return (sample.Clone(), sequence, owner!, stream!); + } + } + + public async Task ConfigureOwnerPublishedFlowAndSelectedDiskRuleAsync(Guid agentId, string scope, + PhysicalThresholds thresholds, CancellationToken ct) + { + Require(agentId == agent && scope == "/netratel-physical-disk", "Only the real admitted selected volume can be configured."); + var setupBase = $"/api/v2/tenants/{Tenant}/connectors/rateldesk"; + var setup = await GetAsync(pair.NetRatel.Administrator, setupBase + "/setup", ct); + var approved = setup.ManagedLinks.Single(x => x.LinkId == pair.Review.GrantSummary.LinkId && + x.OrganizationId == pair.RatelDesk.OrganizationId && x.CustomerId == pair.RatelDesk.CustomerId); + Require(setup.FlowSourceInstanceId == pair.NetRatel.SourceInstanceId && setup.AdoptedSourceInstanceId == setup.FlowSourceInstanceId.ToString("D"), + "The managed setup differs from the actual adopted persisted Flow producer."); + connector = Guid.NewGuid(); + var configuration = new RatelDeskConnectorConfiguration("Physical disk acceptance", approved.ApiBaseUrl, + approved.OrganizationId, approved.CustomerId, null, [], new(), true); + var saved = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Put, setupBase + $"/{connector:D}", + new SaveRatelDeskConnectorRequest(0, configuration, new("service_link", approved.LinkId)), ct); + Require(saved.Id == connector && saved.Authentication?.ManagedLinkId == approved.LinkId && saved.Revision > 0, + "The ordinary owner save did not bind the exact approved managed link."); + var connection = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, + setupBase + $"/{connector:D}/connection-test", new { }, ct); + // Export only the existing bounded server result; no response body, URL, credential or exception text. + var readinessCode = connection.Code is "connector-changed-during-readiness" or "receiver-ready" or + "receiver-current-authority-denied" or "receiver-readiness-unverified" or "receiver-readiness-timeout" or + "connector-busy" or "receiver-authentication-rejected" or "receiver-current-grant-rejected" or + "receiver-target-rejected" or "receiver-rate-limited" or "receiver-redirect-refused" or + "receiver-capability-unavailable" or "receiver-target-validation-unavailable" ? connection.Code : "unclassified"; + Require(connection.AutomaticDeliveryAvailable && connection.Status == RatelDeskConnectionTestStatus.MappingValidated, + $"Actual capability/namespace/targets/current credential did not permit delivery (status={(int)connection.Status}, " + + $"code={readinessCode}, automaticDelivery={connection.AutomaticDeliveryAvailable}, " + + $"retryAfterSeconds={Math.Clamp(connection.RetryAfterSeconds ?? 0, 0, 300)})."); + var readiness = await ReadReadinessAsync(ct); + sourceNamespace = readiness.Peer.SourceNamespaceId; + Require(readiness.Peer.SourceInstanceId == pair.NetRatel.SourceInstanceId && readiness.Peer.ReceiverInstanceId == pair.RatelDesk.InstanceId.ToString("D"), + "The actual readiness pinned another receiver/source tuple."); + var flowBase = $"/api/v1/tenants/{Tenant}/flows"; + var catalog = await GetAsync(pair.NetRatel.Administrator, flowBase + "/connectors", ct); + Require(catalog.Single(x => x.Id == connector).CanExecute, "The actual current connector catalog did not permit execution."); + var template = await GetAsync(pair.NetRatel.Administrator, flowBase + "/template", ct); + var action = template.Nodes.Single(x => x.Kind == FlowNodeKind.CreateIncident); + selectedGraph = template with { Nodes = template.Nodes.Select(x => x.Id == action.Id + ? x with { ConnectorId = connector, ConnectorRevision = saved.Revision } : x).ToArray() }; + var validation = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, flowBase + "/validate", selectedGraph, ct); + Require(validation.Valid, "The ordinary supported Flow graph validation failed."); + var flow = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, flowBase, new FlowCreateRequest("Physical disk incident"), ct); + flowId = flow.Id; + flow = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Put, flowBase + $"/{flowId:D}/draft", + new FlowSaveDraftRequest(flow.Revision, flow.Name, selectedGraph), ct); + var version = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, flowBase + $"/{flowId:D}/publish", new FlowRevisionRequest(flow.Revision), ct); + flow = await GetAsync(pair.NetRatel.Administrator, flowBase + $"/{flowId:D}", ct); + if (!flow.Enabled) flow = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Put, + flowBase + $"/{flowId:D}/enabled", new FlowEnabledRequest(flow.Revision, true), ct); + Require(flow.Enabled && flow.PublishedVersionId == version.Id && !string.IsNullOrEmpty(version.PublishedBy), + "The actual owner did not publish and enable an immutable bounded Flow."); + var monitoring = $"/api/v2/tenants/{Tenant}/monitoring"; + var current = await GetAsync(pair.NetRatel.Administrator, monitoring + "/configuration", ct); + var ruleId = Guid.NewGuid(); + var targets = new MonitoringTargetSelectionDto(MonitoringTargetMode.Selected, [agentId], []); + var condition = new MonitoringConditionDto(MonitoringMetricKind.DiskFreeSpace, MonitoringNumericUnit.Bytes, + thresholds.BreachBytes, thresholds.RecoveryBytes, scope, null, []); + var preview = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, + monitoring + "/targets/preview", new MonitoringTargetPreviewRequest(targets, condition), ct); + Require(preview.ConfigurationRevision == current.Revision && preview.AgentIds.SequenceEqual([agentId]) && + preview.Details.Single().Support == MonitoringTargetSupport.Supported, "The ordinary target preview did not authorize the genuine enrolled disk."); + var rule = new MonitoringRuleDto(Tenant, ruleId, 1, 1, "Physical disk free space", true, MonitoringSeverity.Warning, + targets, condition, TimeSpan.FromSeconds(thresholds.HoldSeconds), TimeSpan.FromSeconds(thresholds.RecoveryHoldSeconds), + TimeSpan.FromSeconds(thresholds.FreshnessSeconds), version.Id); + current = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Put, + monitoring + $"/rules/{ruleId:D}", new MonitoringRuleWriteDto(rule, current.Revision, "Configure owned physical disk proof"), ct); + rule = current.Rules.Single(x => x.RuleId == ruleId); + Require(!string.IsNullOrEmpty(rule.ExecutionPrincipalId) && rule.ExecutionCredentialId is null && rule.PublishedFlowVersionId == version.Id, + "The real rule mutation did not attribute its actual owner authority."); + selectedRule = new(ruleId, version.Id, version.ConfigurationHash, MonitoringSeriesEvaluator.DiskResourceKey(scope), + checked((long)current.Revision), connector, saved.Revision); + return selectedRule; + } + + public async Task ValidateAndDryRunWithoutEffectsAsync(PhysicalRule rule, CancellationToken ct) + { + var before = await ReadScopedDurableProofAsync(rule, ct); + var sample = await ReadActualAcceptedSelectedDiskAsync(agent, ct); + var preview = await SendAsync(pair.NetRatel.Administrator, HttpMethod.Post, + $"/api/v1/tenants/{Tenant}/flows/dry-run", new FlowDryRunRequest(selectedGraph!, + new(agent, rule.RuleId, "Physical disk free space", "Disposable genuine Client", sample.Scope, + "DiskFreeSpace", "Warning", sample.FreeBytes, null, sample.CollectedAtUtc)), ct); + Require(preview.Valid && !preview.Skipped && preview.Incident is not null, "The actual dry-run did not render the bounded incident mapping."); + var after = await ReadScopedDurableProofAsync(rule, ct); + Assert.Equal(before.Occurrences, after.Occurrences); Assert.Equal(before.Actions, after.Actions); + Assert.Equal(before.Incidents, after.Incidents); Assert.Equal(before.Receipts, after.Receipts); + Require(before.Actions.Length == 0 && after.Actions.Length == 0, "Connection test, validation, preview or healthy configuration created effects."); + } + + private async Task ReadReadinessAsync(CancellationToken ct) + { + await using var scope = pair.NetRatel.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.Set().AsNoTracking().SingleAsync(x => x.TenantId == Tenant && x.Id == connector, ct); + return Parse(row.ReadinessJson); + } + + public async Task ArmOneActualReceiver201AfterCommitLossAsync(PhysicalRule rule, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var probe = new PhysicalIncidentDurableReadProbe(pair.NetRatel.Services, pair.RatelDesk, Tenant, rule.RuleId, agent, rule.ResourceKey, rule.FlowVersionId); + responseLoss = new(pair.RatelDesk.Proxy.LoseOneCommittedIncidentResponse(pair.NetRatel.SourceInstanceId, sourceNamespace, + probe.ReadCommittedAsync)); + return responseLoss; + } + + public async Task AllocateOwnedVolumeAsync(long count, CancellationToken ct) + { + Require(count == 256L * 1024 * 1024, "The physical stimulus differs from the bounded reviewed allocation."); + var allocation = Allocation(await disk.AllocateAsync(ct), false); + if (++allocationCount == 2) secondAllocation = allocation; + Require(allocationCount <= 2, "The physical fixture attempted an extra allocation cycle."); + return allocation; + } + public async Task DeleteOnlyOwnedAllocationAsync(CancellationToken ct) => Allocation(await disk.RecoverAsync(ct), true); + private static PhysicalAllocation Allocation(JsonElement value, bool recovered) + { + var ticks = value.GetProperty("observedAtUtcUnixNanoseconds").GetInt64() / 100; + return new(DateTimeOffset.UnixEpoch.AddTicks(ticks), recovered ? 0 : value.GetProperty("stimulusBytes").GetInt64(), + recovered ? 0 : value.GetProperty("actualAllocatedBytes").GetInt64(), value.GetProperty("selectedAvailableBefore").GetInt64(), + value.GetProperty("selectedAvailableAfter").GetInt64(), value.GetProperty("hostAvailableAfter").GetInt64()); + } + + public async Task RestartRealApiWorkerReplicasWithPersistedStateAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var before = pair.NetRatel.PhysicalProcesses.Where(x => x.ExitedAtUtc is null).Select(x => x.ProcessId).ToArray(); + Require(before.Length == 2, "Two actual owned worker processes must exist before the crash."); + await pair.NetRatel.RestartAsync(ct); + var after = pair.NetRatel.PhysicalProcesses; + Require(before.All(id => after.Single(x => x.ProcessId == id).ExitedAtUtc is not null) && + after.Count(x => x.ExitedAtUtc is null) == 2 && !after.Where(x => x.ExitedAtUtc is null).Any(x => before.Contains(x.ProcessId)), + "The actual persisted-state worker processes did not exit and restart independently."); + await pair.RatelDesk.RestartAsync(); + responseLoss!.Inner.MarkActualProcessesRestarted(); + } + + private sealed class PhysicalLoss(CommittedIncidentResponseLoss inner) : IPhysicalCommittedResponseLoss + { + public CommittedIncidentResponseLoss Inner => inner; + public async Task WaitIndependentCommittedReceiptAsync(CancellationToken ct) + { var value = await inner.Committed.WaitAsync(ct); return new(201, true, true, value.NamespaceId, value.Key, value.Fingerprint, 1, 1, 1); } + public async Task ObserveRealSameKeyRecoveryAfterRestartAsync(CancellationToken ct) => _ = await inner.PostRestartRecovery.WaitAsync(ct); + public void ReleaseIdenticalRetryGate() => inner.Release(); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + + private async Task GetAsync(HttpClient owner, string path, CancellationToken ct) => await SendAsync(owner, HttpMethod.Get, path, null, ct); + private async Task SendAsync(HttpClient owner, HttpMethod method, string path, object? value, CancellationToken ct) + { + using var request = new HttpRequestMessage(method, path); + if (method != HttpMethod.Get) request.Headers.Add("X-NetRatel-Account-Request", "1"); + if (value is not null) request.Content = JsonContent.Create(value, value.GetType(), options: json); + using var response = await owner.SendAsync(request, ct); + Require(response.IsSuccessStatusCode, $"The actual owner operation {method} {path} returned HTTP {(int)response.StatusCode}."); + return await response.Content.ReadFromJsonAsync(json, ct) ?? throw new InvalidOperationException("The actual owner operation returned no bounded typed response."); + } + private T Parse(string? raw) => raw is not null && Encoding.UTF8.GetByteCount(raw) <= 131_072 + ? JsonSerializer.Deserialize(raw, json) ?? throw new InvalidOperationException("The durable physical DTO was absent.") + : throw new InvalidOperationException("The durable physical DTO was absent or oversized."); + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } + private static string FindRepository() + { + for (var root = new DirectoryInfo(AppContext.BaseDirectory); root is not null; root = root.Parent) + if (File.Exists(Path.Combine(root.FullName, "NetRatel.sln"))) return root.FullName; + throw new InvalidOperationException("The physical fixture must execute within its actual committed source checkout."); + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkHttpProxy.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkHttpProxy.cs index 5c24306a..6eaf9177 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkHttpProxy.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkHttpProxy.cs @@ -21,11 +21,14 @@ internal sealed class ServiceLinkHttpProxy : IAsyncDisposable private string? lostResponseSuffix; private int lossPending; private readonly object observationSync = new(); + private long requestAdmissionGeneration; + internal long CurrentRequestAdmission => Interlocked.Read(ref requestAdmissionGeneration); private PendingObservation? pendingObservation; private readonly List completedObservations = []; private readonly List rotationResponseFaults = []; private readonly ConcurrentQueue businessObservations = new(); private TerminalDeliveryGate? terminalDelivery; + private CommittedIncidentResponseLoss? incidentResponseLoss; public IReadOnlyList BusinessObservations => businessObservations.ToArray(); public string BaseUrl { get; } public int LostResponses { get; private set; } @@ -64,6 +67,24 @@ public async Task StartAsync() await app.StartAsync(); } + public CommittedIncidentResponseLoss LoseOneCommittedIncidentResponse(Guid source, Guid sourceNamespace, + Func> independentRead) + { + lock (observationSync) + { + if (incidentResponseLoss is not null) + throw new InvalidOperationException("The actual incident response-loss fixture is already armed."); + CommittedIncidentResponseLoss? fault = null; + fault = new(new Uri(BaseUrl), source, sourceNamespace, independentRead, () => + { + lock (observationSync) + if (ReferenceEquals(incidentResponseLoss, fault)) incidentResponseLoss = null; + }); + incidentResponseLoss = fault; + return fault; + } + } + public void LoseNextCompletedResponse(string pathSuffix) { lostResponseSuffix = pathSuffix; @@ -147,6 +168,9 @@ public Task ObserveNextSuccessfulOperation(string private async Task ForwardAsync(HttpContext context) { + // Capture admission before any await so an old queued handler cannot + // satisfy a recovery observation armed after its application stopped. + var requestAdmission = Interlocked.Increment(ref requestAdmissionGeneration); var pause = Volatile.Read(ref terminalDelivery); if (pause is not null && HttpMethods.IsPost(context.Request.Method) && context.Request.Path.Value is { } path && path.StartsWith(ServiceLinkContract.EndpointPath + "/links/", StringComparison.Ordinal) && @@ -176,11 +200,24 @@ private async Task ForwardAsync(HttpContext context) var rotationClaimOwned = false; var backendResponseObserved = false; byte[]? businessRequest = null; + var incidentFault = Volatile.Read(ref incidentResponseLoss); + if (incidentFault?.Matches(context) != true) incidentFault = null; + var firstIncidentCreate = false; try { var observeBusiness = HttpMethods.IsPost(context.Request.Method) && !context.Request.QueryString.HasValue && context.Request.Path.Value is "/internal/ingest" or "/api/v1/orchestration/provider/callback"; - if (possibleRotationFaults.Length != 0) + if (incidentFault is not null) + { + observedRequest = await ReadBoundedAsync(context.Request.Body, 131_072, context.RequestAborted); + firstIncidentCreate = await incidentFault.BeforeForwardAsync(context, observedRequest); + if (observedRequest.Length != 0) + { + forwardedRequest = observedRequest.ToArray(); + request.Content = new ByteArrayContent(forwardedRequest); + } + } + else if (possibleRotationFaults.Length != 0) { observedRequest = await ReadBoundedAsync(context.Request.Body, 131_072, context.RequestAborted); var lifecycle = ServiceLinkCanonicalJson.Deserialize(Encoding.UTF8.GetString(observedRequest)); @@ -188,7 +225,7 @@ private async Task ForwardAsync(HttpContext context) rotationFault = possibleRotationFaults.SingleOrDefault(x => x.Matches(lifecycle)); if (rotationFault is not null) { - rotationClaimOwned = rotationFault.TryClaim(lifecycle); + rotationClaimOwned = rotationFault.TryClaim(lifecycle, requestAdmission); if (!rotationClaimOwned) { // After losing the committed response, hold identical recovery delivery @@ -237,8 +274,23 @@ private async Task ForwardAsync(HttpContext context) if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } - using var response = await forward.SendAsync(request, HttpCompletionOption.ResponseContentRead, context.RequestAborted); + // This actual telemetry read is a long-lived SSE body. Forward its + // headers immediately; buffering to EOF prevents the owner receiving any envelope. + var telemetryStream = HttpMethods.IsGet(context.Request.Method) && + context.Request.Path.Value is { } telemetryPath && + telemetryPath.StartsWith("/api/v2/agents/", StringComparison.Ordinal) && + telemetryPath.EndsWith("/telemetry/stream", StringComparison.Ordinal); + using var response = await forward.SendAsync(request, incidentFault is null && !telemetryStream + ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); backendResponseObserved = true; + if (firstIncidentCreate) + { + await using var responseBody = await response.Content.ReadAsStreamAsync(context.RequestAborted); + observedResponse = await ReadBoundedAsync(responseBody, 131_072, context.RequestAborted); + await incidentFault!.AfterActualResponseAsync(context, response, observedResponse, true); + LostResponses++; + return; // The proven committed response was aborted before downstream headers/body. + } if (rotationFault is not null) { if (!response.IsSuccessStatusCode) @@ -362,6 +414,11 @@ private async Task ForwardAsync(HttpContext context) foreach (var header in response.Headers.Concat(response.Content.Headers)) if (!header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) context.Response.Headers[header.Key] = header.Value.ToArray(); + if (telemetryStream) + { + context.Features.Get()?.DisableBuffering(); + await context.Response.StartAsync(context.RequestAborted); + } if (!HttpMethods.IsHead(context.Request.Method) && (int)response.StatusCode >= 200 && response.StatusCode is not HttpStatusCode.NoContent and not HttpStatusCode.NotModified) await response.Content.CopyToAsync(context.Response.Body, context.RequestAborted); @@ -386,6 +443,8 @@ private async Task ForwardAsync(HttpContext context) public async ValueTask DisposeAsync() { Interlocked.Exchange(ref terminalDelivery, null)?.Released.TrySetResult(true); + if (Interlocked.Exchange(ref incidentResponseLoss, null) is { } incidentFault) + await incidentFault.DisposeAsync(); if (app is not null) await app.DisposeAsync(); lock (observationSync) { @@ -485,6 +544,7 @@ internal sealed class ServiceLinkRotationResponseFault(ServiceLinkHttpProxy owne private readonly object recoverySync = new(); private long recoveryGeneration; private long awaitedRecoveryGeneration; + private long postRestartAdmissionBoundary; private TaskCompletionSource? postRestartRecovery; private int postRestartRecoveriesProven; private bool disposed; @@ -505,6 +565,7 @@ public Task ObserveRecoveryAfterRestart() if (postRestartRecovery is not null) throw new InvalidOperationException("A post-restart recovery generation is already armed."); awaitedRecoveryGeneration = checked(recoveryGeneration + 1); + postRestartAdmissionBoundary = owner.CurrentRequestAdmission; postRestartRecovery = new(TaskCreationOptions.RunContinuationsAsynchronously); return postRestartRecovery.Task; } @@ -518,7 +579,7 @@ internal bool Matches(ServiceLinkLifecycleRequest request) if (Phase == "offer") boundRotationId ??= request.RotationId; return true; } - internal bool TryClaim(ServiceLinkLifecycleRequest request) + internal bool TryClaim(ServiceLinkLifecycleRequest request, long requestAdmission) { var fingerprint = ServiceLinkLifecycleProjection.Hash(Phase == "verify" ? "verify" : "rotate", request); lock (recoverySync) @@ -528,7 +589,8 @@ internal bool TryClaim(ServiceLinkLifecycleRequest request) throw new InvalidOperationException("The real background recovery changed a durable rotation operation's semantic payload."); if (claimed == 0) { claimed = 1; requestFingerprint ??= fingerprint; return true; } recoveryGeneration = checked(recoveryGeneration + 1); - if (postRestartRecovery is not null && recoveryGeneration >= awaitedRecoveryGeneration) + if (postRestartRecovery is not null && recoveryGeneration >= awaitedRecoveryGeneration && + requestAdmission > postRestartAdmissionBoundary) { postRestartRecoveriesProven++; postRestartRecovery.TrySetResult(recoveryGeneration); diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.Physical.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.Physical.cs new file mode 100644 index 00000000..fbf4c1e6 --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.Physical.cs @@ -0,0 +1,417 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Jobs; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.Services; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +internal sealed record PhysicalApiProcess(int ProcessId, DateTimeOffset StartedAtUtc, + DateTimeOffset? ExitedAtUtc, int? ExitCode, string Role, int Generation); + +internal sealed partial class ServiceLinkNetRatelPeer +{ + private readonly string physicalRoot = Path.Combine(Path.GetTempPath(), "netratel-physical-api-" + Guid.NewGuid().ToString("N")); + private ServiceProvider? physicalReadServices; + private OwnedPhysicalApiProcess? physicalPrimary; + private OwnedPhysicalApiProcess? physicalReplica; + private IReadOnlyDictionary? physicalSettings; + private readonly List processHistory = []; + private int generation; + public IReadOnlyList PhysicalProcesses => processHistory.Concat( + new[] { physicalPrimary, physicalReplica }.Where(x => x is not null).Select(x => x!.Proof)).ToArray(); + public string PhysicalReplicaBaseUrl => physicalReplica?.BaseUrl ?? throw new InvalidOperationException("The actual API replica is unavailable."); + public bool PhysicalOwnedCleanupComplete => !physicalIncidentMode || physicalPrimary is null && physicalReplica is null && + physicalReadServices is null && physicalSettings is null && !Directory.Exists(physicalRoot); + + private async Task StartPhysicalProcessAsync(CancellationToken ct = default) + { + if (nativeListener is null) throw new InvalidOperationException("Physical API requires the owned real TLS listener identity."); + if (physicalSettings is null) + { + Directory.CreateDirectory(physicalRoot); + File.SetUnixFileMode(physicalRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var apiDll = PhysicalApiAssembly(); + var baseline = new ConfigurationBuilder().SetBasePath(Path.GetDirectoryName(apiDll)!) + .AddJsonFile("appsettings.json", optional: true).Build(); + var settings = baseline.AsEnumerable().Where(x => x.Value is not null) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + foreach (var item in databaseOwner.PhysicalRuntimeSettings()) settings[item.Key] = item.Value; + foreach (var item in configuration) settings[item.Key] = item.Value; + var certificate = Path.Combine(physicalRoot, "listener.pfx"); + var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + await File.WriteAllBytesAsync(certificate, nativeListener.ServerCertificate.Export(X509ContentType.Pfx, password)); + File.SetUnixFileMode(certificate, UnixFileMode.UserRead | UnixFileMode.UserWrite); + // Kestrel's supported endpoint configuration augments Program's unchanged + // separate REST/h2c listeners. No certificate validation is overridden. + settings["Kestrel:Endpoints:Physical:Url"] = nativeListener.Endpoint; + settings["Kestrel:Endpoints:Physical:Protocols"] = "Http2"; + settings["Kestrel:Endpoints:Physical:Certificate:Path"] = certificate; + settings["Kestrel:Endpoints:Physical:Certificate:Password"] = password; + // ASP.NET's documented HTTPS_PORT input keeps the independent fixture + // REST listener from redirecting into the native HTTP/2-only listener. + settings["HTTPS_PORT"] = "-1"; + settings["TelemetryInteractive:BaselineSlowIntervalSeconds"] = "5"; + physicalSettings = settings; + var services = new ServiceCollection(); + // This provider can only read durable state. It contains no gateway, + // actor, hosted worker, bootstrap operation, dispatcher or fake clock. + services.AddDbContext(o => o.UseNpgsql(settings["ConnectionStrings:NetRatelDb"])); + services.AddSingleton(); + services.AddScoped(); + physicalReadServices = services.BuildServiceProvider(); + } + physicalPrimary = await OwnedPhysicalApiProcess.StartAsync(physicalRoot, "primary", ++generation, + physicalSettings, backendPort, ct); + Anonymous = NewClient(); Administrator = NewClient(cookies: true); + using var login = await Administrator.PostAsJsonAsync("/api/v2/local-auth/login", new + { Email = global::ApiFactory.LocalAdministratorEmail, Password = global::ApiFactory.LocalAdministratorPassword, RememberMe = false }, ct); + if (login.StatusCode != HttpStatusCode.NoContent) + throw new InvalidOperationException("The actual physical API process did not authenticate the disposable administrator."); + } + + public async Task StartPhysicalWorkerReplicaAsync(CancellationToken ct = default) + { + if (!physicalIncidentMode || physicalSettings is null || physicalReplica is not null) + throw new InvalidOperationException("The actual physical API replica cannot start in this phase."); + var port = ServiceLinkHttpProxy.AllocatePort(); + var settings = physicalSettings.Where(x => !x.Key.StartsWith("Kestrel:Endpoints:Physical:", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + settings["NetRatel_HTTP_PORT"] = port.ToString(System.Globalization.CultureInfo.InvariantCulture); + settings["NetRatelAkka:GatewayGrpcPort"] = ServiceLinkHttpProxy.AllocatePort().ToString(System.Globalization.CultureInfo.InvariantCulture); + // The same database, Data Protection ring, AgentAuth signing key and issuer + // inputs are retained. This starts Program and all production workers. + physicalReplica = await OwnedPhysicalApiProcess.StartAsync(physicalRoot, "replica", ++generation, settings, port, ct); + AssertPhysicalWorkerProcesses(); + } + + public void AssertPhysicalWorkerProcesses() + { + if (physicalPrimary is null || physicalReplica is null || physicalPrimary.ProcessId == physicalReplica.ProcessId || + !physicalPrimary.Alive || !physicalReplica.Alive) + throw new InvalidOperationException("Two independent actual production API/worker OS processes are required."); + } + + private async Task RestartPhysicalProcessesAsync(CancellationToken ct) + { + AssertPhysicalWorkerProcesses(); + Administrator.Dispose(); Anonymous.Dispose(); + foreach (var owned in new[] { physicalPrimary!, physicalReplica! }) + { + await owned.CrashOwnedAsync(); + processHistory.Add(owned.Proof); + } + physicalPrimary = null; physicalReplica = null; + await StartPhysicalProcessAsync(ct); + await StartPhysicalWorkerReplicaAsync(ct); + } + + private async Task StopPhysicalProcessesAsync() + { + if (!physicalIncidentMode) return; + var failures = new List(); + // A failed owner remains reachable for a subsequent bounded teardown; + // its sibling is always attempted during this teardown too. + if (physicalReplica is not null) + { + try { await physicalReplica.DisposeAsync(); processHistory.Add(physicalReplica.Proof); physicalReplica = null; } + catch (Exception e) { failures.Add(e); } + } + if (physicalPrimary is not null) + { + try { await physicalPrimary.DisposeAsync(); processHistory.Add(physicalPrimary.Proof); physicalPrimary = null; } + catch (Exception e) { failures.Add(e); } + } + if (physicalReadServices is not null) + { + try { await physicalReadServices.DisposeAsync(); physicalReadServices = null; } + catch (Exception e) { failures.Add(e); } + } + // Keep exact private inputs if a process is not yet positively closed. + // Removing them could discard the remaining owner's cleanup identity. + if (physicalPrimary is null && physicalReplica is null && physicalReadServices is null) + { + try { if (Directory.Exists(physicalRoot)) Directory.Delete(physicalRoot, recursive: true); physicalSettings = null; } + catch (Exception e) { failures.Add(e); } + } + if (failures.Count > 0) throw new AggregateException("Owned physical API cleanup was incomplete.", failures); + } + + private sealed class OwnedPhysicalApiProcess : IAsyncDisposable + { + private readonly Process process; + private readonly Task stdout; + private readonly Task stderr; + private readonly StartupOutputMarkers stdoutMarkers; + private readonly StartupOutputMarkers stderrMarkers; + private readonly string directory; + public PhysicalApiProcess Proof { get; private set; } + public int ProcessId => process.Id; + public string BaseUrl { get; } + public bool Alive => !process.HasExited; + private bool closed; + private bool processDisposed; + private bool outputDrained; + + private OwnedPhysicalApiProcess(Process process, string directory, string role, int generation, int restPort) + { + this.process = process; this.directory = directory; + BaseUrl = $"http://127.0.0.1:{restPort}"; + Proof = new(process.Id, DateTimeOffset.UtcNow, null, null, role, generation); + stdoutMarkers = new(restPort); + stderrMarkers = new(restPort); + stdout = DrainAsync(process.StandardOutput, stdoutMarkers); + stderr = DrainAsync(process.StandardError, stderrMarkers); + } + + public static async Task StartAsync(string root, string role, int generation, + IReadOnlyDictionary settings, int restPort, CancellationToken outer) + { + if (!OperatingSystem.IsLinux()) throw new InvalidOperationException("The owned physical API process requires Linux private-state permissions."); + var directory = Path.Combine(root, role + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var file = Path.Combine(directory, "appsettings.json"); + await File.WriteAllTextAsync(file, JsonSerializer.Serialize(settings)); + File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite); + var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { UseShellExecute = false, WorkingDirectory = directory, RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add(PhysicalApiAssembly()); + // Deployment inputs inherited from a different host may not shadow this + // fixture's private exact configuration. No settings value enters argv. + foreach (var key in start.Environment.Keys.Where(key => settings.ContainsKey(key.Replace("__", ":", StringComparison.Ordinal)) || + key.StartsWith("NETRATEL_SERVICE_LINK_PRIVATE_", StringComparison.Ordinal) || + key.StartsWith("ASPNETCORE_", StringComparison.Ordinal) || key is "DOTNET_ENVIRONMENT").ToArray()) + start.Environment.Remove(key); + start.Environment["DOTNET_ENVIRONMENT"] = "Production"; + start.Environment["ASPNETCORE_CONTENTROOT"] = directory; + // Both supported host prefixes identify this owned private configuration. + // The REST port is a nonsensitive fixture binding, identical to its probe. + start.Environment["DOTNET_CONTENTROOT"] = directory; + start.Environment["NetRatel_HTTP_PORT"] = restPort.ToString(System.Globalization.CultureInfo.InvariantCulture); + var owned = new OwnedPhysicalApiProcess(Process.Start(start) ?? throw new InvalidOperationException("The owned production API process could not start."), directory, role, generation, restPort); + var readinessStarted = Stopwatch.GetTimestamp(); + var probeAttempts = 0; + int? lastObservedHttpStatus = null; + var lastProbeException = "none"; + var lastHttpRequestError = "none"; + var lastSocketError = "none"; + try + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(outer); + deadline.CancelAfter(TimeSpan.FromSeconds(30)); + using var client = new HttpClient(new HttpClientHandler { UseProxy = false, AllowAutoRedirect = false }) + { BaseAddress = new Uri($"http://127.0.0.1:{restPort}"), Timeout = TimeSpan.FromSeconds(2) }; + while (true) + { + deadline.Token.ThrowIfCancellationRequested(); + if (!owned.Alive) throw new InvalidOperationException("The actual production API process exited during startup."); + try + { + probeAttempts++; + using var response = await client.GetAsync("/api/integrations/service-link/metadata", deadline.Token); + lastObservedHttpStatus = (int)response.StatusCode; + lastProbeException = "none"; + lastHttpRequestError = "none"; + lastSocketError = "none"; + if (response.StatusCode == HttpStatusCode.OK) break; + if ((int)response.StatusCode is >= 300 and < 400) + throw new InvalidOperationException("The physical REST endpoint redirected into a different protocol endpoint."); + } + catch (HttpRequestException error) + { + lastProbeException = nameof(HttpRequestException); + lastHttpRequestError = error.HttpRequestError.ToString(); + lastSocketError = error.InnerException is System.Net.Sockets.SocketException socket + ? socket.SocketErrorCode.ToString() : "none"; + } + catch (TaskCanceledException) when (!deadline.IsCancellationRequested) + { + lastProbeException = nameof(TaskCanceledException); + lastHttpRequestError = "none"; + lastSocketError = "none"; + } + await Task.Delay(200, deadline.Token); + } + return owned; + } + catch (Exception error) + { + // Project only fixed codes and numeric readiness facts before owned + // cleanup. Never retain or emit raw process output, response bodies, + // headers, paths, settings, exception messages or credentials. + try + { + var ownerRole = role is "primary" or "replica" ? role : "unknown"; + var alive = owned.Alive; + var failureType = error switch + { + TaskCanceledException => nameof(TaskCanceledException), + OperationCanceledException => nameof(OperationCanceledException), + HttpRequestException => nameof(HttpRequestException), + InvalidOperationException => nameof(InvalidOperationException), + _ => "other-startup-exception" + }; + var observedStatus = lastObservedHttpStatus?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "none"; + var exitCode = alive ? "none" : owned.process.ExitCode.ToString(System.Globalization.CultureInfo.InvariantCulture); + Console.Error.WriteLine($"Owned physical API readiness failed: role={ownerRole}; failureType={failureType}; " + + $"elapsedMs={(long)Stopwatch.GetElapsedTime(readinessStarted).TotalMilliseconds}; probes={probeAttempts}; " + + $"lastObservedHttpStatus={observedStatus}; lastProbeException={lastProbeException}; " + + $"lastHttpRequestError={lastHttpRequestError}; lastSocketError={lastSocketError}; " + + $"alive={alive}; exitCode={exitCode}; outerCancelled={outer.IsCancellationRequested}; " + + $"stdoutMarkers={owned.stdoutMarkers.Snapshot()}; stderrMarkers={owned.stderrMarkers.Snapshot()}"); + } + catch + { + // Diagnostics cannot replace the original startup failure or + // prevent the unchanged exact owned-process cleanup below. + } + await owned.DisposeAsync(); + throw; + } + } + + public async Task CrashOwnedAsync() + { + // Actual termination of only this newly owned process exercises persisted + // leases and MayHaveCommitted recovery. No application completion is seeded. + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Proof = Proof with { ExitedAtUtc = DateTimeOffset.UtcNow, ExitCode = process.ExitCode }; + await DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (closed) return; + var failures = new List(); + if (!processDisposed) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Proof = Proof with { ExitedAtUtc = Proof.ExitedAtUtc ?? DateTimeOffset.UtcNow, ExitCode = process.ExitCode }; + } + catch (Exception e) { failures.Add(e); } + // Draining/private cleanup is still attempted after termination + // failure whenever this exact process is observed to have exited. + if (Proof.ExitedAtUtc is not null) + { + try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); outputDrained = true; } + catch (Exception e) { failures.Add(e); } + if (outputDrained) + try { process.Dispose(); processDisposed = true; } catch (Exception e) { failures.Add(e); } + } + } + if (Proof.ExitedAtUtc is not null) + try { if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } + catch (Exception e) { failures.Add(e); } + closed = processDisposed && outputDrained && !Directory.Exists(directory); + if (failures.Count > 0 || !closed) + throw new AggregateException("Owned API process cleanup was incomplete.", failures); + } + private static async Task DrainAsync(StreamReader stream, StartupOutputMarkers markers) + { + var buffer = new char[2048]; + var matches = new int[markers.PatternCount]; + try + { + int read; + while ((read = await stream.ReadAsync(buffer)) != 0) + markers.Observe(buffer.AsSpan(0, read), matches); + } + finally { Array.Clear(buffer); } + } + + // Streaming fixed-marker projection keeps the original drain bounded. + // Each channel retains only match positions and a fixed-size boolean set; + // no raw line, arbitrary exception text or private value survives a read. + private sealed class StartupOutputMarkers + { + private static readonly (string Text, string Code)[] CommonPatterns = + [ + ("Now listening on:", "listener-reported"), + ("Application started.", "host-started"), + ("Application is shutting down", "host-stopping"), + ("Agent-auth signing key loaded.", "agent-signing-key-loaded"), + ("Agent-auth signing key probe failed;", "agent-signing-key-probe-failed"), + ("NetRatel infrastructure migration failed during startup.", "startup-migration-failed"), + ("Unhandled exception.", "unhandled-exception-marker"), + ("System.InvalidOperationException", "invalid-operation-exception-marker"), + ("System.IO.IOException", "io-exception-marker"), + ("System.Net.Sockets.SocketException", "socket-exception-marker"), + ("Microsoft.AspNetCore.Connections.AddressInUseException", "address-in-use-exception-marker"), + ("Npgsql.NpgsqlException", "npgsql-exception-marker"), + ("System.TimeoutException", "timeout-exception-marker") + ]; + private readonly (string Text, string Code)[] patterns; + public int PatternCount => patterns.Length; + private readonly object sync = new(); + private readonly bool[] observed; + + public StartupOutputMarkers(int expectedRestPort) + { + var port = expectedRestPort.ToString(System.Globalization.CultureInfo.InvariantCulture); + patterns = + [ + .. CommonPatterns, + ($"Now listening on: http://[::]:{port}\n", "expected-rest-listener-reported"), + ($"Now listening on: http://0.0.0.0:{port}\n", "expected-rest-listener-reported"), + ($"Now listening on: http://127.0.0.1:{port}\n", "expected-rest-listener-reported") + ]; + observed = new bool[patterns.Length]; + } + + public void Observe(ReadOnlySpan chunk, int[] matches) + { + lock (sync) + { + foreach (var character in chunk) + for (var index = 0; index < patterns.Length; index++) + { + var text = patterns[index].Text; + var matched = matches[index]; + matched = character == text[matched] ? matched + 1 : character == text[0] ? 1 : 0; + if (matched == text.Length) { observed[index] = true; matched = 0; } + matches[index] = matched; + } + } + } + + public string Snapshot() + { + lock (sync) + { + var codes = patterns.Where((_, index) => observed[index]).Select(x => x.Code).Distinct(StringComparer.Ordinal).ToArray(); + return codes.Length == 0 ? "none" : string.Join(',', codes); + } + } + } + } + + private static string PhysicalApiAssembly() + { + var configuration = typeof(ServiceLinkNativeArtifacts).Assembly.GetCustomAttribute()?.Configuration; + for (var root = new DirectoryInfo(AppContext.BaseDirectory); root is not null; root = root.Parent) + { + if (!File.Exists(Path.Combine(root.FullName, "NetRatel.sln"))) continue; + var assembly = Path.Combine(root.FullName, "src", "NetRatel", "NetRatel.API", "bin", configuration!, "net10.0", "NetRatel.API.dll"); + if (configuration is not ("Release" or "Debug") || !File.Exists(assembly) || + !File.Exists(Path.ChangeExtension(assembly, ".runtimeconfig.json")) || + !SHA256.HashData(File.ReadAllBytes(assembly)).SequenceEqual(SHA256.HashData(File.ReadAllBytes(typeof(global::Program).Assembly.Location)))) + throw new InvalidOperationException("The standalone production API artifact differs from the compiled candidate referenced by the test."); + return assembly; + } + throw new InvalidOperationException("The standalone physical API requires the actual clean source checkout."); + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.cs index c9910f82..550402cd 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkNetRatelPeer.cs @@ -20,7 +20,7 @@ namespace NetRatel.API.IntegrationTests.ServiceLinks; /// The complete NetRatel Production application, real PostgreSQL, and real administrator cookies. -internal sealed class ServiceLinkNetRatelPeer : IAsyncDisposable +internal sealed partial class ServiceLinkNetRatelPeer : IAsyncDisposable { private readonly global::ApiFactory databaseOwner = new(isolateServiceLinkHostSettings: true); private readonly Dictionary configuration; @@ -29,30 +29,39 @@ internal sealed class ServiceLinkNetRatelPeer : IAsyncDisposable private readonly ServiceLinkRotationTestPolicy? rotationPolicy; private readonly bool useSystemTime; private long? firstSensitiveResponseTimestamp; + private readonly bool physicalIncidentMode; private readonly int backendPort = ServiceLinkHttpProxy.AllocatePort(); private readonly int gatewayPort = ServiceLinkHttpProxy.AllocatePort(); private global::ApiFactory? sibling; private WebApplicationFactory? app; + private bool ownedCleanupComplete; + private bool proxyDisposed; + private bool databaseOwnerDisposed; + public bool OwnedCleanupComplete => ownedCleanupComplete; public HttpClient Administrator { get; private set; } = null!; public HttpClient Anonymous { get; private set; } = null!; - public IServiceProvider Services => app!.Services; + public IServiceProvider Services => physicalIncidentMode ? physicalReadServices! : app!.Services; public ServiceLinkHttpProxy Proxy { get; } public ServiceLinkTestClock Clock { get; } = new(); public string BaseUrl => Proxy.BaseUrl; public string WebBaseUrl => BaseUrl; public Guid InstanceId { get; } = Guid.NewGuid(); public string TenantId { get; private set; } = ""; - public Guid SourceInstanceId { get; } = Guid.NewGuid(); + public Guid SourceInstanceId { get; private set; } = Guid.NewGuid(); public Guid ResourceId { get; private set; } = Guid.NewGuid(); public long RequestDefinitionId { get; private set; } private ServiceLinkNetRatelPeer(string reachableHost, IInterceptor? interceptor, ServiceLinkNativeListener? nativeListener, - ServiceLinkRotationTestPolicy? rotationPolicy, bool useSystemTime) + ServiceLinkRotationTestPolicy? rotationPolicy, bool useSystemTime, bool physicalIncidentMode) { this.interceptor = interceptor; this.nativeListener = nativeListener; this.rotationPolicy = rotationPolicy; - this.useSystemTime = useSystemTime; + this.useSystemTime = useSystemTime || physicalIncidentMode; + this.physicalIncidentMode = physicalIncidentMode; + if (physicalIncidentMode && nativeListener is null) + throw new InvalidOperationException("A real TLS/HTTP2 listener is required for physical disk acceptance."); + if (physicalIncidentMode) SourceInstanceId = Guid.Empty; if (nativeListener is not null && (nativeListener.Port == backendPort || nativeListener.Port == gatewayPort || backendPort == gatewayPort)) throw new InvalidOperationException("The physical listener ports must be independent."); Proxy = new($"http://127.0.0.1:{backendPort}", reachableHost); @@ -77,6 +86,7 @@ private ServiceLinkNetRatelPeer(string reachableHost, IInterceptor? interceptor, ["M2M:AllowedCallerClientIds:0"] = "synthetic-legacy-allowed" }; if (nativeListener is not null) configuration["ServiceLinks:GatewayBaseUrl"] = nativeListener.Endpoint; + if (physicalIncidentMode) configuration["TelemetryInteractive:BaselineSlowIntervalSeconds"] = "5"; if (rotationPolicy is not null) { configuration["ServiceLinks:AutomaticRotationEnabled"] = (rotationPolicy.Automatic && rotationPolicy.NetRatelIssuer).ToString(); @@ -90,9 +100,9 @@ private ServiceLinkNetRatelPeer(string reachableHost, IInterceptor? interceptor, } public static async Task CreateAsync(string reachableHost, IInterceptor? interceptor = null, ServiceLinkNativeListener? nativeListener = null, - ServiceLinkRotationTestPolicy? rotationPolicy = null, bool useSystemTime = false) + ServiceLinkRotationTestPolicy? rotationPolicy = null, bool useSystemTime = false, bool physicalIncidentMode = false) { - var peer = new ServiceLinkNetRatelPeer(reachableHost, interceptor, nativeListener, rotationPolicy, useSystemTime); + var peer = new ServiceLinkNetRatelPeer(reachableHost, interceptor, nativeListener, rotationPolicy, useSystemTime, physicalIncidentMode); try { await peer.databaseOwner.InitializeAsync(); @@ -101,12 +111,15 @@ public static async Task CreateAsync(string reachableHo using var tenants = await peer.Administrator.GetFromJsonAsync("/api/v2/access/tenants"); peer.TenantId = tenants!.RootElement.EnumerateArray().Single(t => t.GetProperty("name").GetString() == "OpenAPI tenant") .GetProperty("tenantId").GetInt32().ToString(System.Globalization.CultureInfo.InvariantCulture); - var identity = await peer.Administrator.GetFromJsonAsync("/api/v1/admin/service-links/identity"); - peer.firstSensitiveResponseTimestamp = Stopwatch.GetTimestamp(); - using var adoption = await peer.AdminAsync("/api/v1/admin/service-links/identity/source", - new ServiceLinkAdoptSourceRequest(peer.SourceInstanceId.ToString("D"), identity!.Revision)); - adoption.EnsureSuccessStatusCode(); - await using (var scope = peer.Services.CreateAsyncScope()) + if (!physicalIncidentMode) + { + var identity = await peer.Administrator.GetFromJsonAsync("/api/v1/admin/service-links/identity"); + peer.firstSensitiveResponseTimestamp = Stopwatch.GetTimestamp(); + using var adoption = await peer.AdminAsync("/api/v1/admin/service-links/identity/source", + new ServiceLinkAdoptSourceRequest(peer.SourceInstanceId.ToString("D"), identity!.Revision)); + adoption.EnsureSuccessStatusCode(); + } + if (!physicalIncidentMode) await using (var scope = peer.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var tenant = int.Parse(peer.TenantId, System.Globalization.CultureInfo.InvariantCulture); @@ -126,6 +139,7 @@ public static async Task CreateAsync(string reachableHo private async Task StartAsync() { + if (physicalIncidentMode) { await StartPhysicalProcessAsync(); return; } sibling = databaseOwner.CreateRuntimeSibling(configuration); app = sibling.WithWebHostBuilder(builder => builder.ConfigureServices(services => { @@ -209,12 +223,57 @@ public async Task WaitForInitialSensitiveWindowAsync(CancellationToken ct = defa throw new InvalidOperationException("The initial sensitive identity route remained throttled after the bounded replenishment check."); } - public async Task RestartAsync() + public async Task RestartAsync(CancellationToken ct = default) { + if (physicalIncidentMode) { await RestartPhysicalProcessesAsync(ct); return; } + await StopForRestartAsync(); + await StartAfterRestartAsync(); + } + + internal async Task StopForRestartAsync() + { + if (physicalIncidentMode) throw new InvalidOperationException("Physical process restart uses its owned process lifecycle."); Administrator.Dispose(); Anonymous.Dispose(); - await app!.DisposeAsync(); - sibling!.Dispose(); - await StartAsync(); + await app!.DisposeAsync(); app = null; + sibling!.Dispose(); sibling = null; + } + + internal Task StartAfterRestartAsync() + { + if (physicalIncidentMode) throw new InvalidOperationException("Physical process restart uses its owned process lifecycle."); + return StartAsync(); + } + + public async Task AdoptActualPhysicalFlowProducerAsync(CancellationToken ct) + { + if (!physicalIncidentMode || nativeListener is null) + throw new InvalidOperationException("Only the unseeded physical fixture may resolve its production Flow source."); + var setupPath = $"/api/v2/tenants/{TenantId}/connectors/rateldesk/setup"; + var before = await Administrator.GetFromJsonAsync(setupPath, ct) + ?? throw new InvalidOperationException("The real owner Flow-source setup returned no current identity."); + using var request = new HttpRequestMessage(HttpMethod.Post, setupPath + "/source") + { + Content = JsonContent.Create(new NetRatel.Shared.Contracts.RatelDesk.AdoptRatelDeskFlowSourceRequest(before.IdentityRevision)) + }; + request.Headers.Add("X-NetRatel-Account-Request", "1"); + using var response = await Administrator.SendAsync(request, ct); + if (response.StatusCode != HttpStatusCode.OK) + throw new InvalidOperationException($"The actual owner Flow-source adoption returned HTTP {(int)response.StatusCode}."); + var after = await response.Content.ReadFromJsonAsync(cancellationToken: ct) + ?? throw new InvalidOperationException("The actual owner source adoption returned no identity."); + if (after.FlowSourceInstanceId == Guid.Empty || after.FlowSourceInstanceId != before.FlowSourceInstanceId || + after.InstallationInstanceId != InstanceId.ToString("D") || + after.AdoptedSourceInstanceId != after.FlowSourceInstanceId.ToString("D")) + throw new InvalidOperationException("The ordinary owner adoption did not preserve the real installation/Flow producer tuple."); + await using var scope = Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await db.Set().AnyAsync(ct) || await db.Agents.AnyAsync(ct) || await db.Jobs.AnyAsync(ct) || + await db.FlowRuns.AnyAsync(ct) || await db.FlowActions.AnyAsync(ct)) + throw new InvalidOperationException("Physical source setup must precede pairing and contain no seeded execution authority."); + var persisted = await db.FlowRuntimeIdentity.AsNoTracking().Where(x => x.Id == 1).Select(x => x.SourceInstanceId).SingleAsync(ct); + if (persisted != after.FlowSourceInstanceId) + throw new InvalidOperationException("Owner source adoption differs from the actual persisted Flow singleton."); + SourceInstanceId = persisted; } public async Task CreateNativeJobAndSelectTargetAsync(Guid enrolledAgentId, string name, CancellationToken ct) @@ -266,11 +325,21 @@ internal static object ProjectLifecycle(string path, object body) => body is Ser public async ValueTask DisposeAsync() { - Administrator?.Dispose(); Anonymous?.Dispose(); - if (app is not null) await app.DisposeAsync(); - sibling?.Dispose(); - await Proxy.DisposeAsync(); - await ((IAsyncDisposable)databaseOwner).DisposeAsync(); + if (ownedCleanupComplete) return; + var failures = new List(); + try { Administrator?.Dispose(); } catch (Exception e) { failures.Add(e); } + try { Anonymous?.Dispose(); } catch (Exception e) { failures.Add(e); } + try { await StopPhysicalProcessesAsync(); } catch (Exception e) { failures.Add(e); } + if (app is not null) + try { await app.DisposeAsync(); app = null; } catch (Exception e) { failures.Add(e); } + if (sibling is not null) + try { sibling.Dispose(); sibling = null; } catch (Exception e) { failures.Add(e); } + if (!proxyDisposed) + try { await Proxy.DisposeAsync(); proxyDisposed = true; } catch (Exception e) { failures.Add(e); } + if (!databaseOwnerDisposed) + try { await ((IAsyncDisposable)databaseOwner).DisposeAsync(); databaseOwnerDisposed = true; } catch (Exception e) { failures.Add(e); } + ownedCleanupComplete = failures.Count == 0 && PhysicalOwnedCleanupComplete && app is null && sibling is null && proxyDisposed && databaseOwnerDisposed; + if (!ownedCleanupComplete) throw new AggregateException("Owned NetRatel cleanup was incomplete.", failures); } } diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPair.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPair.cs index 630e6bc8..620f098d 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPair.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPair.cs @@ -25,13 +25,13 @@ internal sealed class ServiceLinkPair : IAsyncDisposable public ServiceLinkHttpProxy ResponderProxy => NetRatelInitiates ? RatelDesk.Proxy : NetRatel.Proxy; public static async Task CreateAsync(bool netRatelInitiates, IInterceptor? interceptor = null, ServiceLinkNativeListener? nativeListener = null, - ServiceLinkRotationTestPolicy? rotationPolicy = null, bool useSystemTime = false) + ServiceLinkRotationTestPolicy? rotationPolicy = null, bool useSystemTime = false, bool physicalIncidentMode = false) { var pair = new ServiceLinkPair { NetRatelInitiates = netRatelInitiates }; try { pair.RatelDesk = await ServiceLinkPublishedRatelDeskPeer.CreateAsync(rotationPolicy); - pair.NetRatel = await ServiceLinkNetRatelPeer.CreateAsync(pair.RatelDesk.ReachableHost, interceptor, nativeListener, rotationPolicy, useSystemTime); + pair.NetRatel = await ServiceLinkNetRatelPeer.CreateAsync(pair.RatelDesk.ReachableHost, interceptor, nativeListener, rotationPolicy, useSystemTime, physicalIncidentMode); pair.WriteEvidence("isolated-products-started"); return pair; } @@ -244,7 +244,11 @@ private void WriteEvidence(string phase, ServiceLinkAdminStatus? local = null, S public async ValueTask DisposeAsync() { - if (NetRatel is not null) await NetRatel.DisposeAsync(); - if (RatelDesk is not null) await RatelDesk.DisposeAsync(); + var failures = new List(); + if (NetRatel is not null) try { await NetRatel.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + if (RatelDesk is not null) try { await RatelDesk.DisposeAsync(); } catch (Exception e) { failures.Add(e); } + if (failures.Count > 0) throw new AggregateException("Owned reciprocal product cleanup was incomplete.", failures); } + public bool OwnedCleanupComplete => (NetRatel is null || NetRatel.OwnedCleanupComplete) && + (RatelDesk is null || RatelDesk.OwnedCleanupComplete); } diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.Physical.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.Physical.cs new file mode 100644 index 00000000..7d1bf3da --- /dev/null +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.Physical.cs @@ -0,0 +1,93 @@ +using System.Text.Json; + +namespace NetRatel.API.IntegrationTests.ServiceLinks; + +// Private fixture facts from the actual published receiver's PostgreSQL database. +// Raw accepted bodies stay in memory and are never exported as public proof. +internal sealed record PhysicalPublishedIncidentCommit(Guid SourceInstanceId, Guid NamespaceId, + string Key, string Fingerprint, Guid ReceiptId, string IncidentId, string OrganizationId, + string CustomerId, string AcceptedJson, DateTimeOffset CommittedAtUtc, long IncidentRows, + Guid[] ConfirmationIds); + +internal sealed partial class ServiceLinkPublishedRatelDeskPeer +{ + public Task StopPhysicalApiAsync(CancellationToken ct) => ComposeAsync(["stop", "api"], ct); + public async Task RestorePhysicalApiAsync(CancellationToken ct) + { + await ComposeAsync(["up", "-d", "--no-build", "--wait", "--wait-timeout", "90", "api"], ct); + await LoginAsync(); + } + + public async Task<(long Receipts, long Incidents, long Confirmations)> ReadPhysicalNamespaceCountsAsync( + Guid source, Guid sourceNamespace, CancellationToken ct) + { + if (source == Guid.Empty || sourceNamespace == Guid.Empty) throw new ArgumentException("The actual namespace identity is required."); + var sql = $$""" + SELECT json_build_object('receipts', count(*), + 'incidents', count(DISTINCT t."Id"), + 'confirmations', (SELECT count(*) FROM "MailboxOutboxEffect" e JOIN "IncidentCreateReceipts" er ON er."Id"=e."ReceiptId" + WHERE er."SourceNamespaceId"='{{sourceNamespace:D}}' AND e."Kind"=0 AND e."Payload"::jsonb ? 'SupportDeliveryId' + AND e."Payload"::jsonb ->> 'SupportDeliveryId' IS NULL)) + FROM "IncidentCreateReceipts" r JOIN "IncidentReceiverSources" s ON s."SourceNamespaceId"=r."SourceNamespaceId" + JOIN "Tickets" t ON t."Id"=r."IncidentId" AND t."Discriminator"='Incident' + WHERE s."SourceInstanceId"='{{source:D}}' AND r."SourceNamespaceId"='{{sourceNamespace:D}}'; + """; + var output = await ComposeAsync(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "rateldesk", "-d", "rateldesk", "-At", "-c", sql], ct); + using var result = JsonDocument.Parse(output.Trim()); + return (result.RootElement.GetProperty("receipts").GetInt64(), result.RootElement.GetProperty("incidents").GetInt64(), result.RootElement.GetProperty("confirmations").GetInt64()); + } + + public async Task ReadPhysicalIncidentCommitAsync( + Guid source, Guid sourceNamespace, string key, CancellationToken ct) + { + if (source == Guid.Empty || sourceNamespace == Guid.Empty || + key is not { Length: > 0 and <= 256 } || + !key.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '~' or '-')) + throw new ArgumentException("A canonical current source/namespace/key is required."); + // Every interpolated value has been restricted above. No caller-supplied SQL, + // title search, seeded receipt or transaction shared with HTTP is supported. + var sql = $$""" + SELECT json_build_object( + 'sourceInstanceId', s."SourceInstanceId", 'namespaceId', r."SourceNamespaceId", + 'key', r."Key", 'fingerprint', r."Fingerprint", 'receiptId', r."Id", + 'incidentId', r."IncidentId", 'organizationId', r."OrganizationId", + 'customerId', r."CustomerId", 'acceptedJson', r."AcceptedJson", + 'committedAtUtc', r."CommittedAtUtc", + 'incidentRows', (SELECT count(*) FROM "Tickets" t + WHERE t."Discriminator" = 'Incident' AND t."Id" = r."IncidentId" + AND t."OrganizationId" = r."OrganizationId" AND t."CustomerId" = r."CustomerId"), + 'confirmationIds', (SELECT COALESCE(json_agg(e."Id" ORDER BY e."Id"), '[]'::json) + FROM "MailboxOutboxEffect" e + WHERE e."ReceiptId" = r."Id" AND e."Kind" = 0 + AND e."Payload"::jsonb ? 'SupportDeliveryId' + AND e."Payload"::jsonb ->> 'SupportDeliveryId' IS NULL + AND e."Payload"::jsonb ->> 'TicketId' = r."IncidentId" + AND e."Payload"::jsonb ->> 'OrganizationId' = r."OrganizationId")) + FROM "IncidentCreateReceipts" r JOIN "IncidentReceiverSources" s + ON s."SourceNamespaceId" = r."SourceNamespaceId" + WHERE s."SourceInstanceId" = '{{source:D}}' + AND r."SourceNamespaceId" = '{{sourceNamespace:D}}' AND r."Key" = '{{key}}'; + """; + var output = await ComposeAsync(["exec", "-T", "postgres", "psql", "-v", + "ON_ERROR_STOP=1", "-U", "rateldesk", "-d", "rateldesk", "-At", "-c", sql], ct); + // Unique source/namespace/key means exactly one JSON row. Reject missing, + // duplicated, truncated and malformed output with a fixed nonsecret error. + PhysicalPublishedIncidentCommit? value; + try + { + value = JsonSerializer.Deserialize(output.Trim(), + new JsonSerializerOptions(JsonSerializerDefaults.Web) { MaxDepth = 32 }); + } + catch (JsonException) + { + throw new InvalidOperationException("The published receiver committed-row read was invalid."); + } + if (value is null || value.SourceInstanceId != source || value.NamespaceId != sourceNamespace || + value.Key != key || value.ReceiptId == Guid.Empty || value.CommittedAtUtc == default || + value.IncidentRows != 1 || value.ConfirmationIds is not { Length: 1 } || + value.ConfirmationIds[0] == Guid.Empty || string.IsNullOrEmpty(value.IncidentId) || + string.IsNullOrEmpty(value.AcceptedJson) || value.AcceptedJson.Length > 131_072) + throw new InvalidOperationException("The actual committed incident/receipt/confirmation identity did not match."); + return value; + } +} diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.cs index 7e530f8f..cad17318 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkPublishedRatelDeskPeer.cs @@ -8,12 +8,12 @@ namespace NetRatel.API.IntegrationTests.ServiceLinks; /// Runs the published companion API/Web, without building or impersonating RatelDesk. -internal sealed class ServiceLinkPublishedRatelDeskPeer : IAsyncDisposable +internal sealed partial class ServiceLinkPublishedRatelDeskPeer : IAsyncDisposable { - public const string PublishedSource = "e543fb13eb0e23db09fdfd6b4232067cecf61675"; - public const string PublishedVersion = "0.1.1-beta.15"; - internal const string ApiImage = "ghcr.io/bostontechnologies/rateldesk-api@sha256:4f266560a2cd925210d492d47ae5ba0ffcbc9c3af44631aff39ee403f4be30b8"; - internal const string WebImage = "ghcr.io/bostontechnologies/rateldesk-web@sha256:2c623963456ee61ff9040e4462fbd85bb2d8d0f077b0fdad74840f9447f3bec6"; + public const string PublishedSource = "a428d84b228213a3b1036b896610a19e51e19fa6"; + public const string PublishedVersion = "0.1.1-beta.16"; + internal const string ApiImage = "ghcr.io/bostontechnologies/rateldesk-api@sha256:7d8c84b756981f4c94c04d2fdeffefe08f472739c06eb60b05e57b74f4110410"; + internal const string WebImage = "ghcr.io/bostontechnologies/rateldesk-web@sha256:6a79b193ed20a7d14383821aab64dfb8b72fb4a68ec42ef5a16a061710c4d64c"; private readonly string root = Path.Combine(Path.GetTempPath(), "netratel-rateldesk-pair", Guid.NewGuid().ToString("N")); private readonly string project = "netratel-service-link-" + Guid.NewGuid().ToString("N"); private readonly string password = "aA1!" + Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); @@ -25,7 +25,7 @@ internal sealed class ServiceLinkPublishedRatelDeskPeer : IAsyncDisposable public string ApiBaseUrl => Proxy.BaseUrl; public string WebBaseUrl { get; } public string ReachableHost { get; } - public Guid InstanceId { get; } = Guid.NewGuid(); + public Guid InstanceId { get; private set; } = Guid.NewGuid(); public string OrganizationId { get; private set; } = ""; public string CustomerId { get; private set; } = ""; public HttpClient Administrator { get; private set; } = null!; @@ -56,6 +56,25 @@ public static async Task CreateAsync(ServiceL var initialized = await peer.ComposeAsync(["run", "--rm", "--no-deps", "api", "--initialize-unattended"]); if (!initialized.Contains("RatelDesk initialization completed.", StringComparison.Ordinal)) throw new InvalidOperationException("The published RatelDesk image did not complete its actual unattended bootstrap."); + // The receiver owns its stable identity in the actual bootstrap marker. + // Bind service metadata to that identity before the first API/Web startup + // or pairing; neither descriptor state nor initialized database bytes change. + var instanceText = (await DockerAsync(["compose", "-p", peer.project, "-f", peer.composeFile, + "exec", "-T", "postgres", "psql", "-X", "-v", "ON_ERROR_STOP=1", "-U", "rateldesk", "-d", "rateldesk", "-At", "-c", + "SELECT \"InstanceId\" FROM \"InstanceInitializations\" WHERE \"Id\" = 1"], TimeSpan.FromSeconds(5))).Trim(); + if (!Guid.TryParseExact(instanceText, "D", out var instanceId) || instanceId == Guid.Empty || instanceId.ToString("D") != instanceText) + throw new InvalidOperationException("The initialized published peer has no single canonical receiver instance identity."); + var compose = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(peer.composeFile)) + ?? throw new InvalidOperationException("The owned published-peer Compose configuration is unavailable."); + var environment = compose["services"]?["api"]?["environment"]?.AsObject() + ?? throw new InvalidOperationException("The owned published-peer API environment is unavailable."); + if (environment["ServiceIdentity__InstanceId"]?.GetValue() != peer.InstanceId.ToString("D")) + throw new InvalidOperationException("The owned published-peer identity configuration changed during initialization."); + // Rewrite only this configuration value. Re-running WriteCompose would + // generate another database password for the already initialized volume. + environment["ServiceIdentity__InstanceId"] = instanceText; + File.WriteAllText(peer.composeFile, compose.ToJsonString()); + peer.InstanceId = instanceId; await peer.ComposeAsync(["up", "-d", "--no-build", "--wait", "--wait-timeout", "90", "api", "web"]); peer.Administrator = peer.NewClient(cookies: true); peer.Anonymous = peer.NewClient(); @@ -205,6 +224,7 @@ private void WriteCompose() ["Bootstrap__StateDirectory"] = "/var/lib/rateldesk/bootstrap", ["Bootstrap__DataDirectory"] = "/var/lib/rateldesk/data", ["StorageOptions__RootPath"] = "/app/storage", + ["StorageOptions__PublicApiBaseUrl"] = ApiBaseUrl, ["Bootstrap__Unattended__Provider"] = "PostgreSql", ["Bootstrap__Unattended__PostgreSqlConnectionString"] = connection, ["Bootstrap__Unattended__Email"] = "admin@example.test", @@ -360,6 +380,13 @@ public async Task WaitForPostActivationSensitiveWindowAsync(CancellationToken ct public async Task RestartAsync() { await ComposeAsync(["restart", "api", "web"]); + await StartAfterRestartAsync(); + } + + internal Task StopForRestartAsync() => ComposeAsync(["stop", "api", "web"]); + + internal async Task StartAfterRestartAsync() + { await ComposeAsync(["up", "-d", "--no-build", "--wait", "--wait-timeout", "90", "api", "web"]); await LoginAsync(); } @@ -430,11 +457,12 @@ FROM changed c JOIN original o USING ("ServicePrincipalId", "CredentialRevision" return evidence; } - private Task ComposeAsync(string[] arguments) => DockerAsync(["compose", "-p", project, "-f", composeFile, .. arguments]); + private Task ComposeAsync(string[] arguments, CancellationToken ct = default) => DockerAsync(["compose", "-p", project, "-f", composeFile, .. arguments], ct: ct); - private static async Task DockerAsync(string[] arguments, TimeSpan? operationBudget = null, bool includeStandardError = false) + private static async Task DockerAsync(string[] arguments, TimeSpan? operationBudget = null, bool includeStandardError = false, CancellationToken ct = default) { - using var timeout = new CancellationTokenSource(operationBudget ?? TimeSpan.FromMinutes(3)); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(operationBudget ?? TimeSpan.FromMinutes(3)); var start = new ProcessStartInfo("docker") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; foreach (var name in new[] { "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH" }) start.Environment.Remove(name); start.ArgumentList.Add("--host=unix:///var/run/docker.sock"); @@ -470,7 +498,10 @@ private static async Task DockerAsync(string[] arguments, TimeSpan? oper public async ValueTask DisposeAsync() { - Administrator?.Dispose(); Anonymous?.Dispose(); + if (OwnedCleanupComplete) return; + var failures = new List(); + try { Administrator?.Dispose(); } catch (Exception e) { failures.Add(e); } + try { Anonymous?.Dispose(); } catch (Exception e) { failures.Add(e); } if (File.Exists(composeFile)) { // An explicit private diagnostic run also retains failures after startup. @@ -479,10 +510,22 @@ public async ValueTask DisposeAsync() try { await CapturePrivateFailureDiagnosticsAsync(); } catch { /* Optional diagnostic capture must not prevent owned cleanup. */ } } - try { await ComposeAsync(["down", "--volumes", "--remove-orphans"]); } - finally { File.Delete(composeFile); } + try + { + await ComposeAsync(["down", "--volumes", "--remove-orphans"]); + // Keep exact compose ownership/private input after failed down. + // A later bounded dispose must still address the same stack. + File.Delete(composeFile); + } + catch (Exception e) { failures.Add(e); } } - await Proxy.DisposeAsync(); - if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + if (!cleanupProxyDisposed) + try { await Proxy.DisposeAsync(); cleanupProxyDisposed = true; } catch (Exception e) { failures.Add(e); } + if (!File.Exists(composeFile)) + try { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } catch (Exception e) { failures.Add(e); } + OwnedCleanupComplete = failures.Count == 0 && cleanupProxyDisposed && !File.Exists(composeFile) && !Directory.Exists(root); + if (!OwnedCleanupComplete) throw new AggregateException("Owned RatelDesk cleanup was incomplete.", failures); } + private bool cleanupProxyDisposed; + public bool OwnedCleanupComplete { get; private set; } } diff --git a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkRotationHttpPostgresTests.cs b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkRotationHttpPostgresTests.cs index ffef1be9..d432f8da 100644 --- a/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkRotationHttpPostgresTests.cs +++ b/src/NetRatel/NetRatel.API.IntegrationTests/ServiceLinkRotationHttpPostgresTests.cs @@ -300,15 +300,16 @@ private static async Task RotationAsync(ServiceLinkP Assert.Single((await pair.StatusAsync(netRatelProduct ? pair.NetRatel.Administrator : pair.RatelDesk.Administrator, ct)).Rotations, r => r.RotationId == id); - private static async Task RestartBothAsync(ServiceLinkPair pair) - { await pair.NetRatel.RestartAsync(); await pair.RatelDesk.RestartAsync(); } - private static async Task RestartBothAndObserveRecoveryAsync(ServiceLinkPair pair, ServiceLinkRotationResponseFault fault, CancellationToken ct) { - await RestartBothAsync(pair); - // Arm only AFTER both previous application processes have stopped and their - // genuine replacements have started. Earlier duplicates cannot satisfy this. - await fault.ObserveRecoveryAfterRestart().WaitAsync(TimeSpan.FromSeconds(30), ct); + await pair.NetRatel.StopForRestartAsync(); + await pair.RatelDesk.StopForRestartAsync(); + // Arm after both old runtimes stop and before either replacement can retry. + // The proxy also excludes handlers admitted before this restart boundary. + var recovery = fault.ObserveRecoveryAfterRestart(); + await pair.NetRatel.StartAfterRestartAsync(); + await pair.RatelDesk.StartAfterRestartAsync(); + await recovery.WaitAsync(TimeSpan.FromSeconds(30), ct); } private static async Task WaitForCompletedAsync(ServiceLinkPair pair, string rotationId, CancellationToken ct) diff --git a/src/NetRatel/NetRatel.API/Bootstrap/RatelDeskReceiverRegistration.cs b/src/NetRatel/NetRatel.API/Bootstrap/RatelDeskReceiverRegistration.cs new file mode 100644 index 00000000..67e6aef2 --- /dev/null +++ b/src/NetRatel/NetRatel.API/Bootstrap/RatelDeskReceiverRegistration.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; + +namespace NetRatel.API.Bootstrap; + +public static class RatelDeskReceiverRegistration +{ + public static IServiceCollection AddRatelDeskReceiverAdapter(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions().Bind(configuration.GetSection(RatelDeskReceiverOptions.SectionName)).ValidateOnStart(); + services.TryAddEnumerable(ServiceDescriptor.Singleton, RatelDeskReceiverOptionsValidator>()); + services.TryAddSingleton(); + foreach (var (name, mode) in new[] + { + (RatelDeskReceiverHttpPipeline.ManualClient, RatelDeskAuthenticationMode.ManualApiBearer), + (RatelDeskReceiverHttpPipeline.ManagedClient, RatelDeskAuthenticationMode.ManagedServiceLink) + }) + { + services.AddHttpClient(name, http => http.Timeout = Timeout.InfiniteTimeSpan) + .ConfigureAdditionalHttpMessageHandlers((handlers, _) => + { + // Receiver attempts and deadlines are durable application policy. A global + // resilience handler must never replay a POST or introduce another budget. + for (var index = handlers.Count - 1; index >= 0; index--) + if (handlers[index] is Microsoft.Extensions.Http.Resilience.ResilienceHandler) + handlers.RemoveAt(index); + }) + .ConfigurePrimaryHttpMessageHandler(provider => RatelDeskReceiverSafeHttpMessageHandler.Create( + mode, provider.GetRequiredService())); + } + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(p => p.GetRequiredService()); + services.TryAddSingleton(p => p.GetRequiredService()); + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped(p => p.GetRequiredService())); + services.TryAddScoped(p => p.GetRequiredService()); + services.TryAddScoped(p => p.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + return services; + } +} diff --git a/src/NetRatel/NetRatel.API/Endpoints/ApiEndpointRegistrationExtensions.cs b/src/NetRatel/NetRatel.API/Endpoints/ApiEndpointRegistrationExtensions.cs index e259dbf8..955a503d 100644 --- a/src/NetRatel/NetRatel.API/Endpoints/ApiEndpointRegistrationExtensions.cs +++ b/src/NetRatel/NetRatel.API/Endpoints/ApiEndpointRegistrationExtensions.cs @@ -2,6 +2,7 @@ using NetRatel.API.Endpoints.Auth; using NetRatel.API.Endpoints.Search; using NetRatel.API.Endpoints.Systems; +using NetRatel.API.Endpoints.RatelDesk; using NetRatel.API.Gateway; using NetRatel.API.Realtime; using NetRatel.API.Realtime.Operations; @@ -25,6 +26,7 @@ public static WebApplication MapApiEndpoints(this WebApplication app) app.MapTenantEndpoints(); app.MapSecretEndpoints(); + app.MapRatelDeskConnectorEndpoints(); app.MapScriptEndpoints(); app.MapPrimaryClientAgentBindingReadEndpoints(); app.MapMcpOperatorPolicyAdministrationEndpoints(); diff --git a/src/NetRatel/NetRatel.API/Endpoints/RatelDesk/RatelDeskConnectorEndpoints.cs b/src/NetRatel/NetRatel.API/Endpoints/RatelDesk/RatelDeskConnectorEndpoints.cs new file mode 100644 index 00000000..0d9e080a --- /dev/null +++ b/src/NetRatel/NetRatel.API/Endpoints/RatelDesk/RatelDeskConnectorEndpoints.cs @@ -0,0 +1,59 @@ +using NetRatel.Application.RatelDesk; +using NetRatel.Application.Tenants; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; + +namespace NetRatel.API.Endpoints.RatelDesk; + +public static class RatelDeskConnectorEndpoints +{ + public static IEndpointRouteBuilder MapRatelDeskConnectorEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v2/connectors/rateldesk/tenants", (HttpContext http, ITenantService tenants, + IRatelDeskConnectorAuthorization authorization, CancellationToken ct) => ExecuteAsync(http, false, async () => + { + var allowed = new List(); + foreach (var tenant in await tenants.ListAsync(ct)) + if (await authorization.CanManageAsync(http.User, tenant.TenantId, ct)) + allowed.Add(new(tenant.TenantId, tenant.Name)); + return Results.Ok(allowed); + })).WithTags("RatelDesk Connectors").RequireAuthorization(); + var group = app.MapGroup("/api/v2/tenants/{tenantId:int}/connectors/rateldesk").WithTags("RatelDesk Connectors").RequireAuthorization(); + group.MapGet("/setup", (int tenantId, HttpContext http, IRatelDeskConnectorSetupService service, CancellationToken ct) => + ExecuteAsync(http, false, async () => Results.Ok(await service.GetAsync(tenantId, http.User, ct)))); + group.MapPost("/setup/source", (int tenantId, AdoptRatelDeskFlowSourceRequest request, HttpContext http, + IRatelDeskConnectorSetupService service, CancellationToken ct) => ExecuteAsync(http, true, + async () => Results.Ok(await service.AdoptAsync(tenantId, request.ExpectedIdentityRevision, http.User, ct)))).RequireAuthorization("InteractiveAccount"); + group.MapGet("", (int tenantId, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, false, async () => Results.Ok(await service.ListAsync(tenantId, http.User, ct)))); + group.MapGet("/{id:guid}", (int tenantId, Guid id, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, false, async () => await service.GetAsync(tenantId, id, http.User, ct) is { } value ? Results.Ok(value) : Results.NotFound())); + group.MapPut("/{id:guid}", (int tenantId, Guid id, SaveRatelDeskConnectorRequest request, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, true, async () => Results.Ok(await service.SaveAsync(tenantId, id, request, http.User, ct)))); + group.MapPost("/{id:guid}/credential", (int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, true, async () => Results.Ok(await service.RotateAsync(tenantId, id, request, http.User, ct)))); + group.MapPost("/{id:guid}/connection-test", (int tenantId, Guid id, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, true, async () => Results.Ok(await service.TestAsync(tenantId, id, http.User, ct)))); + group.MapPost("/{id:guid}/dry-run", (int tenantId, Guid id, RatelDeskDryRunRequest request, HttpContext http, IRatelDeskConnectorService service, CancellationToken ct) => + ExecuteAsync(http, false, async () => Results.Ok(await service.DryRunAsync(tenantId, id, request, http.User, ct)))); + return app; + } + private static async Task ExecuteAsync(HttpContext http, bool mutation, Func> action) + { + http.Response.Headers.CacheControl = "no-store"; + if (mutation && http.User.HasClaim("auth_mode", "local") && http.Request.Headers["X-NetRatel-Account-Request"] != "1") return Results.Forbid(); + try { return await action().ConfigureAwait(false); } + catch (UnauthorizedAccessException) { return Results.Forbid(); } + catch (ServiceLinkProtocolException error) + { + var code = error.Code is "source-identity-conflict" or "identity-revision-conflict" or "identity-configuration-drift" + ? error.Code : "receiver-current-profile-unavailable"; + return Results.Json(new { code }, statusCode: error.StatusCode is 400 or 401 or 403 or 409 or 422 or 503 ? error.StatusCode : 503); + } + catch (KeyNotFoundException) { return Results.NotFound(); } + catch (ArgumentException) { return Results.BadRequest(new { code = "invalid-connector-request" }); } + catch (InvalidOperationException e) when (e.Message == "connector-conflict") { return Results.Conflict(new { code = "connector-conflict" }); } + catch (InvalidOperationException e) when (e.Message == "connector-capacity-exhausted") + { return Results.Json(new { code = "connector-capacity-exhausted" }, statusCode: StatusCodes.Status429TooManyRequests); } + } +} diff --git a/src/NetRatel/NetRatel.API/Program.cs b/src/NetRatel/NetRatel.API/Program.cs index 61b32b36..f8d69c71 100644 --- a/src/NetRatel/NetRatel.API/Program.cs +++ b/src/NetRatel/NetRatel.API/Program.cs @@ -741,6 +741,8 @@ NegotiateAuthenticationStatusCode.Completed or .SetApplicationName( builder.Configuration.GetSection("DataProtection")["ApplicationName"] ?? "NetRatel-Keyring"); +builder.Services.AddSingleton(); #endregion builder.Services.AddSingleton(); @@ -878,6 +880,7 @@ NegotiateAuthenticationStatusCode.Completed or builder.Services.AddNetRatelServiceIdentityApi(builder.Configuration); builder.Services.AddServiceLinkProtocol(builder.Configuration); builder.Services.AddOrchestrationManagedServices(); +builder.Services.AddRatelDeskReceiverAdapter(builder.Configuration); builder.Services.AddIdentityCore(options => { options.User.RequireUniqueEmail = true; diff --git a/src/NetRatel/NetRatel.API/Services/RatelDesk/RatelDeskCredentialProtector.cs b/src/NetRatel/NetRatel.API/Services/RatelDesk/RatelDeskCredentialProtector.cs new file mode 100644 index 00000000..2f2859a4 --- /dev/null +++ b/src/NetRatel/NetRatel.API/Services/RatelDesk/RatelDeskCredentialProtector.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.DataProtection; +using NetRatel.Application.RatelDesk; + +namespace NetRatel.API.Services.RatelDesk; + +/// Uses the API's existing persisted key ring, binding ciphertext to the tenant and connector. +public sealed class RatelDeskCredentialProtector(IDataProtectionProvider protection) : IRatelDeskCredentialProtector +{ + private IDataProtector For(int tenantId, Guid connectorId) => protection.CreateProtector( + "NetRatel.RatelDesk.Credential.v1", tenantId.ToString(System.Globalization.CultureInfo.InvariantCulture), connectorId.ToString("D")); + public string Protect(int tenantId, Guid connectorId, string credential) => For(tenantId, connectorId).Protect(credential); + public string Unprotect(int tenantId, Guid connectorId, string ciphertext) => For(tenantId, connectorId).Unprotect(ciphertext); +} diff --git a/src/NetRatel/NetRatel.Application/RatelDesk/RatelDeskConnectorContracts.cs b/src/NetRatel/NetRatel.Application/RatelDesk/RatelDeskConnectorContracts.cs new file mode 100644 index 00000000..00305cdf --- /dev/null +++ b/src/NetRatel/NetRatel.Application/RatelDesk/RatelDeskConnectorContracts.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Application.RatelDesk; + +/// Credential ciphertext is an internal persistence value and never part of a response DTO or graph. +public sealed record RatelDeskConnectorState(Guid Id, int TenantId, long Revision, long RowVersion, + string OwnerPrincipalId, RatelDeskConnectorConfiguration Configuration, string? ProtectedCredential, + long CredentialRevision, RatelDeskConnectorAuthentication? Authentication = null, + RatelDeskReadinessObservation? Readiness = null); + +public interface IRatelDeskConnectorStore +{ + Task GetAsync(int tenantId, Guid id, CancellationToken cancellationToken); + Task> ListAsync(int tenantId, CancellationToken cancellationToken); + Task SaveAsync(RatelDeskConnectorState state, long expectedRowVersion, CancellationToken cancellationToken); +} + +public interface IRatelDeskCredentialProtector +{ + string Protect(int tenantId, Guid connectorId, string credential); + string Unprotect(int tenantId, Guid connectorId, string ciphertext); +} + +public interface IRatelDeskOriginPolicy +{ + bool TryValidate(string origin, out Uri? normalizedOrigin); +} + +/// Uses only actual read-only ticketing lookup routes. This interface has no capability guess or incident write. +public interface IRatelDeskConnectionTester +{ + Task TestAsync(int tenantId, Guid connectorId, RatelDeskConnectorConfiguration configuration, + string credential, CancellationToken cancellationToken); +} + +/// The normal create contract alone does not support safe replay. Automatic dispatch never calls this transport. +public interface IRatelDeskIncidentTransport +{ + Task CreateAsync(int tenantId, Guid connectorId, string origin, string credential, RatelDeskCreateIncidentDto payload, + CancellationToken cancellationToken); +} + +public interface IRatelDeskConnectorAuthorization +{ + Task CanManageAsync(ClaimsPrincipal principal, int tenantId, CancellationToken cancellationToken); + Task CanExecuteAsync(string principalId, string? integrationCredentialId, int tenantId, CancellationToken cancellationToken); +} + +public interface IRatelDeskConnectorService +{ + Task> ListAsync(int tenantId, ClaimsPrincipal principal, CancellationToken cancellationToken); + Task GetAsync(int tenantId, Guid id, ClaimsPrincipal principal, CancellationToken cancellationToken); + Task SaveAsync(int tenantId, Guid id, SaveRatelDeskConnectorRequest request, + ClaimsPrincipal principal, CancellationToken cancellationToken); + Task RotateAsync(int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, + ClaimsPrincipal principal, CancellationToken cancellationToken); + Task TestAsync(int tenantId, Guid id, ClaimsPrincipal principal, CancellationToken cancellationToken); + Task DryRunAsync(int tenantId, Guid id, RatelDeskDryRunRequest request, + ClaimsPrincipal principal, CancellationToken cancellationToken); +} + +/// Authentication reference only; managed credentials remain in the approved service-link profile. +public interface IRatelDeskConnectorBindingStore +{ + Task GetAuthenticationAsync(int tenantId, Guid connectorId, CancellationToken ct); +} + +public sealed record RatelDeskReadinessObservation(long ConnectorRevision, + RatelDeskSemanticPeer Peer, RatelDeskVerifiedCapability Capability, DateTimeOffset TargetValidatedAtUtc); +public interface IRatelDeskConnectorReadinessStore +{ + Task SaveReadinessAsync(RatelDeskConnectorState current, RatelDeskReadinessObservation observation, CancellationToken ct); + Task ClearReadinessAsync(RatelDeskConnectorState current, CancellationToken ct); +} +public interface IRatelDeskConnectorReadiness +{ + Task<(bool Available, string Code)> CurrentAsync(RatelDeskConnectorState connector, CancellationToken ct); +} + +public interface IRatelDeskConnectorSetupService +{ + Task GetAsync(int tenantId, ClaimsPrincipal actor, CancellationToken ct); + Task AdoptAsync(int tenantId, long expectedIdentityRevision, ClaimsPrincipal actor, CancellationToken ct); +} diff --git a/src/NetRatel/NetRatel.Application/RatelDesk/ReceiverAdapterContracts.cs b/src/NetRatel/NetRatel.Application/RatelDesk/ReceiverAdapterContracts.cs new file mode 100644 index 00000000..3f91f6b0 --- /dev/null +++ b/src/NetRatel/NetRatel.Application/RatelDesk/ReceiverAdapterContracts.cs @@ -0,0 +1,122 @@ +using NetRatel.Application.Flows; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Application.RatelDesk; + +public enum RatelDeskAuthenticationMode { ManualApiBearer = 1, ManagedServiceLink = 2 } + +// Reference only. Managed credentials remain in the existing ServiceLinkAttempt profile. +public sealed record RatelDeskConnectorAuthentication( + RatelDeskAuthenticationMode Mode, string? ManagedLinkId); + +// Persisted evidence: no client secret, bearer, credential revision or mutable display name. +public sealed record RatelDeskSemanticPeer( + RatelDeskAuthenticationMode Mode, int LocalTenantId, Guid ConnectorId, string? LinkId, + long? LinkRevision, string? GrantHash, string ReceiverInstanceId, + string PeerTenantId, string ApiBaseUrl, string? Issuer, string? Audience, + string? TokenEndpoint, string? ClientId, string? DirectionId, + Guid SourceInstanceId, Guid SourceNamespaceId, string OrganizationId, + string CustomerId, string? AssignedToId, string[] CategoryIds); + +public sealed record RatelDeskReceiverEndpoints( + string Capabilities, string Create, string ReceiptTemplate, string TargetValidation); + +// A bounded observation, never a substitute for current authority or live capability. +public sealed record RatelDeskVerifiedCapability( + string ContractVersion, string ReceiverInstanceId, Guid SourceInstanceId, + Guid SourceNamespaceId, RatelDeskReceiverEndpoints Endpoints, + long MinimumReceiptRetentionSeconds, long MaximumAutomaticReplaySeconds, + DateTimeOffset ObservedAtUtc); + +public sealed record RatelDeskReceiverPreparationV2( + int SchemaVersion, Guid ConnectorId, long ConnectorRevision, + RatelDeskSemanticPeer Peer, RatelDeskVerifiedCapability Capability, + string ReceiverIdempotencyKey, string KeyAlgorithm, + string ExactCreateBodyJson, string ReceiverFingerprint, + DateTimeOffset OriginalActionCreatedAtUtc, DateTimeOffset AutomaticReplayUntilUtc, + string EvidenceFingerprint); + +// Existing IncidentDto fields may be additive; retain the complete accepted body privately. +public sealed record RatelDeskVerifiedReceipt( + string ContractVersion, string ReceiverInstanceId, Guid SourceNamespaceId, + Guid SourceInstanceId, string Key, string Fingerprint, string Outcome, + string IncidentId, string TrackingId, string OrganizationId, string CustomerId, + DateTimeOffset CommittedAtUtc, string Location, string ExactAcceptedBodyJson); + +public sealed record RatelDeskDispatchEvidence( + RatelDeskReceiverPreparationV2 Prepared, bool MayHaveCommitted, + int Attempts, DateTimeOffset? FirstPostAttemptAtUtc, bool FinalReconciliationPending = false); + +public enum RatelDeskReceiverObservationKind +{ + Committed = 1, Missing = 2, Gone = 3, AuthenticationRejected = 4, + PayloadRejected = 5, FingerprintConflict = 6, RateLimited = 7, + Unavailable = 8, TransientReadFailure = 9, PossibleCommit = 10 +} +public sealed record RatelDeskReceiverObservation( + RatelDeskReceiverObservationKind Kind, string Code, + RatelDeskVerifiedReceipt? Receipt = null, TimeSpan? RetryAfter = null); + +// Scope is one of the frozen incident create/receipt/target scopes. +// The implementation must re-resolve current credentials for THIS target. +public interface IRatelDeskOutboundBindingResolver +{ + Task CaptureAsync(RatelDeskConnectorState connector, + RatelDeskConnectorAuthentication authentication, Guid flowSourceInstanceId, + CancellationToken cancellationToken); + Task GetBearerAsync(RatelDeskConnectorState currentConnector, + RatelDeskSemanticPeer capturedPeer, string requiredScope, + CancellationToken cancellationToken); +} + +public interface IRatelDeskReceiverTransport +{ + Task CapabilitiesAsync(RatelDeskSemanticPeer peer, + string bearer, CancellationToken cancellationToken); + Task ValidateTargetsAsync(RatelDeskSemanticPeer peer, + RatelDeskVerifiedCapability capability, string bearer, + CancellationToken cancellationToken); + Task LookupAsync(RatelDeskReceiverPreparationV2 prepared, + string bearer, CancellationToken cancellationToken); + Task CreateAsync(RatelDeskReceiverPreparationV2 prepared, + string bearer, CancellationToken cancellationToken); +} + +public interface IFlowSourceIdentityResolver +{ + // Creates only the existing FlowRuntimeIdentity singleton when absent. + Task EnsureAsync(CancellationToken cancellationToken); +} + +// Kept separate from the immutable V1 event/draft/prepared records and their hashes. +// Every mutation has exactly the existing CurrentLeaseAsync transaction/fence checks. +public interface IFlowReceiverEvidenceStore +{ + Task GetOriginalCreatedAtAsync(FlowRunLease lease, CancellationToken cancellationToken); + Task ReadAsync(FlowRunLease lease, Guid nodeId, + CancellationToken cancellationToken); + Task SavePreparedAsync(FlowRunLease lease, Guid nodeId, + RatelDeskReceiverPreparationV2 preparation, CancellationToken cancellationToken); + Task SavePreparedActionAsync(FlowRunLease lease, Guid nodeId, + FlowIncidentActionRequest action, RatelDeskReceiverPreparationV2 preparation, CancellationToken cancellationToken); + Task SchedulePreparationRetryAsync(FlowRunLease lease, Guid nodeId, + TimeSpan? retryAfter, CancellationToken cancellationToken); + Task MarkPostAttemptAsync(FlowRunLease lease, Guid nodeId, + CancellationToken cancellationToken); + Task SaveReceiptAsync(FlowRunLease lease, Guid nodeId, + RatelDeskVerifiedReceipt receipt, CancellationToken cancellationToken); +} + +// Used by FlowRunProcessor when the selected connector supports the actual receiver. +// OriginalCreatedAtUtc comes from the current locked durable run, never a browser/draft clock. +public sealed record FlowReceiverPreparationResult(FlowIncidentPreparationStatus Status, + FlowIncidentActionRequest? Action = null, RatelDeskReceiverPreparationV2? Evidence = null, + string? Code = null, TimeSpan? RetryAfter = null); +public interface IFlowReceiverDispatcher +{ + Task PrepareReceiverAsync(FlowIncidentActionDraft draft, + DateTimeOffset originalCreatedAtUtc, CancellationToken cancellationToken); + Task DispatchReceiverAsync(FlowRunLease lease, Guid nodeId, + RatelDeskDispatchEvidence evidence, Func> recheckFlowGuard, + CancellationToken cancellationToken); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/DependencyInjection.cs b/src/NetRatel/NetRatel.Infrastructure/DependencyInjection.cs index 3f1d195d..b5326c87 100644 --- a/src/NetRatel/NetRatel.Infrastructure/DependencyInjection.cs +++ b/src/NetRatel/NetRatel.Infrastructure/DependencyInjection.cs @@ -22,6 +22,7 @@ using NetRatel.Infrastructure.Flows; using NetRatel.Infrastructure.Requests; using NetRatel.Infrastructure.Services; +using NetRatel.Infrastructure.RatelDesk; using NetRatel.Shared.Operations; namespace NetRatel.Infrastructure; @@ -53,6 +54,7 @@ public static IServiceCollection AddNetRatelInfrastructure(this IServiceCollecti services.AddNetRatelClientServicesPersistence(); services.AddNetRatelMonitoringPersistence(); services.AddNetRatelFlows(); + services.AddRatelDeskConnector(); services.AddNetRatelRemoteSupportLifecycle(); services.AddSingleton(); diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowExecutionPersistenceService.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowExecutionPersistenceService.cs index 87c35a1b..1fccbb13 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowExecutionPersistenceService.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowExecutionPersistenceService.cs @@ -1,6 +1,9 @@ using System.Text; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; using Microsoft.Extensions.DependencyInjection; +using NetRatel.Infrastructure.ServiceLinks; using NetRatel.Application.Flows; using NetRatel.Infrastructure.Persistence; using NetRatel.Shared.Contracts.Flows; @@ -20,9 +23,14 @@ public sealed partial class FlowPersistenceService return run is null ? null : Summary(run); }); - public Task ClaimAsync(Guid workerId, CancellationToken ct = default) => WithDb(async (db, _) => + public Task ClaimAsync(Guid workerId, CancellationToken ct = default) => WithDb(async (db, services) => { if (workerId == Guid.Empty) return null; + // Apply an explicit deployment producer before creating the Flow singleton. The optional + // store is solely the existing standalone Flow-test registration seam; the production + // receiver/continuity registration requires it and EnsureAsync always resolves it. + if (services.GetService() is { } installation) + _ = await installation.GetAsync(ct).ConfigureAwait(false); await using var transaction = await BeginAsync(db, ct).ConfigureAwait(false); var now = Now; FlowRunRecord? run; if (db.Database.IsNpgsql()) @@ -33,7 +41,10 @@ public sealed partial class FlowPersistenceService if (run.CreatedAtUtc + FlowLimits.MaximumRetryAge <= now || run.Attempts >= FlowLimits.MaximumActionAttempts * 2) { var actions = await db.FlowActions.Where(action => action.TenantId == run.TenantId && action.RunId == run.Id).ToListAsync(ct).ConfigureAwait(false); - foreach (var action in actions.Where(action => action.Status == FlowActionStatus.Dispatching)) + var uncertainNodes = await db.Set().Where(row => row.TenantId == run.TenantId && + row.RunId == run.Id && row.MayHaveCommitted).Select(row => row.NodeId).ToListAsync(ct).ConfigureAwait(false); + foreach (var action in actions.Where(action => action.Status == FlowActionStatus.Dispatching || + uncertainNodes.Contains(action.NodeId) && action.Status != FlowActionStatus.Succeeded)) { action.Status = FlowActionStatus.DeliveryUnknown; action.Code = "interrupted-delivery-unknown"; action.LeaseFence = checked(run.Fence + 1); } if (actions.Any(action => action.Status == FlowActionStatus.DeliveryUnknown)) { run.Status = FlowRunStatus.DeliveryUnknown; run.Code = "delivery-unknown"; } @@ -43,7 +54,7 @@ public sealed partial class FlowPersistenceService run.CompletedAtUtc = now; run.LeaseToken = null; run.LeaseOwner = null; run.LeaseExpiresAtUtc = null; run.Fence++; await db.SaveChangesAsync(ct).ConfigureAwait(false); if (transaction is not null) await transaction.CommitAsync(ct).ConfigureAwait(false); return null; } - var sourceId = await SourceIdentityAsync(db, ct).ConfigureAwait(false); + var sourceId = await EnsureSourceInContextAsync(db, ct).ConfigureAwait(false); var version = await db.FlowVersions.AsNoTracking().SingleAsync(row => row.TenantId == run.TenantId && row.Id == run.FlowVersionId, ct).ConfigureAwait(false); run.Status = FlowRunStatus.Running; run.Fence = checked(run.Fence + 1); run.LeaseToken = Guid.NewGuid(); run.LeaseOwner = workerId; run.LeaseExpiresAtUtc = now + FlowLimits.LeaseDuration; run.Attempts++; run.NextAttemptAtUtc = null; @@ -51,17 +62,15 @@ public sealed partial class FlowPersistenceService return new(run.Id, run.LeaseToken.Value, workerId, run.Fence, run.LeaseExpiresAtUtc.Value, sourceId, Version(version), Parse(run.EventJson), run.Attempts); }); - private static async Task SourceIdentityAsync(OrchestratorDbContext db, CancellationToken ct) + // Receipt-only recovery may outlive Monitoring evidence, but never the actual Flow lease. + // Use the same caller transaction and PostgreSQL wall-clock floor as fresh-action admission. + private async Task ReceiverLeaseEffectiveAsync(OrchestratorDbContext db, FlowRunLease lease, CancellationToken ct) { - if (db.Database.IsNpgsql()) - { - var proposed = Guid.NewGuid(); - await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO \"FlowRuntimeIdentity\" (\"Id\", \"SourceInstanceId\") VALUES (1, {proposed}) ON CONFLICT (\"Id\") DO NOTHING", ct).ConfigureAwait(false); - } - else if (!await db.FlowRuntimeIdentity.AnyAsync(ct).ConfigureAwait(false)) - { db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = Guid.NewGuid() }); await db.SaveChangesAsync(ct).ConfigureAwait(false); } - var id = await db.FlowRuntimeIdentity.Where(row => row.Id == 1).Select(row => row.SourceInstanceId).SingleAsync(ct).ConfigureAwait(false); - return id == Guid.Empty ? throw new InvalidOperationException("flow-source-identity") : id; + ct.ThrowIfCancellationRequested(); + if (!db.Database.IsNpgsql()) return lease.ExpiresAtUtc > Now; + if (db.Database.CurrentTransaction is not { } transaction) return false; + return lease.ExpiresAtUtc > await ClientConnectionEpochStore.EffectiveNowAsync( + (NpgsqlConnection)db.Database.GetDbConnection(), (NpgsqlTransaction)transaction.GetDbTransaction(), clock, ct).ConfigureAwait(false); } private async Task CurrentLeaseAsync(OrchestratorDbContext db, FlowRunLease lease, CancellationToken ct) @@ -115,7 +124,8 @@ public Task SavePreparedActionAsync(FlowRunLease lease, Guid nodeId, FlowI await using var transaction = await BeginAsync(db, ct).ConfigureAwait(false); if (await CurrentLeaseAsync(db, lease, ct).ConfigureAwait(false) is null) return false; var row = await db.FlowActions.SingleOrDefaultAsync(row => row.TenantId == lease.Event.TenantId && row.RunId == lease.RunId && row.NodeId == nodeId, ct).ConfigureAwait(false); - if (row is null || row.Status != FlowActionStatus.Pending || !FlowContractValidation.ValidPrepared(request, Parse(row.DraftJson))) return false; + if (row is null || row.Status is not (FlowActionStatus.Pending or FlowActionStatus.RetryWaiting) || row.Attempts != 0 || + !FlowContractValidation.ValidPrepared(request, Parse(row.DraftJson))) return false; if (row.PreparedJson is not null) return Serialize(Parse(row.PreparedJson)) == Serialize(request); row.PreparedJson = Serialize(request); row.SemanticFingerprint = request.SemanticFingerprint; row.ConnectorRevision = request.ConnectorRevision; await db.SaveChangesAsync(ct).ConfigureAwait(false); if (transaction is not null) await transaction.CommitAsync(ct).ConfigureAwait(false); return true; @@ -124,13 +134,68 @@ public Task SavePreparedActionAsync(FlowRunLease lease, Guid nodeId, FlowI public Task StartActionAsync(FlowRunLease lease, Guid nodeId, CancellationToken ct = default) => WithDb(async (db, services) => { await using var transaction = await BeginAsync(db, ct).ConfigureAwait(false); - var admission = services.GetRequiredService(); + Task CheckFreshAdmissionAsync() => + services.GetRequiredService().CanStartActionAsync(db, lease, ct); ct.ThrowIfCancellationRequested(); - if (!(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed || lease.ExpiresAtUtc <= Now) return null; + var receiverHint = await db.Set().AsNoTracking().SingleOrDefaultAsync(evidence => + evidence.TenantId == lease.Event.TenantId && evidence.RunId == lease.RunId && evidence.NodeId == nodeId, ct).ConfigureAwait(false); + var requireFreshMonitoringAdmission = receiverHint is null; + if (receiverHint is not null) + { + var actionHint = await db.FlowActions.AsNoTracking().SingleOrDefaultAsync(action => action.TenantId == lease.Event.TenantId && + action.RunId == lease.RunId && action.NodeId == nodeId, ct).ConfigureAwait(false); + // No fresh effect starts under a retired owner or stale Monitoring evidence. A prior + // possible commit may still enter a lease-fenced authenticated read-only lookup. + requireFreshMonitoringAdmission = !receiverHint.MayHaveCommitted && actionHint?.Status != FlowActionStatus.Dispatching; + if (requireFreshMonitoringAdmission && + !(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed) + return null; + if (!await services.GetRequiredService() + .AuthorizeAsync(lease.Event.TenantId, lease.Event.Authority, ct).ConfigureAwait(false)) return null; + } + if (receiverHint is null && !(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed) return null; + if (!await ReceiverLeaseEffectiveAsync(db, lease, ct).ConfigureAwait(false)) return null; if (await CurrentLeaseAsync(db, lease, ct).ConfigureAwait(false) is null) return null; var row = await db.FlowActions.SingleOrDefaultAsync(row => row.TenantId == lease.Event.TenantId && row.RunId == lease.RunId && row.NodeId == nodeId, ct).ConfigureAwait(false); if (row is null || row.PreparedJson is null) return null; if (row.Status is FlowActionStatus.Succeeded or FlowActionStatus.Failed or FlowActionStatus.DeliveryUnknown) return ActionState(row); + var receiver = await db.Set().SingleOrDefaultAsync(evidence => + evidence.TenantId == lease.Event.TenantId && evidence.RunId == lease.RunId && evidence.NodeId == nodeId, ct).ConfigureAwait(false); + if (receiver is not null) + { + if (row.Status == FlowActionStatus.Dispatching && row.LeaseFence == lease.Fence) return null; + if (row.NextAttemptAtUtc > Now) return ActionState(row); + if (row.Attempts >= FlowLimits.MaximumActionAttempts) + { + if (!receiver.MayHaveCommitted || receiver.FinalReconciliationAttempted) + { + row.Status = receiver.MayHaveCommitted ? FlowActionStatus.DeliveryUnknown : FlowActionStatus.Failed; + row.Code = receiver.MayHaveCommitted ? "final-reconciliation-exhausted-unknown" : "action-budget-exhausted"; + row.LeaseFence = lease.Fence; + if (!await ReceiverLeaseEffectiveAsync(db, lease, ct).ConfigureAwait(false)) return null; + ct.ThrowIfCancellationRequested(); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + if (!await ReceiverLeaseEffectiveAsync(db, lease, ct).ConfigureAwait(false)) return null; + ct.ThrowIfCancellationRequested(); + if (transaction is not null) await transaction.CommitAsync(ct).ConfigureAwait(false); + return ActionState(row); + } + receiver.FinalReconciliationAttempted = true; receiver.RowVersion = checked(receiver.RowVersion + 1); + } + // A recovered in-flight call is reconciled before any replay, including the fifth POST. + // Read-only recovery does not consume another POST attempt or extend either horizon. + row.Status = FlowActionStatus.Dispatching; row.LeaseFence = lease.Fence; row.NextAttemptAtUtc = null; + if (!await ReceiverLeaseEffectiveAsync(db, lease, ct).ConfigureAwait(false) || requireFreshMonitoringAdmission && + !(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed) return null; + ct.ThrowIfCancellationRequested(); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + // Existing locks prevent replacement but do not freeze the owner's deadline. + if (!await ReceiverLeaseEffectiveAsync(db, lease, ct).ConfigureAwait(false) || requireFreshMonitoringAdmission && + !(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed) return null; + ct.ThrowIfCancellationRequested(); + if (transaction is not null) await transaction.CommitAsync(ct).ConfigureAwait(false); + return ActionState(row); + } if (row.Status == FlowActionStatus.Dispatching) { if (row.LeaseFence == lease.Fence) return null; @@ -151,10 +216,10 @@ public Task SavePreparedActionAsync(FlowRunLease lease, Guid nodeId, FlowI else { row.Status = FlowActionStatus.Dispatching; row.LeaseFence = lease.Fence; row.Attempts++; row.NextAttemptAtUtc = null; } } ct.ThrowIfCancellationRequested(); - if (!(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed || lease.ExpiresAtUtc <= Now) return null; + if (!(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed || lease.ExpiresAtUtc <= Now) return null; await db.SaveChangesAsync(ct).ConfigureAwait(false); ct.ThrowIfCancellationRequested(); - if (!(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed || lease.ExpiresAtUtc <= Now) return null; + if (!(await CheckFreshAdmissionAsync().ConfigureAwait(false)).Allowed || lease.ExpiresAtUtc <= Now) return null; ct.ThrowIfCancellationRequested(); if (transaction is not null) await transaction.CommitAsync(ct).ConfigureAwait(false); return ActionState(row); @@ -167,15 +232,32 @@ public Task CompleteActionAsync(FlowRunLease lease, Guid nodeId, FlowIncid if (await CurrentLeaseAsync(db, lease, ct).ConfigureAwait(false) is null) return false; var row = await db.FlowActions.SingleOrDefaultAsync(row => row.TenantId == lease.Event.TenantId && row.RunId == lease.RunId && row.NodeId == nodeId, ct).ConfigureAwait(false); if (row is null || row.Status is not (FlowActionStatus.Dispatching or FlowActionStatus.Pending or FlowActionStatus.RetryWaiting)) return false; + var receiver = await db.Set().SingleOrDefaultAsync(evidence => + evidence.TenantId == lease.Event.TenantId && evidence.RunId == lease.RunId && evidence.NodeId == nodeId, ct).ConfigureAwait(false); + if (receiver is not null) + { + // Full original receiver receipts commit through SaveReceiptAsync only. + if (result.Kind == FlowIncidentActionResultKind.Succeeded) return false; + if (receiver.MayHaveCommitted && result.Kind is FlowIncidentActionResultKind.Failed or FlowIncidentActionResultKind.Unavailable) + result = new(FlowIncidentActionResultKind.DeliveryUnknown, result.Code); + } if (row.LeaseFence != lease.Fence && !(row.LeaseFence < lease.Fence && row.Status == FlowActionStatus.Dispatching && result.Kind == FlowIncidentActionResultKind.DeliveryUnknown)) return false; - if (row.Status is FlowActionStatus.Pending or FlowActionStatus.RetryWaiting && result.Kind is not (FlowIncidentActionResultKind.Failed or FlowIncidentActionResultKind.Unavailable)) return false; + if (row.Status is FlowActionStatus.Pending or FlowActionStatus.RetryWaiting && + result.Kind is not (FlowIncidentActionResultKind.Failed or FlowIncidentActionResultKind.Unavailable) && + !(receiver?.MayHaveCommitted == true && result.Kind == FlowIncidentActionResultKind.DeliveryUnknown)) return false; row.LeaseFence = lease.Fence; row.Code = result.Code; row.Status = result.Kind switch { FlowIncidentActionResultKind.Succeeded => FlowActionStatus.Succeeded, FlowIncidentActionResultKind.DeliveryUnknown => FlowActionStatus.DeliveryUnknown, FlowIncidentActionResultKind.RetryableSafe => FlowActionStatus.RetryWaiting, _ => FlowActionStatus.Failed }; if (row.Status == FlowActionStatus.RetryWaiting) { - if (row.Attempts >= FlowLimits.MaximumActionAttempts) { row.Status = FlowActionStatus.Failed; row.Code = "action-budget-exhausted"; } + if (row.Attempts >= FlowLimits.MaximumActionAttempts && + !(row.Attempts == FlowLimits.MaximumActionAttempts && receiver?.MayHaveCommitted == true && !receiver.FinalReconciliationAttempted)) + { + row.Status = receiver?.MayHaveCommitted == true ? FlowActionStatus.DeliveryUnknown : FlowActionStatus.Failed; + row.Code = receiver?.MayHaveCommitted == true ? "action-budget-exhausted-unknown" : "action-budget-exhausted"; + row.NextAttemptAtUtc = null; + } else row.NextAttemptAtUtc = Now + (result.RetryAfter ?? TimeSpan.FromSeconds(Math.Min(300, 5 * Math.Pow(2, row.Attempts - 1)))); } if (result.Receipt is not null) row.ReceiptJson = Serialize(result.Receipt); diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowPersistenceService.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowPersistenceService.cs index 5ef5fac9..f490562a 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowPersistenceService.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowPersistenceService.cs @@ -5,13 +5,14 @@ using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; using NetRatel.Infrastructure.Persistence; using NetRatel.Shared.Contracts.Flows; namespace NetRatel.Infrastructure.Flows; /// Operation-scoped contexts and transaction/lease fences own durability; no browser or library model is stored. -public sealed partial class FlowPersistenceService(IServiceScopeFactory scopes, TimeProvider clock) : IFlowDefinitionService, IFlowEventIngress, IFlowExecutionStore +public sealed partial class FlowPersistenceService(IServiceScopeFactory scopes, TimeProvider clock) : IFlowDefinitionService, IFlowEventIngress, IFlowExecutionStore, IFlowReceiverEvidenceStore, IFlowSourceIdentityResolver { // PostgreSQL stores microseconds; normalize before returning lease timestamps that are compared // with a fresh scoped read during receipt commits. diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowReceiverEvidenceStore.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowReceiverEvidenceStore.cs new file mode 100644 index 00000000..8791d1de --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowReceiverEvidenceStore.cs @@ -0,0 +1,198 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; + +namespace NetRatel.Infrastructure.Flows; + +public sealed partial class FlowPersistenceService +{ + Task IFlowReceiverEvidenceStore.GetOriginalCreatedAtAsync(FlowRunLease lease, + CancellationToken ct) => WithDb(async (db, _) => + { + await using var tx = await BeginAsync(db, ct); + var run = await CurrentLeaseAsync(db, lease, ct); + if (run is null) return null; + if (tx is not null) await tx.CommitAsync(ct); + return run.CreatedAtUtc; + }); + + Task IFlowReceiverEvidenceStore.ReadAsync(FlowRunLease lease, + Guid nodeId, CancellationToken ct) => WithDb(async (db, services) => + { + await using var tx = await BeginAsync(db, ct); + var run = await CurrentLeaseAsync(db, lease, ct); if (run is null) return null; + var action = await db.FlowActions.AsNoTracking().SingleOrDefaultAsync(x => + x.TenantId == lease.Event.TenantId && x.RunId == lease.RunId && x.NodeId == nodeId, ct); + var record = await db.Set().AsNoTracking().SingleOrDefaultAsync(x => + x.TenantId == lease.Event.TenantId && x.RunId == lease.RunId && x.NodeId == nodeId, ct); + if (action is null || record is null || action.PreparedJson is null) return null; + var prepared = Parse(record.PreparationJson); + if (record.TenantId != run.TenantId || record.SchemaVersion != 2 || prepared.SchemaVersion != 2 || + record.ReceiverIdempotencyKey != prepared.ReceiverIdempotencyKey || + record.ReceiverFingerprint != prepared.ReceiverFingerprint || + record.OriginalCreatedAtUtc != run.CreatedAtUtc || record.AutomaticReplayUntilUtc != prepared.AutomaticReplayUntilUtc || + prepared.EvidenceFingerprint != record.EvidenceFingerprint || + ReceiverPreparationBuilder.EvidenceHash(prepared) != record.EvidenceFingerprint || + prepared.Peer.LocalTenantId != lease.Event.TenantId || prepared.Peer.SourceInstanceId != lease.SourceInstanceId || + !ReceiverPreparedBinding.Valid(prepared, Parse(action.PreparedJson), + Parse(action.DraftJson), run.CreatedAtUtc, + services.GetRequiredService())) + throw new InvalidOperationException("persisted-receiver-evidence-invalid"); + if (tx is not null) await tx.CommitAsync(ct); + return new(prepared, record.MayHaveCommitted, action.Attempts, record.FirstPostAttemptAtUtc, + record.MayHaveCommitted && action.Attempts == FlowLimits.MaximumActionAttempts && !record.FinalReconciliationAttempted); + }); + + Task IFlowReceiverEvidenceStore.SavePreparedAsync(FlowRunLease lease, Guid nodeId, + RatelDeskReceiverPreparationV2 evidence, CancellationToken ct) => WithDb((db, services) => + SaveReceiverPreparationAsync(db, services, lease, nodeId, null, evidence, ct)); + + Task IFlowReceiverEvidenceStore.SavePreparedActionAsync(FlowRunLease lease, Guid nodeId, + FlowIncidentActionRequest request, RatelDeskReceiverPreparationV2 evidence, CancellationToken ct) => WithDb((db, services) => + SaveReceiverPreparationAsync(db, services, lease, nodeId, request, evidence, ct)); + + private async Task SaveReceiverPreparationAsync(OrchestratorDbContext db, IServiceProvider services, + FlowRunLease lease, Guid nodeId, FlowIncidentActionRequest? request, RatelDeskReceiverPreparationV2 evidence, CancellationToken ct) + { + await using var tx = await BeginAsync(db, ct).ConfigureAwait(false); + var run = await CurrentLeaseAsync(db, lease, ct).ConfigureAwait(false); if (run is null) return false; + var action = await db.FlowActions.SingleOrDefaultAsync(x => x.TenantId == lease.Event.TenantId && + x.RunId == lease.RunId && x.NodeId == nodeId, ct).ConfigureAwait(false); + if (action is null || action.Status is not (FlowActionStatus.Pending or FlowActionStatus.RetryWaiting) || + action.Attempts != 0 || action.LeaseFence != lease.Fence || request is null && action.PreparedJson is null) return false; + var prepared = request ?? Parse(action.PreparedJson!); + var draft = Parse(action.DraftJson); + var serialized = Serialize(evidence); + if (!FlowContractValidation.ValidPrepared(prepared, draft) || !prepared.SupportsSafeReplay || + action.PreparedJson is not null && Serialize(Parse(action.PreparedJson)) != Serialize(prepared) || + evidence.SchemaVersion != 2 || evidence.ConnectorId != prepared.ConnectorId || + evidence.ConnectorRevision != prepared.ConnectorRevision || + evidence.Peer.SourceInstanceId != lease.SourceInstanceId || evidence.Peer.LocalTenantId != run.TenantId || + evidence.Peer.OrganizationId != prepared.Target.OrganizationId || evidence.Peer.CustomerId != prepared.Target.CustomerId || + evidence.OriginalActionCreatedAtUtc != run.CreatedAtUtc || evidence.AutomaticReplayUntilUtc <= run.CreatedAtUtc || + evidence.AutomaticReplayUntilUtc > run.CreatedAtUtc + FlowLimits.MaximumRetryAge || + !RatelDeskReceiverKey.IsConforming(evidence.ReceiverIdempotencyKey) || + ReceiverPreparationBuilder.EvidenceHash(evidence) != evidence.EvidenceFingerprint || + Encoding.UTF8.GetByteCount(serialized) > 65_536 || + !ReceiverPreparedBinding.Valid(evidence, prepared, draft, run.CreatedAtUtc, + services.GetRequiredService())) return false; + var existing = await db.Set().SingleOrDefaultAsync(x => + x.TenantId == lease.Event.TenantId && x.RunId == lease.RunId && x.NodeId == nodeId, ct).ConfigureAwait(false); + if (existing is not null) + { + if (Serialize(Parse(existing.PreparationJson)) != serialized) return false; + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + return true; + } + // The original V1 serializer and hashes are unchanged. Initial preparation and its + // additive receiver sidecar commit atomically; an interrupted save leaves neither. + if (action.PreparedJson is null) + { + action.PreparedJson = Serialize(prepared); action.SemanticFingerprint = prepared.SemanticFingerprint; + action.ConnectorRevision = prepared.ConnectorRevision; + } + db.Set().Add(new() + { + TenantId = lease.Event.TenantId, RunId = lease.RunId, NodeId = nodeId, SchemaVersion = 2, PreparationJson = serialized, + EvidenceFingerprint = evidence.EvidenceFingerprint, ReceiverIdempotencyKey = evidence.ReceiverIdempotencyKey, + ReceiverFingerprint = evidence.ReceiverFingerprint, OriginalCreatedAtUtc = run.CreatedAtUtc, + AutomaticReplayUntilUtc = evidence.AutomaticReplayUntilUtc + }); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + return true; + } + + Task IFlowReceiverEvidenceStore.SchedulePreparationRetryAsync(FlowRunLease lease, Guid nodeId, + TimeSpan? retryAfter, CancellationToken ct) => WithDb(async (db, _) => + { + var delay = retryAfter ?? TimeSpan.FromSeconds(5); + if (delay < TimeSpan.Zero || delay > TimeSpan.FromHours(1)) return false; + await using var tx = await BeginAsync(db, ct); + var run = await CurrentLeaseAsync(db, lease, ct); if (run is null) return false; + var action = await db.FlowActions.SingleOrDefaultAsync(x => x.TenantId == lease.Event.TenantId && + x.RunId == lease.RunId && x.NodeId == nodeId, ct); + if (action is null || action.LeaseFence != lease.Fence || action.Attempts != 0 || + action.Status is not (FlowActionStatus.Pending or FlowActionStatus.RetryWaiting) || + await db.Set().AnyAsync(x => x.RunId == lease.RunId && x.NodeId == nodeId && x.MayHaveCommitted, ct)) + return false; + if (Now >= run.CreatedAtUtc + FlowLimits.MaximumRetryAge || run.Attempts >= FlowLimits.MaximumActionAttempts * 2) + { action.Status = FlowActionStatus.Failed; action.Code = "execution-budget-exhausted"; action.NextAttemptAtUtc = null; } + else + { + action.Status = FlowActionStatus.RetryWaiting; action.Code = "receiver-preparation-retry"; + action.NextAttemptAtUtc = Now + delay; + } + await db.SaveChangesAsync(ct); if (tx is not null) await tx.CommitAsync(ct); return true; + }); + + Task IFlowReceiverEvidenceStore.MarkPostAttemptAsync(FlowRunLease lease, Guid nodeId, + CancellationToken ct) => WithDb(async (db, services) => + { + await using var tx = await BeginAsync(db, ct); + var admission = services.GetRequiredService(); + ct.ThrowIfCancellationRequested(); + // Current config -> committed OwnerKey -> registered evidence -> series -> exact outbox + // locks precede the run/action lock. This transaction contains no HTTP work. + if (!(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed || + !await services.GetRequiredService() + .AuthorizeAsync(lease.Event.TenantId, lease.Event.Authority, ct).ConfigureAwait(false)) return false; + var run = await CurrentLeaseAsync(db, lease, ct); if (run is null) return false; + var action = await db.FlowActions.SingleOrDefaultAsync(x => x.TenantId == lease.Event.TenantId && + x.RunId == lease.RunId && x.NodeId == nodeId, ct); + var record = await db.Set().SingleOrDefaultAsync(x => + x.TenantId == lease.Event.TenantId && x.RunId == lease.RunId && x.NodeId == nodeId, ct); + if (action is null || record is null || action.Status != FlowActionStatus.Dispatching || + action.LeaseFence != lease.Fence || record.LastPostLeaseFence == lease.Fence || record.FullReceiptJson is not null || + action.Attempts is < 0 or >= FlowLimits.MaximumActionAttempts || Now >= record.AutomaticReplayUntilUtc) + return false; + if (action.PreparedJson is null || !ReceiverPreparedBinding.Valid(Parse(record.PreparationJson), + Parse(action.PreparedJson), Parse(action.DraftJson), + run.CreatedAtUtc, services.GetRequiredService())) return false; + action.Attempts = checked(action.Attempts + 1); record.LastPostLeaseFence = lease.Fence; + record.MayHaveCommitted = true; record.FirstPostAttemptAtUtc ??= Now; record.RowVersion = checked(record.RowVersion + 1); + if (lease.ExpiresAtUtc <= Now || + !(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed) return false; + ct.ThrowIfCancellationRequested(); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + if (lease.ExpiresAtUtc <= Now || + !(await admission.CanStartActionAsync(db, lease, ct).ConfigureAwait(false)).Allowed) return false; + ct.ThrowIfCancellationRequested(); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + return true; + }); + + Task IFlowReceiverEvidenceStore.SaveReceiptAsync(FlowRunLease lease, Guid nodeId, + RatelDeskVerifiedReceipt receipt, CancellationToken ct) => WithDb(async (db, services) => + { + await using var tx = await BeginAsync(db, ct); + if (!await services.GetRequiredService() + .AuthorizeAsync(lease.Event.TenantId, lease.Event.Authority, ct).ConfigureAwait(false)) return false; + var run = await CurrentLeaseAsync(db, lease, ct); if (run is null) return false; + var action = await db.FlowActions.SingleOrDefaultAsync(x => x.TenantId == lease.Event.TenantId && + x.RunId == lease.RunId && x.NodeId == nodeId, ct); + var record = await db.Set().SingleOrDefaultAsync(x => + x.TenantId == lease.Event.TenantId && x.RunId == lease.RunId && x.NodeId == nodeId, ct); + if (Encoding.UTF8.GetByteCount(receipt.ExactAcceptedBodyJson) > NetRatel.Shared.Contracts.RatelDesk.RatelDeskConnectorLimits.MaximumResponseBytes || + action is null || record is null || action.LeaseFence != lease.Fence || + action.Status != FlowActionStatus.Dispatching) return false; + var prepared = Parse(record.PreparationJson); + if (action.PreparedJson is null || !ReceiverPreparedBinding.Valid(prepared, + Parse(action.PreparedJson), Parse(action.DraftJson), + run.CreatedAtUtc, services.GetRequiredService())) return false; + var verified = ReceiverWireValidation.Receipt(Encoding.UTF8.GetBytes(receipt.ExactAcceptedBodyJson), receipt.Location, prepared); + // The raw accepted body is immutable, including its initial top-level incident data. + var serialized = Serialize(verified); + if (record.FullReceiptJson is not null && Serialize(Parse(record.FullReceiptJson)) != serialized) return false; + record.FullReceiptJson ??= serialized; record.MayHaveCommitted = false; record.RowVersion = checked(record.RowVersion + 1); + action.ReceiptJson = Serialize(new FlowActionReceiptDto(verified.IncidentId, verified.TrackingId, null)); + action.Status = FlowActionStatus.Succeeded; action.Code = "incident-receipt-verified"; + await db.SaveChangesAsync(ct); if (tx is not null) await tx.CommitAsync(ct); return true; + }); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.Receiver.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.Receiver.cs new file mode 100644 index 00000000..10b35d2b --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.Receiver.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.Flows; + +namespace NetRatel.Infrastructure.Flows; + +public sealed partial class FlowRunProcessor +{ + private async Task ExecuteReceiverActionAsync(FlowRunLease lease, FlowIncidentActionDraft draft, + FlowActionExecutionState existing, IServiceProvider provider, IFlowReceiverDispatcher receiver, CancellationToken ct) + { + var evidenceStore = provider.GetRequiredService(); + var clock = provider.GetRequiredService(); + Task Admit(CancellationToken token) => AdmitDispatchAsync(provider, lease, token); + var evidence = await evidenceStore.ReadAsync(lease, draft.ActionNodeId, ct).ConfigureAwait(false); + var reconcile = evidence?.MayHaveCommitted == true || existing.Status == FlowActionStatus.Dispatching; + if (!await provider.GetRequiredService() + .AuthorizeAsync(lease.Event.TenantId, lease.Event.Authority, ct).ConfigureAwait(false)) + return await DenyReceiverAsync(lease, draft.ActionNodeId, existing, evidence, "execution-authority-revoked", ct).ConfigureAwait(false); + var gate = await Admit(ct).ConfigureAwait(false); + // Current suppression/disablement prevents a fresh effect, while authorized read-only + // reconciliation can still recover an original committed receipt. + if (!gate.Allowed && !reconcile) + return await DenyReceiverAsync(lease, draft.ActionNodeId, existing, evidence, gate.Code, ct).ConfigureAwait(false); + if (evidence is null) + { + // Historical V1 requests have no receiver wire-key provenance. Retain their bytes and + // hashes; a current capability cannot retrofit safe replay onto an earlier dispatch. + if (existing.Request is not null || existing.Status == FlowActionStatus.Dispatching || existing.Attempts > 0) + { + var unavailable = new FlowIncidentActionResult(existing.Status == FlowActionStatus.Dispatching || existing.Attempts > 0 + ? FlowIncidentActionResultKind.DeliveryUnknown : FlowIncidentActionResultKind.Unavailable, + "historical-receiver-evidence-unavailable"); + await store.CompleteActionAsync(lease, draft.ActionNodeId, unavailable, ct).ConfigureAwait(false); + return unavailable; + } + var createdAt = await evidenceStore.GetOriginalCreatedAtAsync(lease, ct).ConfigureAwait(false); + if (createdAt is null) return new(FlowIncidentActionResultKind.Failed, "action-lease-lost"); + var preparation = await receiver.PrepareReceiverAsync(draft, createdAt.Value, ct).ConfigureAwait(false); + if (preparation.Status != FlowIncidentPreparationStatus.Ready || preparation.Action is null || preparation.Evidence is null) + { + if (preparation.Status == FlowIncidentPreparationStatus.Unavailable && preparation.RetryAfter is not null && + await evidenceStore.SchedulePreparationRetryAsync(lease, draft.ActionNodeId, preparation.RetryAfter, ct).ConfigureAwait(false)) + return new(FlowIncidentActionResultKind.RetryableSafe, "receiver-preparation-retry", RetryAfter: preparation.RetryAfter); + var unavailable = new FlowIncidentActionResult(preparation.Status == FlowIncidentPreparationStatus.Unavailable + ? FlowIncidentActionResultKind.Unavailable : FlowIncidentActionResultKind.Failed, + SafeCode(preparation.Code ?? "receiver-preparation-unavailable")); + await store.CompleteActionAsync(lease, draft.ActionNodeId, unavailable, ct).ConfigureAwait(false); + return unavailable; + } + if (!FlowContractValidation.ValidPrepared(preparation.Action, draft) || + !await evidenceStore.SavePreparedActionAsync(lease, draft.ActionNodeId, preparation.Action, preparation.Evidence, ct).ConfigureAwait(false)) + return new(FlowIncidentActionResultKind.Failed, "receiver-preparation-not-committed"); + evidence = await evidenceStore.ReadAsync(lease, draft.ActionNodeId, ct).ConfigureAwait(false); + if (evidence is null) return new(FlowIncidentActionResultKind.Failed, "receiver-evidence-not-persisted"); + } + gate = await Admit(ct).ConfigureAwait(false); + if (!gate.Allowed && !reconcile) + return await DenyReceiverAsync(lease, draft.ActionNodeId, existing, evidence, gate.Code, ct).ConfigureAwait(false); + if (!await provider.GetRequiredService() + .AuthorizeAsync(lease.Event.TenantId, lease.Event.Authority, ct).ConfigureAwait(false)) + return await DenyReceiverAsync(lease, draft.ActionNodeId, existing, evidence, "execution-authority-revoked", ct).ConfigureAwait(false); + var started = await store.StartActionAsync(lease, draft.ActionNodeId, ct).ConfigureAwait(false); + if (started is null) return new(FlowIncidentActionResultKind.Failed, "action-lease-lost"); + if (Terminal(started) is { } terminal) return terminal; + if (started.Status == FlowActionStatus.RetryWaiting) return new(FlowIncidentActionResultKind.RetryableSafe, "retry-waiting"); + if (started.Status != FlowActionStatus.Dispatching) return new(FlowIncidentActionResultKind.Failed, "action-state-invalid"); + evidence = evidence with { Attempts = started.Attempts, + MayHaveCommitted = evidence.MayHaveCommitted || existing.Status == FlowActionStatus.Dispatching }; + RatelDeskReceiverObservation observation; + try + { + observation = await receiver.DispatchReceiverAsync(lease, draft.ActionNodeId, evidence, Admit, ct).ConfigureAwait(false); + } + catch (UnauthorizedAccessException) + { observation = new(RatelDeskReceiverObservationKind.AuthenticationRejected, "receiver-current-authority-denied"); } + catch (ServiceLinkProtocolException error) + { + observation = new(error.StatusCode is 401 or 403 + ? RatelDeskReceiverObservationKind.AuthenticationRejected : RatelDeskReceiverObservationKind.TransientReadFailure, + error.StatusCode is 401 or 403 ? "receiver-current-authority-denied" : "receiver-current-profile-unavailable"); + } + catch (Exception) + { observation = new(RatelDeskReceiverObservationKind.TransientReadFailure, "receiver-operation-interrupted"); } + // Dispatch may have committed MayHaveCommitted after the original snapshot. Use the + // durable value even after cancellation, a lost response, or a malformed accepted body. + using var completion = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var current = await evidenceStore.ReadAsync(lease, draft.ActionNodeId, completion.Token).ConfigureAwait(false); + if (current is null) return new(FlowIncidentActionResultKind.DeliveryUnknown, "action-lease-lost"); + if (observation.Kind == RatelDeskReceiverObservationKind.Committed && observation.Receipt is { } receipt) + { + if (!await evidenceStore.SaveReceiptAsync(lease, draft.ActionNodeId, receipt, completion.Token).ConfigureAwait(false)) + return new(FlowIncidentActionResultKind.DeliveryUnknown, "action-receipt-not-committed"); + return new(FlowIncidentActionResultKind.Succeeded, "incident-receipt-verified", new(receipt.IncidentId, receipt.TrackingId, null)); + } + var result = ReceiverDispatchPolicy.Complete(current, observation, clock.GetUtcNow()); + if (!await store.CompleteActionAsync(lease, draft.ActionNodeId, result, completion.Token).ConfigureAwait(false)) + return new(FlowIncidentActionResultKind.DeliveryUnknown, "action-outcome-not-committed"); + return result; + } + + private async Task DenyReceiverAsync(FlowRunLease lease, Guid nodeId, + FlowActionExecutionState existing, RatelDeskDispatchEvidence? evidence, string code, CancellationToken ct) + { + var denied = new FlowIncidentActionResult(evidence?.MayHaveCommitted == true || existing.Status == FlowActionStatus.Dispatching + ? FlowIncidentActionResultKind.DeliveryUnknown : FlowIncidentActionResultKind.Failed, SafeCode(code)); + await store.CompleteActionAsync(lease, nodeId, denied, ct).ConfigureAwait(false); + return denied; + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.cs index bcc7d130..2150c7a5 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowRunProcessor.cs @@ -2,12 +2,13 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; using NetRatel.Shared.Contracts.Flows; namespace NetRatel.Infrastructure.Flows; /// A bounded single-node backend worker. Every external step passes current grants/suppression and durable receipt fences. -public sealed class FlowRunProcessor(IFlowExecutionStore store, IFlowRuntimeAdapter runtime, IServiceScopeFactory scopes) +public sealed partial class FlowRunProcessor(IFlowExecutionStore store, IFlowRuntimeAdapter runtime, IServiceScopeFactory scopes) { private readonly Guid _workerId = Guid.NewGuid(); public async Task ProcessOneAsync(CancellationToken cancellationToken = default) @@ -29,6 +30,14 @@ private async Task ExecuteActionAsync(FlowRunLease lea if (existing is null) return new(FlowIncidentActionResultKind.Failed, "action-lease-lost"); if (Terminal(existing) is { } terminal) return terminal; await using var scope = scopes.CreateAsyncScope(); var provider = scope.ServiceProvider; + if (provider.GetService() is { } receiver) + return await ExecuteReceiverActionAsync(lease, draft, existing, provider, receiver, ct).ConfigureAwait(false); + return await ExecuteLegacyActionAsync(lease, draft, existing, provider, ct).ConfigureAwait(false); + } + + private async Task ExecuteLegacyActionAsync(FlowRunLease lease, FlowIncidentActionDraft draft, + FlowActionExecutionState existing, IServiceProvider provider, CancellationToken ct) + { var gate = await AdmitDispatchAsync(provider, lease, ct).ConfigureAwait(false); if (!gate.Allowed) { diff --git a/src/NetRatel/NetRatel.Infrastructure/Flows/FlowSourceIdentityPersistence.cs b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowSourceIdentityPersistence.cs new file mode 100644 index 00000000..9913dbc3 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Flows/FlowSourceIdentityPersistence.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.ServiceLinks; + +namespace NetRatel.Infrastructure.Flows; + +public sealed partial class FlowPersistenceService +{ + Task IFlowSourceIdentityResolver.EnsureAsync(CancellationToken ct) => WithDb(async (db, services) => + { + // Production connector setup always requires the real installation/deployment store. + _ = await services.GetRequiredService().GetAsync(ct).ConfigureAwait(false); + await using var tx = await BeginAsync(db, ct).ConfigureAwait(false); + var source = await EnsureSourceInContextAsync(db, ct).ConfigureAwait(false); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + return source; + }); + + private static async Task EnsureSourceInContextAsync(OrchestratorDbContext db, CancellationToken ct) + { + var existing = await db.FlowRuntimeIdentity.AsNoTracking().Where(row => row.Id == 1) + .Select(row => (Guid?)row.SourceInstanceId).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (existing is { } preserved) + return preserved != Guid.Empty ? preserved : throw new InvalidOperationException("flow-source-identity"); + // Keep the actual persisted Flow producer when present. For the first producer only, + // reuse an already explicitly adopted source, otherwise create one durable singleton. + var adopted = await db.Set().AsNoTracking().Where(row => row.Id == 1) + .Select(row => row.SourceInstanceId).SingleOrDefaultAsync(ct).ConfigureAwait(false); + var proposed = adopted ?? Guid.NewGuid(); + if (proposed == Guid.Empty) throw new InvalidOperationException("flow-source-identity"); + if (db.Database.IsNpgsql()) + await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO \"FlowRuntimeIdentity\" (\"Id\", \"SourceInstanceId\") VALUES (1, {proposed}) ON CONFLICT (\"Id\") DO NOTHING", ct).ConfigureAwait(false); + else + { + // Existing isolated in-memory Flow harness seam; never selected by the production host. + db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = proposed }); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + var winner = await db.FlowRuntimeIdentity.AsNoTracking().Where(row => row.Id == 1) + .Select(row => row.SourceInstanceId).SingleAsync(ct).ConfigureAwait(false); + return winner != Guid.Empty ? winner : throw new InvalidOperationException("flow-source-identity"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/NetRatel.Infrastructure.csproj b/src/NetRatel/NetRatel.Infrastructure/NetRatel.Infrastructure.csproj index 8b041d9c..d8f59db1 100644 --- a/src/NetRatel/NetRatel.Infrastructure/NetRatel.Infrastructure.csproj +++ b/src/NetRatel/NetRatel.Infrastructure/NetRatel.Infrastructure.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowPersistenceModel.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowPersistenceModel.cs index b3ebb81b..5d00d1dc 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowPersistenceModel.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowPersistenceModel.cs @@ -38,6 +38,7 @@ public static void Configure(ModelBuilder builder) builder.Entity(entity => { entity.ToTable("FlowActions"); entity.HasKey(row => new { row.RunId, row.NodeId }); + entity.HasAlternateKey(row => new { row.TenantId, row.RunId, row.NodeId }); entity.Property(row => row.IdempotencyKey).HasMaxLength(200).IsRequired(); entity.HasIndex(row => new { row.TenantId, row.IdempotencyKey }).IsUnique(); entity.Property(row => row.DraftJson).HasColumnType("jsonb").IsRequired(); entity.Property(row => row.PreparedJson).HasColumnType("jsonb"); entity.Property(row => row.SemanticFingerprint).HasMaxLength(64); entity.Property(row => row.ReceiptJson).HasColumnType("jsonb"); diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowReceiverEvidenceRecord.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowReceiverEvidenceRecord.cs new file mode 100644 index 00000000..bcecd63c --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/FlowReceiverEvidenceRecord.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; + +namespace NetRatel.Infrastructure.Persistence; + +public sealed class FlowReceiverEvidenceRecord +{ + public int TenantId { get; set; } + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public int SchemaVersion { get; set; } = 2; + public string PreparationJson { get; set; } = ""; + public string EvidenceFingerprint { get; set; } = ""; + public string ReceiverIdempotencyKey { get; set; } = ""; + public string ReceiverFingerprint { get; set; } = ""; + public DateTimeOffset OriginalCreatedAtUtc { get; set; } + public DateTimeOffset AutomaticReplayUntilUtc { get; set; } + // Monotonic until a verified original receipt is durably recorded. + public bool MayHaveCommitted { get; set; } + public DateTimeOffset? FirstPostAttemptAtUtc { get; set; } + public long? LastPostLeaseFence { get; set; } + // A final ambiguous fifth POST receives at most one subsequent automatic read-only turn. + // Consumed durably before lookup; a crash cannot create unbounded reconciliation retries. + public bool FinalReconciliationAttempted { get; set; } + public string? FullReceiptJson { get; set; } + public long RowVersion { get; set; } = 1; +} + +public static class FlowReceiverEvidenceModel +{ + public static void Configure(ModelBuilder builder) + { + builder.Entity(entity => + { + entity.ToTable("FlowReceiverEvidence", table => + { + table.HasCheckConstraint("CK_FlowReceiverEvidence_V2", + """ + "SchemaVersion" = 2 AND "TenantId" > 0 AND "RowVersion" > 0 AND jsonb_typeof("PreparationJson") = 'object' AND "AutomaticReplayUntilUtc" > "OriginalCreatedAtUtc" AND "AutomaticReplayUntilUtc" <= "OriginalCreatedAtUtc" + INTERVAL '24 hours' AND ("LastPostLeaseFence" IS NULL OR "LastPostLeaseFence" > 0) AND (NOT "MayHaveCommitted" OR "FirstPostAttemptAtUtc" IS NOT NULL) AND ("FullReceiptJson" IS NULL OR jsonb_typeof("FullReceiptJson") = 'object') + """); + }); + entity.HasKey(x => new { x.RunId, x.NodeId }); + entity.HasIndex(x => new { x.TenantId, x.RunId, x.NodeId }).IsUnique(); + entity.Property(x => x.PreparationJson).HasColumnType("jsonb").IsRequired(); + entity.Property(x => x.FullReceiptJson).HasColumnType("jsonb"); + entity.Property(x => x.EvidenceFingerprint).HasMaxLength(64).IsRequired(); + entity.Property(x => x.ReceiverFingerprint).HasMaxLength(64).IsRequired(); + entity.Property(x => x.ReceiverIdempotencyKey).HasMaxLength(256).IsRequired(); + entity.Property(x => x.RowVersion).IsConcurrencyToken(); + entity.HasOne().WithOne().HasForeignKey( + x => new { x.TenantId, x.RunId, x.NodeId }).HasPrincipalKey( + x => new { x.TenantId, x.RunId, x.NodeId }).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.Designer.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.Designer.cs new file mode 100644 index 00000000..ba27a83c --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.Designer.cs @@ -0,0 +1,5604 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetRatel.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NetRatel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OrchestratorDbContext))] + [Migration("20261002200000_AddRatelDeskConnectors")] + partial class AddRatelDeskConnectors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedScopesJson") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeviceInfoJson") + .HasColumnType("text"); + + b.Property("DisabledReason") + .HasColumnType("text"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("KeyAlgorithm") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("ecdsa-p256"); + + b.Property("KeyRegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastTokenIssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MtlsThumbprint") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("PublicKeyFingerprint") + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("SupersededAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupersededByAgentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DeviceInfoJson") + .HasDatabaseName("IX_Agents_GlobalSearch_DeviceInfoJson_trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DeviceInfoJson"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DeviceInfoJson"), new[] { "gin_trgm_ops" }); + + b.HasIndex("Name") + .HasDatabaseName("IX_Agents_GlobalSearch_Name_trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("Status"); + + b.HasIndex("SupersededByAgentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "IsEnabled"); + + b.HasIndex("TenantId", "PublicKeyFingerprint") + .IsUnique() + .HasFilter("\"PublicKeyFingerprint\" IS NOT NULL AND \"SupersededAtUtc\" IS NULL"); + + b.ToTable("Agents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", b => + { + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("PolicyRevision") + .HasColumnType("bigint"); + + b.Property("ResumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResumedBy") + .HasColumnType("text"); + + b.Property("SuppressedReleaseId") + .HasColumnType("uuid"); + + b.Property("SuspendedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SuspensionAttemptId") + .HasColumnType("uuid"); + + b.Property("SuspensionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("AgentId"); + + b.HasIndex("TenantId", "SuspendedAtUtc"); + + b.ToTable("AgentClientUpdateStates", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.ToTable("AgentCredentials", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentNonceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("AgentId", "Nonce") + .IsUnique(); + + b.ToTable("AgentNonceLogs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("AgentId", "CreatedAtUtc"); + + b.ToTable("AgentRefreshTokens", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentTokenEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("AgentTokenEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.BootstrapInitializationRecord", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AdministratorUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("BootstrapInstanceId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BootstrapInstanceId") + .IsUnique(); + + b.ToTable("BootstrapInitializations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientConnectionEpochRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("LastIssuedEpoch") + .HasColumnType("bigint"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("ClientConnectionEpochs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientInstallGrant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArtifactSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ArtifactVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EffectiveGatewayBaseUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("EnrollmentCodeId") + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewaySource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("InstallAsService") + .HasColumnType("boolean"); + + b.Property("MaxUses") + .HasColumnType("integer"); + + b.Property("ProtectedScript") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProtectedToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicApiBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PublicApiSource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublicWebBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PublicWebSource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SilentInstall") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("RequestKey") + .IsUnique(); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.ToTable("ClientInstallGrants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseAutomationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CheckEveryHours") + .HasColumnType("integer"); + + b.Property("DeployPrereleaseAutomatically") + .HasColumnType("boolean"); + + b.Property("DownloadPrerelease") + .HasColumnType("boolean"); + + b.Property("DownloadStable") + .HasColumnType("boolean"); + + b.Property("LastAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastSuccessAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasColumnType("uuid"); + + b.Property("LeaseUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextCheckAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishAutomatically") + .HasColumnType("boolean"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("ClientReleaseAutomationSettings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportAsset", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("RuntimeId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConversionContract") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("GitHubAssetId") + .HasColumnType("bigint"); + + b.Property("LocalSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LocalSizeBytes") + .HasColumnType("bigint"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceSizeBytes") + .HasColumnType("bigint"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId", "RuntimeId"); + + b.ToTable("ClientReleaseImportAssets", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("AutomaticPublishAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AutomaticPublishError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("BuildCommit") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("GitHubReleaseId") + .HasColumnType("bigint"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutomatic") + .HasColumnType("boolean"); + + b.Property("LeaseGeneration") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LeaseOwner") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("LeaseUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublicationSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceRepository") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TotalBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubReleaseId") + .IsUnique(); + + b.HasIndex("Version"); + + b.HasIndex("State", "LeaseUntilUtc", "CreatedAtUtc"); + + b.ToTable("ClientReleaseImportOperations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientServicesSnapshotRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("LastAcceptedSequence") + .HasColumnType("numeric(20,0)"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StateJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("ClientServicesSnapshots", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateAttemptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmissionNonceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ConfirmationId") + .HasColumnType("uuid"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FromVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("GatewayConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("GatewayConnectionId") + .HasColumnType("uuid"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("uuid"); + + b.Property("ReadmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseId") + .HasColumnType("integer"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("TargetVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ReleaseId"); + + b.HasIndex("AgentId", "ReleaseId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "UpdatedAtUtc"); + + b.ToTable("ClientUpdateAttempts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateCatalogRevision", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ClientUpdateCatalogRevision", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtifactKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ManifestJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublicId") + .HasColumnType("uuid"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .HasColumnType("text"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("Revision") + .IsUnique(); + + b.HasIndex("RuntimeId", "Version") + .IsUnique(); + + b.HasIndex("Enabled", "RuntimeId", "Channel", "PublishedAtUtc"); + + b.ToTable("ClientUpdateReleases", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientWindowsSessionInventoryRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Completed") + .HasColumnType("boolean"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshRequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RefreshRequestId") + .IsUnique(); + + b.HasIndex("ClientIdentity", "Completed"); + + b.HasIndex("TenantId", "ClientIdentity", "RequestedAtUtc"); + + b.ToTable("ClientWindowsSessionInventoryRefreshes", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientWindowsSessionSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayLabel") + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HelperConnected") + .HasColumnType("boolean"); + + b.Property("HelperLaunchable") + .HasColumnType("boolean"); + + b.Property("HelperPid") + .HasColumnType("integer"); + + b.Property("HelperRepairable") + .HasColumnType("boolean"); + + b.Property("HelperVersion") + .HasColumnType("text"); + + b.Property("HelperVersionMatches") + .HasColumnType("boolean"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAssistable") + .HasColumnType("boolean"); + + b.Property("IsConnected") + .HasColumnType("boolean"); + + b.Property("IsConsoleSession") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("IsWinlogon") + .HasColumnType("boolean"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("SessionType") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stale") + .HasColumnType("boolean"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSidHash") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.Property("WindowsSessionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("ClientIdentity", "InventorySequence"); + + b.HasIndex("ClientIdentity", "WindowsSessionId") + .IsUnique(); + + b.HasIndex("TenantId", "ClientIdentity"); + + b.ToTable("ClientWindowsSessionSnapshots", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandInboxReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DuplicateCount") + .HasColumnType("bigint"); + + b.Property("FirstReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Sequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("FirstReceivedAtUtc"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TenantId", "CommandId", "Version", "Sequence") + .IsUnique() + .HasDatabaseName("UX_CommandInbox_Idempotency"); + + b.ToTable("CommandInboxReceipts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandIntentEventRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Sequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TenantId", "CommandId", "Version", "Sequence") + .IsUnique() + .HasDatabaseName("UX_CommandIntentHistory_Order"); + + b.ToTable("CommandIntentEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandOutboxRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStatus") + .HasColumnType("smallint"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("LastAcceptedSequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("LastAcceptedVersion") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("LastObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ObservedDispatchCount") + .HasColumnType("integer"); + + b.Property("RequestTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CommandId") + .IsUnique() + .HasDatabaseName("UX_CommandOutbox_Command"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TerminalAtUtc", "CreatedAtUtc"); + + b.ToTable("CommandOutbox", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpFileArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MarkerOwned") + .HasColumnType("boolean"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "ExpiresAtUtc"); + + b.ToTable("DevelopmentMcpFileArtifacts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpMarkerJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Marker") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("DevelopmentMcpMarkerJobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpScriptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("standard"); + + b.Property("Marker") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Shell") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("DevelopmentMcpScripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorAcceptedAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Operation") + .HasColumnType("smallint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TargetGrantId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("DevelopmentOperatorAcceptedAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AllowedOperations") + .HasColumnType("integer"); + + b.Property("Classification") + .HasColumnType("smallint"); + + b.Property("EvidenceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileFixtureRoot") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("GrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrantedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevocationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "RevokedAtUtc") + .IsUnique() + .HasFilter("\"RevokedAtUtc\" IS NULL"); + + b.HasIndex("TenantId", "AgentId", "RevokedAtUtc", "ExpiresAtUtc"); + + b.ToTable("DevelopmentOperatorTargetGrants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DevelopmentMcpMarker") + .HasColumnType("text"); + + b.Property("DevelopmentMcpTargetAgentId") + .HasColumnType("uuid"); + + b.Property("MaxUses") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Uses") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ValidFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidToUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.HasIndex("TenantId", "ValidToUtc"); + + b.HasIndex("TenantId", "DevelopmentMcpTargetAgentId", "DevelopmentMcpMarker"); + + b.ToTable("EnrollmentCodes", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowActionRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConnectorRevision") + .HasColumnType("bigint"); + + b.Property("DraftJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LeaseFence") + .HasColumnType("bigint"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreparedJson") + .HasColumnType("jsonb"); + + b.Property("ReceiptJson") + .HasColumnType("jsonb"); + + b.Property("SemanticFingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("RunId", "NodeId"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("TenantId", "RunId"); + + b.ToTable("FlowActions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowId", "AtUtc"); + + b.ToTable("FlowAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DraftJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersionId") + .HasColumnType("uuid"); + + b.Property("PublishedVersionNumber") + .HasColumnType("integer"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UpdatedAtUtc"); + + b.ToTable("FlowDefinitions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("EventJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Fence") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("FlowVersionId") + .HasColumnType("uuid"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasColumnType("uuid"); + + b.Property("LeaseToken") + .HasColumnType("uuid"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowVersionId"); + + b.HasIndex("Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + + b.HasIndex("TenantId", "EventId", "FlowVersionId") + .IsUnique(); + + b.HasIndex("TenantId", "FlowId", "CreatedAtUtc"); + + b.ToTable("FlowRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRuntimeIdentityRecord", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("SourceInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FlowRuntimeIdentity", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowVersionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigurationHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("GraphJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowId", "VersionNumber") + .IsUnique(); + + b.ToTable("FlowVersions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FolderPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("TenantId"); + + b.HasIndex("FolderPath", "Name"); + + b.HasIndex("TenantId", "AgentId"); + + b.ToTable("Jobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobParameterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DefaultValue") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobParameters", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStepOrdinal") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InputsJson") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("JobId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("JobRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobShadowObservationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandCorrelationStatus") + .HasColumnType("smallint"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelatedCommandStatus") + .HasColumnType("smallint"); + + b.Property("CurrentStepOrdinal") + .HasColumnType("integer"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("JobId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobRunId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobStepId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobStepRunId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("Kind") + .HasColumnType("smallint"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RunCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RunStatus") + .HasColumnType("smallint"); + + b.Property("SourceEventId") + .HasColumnType("bigint"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StepOrdinal") + .HasColumnType("integer"); + + b.Property("StepStatus") + .HasColumnType("smallint"); + + b.Property("TaskRequestId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc"); + + b.HasIndex("JobRunId", "SourceEventId"); + + b.HasIndex("SourceSystem", "SourceEventId") + .IsUnique() + .HasDatabaseName("UX_JobShadowObservation_Source"); + + b.HasIndex("TenantId", "TaskRequestId"); + + b.ToTable("JobShadowObservations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Command") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Ordinal") + .HasColumnType("integer"); + + b.Property("PayloadJson") + .HasColumnType("text"); + + b.Property("Runner") + .HasColumnType("text"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("JobId", "Ordinal"); + + b.ToTable("JobSteps", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepRunRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobRunId") + .HasColumnType("bigint"); + + b.Property("JobStepId") + .HasColumnType("bigint"); + + b.Property("Ordinal") + .HasColumnType("integer"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TaskRequestId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobRunId"); + + b.HasIndex("JobStepId"); + + b.HasIndex("JobRunId", "Ordinal"); + + b.ToTable("JobStepRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobRunId") + .HasColumnType("bigint"); + + b.Property("JobStepId") + .HasColumnType("bigint"); + + b.Property("RequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResultJson") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("JobRunId"); + + b.HasIndex("JobStepId"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("JobTaskActivities", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobTaskActivityId") + .HasColumnType("bigint"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sequence") + .HasColumnType("bigint"); + + b.Property("Stream") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("JobTaskActivityId"); + + b.HasIndex("RequestId"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("RequestId", "Sequence", "Stream") + .IsUnique(); + + b.ToTable("JobTaskLogs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.M2MConnectivitySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RemoteAudience") + .HasColumnType("text"); + + b.Property("RemoteBaseUrl") + .HasColumnType("text"); + + b.Property("RemoteSystemName") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("M2MConnectivitySettings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuthorizedParty") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("GroupsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("McpInstance") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScopesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ServicePrincipal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Tool") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId", "OccurredAtUtc"); + + b.HasIndex("RequestId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("McpOperatorAcceptedAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandLength") + .HasColumnType("integer"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EnvironmentReferencesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MaximumOutputBytes") + .HasColumnType("integer"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("CommandId") + .IsUnique(); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("PolicyId"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "State"); + + b.ToTable("McpOperatorCommands", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorConfirmationPlanRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConfirmationClass") + .HasColumnType("smallint"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedIdempotencyId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("McpInstance") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc", "ConsumedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("McpOperatorConfirmationPlans", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorFileArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReadRootFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "ExpiresAtUtc"); + + b.ToTable("McpOperatorFileArtifacts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("Outcome") + .HasColumnType("smallint"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ResultReference") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.HasIndex("Environment", "Subject", "ClientId", "TenantId", "OperationFamily", "Operation", "IdempotencyKey") + .IsUnique(); + + b.ToTable("McpOperatorIdempotencyRecords", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("JobRecordId") + .HasColumnType("uuid"); + + b.Property("JobVersion") + .HasColumnType("bigint"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("JobId", "OccurredAtUtc"); + + b.HasIndex("JobRecordId", "JobVersion") + .IsUnique(); + + b.ToTable("McpOperatorJobAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + + b.ToTable("McpOperatorJobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("JobRunRecordId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("JobRunRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorJobRunAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("JobRecordId") + .HasColumnType("uuid"); + + b.Property("JobRunId") + .HasPrecision(20) + .HasColumnType("bigint"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("JobRecordId"); + + b.HasIndex("JobRunId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("McpOperatorJobRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorPolicyChangeAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "OccurredAtUtc"); + + b.HasIndex("PolicyId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "OccurredAtUtc"); + + b.ToTable("McpOperatorPolicyChangeAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuditReference") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientTag") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Effect") + .HasColumnType("smallint"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LifecycleState") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)1); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Operation") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PrincipalSelectorKind") + .HasColumnType("smallint"); + + b.Property("PrincipalSelectorValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ReviewByUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetClassification") + .HasColumnType("smallint"); + + b.Property("TargetSelectorKind") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Environment", "PrincipalSelectorKind", "PrincipalSelectorValue"); + + b.HasIndex("Environment", "TargetSelectorKind", "TenantId", "AgentId"); + + b.HasIndex("Environment", "TenantId", "DisabledAtUtc", "ExpiresAtUtc", "Effect", "Priority"); + + b.ToTable("McpOperatorPolicies", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestRecordId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("RequestRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorRequestAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClaimReferenceHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("RequestId") + .HasColumnType("integer"); + + b.Property("ResultSummary") + .HasMaxLength(49152) + .HasColumnType("character varying(49152)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("JobId", "UpdatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + + b.ToTable("McpOperatorRequests", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeclaredSideEffectsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ManifestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ParametersJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TenantId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + + b.ToTable("McpOperatorScripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptVersionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeclaredSideEffectsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ManifestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParametersJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ScriptRecordId") + .HasColumnType("uuid"); + + b.Property("ScriptVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("ScriptId", "OccurredAtUtc"); + + b.HasIndex("ScriptRecordId", "ScriptVersion") + .IsUnique(); + + b.ToTable("McpOperatorScriptVersions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTargetProfileRecord", b => + { + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Classification") + .HasColumnType("smallint"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("AgentId"); + + b.HasIndex("TenantId", "Classification"); + + b.ToTable("McpOperatorTargetProfiles", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskRecordId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("TaskRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorTaskAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandLength") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("MaximumOutputBytes") + .HasColumnType("integer"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ResultSummary") + .HasMaxLength(49152) + .HasColumnType("character varying(49152)"); + + b.Property("ScriptContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ScriptVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TaskActivityId") + .HasColumnType("bigint"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("CommandId") + .IsUnique(); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("TaskActivityId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + + b.ToTable("McpOperatorTasks", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DelegationRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Outcome") + .HasColumnType("smallint"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResultReference") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SessionRecordId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("SessionRecordId", "CreatedAtUtc"); + + b.HasIndex("SessionRecordId", "Operation", "DelegationRequestId") + .IsUnique(); + + b.ToTable("McpOperatorTerminalActions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SessionRecordId") + .HasColumnType("uuid"); + + b.Property("State") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("SessionRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorTerminalSessionAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CloseReason") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CloseRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Columns") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Generation") + .HasColumnType("numeric(20,0)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("IdleExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastActivityAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Rows") + .HasColumnType("integer"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("PolicyId"); + + b.HasIndex("SessionId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "State", "ExpiresAtUtc"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "State"); + + b.ToTable("McpOperatorTerminalSessions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringAuditRecord", b => + { + b.Property("AuditId") + .HasColumnType("uuid"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigurationRevision") + .HasColumnType("numeric(20,0)"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("TenantId", "AtUtc"); + + b.ToTable("MonitoringAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringBypassRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("BypassId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "BypassId"); + + b.HasIndex("TenantId", "ExpiresAtUtc"); + + b.ToTable("MonitoringBypasses", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("EventId"); + + b.HasIndex("OccurrenceId"); + + b.HasIndex("TenantId", "AtUtc", "EventId"); + + b.ToTable("MonitoringEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEvidenceStreamRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("EvidenceStreamId") + .HasColumnType("uuid"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("MonitoringEvidenceStreams", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringFlowOutboxRecord", b => + { + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("FlowRunId") + .HasColumnType("uuid"); + + b.Property("HandedOffAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseFence") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LeaseId") + .HasColumnType("uuid"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("OutcomeJson") + .HasColumnType("jsonb"); + + b.Property("StableFlowDispatchKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("WorkerId") + .HasColumnType("uuid"); + + b.HasKey("OutboxId"); + + b.HasIndex("EventId"); + + b.HasIndex("FlowRunId") + .IsUnique() + .HasFilter("\"FlowRunId\" IS NOT NULL"); + + b.HasIndex("StableFlowDispatchKey") + .IsUnique(); + + b.HasIndex("TenantId", "Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + + b.ToTable("MonitoringFlowOutbox", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringGroupRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "GroupId"); + + b.ToTable("MonitoringGroups", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringOccurrenceRecord", b => + { + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RaisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaisedEventId") + .HasColumnType("uuid"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("OccurrenceId"); + + b.HasIndex("RaisedEventId") + .IsUnique(); + + b.HasIndex("TenantId", "EndedAtUtc"); + + b.HasIndex("TenantId", "RuleId", "AgentId", "ResourceKey") + .IsUnique() + .HasDatabaseName("UX_MonitoringOccurrence_OpenSeries") + .HasFilter("\"EndedAtUtc\" IS NULL"); + + b.ToTable("MonitoringOccurrences", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringRuleRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "RuleId"); + + b.ToTable("MonitoringRules", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringSeriesRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ResourceKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Acknowledged") + .HasColumnType("boolean"); + + b.Property("ActiveOccurrenceId") + .HasColumnType("uuid"); + + b.Property("EvidenceQuality") + .HasColumnType("smallint"); + + b.Property("LatestOccurrenceId") + .HasColumnType("uuid"); + + b.Property("Phase") + .HasColumnType("smallint"); + + b.Property("StateJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StateRevision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.Property("Suppressed") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "RuleId", "AgentId", "ResourceKey"); + + b.HasIndex("TenantId", "AgentId"); + + b.ToTable("MonitoringSeries", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringTenantConfigurationRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId"); + + b.ToTable("MonitoringTenantConfigurations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OidcSigningKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivateKeyPem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyId") + .IsUnique(); + + b.ToTable("OidcSigningKeys", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LockOwner") + .HasColumnType("text"); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("NextAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("EntityId"); + + b.HasIndex("OccurredUtc"); + + b.HasIndex("Type"); + + b.HasIndex("Status", "NextAttemptUtc", "LockedUntilUtc"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxProcessedEvent", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("ConsumerName") + .HasColumnType("text"); + + b.Property("ProcessedUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId", "ConsumerName"); + + b.HasIndex("ProcessedUtc"); + + b.ToTable("OutboxProcessedEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxReadReceipt", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ReadUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId", "UserId"); + + b.HasIndex("UserId", "ReadUtc"); + + b.ToTable("OutboxReadReceipts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.PrimaryClientAgentBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("BindingSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("BoundAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("BoundBy") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnrollmentCodeId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PrimaryClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AgentId") + .IsUnique() + .HasFilter("\"AgentId\" IS NOT NULL AND \"Status\" <> 3"); + + b.HasIndex("EnrollmentCodeId") + .IsUnique(); + + b.HasIndex("TenantId", "PrimaryClientIdentity") + .IsUnique() + .HasFilter("\"Status\" <> 3"); + + b.HasIndex("TenantId", "Status", "CreatedAtUtc"); + + b.ToTable("PrimaryClientAgentBindings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RatelDeskConnectorRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CredentialRevision") + .HasColumnType("bigint"); + + b.Property("OwnerPrincipalId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProtectedCredential") + .HasColumnType("text"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("TenantId", "Id"); + + b.ToTable("RatelDeskConnectors", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportAuditEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuditSequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FailureCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LifecycleRevision") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RemoteSupportSessionId") + .HasColumnType("uuid"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RemoteSupportSessionId", "AuditSequence") + .IsUnique(); + + b.HasIndex("RemoteSupportSessionId", "RequestId"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("RemoteSupportAuditEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportSessionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ContractVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrantedCapabilitiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitiatingOperatorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifecycleRevision") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("OpenRequestId") + .HasColumnType("uuid"); + + b.Property("RequestedCapabilitiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetInventorySequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetUserSidHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetWindowsSessionId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TerminalReasonCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OpenRequestId") + .IsUnique(); + + b.HasIndex("TenantId", "InitiatingOperatorId", "UpdatedAtUtc"); + + b.ToTable("RemoteSupportSessions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportTargetSelectionEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("FailureReason") + .HasColumnType("text"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Result") + .IsRequired() + .HasColumnType("text"); + + b.Property("SelectedProvider") + .HasColumnType("text"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("TargetDisplayLabel") + .HasColumnType("text"); + + b.Property("TargetMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserSidHash") + .HasColumnType("text"); + + b.Property("TargetWindowsSessionId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "ClientIdentity", "RequestedAtUtc"); + + b.ToTable("RemoteSupportTargetSelectionEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionId") + .HasColumnType("text") + .HasColumnName("RundeckExecutionId"); + + b.Property("JobDefinitionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("RundeckJobDefinitionId"); + + b.Property("JobInputs") + .HasColumnType("text"); + + b.PrimitiveCollection>("Logs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SourceSystem") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentId") + .HasColumnType("uuid"); + + b.Property("TargetClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetTenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("Status"); + + b.HasIndex("TargetClientIdentity"); + + b.HasIndex("UpdatedAtUtc"); + + b.HasIndex("TargetTenantId", "TargetAgentId"); + + b.ToTable("Requests", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("FolderPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("ManifestJson") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScriptType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceRevision") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("FolderPath", "Name"); + + b.ToTable("Scripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptParameterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Default") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScriptId"); + + b.ToTable("ScriptParameters", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.SecretRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientIdentity") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("TenantId"); + + b.ToTable("Secrets", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoUpdate") + .HasColumnType("boolean"); + + b.Property("AutoUpdateChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("AutoUpdateTargetVersion") + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.PrimitiveCollection>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("SupersededByAgentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", "AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentCredential", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany("Credentials") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentRefreshToken", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany("RefreshTokens") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientInstallGrant", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportAsset", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", "Operation") + .WithMany("Assets") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateAttemptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", "Release") + .WithMany("Attempts") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpFileArtifactRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpMarkerJobRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpScriptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorAcceptedAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowActionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowRunRecord", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowVersionRecord", null) + .WithMany() + .HasForeignKey("TenantId", "FlowVersionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowVersionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowDefinitionRecord", null) + .WithMany() + .HasForeignKey("TenantId", "FlowId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobParameterDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany("Parameters") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany("Steps") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobRunRecord", "JobRun") + .WithMany("Steps") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobStepDefinition", "JobStep") + .WithMany() + .HasForeignKey("JobStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobRun"); + + b.Navigation("JobStep"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobRunRecord", "JobRun") + .WithMany("Activities") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobStepDefinition", "JobStep") + .WithMany() + .HasForeignKey("JobStepId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("JobRun"); + + b.Navigation("JobStep"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskLogRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", "Activity") + .WithMany("Logs") + .HasForeignKey("JobTaskActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Activity"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorCommandRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorFileArtifactRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", null) + .WithMany() + .HasForeignKey("JobRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", null) + .WithMany() + .HasForeignKey("JobRunRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", null) + .WithMany() + .HasForeignKey("JobRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", null) + .WithMany() + .HasForeignKey("RequestRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.RequestRecord", null) + .WithMany() + .HasForeignKey("RequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.ScriptDefinition", null) + .WithMany() + .HasForeignKey("ScriptId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptVersionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", null) + .WithMany() + .HasForeignKey("ScriptRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTargetProfileRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", null) + .WithMany() + .HasForeignKey("TaskRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", null) + .WithMany() + .HasForeignKey("TaskActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalActionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", null) + .WithMany() + .HasForeignKey("SessionRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", null) + .WithMany() + .HasForeignKey("SessionRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.MonitoringOccurrenceRecord", null) + .WithMany() + .HasForeignKey("OccurrenceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringFlowOutboxRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.PrimaryClientAgentBinding", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("NetRatel.Infrastructure.Persistence.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Agent"); + + b.Navigation("EnrollmentCode"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportAuditEventRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.RemoteSupportSessionRecord", null) + .WithMany() + .HasForeignKey("RemoteSupportSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptParameterDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.ScriptDefinition", "Script") + .WithMany("Parameters") + .HasForeignKey("ScriptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Script"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.Navigation("Credentials"); + + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobDefinition", b => + { + b.Navigation("Parameters"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.Navigation("Activities"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptDefinition", b => + { + b.Navigation("Parameters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.cs new file mode 100644 index 00000000..6a55c293 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261002200000_AddRatelDeskConnectors.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetRatel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRatelDeskConnectors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RatelDeskConnectors", + columns: table => new + { + TenantId = table.Column(type: "integer", nullable: false), + Id = table.Column(type: "uuid", nullable: false), + Revision = table.Column(type: "bigint", nullable: false), + RowVersion = table.Column(type: "bigint", nullable: false), + OwnerPrincipalId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ConfigurationJson = table.Column(type: "jsonb", nullable: false), + ProtectedCredential = table.Column(type: "text", nullable: true), + CredentialRevision = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RatelDeskConnectors", x => new { x.TenantId, x.Id }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RatelDeskConnectors"); + } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.Designer.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.Designer.cs new file mode 100644 index 00000000..3c6f52b3 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.Designer.cs @@ -0,0 +1,6731 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetRatel.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NetRatel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OrchestratorDbContext))] + [Migration("20261006195456_AddFlowReceiverEvidence")] + partial class AddFlowReceiverEvidence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedScopesJson") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("text"); + + b.Property("DeviceInfoJson") + .HasColumnType("text"); + + b.Property("DisabledReason") + .HasColumnType("text"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("KeyAlgorithm") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("ecdsa-p256"); + + b.Property("KeyRegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastTokenIssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MtlsThumbprint") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("PublicKeyFingerprint") + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("SupersededAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupersededByAgentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DeviceInfoJson") + .HasDatabaseName("IX_Agents_GlobalSearch_DeviceInfoJson_trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DeviceInfoJson"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DeviceInfoJson"), new[] { "gin_trgm_ops" }); + + b.HasIndex("Name") + .HasDatabaseName("IX_Agents_GlobalSearch_Name_trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("Status"); + + b.HasIndex("SupersededByAgentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "IsEnabled"); + + b.HasIndex("TenantId", "PublicKeyFingerprint") + .IsUnique() + .HasFilter("\"PublicKeyFingerprint\" IS NOT NULL AND \"SupersededAtUtc\" IS NULL"); + + b.ToTable("Agents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", b => + { + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("PolicyRevision") + .HasColumnType("bigint"); + + b.Property("ResumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResumedBy") + .HasColumnType("text"); + + b.Property("SuppressedReleaseId") + .HasColumnType("uuid"); + + b.Property("SuspendedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SuspensionAttemptId") + .HasColumnType("uuid"); + + b.Property("SuspensionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("AgentId"); + + b.HasIndex("TenantId", "SuspendedAtUtc"); + + b.ToTable("AgentClientUpdateStates", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.ToTable("AgentCredentials", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentNonceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("AgentId", "Nonce") + .IsUnique(); + + b.ToTable("AgentNonceLogs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("AgentId", "CreatedAtUtc"); + + b.ToTable("AgentRefreshTokens", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentTokenEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("AgentTokenEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.BootstrapInitializationRecord", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AdministratorUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("BootstrapInstanceId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BootstrapInstanceId") + .IsUnique(); + + b.ToTable("BootstrapInitializations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientConnectionAdmissionRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("AdmissionExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthenticationExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PayloadHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetainUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("TenantId", "AgentId", "ConnectionId"); + + b.HasIndex("RetainUntilUtc", "TenantId", "AgentId"); + + b.HasIndex("TenantId", "AgentId", "ConnectionEpoch") + .IsUnique() + .HasDatabaseName("UX_ClientConnectionAdmissions_Epoch") + .HasFilter("\"ConnectionEpoch\" IS NOT NULL"); + + b.ToTable("ClientConnectionAdmissions", null, t => + { + t.HasCheckConstraint("CK_ClientConnectionAdmissions_Epoch", "\"ConnectionEpoch\" IS NULL OR \"ConnectionEpoch\">0"); + + t.HasCheckConstraint("CK_ClientConnectionAdmissions_Identity", "\"TenantId\" > 0 AND \"AgentId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"ConnectionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_ClientConnectionAdmissions_Pending", "\"Status\" = 2 OR ( \"ConnectionEpoch\" IS NOT NULL AND \"ConnectionEpoch\">0 AND \"OperationId\" IS NOT NULL AND \"OperationId\"<>'00000000-0000-0000-0000-000000000000'::uuid AND \"PayloadHash\" IS NOT NULL AND \"ReceivedAtUtc\" IS NOT NULL AND \"AdmissionExpiresAtUtc\" IS NOT NULL AND \"AuthenticationExpiresAtUtc\" IS NOT NULL AND \"AdmissionExpiresAtUtc\">\"ReceivedAtUtc\" AND \"AuthenticationExpiresAtUtc\">\"ReceivedAtUtc\" AND \"MetadataJson\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ClientConnectionAdmissions_Status", "\"Status\" IN (1,2,3)"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientConnectionEpochRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CancellationBarrierUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastIssuedEpoch") + .HasColumnType("bigint"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("ClientConnectionEpochs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientConnectionOwnerRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AcceptanceClockFloorUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptanceGuardAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Active") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("AdmissionPayloadHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthenticationExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConnectionEpoch") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("LastHeartbeatSequence") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasDefaultValue(0m); + + b.Property("LastReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OwnerRevision") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PresenceExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartOperationId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("ClientConnectionOwners", null, t => + { + t.HasCheckConstraint("CK_ClientConnectionOwners_Active", "\"AcceptanceGuardAtUtc\" IS NULL OR NOT \"Active\" OR ( \"ConnectionEpoch\" > 0 AND \"ConnectionId\" IS NOT NULL AND \"ConnectionId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"StartOperationId\" IS NOT NULL AND \"AdmissionPayloadHash\" IS NOT NULL AND \"LastReceivedAtUtc\" IS NOT NULL AND \"PresenceExpiresAtUtc\" IS NOT NULL AND \"AuthenticationExpiresAtUtc\" IS NOT NULL AND \"LastHeartbeatSequence\" > 0)"); + + t.HasCheckConstraint("CK_ClientConnectionOwners_CommittedShape", "(\"ConnectionEpoch\"=0 AND \"ConnectionId\" IS NULL) OR (\"ConnectionEpoch\">0 AND \"ConnectionId\" IS NOT NULL AND \"StartOperationId\" IS NOT NULL AND \"AdmissionPayloadHash\" IS NOT NULL AND \"LastReceivedAtUtc\" IS NOT NULL AND \"PresenceExpiresAtUtc\" IS NOT NULL AND \"AuthenticationExpiresAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_ClientConnectionOwners_Epoch", "\"ConnectionEpoch\" >= 0 AND \"OwnerRevision\" >= 0"); + + t.HasCheckConstraint("CK_ClientConnectionOwners_Identity", "\"TenantId\" > 0 AND \"AgentId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_ClientConnectionOwners_Sequence", "\"LastHeartbeatSequence\" BETWEEN 0 AND 18446744073709551615"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientInstallGrant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArtifactSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ArtifactVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EffectiveGatewayBaseUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("EnrollmentCodeId") + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewaySource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("InstallAsService") + .HasColumnType("boolean"); + + b.Property("MaxUses") + .HasColumnType("integer"); + + b.Property("ProtectedScript") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProtectedToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicApiBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PublicApiSource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublicWebBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PublicWebSource") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SilentInstall") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("RequestKey") + .IsUnique(); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.ToTable("ClientInstallGrants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseAutomationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CheckEveryHours") + .HasColumnType("integer"); + + b.Property("DeployPrereleaseAutomatically") + .HasColumnType("boolean"); + + b.Property("DownloadPrerelease") + .HasColumnType("boolean"); + + b.Property("DownloadStable") + .HasColumnType("boolean"); + + b.Property("LastAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastSuccessAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasColumnType("uuid"); + + b.Property("LeaseUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextCheckAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishAutomatically") + .HasColumnType("boolean"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("ClientReleaseAutomationSettings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportAsset", b => + { + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("RuntimeId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConversionContract") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("GitHubAssetId") + .HasColumnType("bigint"); + + b.Property("LocalSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LocalSizeBytes") + .HasColumnType("bigint"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceSizeBytes") + .HasColumnType("bigint"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OperationId", "RuntimeId"); + + b.ToTable("ClientReleaseImportAssets", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("AutomaticPublishAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AutomaticPublishError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("BuildCommit") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("GitHubReleaseId") + .HasColumnType("bigint"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutomatic") + .HasColumnType("boolean"); + + b.Property("LeaseGeneration") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LeaseOwner") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("LeaseUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublicationSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceRepository") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TotalBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubReleaseId") + .IsUnique(); + + b.HasIndex("Version"); + + b.HasIndex("State", "LeaseUntilUtc", "CreatedAtUtc"); + + b.ToTable("ClientReleaseImportOperations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientServicesSnapshotRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("LastAcceptedSequence") + .HasColumnType("numeric(20,0)"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StateJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("ClientServicesSnapshots", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateAttemptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmissionNonceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ConfirmationId") + .HasColumnType("uuid"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FromVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("GatewayConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("GatewayConnectionId") + .HasColumnType("uuid"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("uuid"); + + b.Property("ReadmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseId") + .HasColumnType("integer"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("TargetVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ReleaseId"); + + b.HasIndex("AgentId", "ReleaseId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "UpdatedAtUtc"); + + b.ToTable("ClientUpdateAttempts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateCatalogRevision", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ClientUpdateCatalogRevision", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtifactKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ManifestJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublicId") + .HasColumnType("uuid"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .HasColumnType("text"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("Revision") + .IsUnique(); + + b.HasIndex("RuntimeId", "Version") + .IsUnique(); + + b.HasIndex("Enabled", "RuntimeId", "Channel", "PublishedAtUtc"); + + b.ToTable("ClientUpdateReleases", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientWindowsSessionInventoryRefresh", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Completed") + .HasColumnType("boolean"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshRequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RefreshRequestId") + .IsUnique(); + + b.HasIndex("ClientIdentity", "Completed"); + + b.HasIndex("TenantId", "ClientIdentity", "RequestedAtUtc"); + + b.ToTable("ClientWindowsSessionInventoryRefreshes", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientWindowsSessionSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayLabel") + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HelperConnected") + .HasColumnType("boolean"); + + b.Property("HelperLaunchable") + .HasColumnType("boolean"); + + b.Property("HelperPid") + .HasColumnType("integer"); + + b.Property("HelperRepairable") + .HasColumnType("boolean"); + + b.Property("HelperVersion") + .HasColumnType("text"); + + b.Property("HelperVersionMatches") + .HasColumnType("boolean"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAssistable") + .HasColumnType("boolean"); + + b.Property("IsConnected") + .HasColumnType("boolean"); + + b.Property("IsConsoleSession") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("IsWinlogon") + .HasColumnType("boolean"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("SessionType") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stale") + .HasColumnType("boolean"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSidHash") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.Property("WindowsSessionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("ClientIdentity", "InventorySequence"); + + b.HasIndex("ClientIdentity", "WindowsSessionId") + .IsUnique(); + + b.HasIndex("TenantId", "ClientIdentity"); + + b.ToTable("ClientWindowsSessionSnapshots", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandInboxReceipt", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DuplicateCount") + .HasColumnType("bigint"); + + b.Property("FirstReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Sequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("FirstReceivedAtUtc"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TenantId", "CommandId", "Version", "Sequence") + .IsUnique() + .HasDatabaseName("UX_CommandInbox_Idempotency"); + + b.ToTable("CommandInboxReceipts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandIntentEventRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Sequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("StatusTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TenantId", "CommandId", "Version", "Sequence") + .IsUnique() + .HasDatabaseName("UX_CommandIntentHistory_Order"); + + b.ToTable("CommandIntentEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.CommandOutboxRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStatus") + .HasColumnType("smallint"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("LastAcceptedSequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("LastAcceptedVersion") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("LastObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ObservedDispatchCount") + .HasColumnType("integer"); + + b.Property("RequestTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CommandId") + .IsUnique() + .HasDatabaseName("UX_CommandOutbox_Command"); + + b.HasIndex("TenantId", "CorrelationId"); + + b.HasIndex("TerminalAtUtc", "CreatedAtUtc"); + + b.ToTable("CommandOutbox", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpFileArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MarkerOwned") + .HasColumnType("boolean"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "ExpiresAtUtc"); + + b.ToTable("DevelopmentMcpFileArtifacts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpMarkerJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Marker") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("DevelopmentMcpMarkerJobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpScriptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("standard"); + + b.Property("Marker") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Shell") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TargetGrantId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("DevelopmentMcpScripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorAcceptedAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Operation") + .HasColumnType("smallint"); + + b.Property("TargetGrantId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TargetGrantId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("DevelopmentOperatorAcceptedAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AllowedOperations") + .HasColumnType("integer"); + + b.Property("Classification") + .HasColumnType("smallint"); + + b.Property("EvidenceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileFixtureRoot") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("GrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrantedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevocationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "RevokedAtUtc") + .IsUnique() + .HasFilter("\"RevokedAtUtc\" IS NULL"); + + b.HasIndex("TenantId", "AgentId", "RevokedAtUtc", "ExpiresAtUtc"); + + b.ToTable("DevelopmentOperatorTargetGrants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DevelopmentMcpMarker") + .HasColumnType("text"); + + b.Property("DevelopmentMcpTargetAgentId") + .HasColumnType("uuid"); + + b.Property("MaxUses") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Uses") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ValidFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidToUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.HasIndex("TenantId", "ValidToUtc"); + + b.HasIndex("TenantId", "DevelopmentMcpTargetAgentId", "DevelopmentMcpMarker"); + + b.ToTable("EnrollmentCodes", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowActionRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConnectorRevision") + .HasColumnType("bigint"); + + b.Property("DraftJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LeaseFence") + .HasColumnType("bigint"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreparedJson") + .HasColumnType("jsonb"); + + b.Property("ReceiptJson") + .HasColumnType("jsonb"); + + b.Property("SemanticFingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("RunId", "NodeId"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique(); + + b.ToTable("FlowActions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowId", "AtUtc"); + + b.ToTable("FlowAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DraftJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersionId") + .HasColumnType("uuid"); + + b.Property("PublishedVersionNumber") + .HasColumnType("integer"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UpdatedAtUtc"); + + b.ToTable("FlowDefinitions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("AutomaticReplayUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvidenceFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FinalReconciliationAttempted") + .HasColumnType("boolean"); + + b.Property("FirstPostAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FullReceiptJson") + .HasColumnType("jsonb"); + + b.Property("LastPostLeaseFence") + .HasColumnType("bigint"); + + b.Property("MayHaveCommitted") + .HasColumnType("boolean"); + + b.Property("OriginalCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreparationJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReceiverFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReceiverIdempotencyKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SchemaVersion") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("RunId", "NodeId"); + + b.HasIndex("TenantId", "RunId", "NodeId") + .IsUnique(); + + b.ToTable("FlowReceiverEvidence", null, t => + { + t.HasCheckConstraint("CK_FlowReceiverEvidence_V2", "\"SchemaVersion\" = 2 AND \"TenantId\" > 0 AND \"RowVersion\" > 0 AND jsonb_typeof(\"PreparationJson\") = 'object' AND \"AutomaticReplayUntilUtc\" > \"OriginalCreatedAtUtc\" AND \"AutomaticReplayUntilUtc\" <= \"OriginalCreatedAtUtc\" + INTERVAL '24 hours' AND (\"LastPostLeaseFence\" IS NULL OR \"LastPostLeaseFence\" > 0) AND (NOT \"MayHaveCommitted\" OR \"FirstPostAttemptAtUtc\" IS NOT NULL) AND (\"FullReceiptJson\" IS NULL OR jsonb_typeof(\"FullReceiptJson\") = 'object')"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("EventJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Fence") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("FlowVersionId") + .HasColumnType("uuid"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasColumnType("uuid"); + + b.Property("LeaseToken") + .HasColumnType("uuid"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowVersionId"); + + b.HasIndex("Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + + b.HasIndex("TenantId", "EventId", "FlowVersionId") + .IsUnique(); + + b.HasIndex("TenantId", "FlowId", "CreatedAtUtc"); + + b.ToTable("FlowRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRuntimeIdentityRecord", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("SourceInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FlowRuntimeIdentity", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowVersionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigurationHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FlowId") + .HasColumnType("uuid"); + + b.Property("GraphJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublishedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FlowId", "VersionNumber") + .IsUnique(); + + b.ToTable("FlowVersions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FolderPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("TenantId"); + + b.HasIndex("FolderPath", "Name"); + + b.HasIndex("TenantId", "AgentId"); + + b.ToTable("Jobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobParameterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DefaultValue") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobParameters", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunControlRecord", b => + { + b.Property("RunId") + .HasColumnType("bigint"); + + b.Property("CancellationEnqueuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DispatchEnqueuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DispatchOwnerConnectionId") + .HasColumnType("uuid"); + + b.Property("DispatchOwnerEpoch") + .HasColumnType("bigint"); + + b.Property("DispatchPreparedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NativeDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("TerminalReadyAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TerminalResultHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("RunId"); + + b.HasIndex("TerminalReadyAtUtc", "NativeDeadlineUtc"); + + b.ToTable("JobRunControls", null, t => + { + t.HasCheckConstraint("CK_JobRunControls_DispatchOwner", "(\"DispatchOwnerConnectionId\" IS NULL AND \"DispatchOwnerEpoch\" IS NULL) OR (\"DispatchOwnerConnectionId\" IS NOT NULL AND \"DispatchOwnerConnectionId\" <> '00000000-0000-0000-0000-000000000000'::uuid AND \"DispatchOwnerEpoch\" IS NOT NULL AND \"DispatchOwnerEpoch\" > 0)"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStepOrdinal") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InputsJson") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("JobId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("JobRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobShadowObservationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandCorrelationStatus") + .HasColumnType("smallint"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelatedCommandStatus") + .HasColumnType("smallint"); + + b.Property("CurrentStepOrdinal") + .HasColumnType("integer"); + + b.Property("IsAuthoritative") + .HasColumnType("boolean"); + + b.Property("JobId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobRunId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobStepId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("JobStepRunId") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("Kind") + .HasColumnType("smallint"); + + b.Property("ObservedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RunCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RunStatus") + .HasColumnType("smallint"); + + b.Property("SourceEventId") + .HasColumnType("bigint"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StepOrdinal") + .HasColumnType("integer"); + + b.Property("StepStatus") + .HasColumnType("smallint"); + + b.Property("TaskRequestId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecordedAtUtc"); + + b.HasIndex("JobRunId", "SourceEventId"); + + b.HasIndex("SourceSystem", "SourceEventId") + .IsUnique() + .HasDatabaseName("UX_JobShadowObservation_Source"); + + b.HasIndex("TenantId", "TaskRequestId"); + + b.ToTable("JobShadowObservations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Command") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Ordinal") + .HasColumnType("integer"); + + b.Property("PayloadJson") + .HasColumnType("text"); + + b.Property("Runner") + .HasColumnType("text"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("JobId", "Ordinal"); + + b.ToTable("JobSteps", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepRunRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobRunId") + .HasColumnType("bigint"); + + b.Property("JobStepId") + .HasColumnType("bigint"); + + b.Property("Ordinal") + .HasColumnType("integer"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TaskRequestId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobRunId"); + + b.HasIndex("JobStepId"); + + b.HasIndex("JobRunId", "Ordinal"); + + b.ToTable("JobStepRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobRunId") + .HasColumnType("bigint"); + + b.Property("JobStepId") + .HasColumnType("bigint"); + + b.Property("RequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResultJson") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("JobRunId"); + + b.HasIndex("JobStepId"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("JobTaskActivities", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobTaskActivityId") + .HasColumnType("bigint"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sequence") + .HasColumnType("bigint"); + + b.Property("Stream") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("JobTaskActivityId"); + + b.HasIndex("RequestId"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("RequestId", "Sequence", "Stream") + .IsUnique(); + + b.ToTable("JobTaskLogs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.M2MConnectivitySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RemoteAudience") + .HasColumnType("text"); + + b.Property("RemoteBaseUrl") + .HasColumnType("text"); + + b.Property("RemoteSystemName") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("M2MConnectivitySettings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ManagedOrchestrationRequestBinding", b => + { + b.Property("RequestId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CallbackUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GrantHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IngestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("JobDefinitionId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LinkId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LinkRevision") + .HasColumnType("bigint"); + + b.Property("ParentRequestId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PeerInstanceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PeerTenantId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestTaskId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ServicePrincipalId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("RequestId"); + + b.HasIndex("ExecutionId") + .IsUnique() + .HasFilter("\"ExecutionId\" IS NOT NULL"); + + b.HasIndex("ServicePrincipalId", "ParentRequestId", "RequestTaskId") + .IsUnique(); + + b.ToTable("ManagedOrchestrationRequestBindings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuthorizedParty") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("GroupsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("McpInstance") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScopesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ServicePrincipal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Tool") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId", "OccurredAtUtc"); + + b.HasIndex("RequestId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("McpOperatorAcceptedAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandLength") + .HasColumnType("integer"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EnvironmentReferencesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MaximumOutputBytes") + .HasColumnType("integer"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("CommandId") + .IsUnique(); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("PolicyId"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "State"); + + b.ToTable("McpOperatorCommands", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorConfirmationPlanRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConfirmationClass") + .HasColumnType("smallint"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedIdempotencyId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("McpInstance") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc", "ConsumedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.ToTable("McpOperatorConfirmationPlans", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorFileArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReadRootFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "ExpiresAtUtc"); + + b.ToTable("McpOperatorFileArtifacts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("Outcome") + .HasColumnType("smallint"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ResultReference") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.HasIndex("Environment", "Subject", "ClientId", "TenantId", "OperationFamily", "Operation", "IdempotencyKey") + .IsUnique(); + + b.ToTable("McpOperatorIdempotencyRecords", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("JobRecordId") + .HasColumnType("uuid"); + + b.Property("JobVersion") + .HasColumnType("bigint"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("JobId", "OccurredAtUtc"); + + b.HasIndex("JobRecordId", "JobVersion") + .IsUnique(); + + b.ToTable("McpOperatorJobAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + + b.ToTable("McpOperatorJobs", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("JobRunRecordId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("JobRunRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorJobRunAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("JobRecordId") + .HasColumnType("uuid"); + + b.Property("JobRunId") + .HasPrecision(20) + .HasColumnType("bigint"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("JobRecordId"); + + b.HasIndex("JobRunId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + + b.ToTable("McpOperatorJobRuns", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorPolicyChangeAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "OccurredAtUtc"); + + b.HasIndex("PolicyId", "OccurredAtUtc"); + + b.HasIndex("TenantId", "OccurredAtUtc"); + + b.ToTable("McpOperatorPolicyChangeAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuditReference") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientTag") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Effect") + .HasColumnType("smallint"); + + b.Property("Environment") + .HasColumnType("smallint"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LifecycleState") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)1); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Operation") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationFamily") + .HasColumnType("integer"); + + b.Property("PrincipalSelectorKind") + .HasColumnType("smallint"); + + b.Property("PrincipalSelectorValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ReviewByUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetClassification") + .HasColumnType("smallint"); + + b.Property("TargetSelectorKind") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Environment", "PrincipalSelectorKind", "PrincipalSelectorValue"); + + b.HasIndex("Environment", "TargetSelectorKind", "TenantId", "AgentId"); + + b.HasIndex("Environment", "TenantId", "DisabledAtUtc", "ExpiresAtUtc", "Effect", "Priority"); + + b.ToTable("McpOperatorPolicies", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestRecordId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("RequestRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorRequestAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClaimReferenceHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("RequestId") + .HasColumnType("integer"); + + b.Property("ResultSummary") + .HasMaxLength(49152) + .HasColumnType("character varying(49152)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("JobId", "UpdatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + + b.ToTable("McpOperatorRequests", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeclaredSideEffectsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ManifestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ParametersJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("ScriptId") + .IsUnique(); + + b.HasIndex("TenantId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + + b.ToTable("McpOperatorScripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptVersionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeclaredSideEffectsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ManifestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParametersJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ScriptRecordId") + .HasColumnType("uuid"); + + b.Property("ScriptVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("ScriptId", "OccurredAtUtc"); + + b.HasIndex("ScriptRecordId", "ScriptVersion") + .IsUnique(); + + b.ToTable("McpOperatorScriptVersions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTargetProfileRecord", b => + { + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Classification") + .HasColumnType("smallint"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("AgentId"); + + b.HasIndex("TenantId", "Classification"); + + b.ToTable("McpOperatorTargetProfiles", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskRecordId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("TaskRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorTaskAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CancellationRequested") + .HasColumnType("boolean"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CommandHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CommandLength") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("MaximumOutputBytes") + .HasColumnType("integer"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("ResultSummary") + .HasMaxLength(49152) + .HasColumnType("character varying(49152)"); + + b.Property("ScriptContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("ScriptVersion") + .HasColumnType("bigint"); + + b.Property("ShellType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetSetDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TaskActivityId") + .HasColumnType("bigint"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("CommandId") + .IsUnique(); + + b.HasIndex("IdempotencyId") + .IsUnique(); + + b.HasIndex("PolicyId"); + + b.HasIndex("TaskActivityId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + + b.ToTable("McpOperatorTasks", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DelegationRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Outcome") + .HasColumnType("smallint"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResultReference") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SessionRecordId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("SessionRecordId", "CreatedAtUtc"); + + b.HasIndex("SessionRecordId", "Operation", "DelegationRequestId") + .IsUnique(); + + b.ToTable("McpOperatorTerminalActions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SessionRecordId") + .HasColumnType("uuid"); + + b.Property("State") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("SessionRecordId", "OccurredAtUtc"); + + b.ToTable("McpOperatorTerminalSessionAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAuditId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CloseReason") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CloseRequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Columns") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveConstraintsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Generation") + .HasColumnType("numeric(20,0)"); + + b.Property("IdempotencyId") + .HasColumnType("uuid"); + + b.Property("IdleExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastActivityAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("McpInstance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("McpResource") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("PolicyVersion") + .HasColumnType("bigint"); + + b.Property("Rows") + .HasColumnType("integer"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ShellType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("WorkingDirectory") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedAuditId"); + + b.HasIndex("AgentId"); + + b.HasIndex("IdempotencyId") + .IsUnique() + .HasFilter("\"IdempotencyId\" IS NOT NULL"); + + b.HasIndex("PolicyId"); + + b.HasIndex("SessionId") + .IsUnique(); + + b.HasIndex("TenantId", "AgentId", "State", "ExpiresAtUtc"); + + b.HasIndex("TenantId", "AgentId", "Subject", "ClientId", "State"); + + b.ToTable("McpOperatorTerminalSessions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringAuditRecord", b => + { + b.Property("AuditId") + .HasColumnType("uuid"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigurationRevision") + .HasColumnType("numeric(20,0)"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("TenantId", "AtUtc"); + + b.ToTable("MonitoringAudits", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringBypassRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("BypassId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "BypassId"); + + b.HasIndex("TenantId", "ExpiresAtUtc"); + + b.ToTable("MonitoringBypasses", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("EventId"); + + b.HasIndex("OccurrenceId"); + + b.HasIndex("TenantId", "AtUtc", "EventId"); + + b.ToTable("MonitoringEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEvidenceRegistrationAttemptRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("RegistrationId") + .HasColumnType("uuid"); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationOrdinal") + .HasColumnType("bigint"); + + b.Property("RetainUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("TenantId", "AgentId", "RegistrationId"); + + b.HasIndex("RetainUntilUtc"); + + b.HasIndex("TenantId", "AgentId", "RegistrationOrdinal") + .IsUnique(); + + b.ToTable("MonitoringEvidenceRegistrationAttempts", null, t => + { + t.HasCheckConstraint("CK_MonitoringEvidenceRegistrationAttempts_Shape", "\"TenantId\">0 AND \"AgentId\"<>'00000000-0000-0000-0000-000000000000'::uuid AND \"RegistrationId\"<>'00000000-0000-0000-0000-000000000000'::uuid AND \"ConnectionId\"<>'00000000-0000-0000-0000-000000000000'::uuid AND \"ConnectionEpoch\">0 AND \"RegistrationOrdinal\">0 AND \"Status\" IN (1,2,3) AND \"ExpiresAtUtc\">\"CreatedAtUtc\" AND \"RetainUntilUtc\">=\"ExpiresAtUtc\""); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEvidenceRegistrationCounterRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("LastIssuedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("MonitoringEvidenceRegistrationCounters", null, t => + { + t.HasCheckConstraint("CK_MonitoringEvidenceRegistrationCounters_Identity", "\"TenantId\">0 AND \"AgentId\"<>'00000000-0000-0000-0000-000000000000'::uuid AND \"LastIssuedOrdinal\">=0"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEvidenceStreamRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CommittedRegistrationOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ConnectionEpoch") + .HasColumnType("bigint"); + + b.Property("ConnectionId") + .HasColumnType("uuid"); + + b.Property("EvidenceStreamId") + .HasColumnType("uuid"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "AgentId"); + + b.ToTable("MonitoringEvidenceStreams", null, t => + { + t.HasCheckConstraint("CK_MonitoringEvidenceStreams_Ordinal", "\"CommittedRegistrationOrdinal\">=0"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringFlowOutboxRecord", b => + { + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("Code") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("FlowRunId") + .HasColumnType("uuid"); + + b.Property("HandedOffAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseFence") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LeaseId") + .HasColumnType("uuid"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("OutcomeJson") + .HasColumnType("jsonb"); + + b.Property("StableFlowDispatchKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("WorkerId") + .HasColumnType("uuid"); + + b.HasKey("OutboxId"); + + b.HasIndex("EventId"); + + b.HasIndex("FlowRunId") + .IsUnique() + .HasFilter("\"FlowRunId\" IS NOT NULL"); + + b.HasIndex("StableFlowDispatchKey") + .IsUnique(); + + b.HasIndex("TenantId", "Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + + b.ToTable("MonitoringFlowOutbox", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringGroupRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "GroupId"); + + b.ToTable("MonitoringGroups", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringOccurrenceRecord", b => + { + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurrenceJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RaisedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaisedEventId") + .HasColumnType("uuid"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("OccurrenceId"); + + b.HasIndex("RaisedEventId") + .IsUnique(); + + b.HasIndex("TenantId", "EndedAtUtc"); + + b.HasIndex("TenantId", "RuleId", "AgentId", "ResourceKey") + .IsUnique() + .HasDatabaseName("UX_MonitoringOccurrence_OpenSeries") + .HasFilter("\"EndedAtUtc\" IS NULL"); + + b.ToTable("MonitoringOccurrences", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringRuleRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("DefinitionJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId", "RuleId"); + + b.ToTable("MonitoringRules", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringSeriesRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ResourceKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Acknowledged") + .HasColumnType("boolean"); + + b.Property("ActiveOccurrenceId") + .HasColumnType("uuid"); + + b.Property("EvidenceQuality") + .HasColumnType("smallint"); + + b.Property("LatestOccurrenceId") + .HasColumnType("uuid"); + + b.Property("Phase") + .HasColumnType("smallint"); + + b.Property("StateJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StateRevision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.Property("Suppressed") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("TenantId", "RuleId", "AgentId", "ResourceKey"); + + b.HasIndex("TenantId", "AgentId"); + + b.ToTable("MonitoringSeries", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringTenantConfigurationRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("numeric(20,0)"); + + b.HasKey("TenantId"); + + b.ToTable("MonitoringTenantConfigurations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OidcSigningKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivateKeyPem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyId") + .IsUnique(); + + b.ToTable("OidcSigningKeys", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OrchestrationCallbackDelivery", b => + { + b.Property("RequestId") + .HasColumnType("integer"); + + b.Property("Phase") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("DeliveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseId") + .HasColumnType("uuid"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("RequestId", "Phase"); + + b.HasIndex("DeliveredAtUtc", "NextAttemptAtUtc"); + + b.ToTable("OrchestrationCallbackDeliveries", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LockOwner") + .HasColumnType("text"); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("NextAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("EntityId"); + + b.HasIndex("OccurredUtc"); + + b.HasIndex("Type"); + + b.HasIndex("Status", "NextAttemptUtc", "LockedUntilUtc"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxProcessedEvent", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("ConsumerName") + .HasColumnType("text"); + + b.Property("ProcessedUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId", "ConsumerName"); + + b.HasIndex("ProcessedUtc"); + + b.ToTable("OutboxProcessedEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OutboxReadReceipt", b => + { + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ReadUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId", "UserId"); + + b.HasIndex("UserId", "ReadUtc"); + + b.ToTable("OutboxReadReceipts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.PrimaryClientAgentBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("BindingSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("BoundAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("BoundBy") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnrollmentCodeId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PrimaryClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AgentId") + .IsUnique() + .HasFilter("\"AgentId\" IS NOT NULL AND \"Status\" <> 3"); + + b.HasIndex("EnrollmentCodeId") + .IsUnique(); + + b.HasIndex("TenantId", "PrimaryClientIdentity") + .IsUnique() + .HasFilter("\"Status\" <> 3"); + + b.HasIndex("TenantId", "Status", "CreatedAtUtc"); + + b.ToTable("PrimaryClientAgentBindings", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RatelDeskConnectorRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthenticationJson") + .HasColumnType("jsonb"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CredentialRevision") + .HasColumnType("bigint"); + + b.Property("OwnerPrincipalId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProtectedCredential") + .HasColumnType("text"); + + b.Property("ReadinessJson") + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("TenantId", "Id"); + + b.ToTable("RatelDeskConnectors", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportAuditEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("AuditSequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("ContractVersion") + .HasColumnType("integer"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FailureCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LifecycleRevision") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RemoteSupportSessionId") + .HasColumnType("uuid"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RemoteSupportSessionId", "AuditSequence") + .IsUnique(); + + b.HasIndex("RemoteSupportSessionId", "RequestId"); + + b.HasIndex("TenantId", "AgentId", "OccurredAtUtc"); + + b.ToTable("RemoteSupportAuditEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportSessionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("ContractVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrantedCapabilitiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitiatingOperatorId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifecycleRevision") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("OpenRequestId") + .HasColumnType("uuid"); + + b.Property("RequestedCapabilitiesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetInventorySequence") + .HasPrecision(20) + .HasColumnType("numeric(20,0)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetUserSidHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TargetWindowsSessionId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TerminalAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TerminalReasonCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TenantId", "AgentId", "CreatedAtUtc"); + + b.HasIndex("TenantId", "AgentId", "OpenRequestId") + .IsUnique(); + + b.HasIndex("TenantId", "InitiatingOperatorId", "UpdatedAtUtc"); + + b.ToTable("RemoteSupportSessions", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportTargetSelectionEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("FailureReason") + .HasColumnType("text"); + + b.Property("InventorySequence") + .HasColumnType("numeric(20,0)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Result") + .IsRequired() + .HasColumnType("text"); + + b.Property("SelectedProvider") + .HasColumnType("text"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("TargetDisplayLabel") + .HasColumnType("text"); + + b.Property("TargetMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserSidHash") + .HasColumnType("text"); + + b.Property("TargetWindowsSessionId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "ClientIdentity", "RequestedAtUtc"); + + b.ToTable("RemoteSupportTargetSelectionEvents", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionId") + .HasColumnType("text") + .HasColumnName("RundeckExecutionId"); + + b.Property("JobDefinitionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("RundeckJobDefinitionId"); + + b.Property("JobInputs") + .HasColumnType("text"); + + b.PrimitiveCollection>("Logs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SourceSystem") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentId") + .HasColumnType("uuid"); + + b.Property("TargetClientIdentity") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetTenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("Status"); + + b.HasIndex("TargetClientIdentity"); + + b.HasIndex("UpdatedAtUtc"); + + b.HasIndex("TargetTenantId", "TargetAgentId"); + + b.ToTable("Requests", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("FolderPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("ManifestJson") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScriptType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceRevision") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("FolderPath", "Name"); + + b.ToTable("Scripts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptParameterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Default") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ScriptId") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScriptId"); + + b.ToTable("ScriptParameters", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.SecretRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientIdentity") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientIdentity"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("TenantId"); + + b.ToTable("Secrets", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoUpdate") + .HasColumnType("boolean"); + + b.Property("AutoUpdateChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("AutoUpdateTargetVersion") + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.PrimitiveCollection>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServiceIdentityConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WebBaseUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("ServiceIdentityConfiguration", null, t => + { + t.HasCheckConstraint("CK_ServiceIdentityConfiguration_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServicePrincipalRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AliasKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AllowedScopesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ApprovedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AttemptId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CurrentCredentialRevision") + .HasColumnType("bigint"); + + b.Property("DeploymentFingerprint") + .HasColumnType("text"); + + b.Property("DescriptorHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DirectionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("GrantHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LinkId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LinkRevision") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedClientId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PeerInstanceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PeerTenantId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ResourceConstraintsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("TerminalControlUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AliasKey") + .IsUnique(); + + b.HasIndex("NormalizedClientId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.HasIndex("LinkId", "DirectionId") + .IsUnique() + .HasFilter("\"LinkId\" IS NOT NULL"); + + b.ToTable("ServicePrincipalRegistrations", null, t => + { + t.HasCheckConstraint("CK_ServicePrincipal_Status", "\"Status\" IN ('pending','prepared','verified','in_doubt','active','revoked','expired','failed')"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServicePrincipalSecret", b => + { + b.Property("ServicePrincipalId") + .HasColumnType("uuid"); + + b.Property("CredentialRevision") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetireAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ServicePrincipalId", "CredentialRevision"); + + b.HasIndex("ServicePrincipalId") + .IsUnique() + .HasFilter("\"Status\" = 'pending'"); + + b.ToTable("ServicePrincipalSecrets", null, t => + { + t.HasCheckConstraint("CK_ServiceSecret_Status", "\"Status\" IN ('pending','active','retiring','revoked')"); + }); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServiceSigningKey", b => + { + b.Property("Kid") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActiveSlot") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ProtectedPrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicExponent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicModulus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidateUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Kid"); + + b.HasIndex("ActiveSlot") + .IsUnique() + .HasFilter("\"ActiveSlot\" IS NOT NULL"); + + b.ToTable("ServiceSigningKeys", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceLinks.ServiceLinkAttempt", b => + { + b.Property("AttemptId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AbortId") + .HasColumnType("text"); + + b.Property("ActiveRelationshipKey") + .HasColumnType("text"); + + b.Property("CommitId") + .HasColumnType("text"); + + b.Property("ConsentId") + .HasColumnType("text"); + + b.Property("CreatedAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text"); + + b.Property("DescriptorHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("DescriptorJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExchangeDispatched") + .HasColumnType("boolean"); + + b.Property("ExchangeFingerprint") + .HasColumnType("text"); + + b.Property("ExchangeResponseHash") + .HasColumnType("text"); + + b.Property("ExpiresAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("GrantHash") + .HasColumnType("text"); + + b.Property("GrantSummaryJson") + .HasColumnType("text"); + + b.Property("InboundPrincipalId") + .HasColumnType("uuid"); + + b.Property("InitiatorVerificationReceiptId") + .HasColumnType("text"); + + b.Property("LastErrorCode") + .HasColumnType("text"); + + b.Property("LifecycleState") + .IsRequired() + .HasColumnType("text"); + + b.Property("LinkId") + .HasColumnType("text"); + + b.Property("LinkRevision") + .HasColumnType("bigint"); + + b.Property("LocalActiveAcknowledged") + .HasColumnType("boolean"); + + b.Property("LocalActorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LocalBusinessSenderEnabled") + .HasColumnType("boolean"); + + b.Property("LocalInboundActive") + .HasColumnType("boolean"); + + b.Property("LocalPreparedAcknowledged") + .HasColumnType("boolean"); + + b.Property("LocalTenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("NextWorkAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("OutboundProfileRevision") + .HasColumnType("integer"); + + b.Property("PairingCodeHash") + .HasColumnType("text"); + + b.Property("PeerActiveAcknowledged") + .HasColumnType("boolean"); + + b.Property("PeerInstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PeerPreparedAcknowledged") + .HasColumnType("boolean"); + + b.Property("PeerRevocationAcknowledged") + .HasColumnType("boolean"); + + b.Property("PeerTenantId") + .HasColumnType("text"); + + b.Property("ProtectedBrowserState") + .HasColumnType("text"); + + b.Property("ProtectedExchangeResponse") + .HasColumnType("text"); + + b.Property("ProtectedInboundEscrow") + .HasColumnType("text"); + + b.Property("ProtectedOutboundCredential") + .HasColumnType("text"); + + b.Property("ProtectedPairingCode") + .HasColumnType("text"); + + b.Property("ProtectedVerifier") + .HasColumnType("text"); + + b.Property("ResponderVerificationReceiptId") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("RevocationId") + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionBindingHash") + .HasColumnType("text"); + + b.Property("TerminalControlExpiresAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUnixSeconds") + .HasColumnType("bigint"); + + b.HasKey("AttemptId"); + + b.HasIndex("ActiveRelationshipKey") + .IsUnique(); + + b.HasIndex("LinkId") + .IsUnique(); + + b.HasIndex("LifecycleState", "NextWorkAtUnixSeconds"); + + b.HasIndex("PeerInstanceId", "LocalTenantId", "PeerTenantId", "LinkRevision", "Role"); + + b.ToTable("ServiceLinkAttempts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceLinks.ServiceLinkOperation", b => + { + b.Property("LinkId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OperationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Completed") + .HasColumnType("boolean"); + + b.Property("CreatedAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outbound") + .HasColumnType("boolean"); + + b.Property("ProtectedRequestJson") + .HasColumnType("text"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LinkId", "OperationId"); + + b.HasIndex("Outbound", "Completed"); + + b.ToTable("ServiceLinkOperations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceLinks.ServiceLinkRotation", b => + { + b.Property("RotationId") + .HasColumnType("text"); + + b.Property("ActivateDecisionId") + .HasColumnType("text"); + + b.Property("ActiveRotationKey") + .HasColumnType("text"); + + b.Property("CallerSwitchRevision") + .HasColumnType("bigint"); + + b.Property("CreatedAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("DirectionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpectedCurrentCredentialRevision") + .HasColumnType("bigint"); + + b.Property("IsIssuer") + .HasColumnType("boolean"); + + b.Property("LastErrorCode") + .HasColumnType("text"); + + b.Property("LinkId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OfferExpiresAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("PredecessorRetireAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("ProtectedCandidate") + .HasColumnType("text"); + + b.Property("ProtectedOffer") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("RotationState") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuccessorCredentialRevision") + .HasColumnType("bigint"); + + b.Property("SuccessorVerificationReceiptId") + .HasColumnType("text"); + + b.HasKey("RotationId"); + + b.HasIndex("ActiveRotationKey") + .IsUnique(); + + b.HasIndex("LinkId", "DirectionId", "ExpectedCurrentCredentialRevision"); + + b.ToTable("ServiceLinkRotations", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceLinks.ServiceLinkRuntimeIdentity", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("uuid"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SourceAdoptedAtUnixSeconds") + .HasColumnType("bigint"); + + b.Property("SourceAdoptedBy") + .HasColumnType("text"); + + b.Property("SourceInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("ServiceLinkRuntimeIdentity", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceLinks.ServiceLinkVerificationReceipt", b => + { + b.Property("VerificationReceiptId") + .HasColumnType("text"); + + b.Property("AttemptId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CredentialRevision") + .HasColumnType("bigint"); + + b.Property("DirectionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("GrantHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("LinkId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RotationId") + .HasColumnType("text"); + + b.Property("ServicePrincipalId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUnixSeconds") + .HasColumnType("bigint"); + + b.HasKey("VerificationReceiptId"); + + b.HasIndex("LinkId", "ServicePrincipalId", "CredentialRevision", "RotationId"); + + b.ToTable("ServiceLinkVerificationReceipts", (string)null); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("SupersededByAgentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.AgentClientUpdateStateRecord", "AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentCredential", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany("Credentials") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.AgentRefreshToken", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany("RefreshTokens") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientInstallGrant", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportAsset", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", "Operation") + .WithMany("Assets") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateAttemptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", "Release") + .WithMany("Attempts") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpFileArtifactRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpMarkerJobRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentMcpScriptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorAcceptedAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", null) + .WithMany() + .HasForeignKey("TargetGrantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.DevelopmentOperatorTargetGrant", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowActionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowRunRecord", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowActionRecord", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", "TenantId", "RunId", "NodeId") + .HasPrincipalKey("NetRatel.Infrastructure.Persistence.FlowActionRecord", "TenantId", "RunId", "NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowVersionRecord", null) + .WithMany() + .HasForeignKey("TenantId", "FlowVersionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowVersionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowDefinitionRecord", null) + .WithMany() + .HasForeignKey("TenantId", "FlowId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobParameterDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany("Parameters") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunControlRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobRunRecord", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.JobRunControlRecord", "RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", "Job") + .WithMany("Steps") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobStepRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobRunRecord", "JobRun") + .WithMany("Steps") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobStepDefinition", "JobStep") + .WithMany() + .HasForeignKey("JobStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobRun"); + + b.Navigation("JobStep"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobRunRecord", "JobRun") + .WithMany("Activities") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobStepDefinition", "JobStep") + .WithMany() + .HasForeignKey("JobStepId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("JobRun"); + + b.Navigation("JobStep"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskLogRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", "Activity") + .WithMany("Logs") + .HasForeignKey("JobTaskActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Activity"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ManagedOrchestrationRequestBinding", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.RequestRecord", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.ManagedOrchestrationRequestBinding", "RequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorCommandRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorFileArtifactRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", null) + .WithMany() + .HasForeignKey("JobRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", null) + .WithMany() + .HasForeignKey("JobRunRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorJobRunRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorJobRecord", null) + .WithMany() + .HasForeignKey("JobRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", null) + .WithMany() + .HasForeignKey("RequestRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorRequestRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobDefinition", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.RequestRecord", null) + .WithMany() + .HasForeignKey("RequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.ScriptDefinition", null) + .WithMany() + .HasForeignKey("ScriptId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorScriptVersionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorScriptRecord", null) + .WithMany() + .HasForeignKey("ScriptRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTargetProfileRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", null) + .WithMany() + .HasForeignKey("TaskRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTaskRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", null) + .WithMany() + .HasForeignKey("TaskActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalActionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", null) + .WithMany() + .HasForeignKey("SessionRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionAuditRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", null) + .WithMany() + .HasForeignKey("SessionRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.McpOperatorTerminalSessionRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorAcceptedAuditRecord", null) + .WithMany() + .HasForeignKey("AcceptedAuditId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", null) + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorIdempotencyRecord", null) + .WithMany() + .HasForeignKey("IdempotencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NetRatel.Infrastructure.Persistence.McpOperatorPolicyRecord", null) + .WithMany() + .HasForeignKey("PolicyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.MonitoringOccurrenceRecord", null) + .WithMany() + .HasForeignKey("OccurrenceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.MonitoringFlowOutboxRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.MonitoringEventRecord", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.OrchestrationCallbackDelivery", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.RequestRecord", null) + .WithMany() + .HasForeignKey("RequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.PrimaryClientAgentBinding", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Agent", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("NetRatel.Infrastructure.Persistence.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Agent"); + + b.Navigation("EnrollmentCode"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportAuditEventRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.RemoteSupportSessionRecord", null) + .WithMany() + .HasForeignKey("RemoteSupportSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptParameterDefinition", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.ScriptDefinition", "Script") + .WithMany("Parameters") + .HasForeignKey("ScriptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Script"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServicePrincipalRegistration", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.ServiceIdentity.ServicePrincipalSecret", b => + { + b.HasOne("NetRatel.Infrastructure.ServiceIdentity.ServicePrincipalRegistration", null) + .WithMany() + .HasForeignKey("ServicePrincipalId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.Agent", b => + { + b.Navigation("Credentials"); + + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientReleaseImportOperation", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ClientUpdateReleaseRecord", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobDefinition", b => + { + b.Navigation("Parameters"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobRunRecord", b => + { + b.Navigation("Activities"); + + b.Navigation("Steps"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.JobTaskActivityRecord", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.ScriptDefinition", b => + { + b.Navigation("Parameters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.cs new file mode 100644 index 00000000..72b4436a --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/20261006195456_AddFlowReceiverEvidence.cs @@ -0,0 +1,131 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetRatel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddFlowReceiverEvidence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FlowActions_TenantId_RunId", + table: "FlowActions"); + + migrationBuilder.AddColumn( + name: "AuthenticationJson", + table: "RatelDeskConnectors", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReadinessJson", + table: "RatelDeskConnectors", + type: "jsonb", + nullable: true); + + migrationBuilder.AddUniqueConstraint( + name: "AK_FlowActions_TenantId_RunId_NodeId", + table: "FlowActions", + columns: new[] { "TenantId", "RunId", "NodeId" }); + + migrationBuilder.CreateTable( + name: "FlowReceiverEvidence", + columns: table => new + { + RunId = table.Column(type: "uuid", nullable: false), + NodeId = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "integer", nullable: false), + SchemaVersion = table.Column(type: "integer", nullable: false), + PreparationJson = table.Column(type: "jsonb", nullable: false), + EvidenceFingerprint = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ReceiverIdempotencyKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ReceiverFingerprint = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + OriginalCreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + AutomaticReplayUntilUtc = table.Column(type: "timestamp with time zone", nullable: false), + MayHaveCommitted = table.Column(type: "boolean", nullable: false), + FirstPostAttemptAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastPostLeaseFence = table.Column(type: "bigint", nullable: true), + FinalReconciliationAttempted = table.Column(type: "boolean", nullable: false), + FullReceiptJson = table.Column(type: "jsonb", nullable: true), + RowVersion = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FlowReceiverEvidence", x => new { x.RunId, x.NodeId }); + table.CheckConstraint("CK_FlowReceiverEvidence_V2", "\"SchemaVersion\" = 2 AND \"TenantId\" > 0 AND \"RowVersion\" > 0 AND jsonb_typeof(\"PreparationJson\") = 'object' AND \"AutomaticReplayUntilUtc\" > \"OriginalCreatedAtUtc\" AND \"AutomaticReplayUntilUtc\" <= \"OriginalCreatedAtUtc\" + INTERVAL '24 hours' AND (\"LastPostLeaseFence\" IS NULL OR \"LastPostLeaseFence\" > 0) AND (NOT \"MayHaveCommitted\" OR \"FirstPostAttemptAtUtc\" IS NOT NULL) AND (\"FullReceiptJson\" IS NULL OR jsonb_typeof(\"FullReceiptJson\") = 'object')"); + table.ForeignKey( + name: "FK_FlowReceiverEvidence_FlowActions_TenantId_RunId_NodeId", + columns: x => new { x.TenantId, x.RunId, x.NodeId }, + principalTable: "FlowActions", + principalColumns: new[] { "TenantId", "RunId", "NodeId" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FlowReceiverEvidence_TenantId_RunId_NodeId", + table: "FlowReceiverEvidence", + columns: new[] { "TenantId", "RunId", "NodeId" }, + unique: true); + migrationBuilder.Sql(""" + CREATE FUNCTION nr_receiver_evidence_immutable_inputs() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF ROW(NEW."TenantId", NEW."RunId", NEW."NodeId", NEW."SchemaVersion", + NEW."PreparationJson", NEW."EvidenceFingerprint", NEW."ReceiverIdempotencyKey", + NEW."ReceiverFingerprint", NEW."OriginalCreatedAtUtc", NEW."AutomaticReplayUntilUtc") + IS DISTINCT FROM + ROW(OLD."TenantId", OLD."RunId", OLD."NodeId", OLD."SchemaVersion", + OLD."PreparationJson", OLD."EvidenceFingerprint", OLD."ReceiverIdempotencyKey", + OLD."ReceiverFingerprint", OLD."OriginalCreatedAtUtc", OLD."AutomaticReplayUntilUtc") + OR NEW."RowVersion" <= OLD."RowVersion" + OR (OLD."FirstPostAttemptAtUtc" IS NOT NULL AND + NEW."FirstPostAttemptAtUtc" IS DISTINCT FROM OLD."FirstPostAttemptAtUtc") + OR (OLD."LastPostLeaseFence" IS NOT NULL AND + (NEW."LastPostLeaseFence" IS NULL OR NEW."LastPostLeaseFence" < OLD."LastPostLeaseFence")) + OR (OLD."FinalReconciliationAttempted" AND NOT NEW."FinalReconciliationAttempted") + OR (OLD."FullReceiptJson" IS NOT NULL AND + NEW."FullReceiptJson" IS DISTINCT FROM OLD."FullReceiptJson") + OR (OLD."MayHaveCommitted" AND NOT NEW."MayHaveCommitted" AND NEW."FullReceiptJson" IS NULL) + THEN + RAISE EXCEPTION 'receiver-evidence-immutable-input-conflict' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $$; + CREATE TRIGGER "TR_FlowReceiverEvidence_ImmutableInputs" BEFORE UPDATE ON "FlowReceiverEvidence" + FOR EACH ROW EXECUTE FUNCTION nr_receiver_evidence_immutable_inputs(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DROP TRIGGER "TR_FlowReceiverEvidence_ImmutableInputs" ON "FlowReceiverEvidence"; + DROP FUNCTION nr_receiver_evidence_immutable_inputs(); + """); + migrationBuilder.DropTable( + name: "FlowReceiverEvidence"); + + migrationBuilder.DropUniqueConstraint( + name: "AK_FlowActions_TenantId_RunId_NodeId", + table: "FlowActions"); + + migrationBuilder.DropColumn( + name: "AuthenticationJson", + table: "RatelDeskConnectors"); + + migrationBuilder.DropColumn( + name: "ReadinessJson", + table: "RatelDeskConnectors"); + + migrationBuilder.CreateIndex( + name: "IX_FlowActions_TenantId_RunId", + table: "FlowActions", + columns: new[] { "TenantId", "RunId" }); + } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/OrchestratorDbContextModelSnapshot.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/OrchestratorDbContextModelSnapshot.cs index 7d125415..9e11c7d7 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/OrchestratorDbContextModelSnapshot.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/Migrations/OrchestratorDbContextModelSnapshot.cs @@ -1762,8 +1762,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "IdempotencyKey") .IsUnique(); - b.HasIndex("TenantId", "RunId"); - b.ToTable("FlowActions", (string)null); }); @@ -1846,6 +1844,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("FlowDefinitions", (string)null); }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("AutomaticReplayUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvidenceFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FinalReconciliationAttempted") + .HasColumnType("boolean"); + + b.Property("FirstPostAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FullReceiptJson") + .HasColumnType("jsonb"); + + b.Property("LastPostLeaseFence") + .HasColumnType("bigint"); + + b.Property("MayHaveCommitted") + .HasColumnType("boolean"); + + b.Property("OriginalCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreparationJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReceiverFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReceiverIdempotencyKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SchemaVersion") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("integer"); + + b.HasKey("RunId", "NodeId"); + + b.HasIndex("TenantId", "RunId", "NodeId") + .IsUnique(); + + b.ToTable("FlowReceiverEvidence", null, t => + { + t.HasCheckConstraint("CK_FlowReceiverEvidence_V2", "\"SchemaVersion\" = 2 AND \"TenantId\" > 0 AND \"RowVersion\" > 0 AND jsonb_typeof(\"PreparationJson\") = 'object' AND \"AutomaticReplayUntilUtc\" > \"OriginalCreatedAtUtc\" AND \"AutomaticReplayUntilUtc\" <= \"OriginalCreatedAtUtc\" + INTERVAL '24 hours' AND (\"LastPostLeaseFence\" IS NULL OR \"LastPostLeaseFence\" > 0) AND (NOT \"MayHaveCommitted\" OR \"FirstPostAttemptAtUtc\" IS NOT NULL) AND (\"FullReceiptJson\" IS NULL OR jsonb_typeof(\"FullReceiptJson\") = 'object')"); + }); + }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => { b.Property("Id") @@ -4833,6 +4900,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PrimaryClientAgentBindings", (string)null); }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RatelDeskConnectorRecord", b => + { + b.Property("TenantId") + .HasColumnType("integer"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthenticationJson") + .HasColumnType("jsonb"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CredentialRevision") + .HasColumnType("bigint"); + + b.Property("OwnerPrincipalId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProtectedCredential") + .HasColumnType("text"); + + b.Property("ReadinessJson") + .HasColumnType("jsonb"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("TenantId", "Id"); + + b.ToTable("RatelDeskConnectors", (string)null); + }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.RemoteSupportAuditEventRecord", b => { b.Property("Id") @@ -6055,6 +6163,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", b => + { + b.HasOne("NetRatel.Infrastructure.Persistence.FlowActionRecord", null) + .WithOne() + .HasForeignKey("NetRatel.Infrastructure.Persistence.FlowReceiverEvidenceRecord", "TenantId", "RunId", "NodeId") + .HasPrincipalKey("NetRatel.Infrastructure.Persistence.FlowActionRecord", "TenantId", "RunId", "NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("NetRatel.Infrastructure.Persistence.FlowRunRecord", b => { b.HasOne("NetRatel.Infrastructure.Persistence.FlowVersionRecord", null) diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/OrchestratorDbContext.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/OrchestratorDbContext.cs index 2779c141..6297c067 100644 --- a/src/NetRatel/NetRatel.Infrastructure/Persistence/OrchestratorDbContext.cs +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/OrchestratorDbContext.cs @@ -64,6 +64,8 @@ public class OrchestratorDbContext(DbContextOptions optio public DbSet JobShadowObservations => Set(); public DbSet ClientWindowsSessionSnapshots => Set(); public DbSet ClientServicesSnapshots => Set(); + public DbSet RatelDeskConnectors => Set(); + public DbSet FlowReceiverEvidence => Set(); public DbSet MonitoringTenantConfigurations => Set(); public DbSet MonitoringRules => Set(); public DbSet MonitoringGroups => Set(); @@ -123,6 +125,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { MonitoringPersistenceModel.Configure(modelBuilder); FlowPersistenceModel.Configure(modelBuilder); + FlowReceiverEvidenceModel.Configure(modelBuilder); + modelBuilder.Entity(entity => + { + entity.ToTable("RatelDeskConnectors"); + entity.HasKey(record => new { record.TenantId, record.Id }); + entity.Property(record => record.RowVersion).IsConcurrencyToken(); + entity.Property(record => record.OwnerPrincipalId).HasMaxLength(32).IsRequired(); + entity.Property(record => record.ConfigurationJson).HasColumnType("jsonb").IsRequired(); + entity.Property(record => record.AuthenticationJson).HasColumnType("jsonb"); + entity.Property(record => record.ReadinessJson).HasColumnType("jsonb"); + }); modelBuilder.ConfigureServiceIdentityModel(); modelBuilder.ConfigureServiceLinkModel(); modelBuilder.ConfigureManagedOrchestrationModel(); diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorRecord.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorRecord.cs new file mode 100644 index 00000000..d3f619b4 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorRecord.cs @@ -0,0 +1,15 @@ +namespace NetRatel.Infrastructure.Persistence; + +public sealed class RatelDeskConnectorRecord +{ + public int TenantId { get; set; } + public Guid Id { get; set; } + public long Revision { get; set; } + public long RowVersion { get; set; } + public string OwnerPrincipalId { get; set; } = string.Empty; + public string ConfigurationJson { get; set; } = string.Empty; + public string? ProtectedCredential { get; set; } + public long CredentialRevision { get; set; } + public string? AuthenticationJson { get; set; } + public string? ReadinessJson { get; set; } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorStore.cs b/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorStore.cs new file mode 100644 index 00000000..0534218f --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/Persistence/RatelDeskConnectorStore.cs @@ -0,0 +1,95 @@ +using System.Data; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; +using Npgsql; + +namespace NetRatel.Infrastructure.Persistence; + +public sealed class RatelDeskConnectorStore(OrchestratorDbContext db) : IRatelDeskConnectorStore, IRatelDeskConnectorBindingStore, IRatelDeskConnectorReadinessStore +{ + public async Task GetAsync(int tenantId, Guid id, CancellationToken cancellationToken) + { + var record = await db.RatelDeskConnectors.AsNoTracking().SingleOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken).ConfigureAwait(false); + return record is null ? null : Map(record); + } + + public async Task> ListAsync(int tenantId, CancellationToken cancellationToken) + { + var records = await db.RatelDeskConnectors.AsNoTracking().Where(r => r.TenantId == tenantId).OrderBy(r => r.Id) + .Take(RatelDeskConnectorLimits.MaximumConnectorsPerTenant + 1).ToListAsync(cancellationToken).ConfigureAwait(false); + if (records.Count > RatelDeskConnectorLimits.MaximumConnectorsPerTenant) + throw new InvalidOperationException("connector-capacity-exhausted"); + return records.Select(Map).ToArray(); + } + + public async Task SaveAsync(RatelDeskConnectorState state, long expectedRowVersion, CancellationToken cancellationToken) + { + if (state.TenantId <= 0 || state.Id == Guid.Empty || state.RowVersion != checked(expectedRowVersion + 1) || state.Revision <= 0) + throw new ArgumentException("invalid-connector-state"); + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken).ConfigureAwait(false) : null; + // Serialize different-ID admission before its lookup/count/insert; row CAS alone cannot bound a tenant aggregate. + if (expectedRowVersion == 0 && db.Database.IsNpgsql()) + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock({733460001}, {state.TenantId})", cancellationToken).ConfigureAwait(false); + var record = await db.RatelDeskConnectors.SingleOrDefaultAsync(r => r.TenantId == state.TenantId && r.Id == state.Id, cancellationToken).ConfigureAwait(false); + var configurationJson = JsonSerializer.Serialize(state.Configuration); + var authentication = state.Authentication ?? new(RatelDeskAuthenticationMode.ManualApiBearer, null); + var authenticationJson = state.Authentication is null ? null : JsonSerializer.Serialize(authentication); + if (record is null && expectedRowVersion != 0 || record is not null && + (record.RowVersion != expectedRowVersion || record.OwnerPrincipalId != state.OwnerPrincipalId || state.Revision < record.Revision || state.CredentialRevision < record.CredentialRevision)) + return false; + if (record is not null && (state.Revision > record.Revision + 1 || + state.Revision == record.Revision && configurationJson != JsonSerializer.Serialize( + JsonSerializer.Deserialize(record.ConfigurationJson) ?? throw new InvalidOperationException("invalid-connector-record")) || + state.Revision == record.Revision && JsonSerializer.Serialize(authentication) != JsonSerializer.Serialize( + record.AuthenticationJson is null ? new RatelDeskConnectorAuthentication(RatelDeskAuthenticationMode.ManualApiBearer, null) : + JsonSerializer.Deserialize(record.AuthenticationJson) ?? throw new InvalidOperationException("invalid-connector-authentication")) || + state.CredentialRevision == record.CredentialRevision && state.ProtectedCredential != record.ProtectedCredential)) return false; + if (record is null) + { + if (await db.RatelDeskConnectors.CountAsync(r => r.TenantId == state.TenantId, cancellationToken).ConfigureAwait(false) >= RatelDeskConnectorLimits.MaximumConnectorsPerTenant) + throw new InvalidOperationException("connector-capacity-exhausted"); + record = new() { TenantId = state.TenantId, Id = state.Id, OwnerPrincipalId = state.OwnerPrincipalId }; + db.RatelDeskConnectors.Add(record); + } + record.ConfigurationJson = configurationJson; + record.Revision = state.Revision; record.RowVersion = state.RowVersion; + record.ProtectedCredential = state.ProtectedCredential; record.CredentialRevision = state.CredentialRevision; + record.AuthenticationJson = authenticationJson; + record.ReadinessJson = state.Readiness is null ? null : JsonSerializer.Serialize(state.Readiness); + try + { + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + if (transaction is not null) await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (DbUpdateConcurrencyException) { db.Entry(record).State = EntityState.Detached; return false; } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "PK_RatelDeskConnectors" }) + { db.Entry(record).State = EntityState.Detached; return false; } + } + + public async Task GetAuthenticationAsync(int tenantId, Guid connectorId, CancellationToken ct) + { + var current = await GetAsync(tenantId, connectorId, ct).ConfigureAwait(false) ?? throw new UnauthorizedAccessException("connector-not-found"); + return current.Authentication ?? new(RatelDeskAuthenticationMode.ManualApiBearer, null); + } + + public Task SaveReadinessAsync(RatelDeskConnectorState current, RatelDeskReadinessObservation observation, CancellationToken ct) + { + if (observation.ConnectorRevision != current.Revision || observation.Peer.ConnectorId != current.Id || + observation.Peer.LocalTenantId != current.TenantId) throw new ArgumentException("invalid-connector-readiness"); + return SaveAsync(current with { RowVersion = checked(current.RowVersion + 1), Readiness = observation }, current.RowVersion, ct); + } + + public Task ClearReadinessAsync(RatelDeskConnectorState current, CancellationToken ct) => + SaveAsync(current with { RowVersion = checked(current.RowVersion + 1), Readiness = null }, current.RowVersion, ct); + + private static RatelDeskConnectorState Map(RatelDeskConnectorRecord r) => new(r.Id, r.TenantId, r.Revision, r.RowVersion, + r.OwnerPrincipalId, JsonSerializer.Deserialize(r.ConfigurationJson) ?? throw new InvalidOperationException("invalid-connector-record"), + r.ProtectedCredential, r.CredentialRevision, + r.AuthenticationJson is null ? null : + JsonSerializer.Deserialize(r.AuthenticationJson) ?? throw new InvalidOperationException("invalid-connector-authentication"), + r.ReadinessJson is null ? null : JsonSerializer.Deserialize(r.ReadinessJson) ?? throw new InvalidOperationException("invalid-connector-readiness")); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManagedRatelDeskBindingResolver.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManagedRatelDeskBindingResolver.cs new file mode 100644 index 00000000..da2606c0 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManagedRatelDeskBindingResolver.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class ManagedRatelDeskBindingResolver(ServiceLinkProfileService profiles, + ServiceLinkIdentityStore installation, IFlowSourceIdentityResolver flowIdentity, + IRatelDeskConnectorAuthorization authorization, IRatelDeskConnectorStore connectors) : IRatelDeskOutboundBindingResolver +{ + public async Task CaptureAsync(RatelDeskConnectorState connector, + RatelDeskConnectorAuthentication authentication, Guid source, CancellationToken ct) + { + if (authentication.Mode != RatelDeskAuthenticationMode.ManagedServiceLink || + string.IsNullOrEmpty(authentication.ManagedLinkId)) + throw new InvalidOperationException("explicit-manual-binding-resolver-required"); + await CurrentOwner(connector, ct); + if (source == Guid.Empty || await flowIdentity.EnsureAsync(ct) != source) + throw new InvalidOperationException("flow-source-identity-drift"); + var installed = await installation.GetAsync(ct); + if (installed.SourceInstanceId != source.ToString("D")) + throw new InvalidOperationException("flow-source-adoption-required"); + var current = await profiles.ResolveAsync(connector.TenantId, authentication.ManagedLinkId, + "rateldesk.incidents.create", ct); + RequireIncidentScopes(current); + var grant = current.Grant; + var constraints = grant.ResourceConstraints; + if (current.Peer.ApiBaseUrl != connector.Configuration.Origin || current.SourceInstanceId != source.ToString("D") || + current.PeerTenantId != connector.Configuration.OrganizationId || + constraints.OrganizationId != connector.Configuration.OrganizationId || + !constraints.CustomerIds.Contains(connector.Configuration.CustomerId, StringComparer.Ordinal) || + constraints.CustomerIds.Length != 1) + throw new UnauthorizedAccessException("approved-incident-target-mismatch"); + return new(RatelDeskAuthenticationMode.ManagedServiceLink, connector.TenantId, connector.Id, + current.LinkId, current.LinkRevision, current.GrantHash, current.PeerInstanceId, + current.PeerTenantId, current.Peer.ApiBaseUrl, current.Credential.Issuer, + current.Credential.Audience, current.Credential.TokenEndpoint, current.Credential.ClientId, + grant.DirectionId, source, Guid.ParseExact(current.SourceNamespaceId, "D"), + connector.Configuration.OrganizationId, connector.Configuration.CustomerId, + connector.Configuration.AssignedToId, + connector.Configuration.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal).ToArray()); + } + + public Task GetBearerAsync(RatelDeskConnectorState currentConnector, + RatelDeskSemanticPeer captured, string scope, CancellationToken ct) => + GetBearerWithRetryAsync(currentConnector, captured, scope, remainingRefreshes: 1, ct); + + private async Task GetBearerWithRetryAsync(RatelDeskConnectorState currentConnector, + RatelDeskSemanticPeer captured, string scope, int remainingRefreshes, CancellationToken ct) + { + if (captured.Mode != RatelDeskAuthenticationMode.ManagedServiceLink || captured.LinkId is null) + throw new InvalidOperationException("explicit-manual-binding-resolver-required"); + currentConnector = await CurrentConnectorAsync(currentConnector, captured, ct); + var current = await profiles.ResolveAsync(captured.LocalTenantId, captured.LinkId, scope, ct); + RequireCapturedPeer(current, captured); + // Fresh profile, including its current credential revision, is passed to the existing + // token cache. If rotation races this call, reacquire same target and retry BEFORE send. + try + { + var bearer = await profiles.GetAccessTokenAsync(current, scope, ct); + // Token acquisition is remote I/O. All local authority observations made + // before it must be repeated against durable current state after it. + currentConnector = await CurrentConnectorAsync(currentConnector, captured, ct); + var afterToken = await profiles.ResolveAsync(captured.LocalTenantId, captured.LinkId, scope, ct); + RequireCapturedPeer(afterToken, captured); + ServiceLinkProfileService.RequireCurrentSnapshot(current, afterToken); + return bearer; + } + catch (ServiceLinkProtocolException error) when (remainingRefreshes > 0 && + error.StatusCode == 409 && error.Code == "profile-revision-conflict") + { + // Exactly one bounded fresh retry, before business send. Every semantic, + // source, owner and connector check above runs again for the same capture. + return await GetBearerWithRetryAsync(currentConnector, captured, scope, remainingRefreshes - 1, ct); + } + } + + private async Task CurrentConnectorAsync(RatelDeskConnectorState expected, + RatelDeskSemanticPeer captured, CancellationToken ct) + { + var current = await connectors.GetAsync(expected.TenantId, expected.Id, ct); + if (current is null || current.Revision != expected.Revision || current.OwnerPrincipalId != expected.OwnerPrincipalId) + throw new UnauthorizedAccessException("captured-connector-revision-no-longer-authorized"); + if (current.Authentication?.Mode != RatelDeskAuthenticationMode.ManagedServiceLink || + current.Authentication?.ManagedLinkId != captured.LinkId) + throw new UnauthorizedAccessException("captured-connector-binding-no-longer-authorized"); + await CurrentOwner(current, ct); + if (current.TenantId != captured.LocalTenantId || current.Id != captured.ConnectorId || + current.Configuration.Origin != captured.ApiBaseUrl || + current.Configuration.OrganizationId != captured.OrganizationId || + current.Configuration.CustomerId != captured.CustomerId || + current.Configuration.AssignedToId != captured.AssignedToId || + !current.Configuration.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal) + .SequenceEqual(captured.CategoryIds, StringComparer.Ordinal) || + await flowIdentity.EnsureAsync(ct) != captured.SourceInstanceId || + (await installation.GetAsync(ct)).SourceInstanceId != captured.SourceInstanceId.ToString("D")) + throw new UnauthorizedAccessException("captured-flow-source-no-longer-authorized"); + return current; + } + + private static void RequireCapturedPeer(ServiceLinkResolvedProfile current, RatelDeskSemanticPeer captured) + { + RequireIncidentScopes(current); + // CredentialRevision/ProfileRevision deliberately excluded: rotation must not retarget work. + if (current.LinkRevision != captured.LinkRevision || current.GrantHash != captured.GrantHash || + current.PeerInstanceId != captured.ReceiverInstanceId || current.PeerTenantId != captured.PeerTenantId || + current.Peer.ApiBaseUrl != captured.ApiBaseUrl || current.Credential.Issuer != captured.Issuer || + current.Credential.Audience != captured.Audience || current.Credential.TokenEndpoint != captured.TokenEndpoint || + current.Credential.ClientId != captured.ClientId || current.Grant.DirectionId != captured.DirectionId || + current.SourceInstanceId != captured.SourceInstanceId.ToString("D") || + current.SourceNamespaceId != captured.SourceNamespaceId.ToString("D") || + current.Grant.ResourceConstraints.OrganizationId != captured.OrganizationId || + current.Grant.ResourceConstraints.CustomerIds.Length != 1 || + !current.Grant.ResourceConstraints.CustomerIds.Contains(captured.CustomerId, StringComparer.Ordinal)) + throw new UnauthorizedAccessException("captured-semantic-target-no-longer-authorized"); + } + + private async Task CurrentOwner(RatelDeskConnectorState connector, CancellationToken ct) + { + if (!connector.Configuration.Enabled || !await authorization.CanExecuteAsync( + connector.OwnerPrincipalId, null, connector.TenantId, ct)) + throw new UnauthorizedAccessException("connector-disabled-or-owner-denied"); + } + private static void RequireIncidentScopes(ServiceLinkResolvedProfile profile) + { + foreach (var scope in new[] { "rateldesk.incidents.create", "rateldesk.incident-receipts.read", "rateldesk.incident-targets.read" }) + if (!profile.Grant.Scopes.Contains(scope, StringComparer.Ordinal)) + throw new UnauthorizedAccessException("complete-incident-receiver-grant-required"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManualRatelDeskBindingResolver.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManualRatelDeskBindingResolver.cs new file mode 100644 index 00000000..cdbecd4e --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ManualRatelDeskBindingResolver.cs @@ -0,0 +1,158 @@ +using System.Security.Cryptography; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.ServiceLinks; + +namespace NetRatel.Infrastructure.RatelDesk; + +public interface IRatelDeskProducerContinuity +{ + Task RequireCurrentAsync(Guid expectedFlowSource, CancellationToken ct); +} +public interface IRatelDeskInstallationIdentityReader +{ + Task GetAsync(CancellationToken ct); +} +// Read adapter only: all deployment application and identity authority stay in the existing store. +public sealed class RatelDeskInstallationIdentityReader(ServiceLinkIdentityStore installation) + : IRatelDeskInstallationIdentityReader +{ + public Task GetAsync(CancellationToken ct) => installation.GetAsync(ct); +} +public sealed class RatelDeskProducerContinuity(IFlowSourceIdentityResolver flow, + IRatelDeskInstallationIdentityReader installation) : IRatelDeskProducerContinuity +{ + public async Task RequireCurrentAsync(Guid expectedFlowSource, CancellationToken ct) + { + if (expectedFlowSource == Guid.Empty) throw new UnauthorizedAccessException("flow-source-identity-drift"); + // Apply explicit deployment identity before an absent Flow singleton can be seeded. + var before = await installation.GetAsync(ct); + if (await flow.EnsureAsync(ct) != expectedFlowSource) + throw new UnauthorizedAccessException("flow-source-identity-drift"); + var current = await installation.GetAsync(ct); // Recheck after potentially creating Flow identity. + if (current.InstanceId != before.InstanceId) + throw new UnauthorizedAccessException("installation-identity-drift"); + // Manual source registration does not require enabling or creating an OAuth service link. + // An already adopted distinct identity is preserved and requires explicit mapping/reapproval. + if (current.SourceInstanceId is not null && current.SourceInstanceId != expectedFlowSource.ToString("D")) + throw new UnauthorizedAccessException("explicit-source-mapping-and-reapproval-required"); + } +} + +public sealed record RatelDeskManualProfileObservation(RatelDeskSemanticPeer Peer, + RatelDeskVerifiedCapability Capability); +public interface IRatelDeskManualProfileProbe +{ + Task CaptureAsync(RatelDeskConnectorState connector, + Guid source, string bearer, CancellationToken ct); +} + +public sealed class RatelDeskManualProfileProbe(RatelDeskReceiverHttpPipeline http, + RatelDeskReceiverNetworkPolicy network, TimeProvider time) : IRatelDeskManualProfileProbe +{ + public async Task CaptureAsync(RatelDeskConnectorState connector, + Guid source, string bearer, CancellationToken ct) + { + var api = network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, connector.Configuration.Origin); + var reply = await http.ReadAsync(RatelDeskAuthenticationMode.ManualApiBearer, connector.TenantId, + connector.Id, api, source, bearer, HttpMethod.Get, + ReceiverWireValidation.Endpoint(api, ReceiverWireValidation.CapabilitiesPath), null, ct); + RatelDeskReceiverReadException.RequireJsonSuccess(reply, "capability"); + // This first authenticated 200 supplies receiver and namespace identity. No header/default/receipt fallback. + var observation = ReceiverWireValidation.CaptureManual(reply.Body, connector, api, source, time.GetUtcNow()); + var targetBody = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new + { + organizationId = observation.Peer.OrganizationId, customerId = observation.Peer.CustomerId, + assignedToId = observation.Peer.AssignedToId, categoryIds = observation.Peer.CategoryIds + }, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + var target = await http.ReadAsync(RatelDeskAuthenticationMode.ManualApiBearer, connector.TenantId, + connector.Id, api, source, bearer, HttpMethod.Post, + observation.Capability.Endpoints.TargetValidation, targetBody, ct); + RatelDeskReceiverReadException.RequireJsonSuccess(target, "target-validation"); + ReceiverWireValidation.Target(target.Body, observation.Peer); + return observation; + } +} + +public sealed class ManualRatelDeskBindingResolver(IRatelDeskConnectorStore connectors, + IRatelDeskConnectorBindingStore bindings, IRatelDeskConnectorAuthorization authorization, + IRatelDeskCredentialProtector protector, IRatelDeskProducerContinuity source, + IRatelDeskManualProfileProbe probe, RatelDeskReceiverNetworkPolicy network) : IRatelDeskOutboundBindingResolver +{ + public async Task CaptureAsync(RatelDeskConnectorState connector, + RatelDeskConnectorAuthentication authentication, Guid flowSource, CancellationToken ct) + { + RequireManual(authentication); + var current = await CurrentAsync(connector, ct); + await source.RequireCurrentAsync(flowSource, ct); + var bearer = Unprotect(current); + var captured = await probe.CaptureAsync(current, flowSource, bearer, ct); + // Remote awaits cannot authorize a changed local owner, source, mode, target or credential. + // A concurrent credential replacement triggers a new explicit read-only capture, not mixed responses. + var after = await CurrentAsync(current, ct); + await source.RequireCurrentAsync(flowSource, ct); + if (after.CredentialRevision != current.CredentialRevision || after.ProtectedCredential != current.ProtectedCredential) + throw new InvalidOperationException("manual-credential-changed-during-capture"); + RequireSemantic(after, captured.Peer); + return captured.Peer; + } + + public async Task GetBearerAsync(RatelDeskConnectorState connector, + RatelDeskSemanticPeer captured, string requiredScope, CancellationToken ct) + { + if (!IncidentScopes.Contains(requiredScope, StringComparer.Ordinal)) + throw new UnauthorizedAccessException("incident-operation-scope-required"); + var current = await CurrentAsync(connector, ct); + await source.RequireCurrentAsync(captured.SourceInstanceId, ct); + RequireSemantic(current, captured); + // Credential revision is deliberately not a semantic match. Rotation supplies the + // newest existing protected rdk_ value for the SAME peer, source namespace and mapping. + // The receiver checks credential revocation/owner/source/binding on every actual request. + return Unprotect(current); + } + + private async Task CurrentAsync(RatelDeskConnectorState captured, CancellationToken ct) + { + var current = await connectors.GetAsync(captured.TenantId, captured.Id, ct); + if (current is null || current.Id != captured.Id || current.TenantId != captured.TenantId || + current.Revision != captured.Revision || current.OwnerPrincipalId != captured.OwnerPrincipalId || !current.Configuration.Enabled || + !await authorization.CanExecuteAsync(current.OwnerPrincipalId, null, current.TenantId, ct)) + throw new UnauthorizedAccessException("connector-current-owner-or-revision-denied"); + RequireManual(await bindings.GetAuthenticationAsync(current.TenantId, current.Id, ct)); + _ = network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, current.Configuration.Origin); + return current; + } + + private static readonly string[] IncidentScopes = + ["rateldesk.incidents.create", "rateldesk.incident-receipts.read", "rateldesk.incident-targets.read"]; + private static void RequireManual(RatelDeskConnectorAuthentication authentication) + { + if (authentication.Mode != RatelDeskAuthenticationMode.ManualApiBearer || authentication.ManagedLinkId is not null) + throw new UnauthorizedAccessException("explicit-manual-api-bearer-binding-required"); + } + private static void RequireSemantic(RatelDeskConnectorState connector, RatelDeskSemanticPeer peer) + { + if (peer.Mode != RatelDeskAuthenticationMode.ManualApiBearer || peer.LinkId is not null || + peer.LinkRevision is not null || peer.GrantHash is not null || peer.Issuer is not null || + peer.Audience is not null || peer.TokenEndpoint is not null || peer.ClientId is not null || peer.DirectionId is not null || + peer.LocalTenantId != connector.TenantId || peer.ConnectorId != connector.Id || + peer.ApiBaseUrl != RatelDeskApiBase.Canonical(connector.Configuration.Origin) || + peer.PeerTenantId != connector.Configuration.OrganizationId || + peer.OrganizationId != connector.Configuration.OrganizationId || peer.CustomerId != connector.Configuration.CustomerId || + peer.AssignedToId != connector.Configuration.AssignedToId || peer.SourceNamespaceId == Guid.Empty || + !Guid.TryParseExact(peer.ReceiverInstanceId, "D", out var receiver) || receiver == Guid.Empty || receiver.ToString("D") != peer.ReceiverInstanceId || + !connector.Configuration.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal) + .SequenceEqual(peer.CategoryIds, StringComparer.Ordinal)) + throw new UnauthorizedAccessException("captured-manual-semantic-target-denied"); + } + private string Unprotect(RatelDeskConnectorState current) + { + if (current.ProtectedCredential is null) throw new UnauthorizedAccessException("manual-api-bearer-required"); + string bearer; + try { bearer = protector.Unprotect(current.TenantId, current.Id, current.ProtectedCredential); } + catch (CryptographicException) { throw new UnauthorizedAccessException("manual-api-bearer-unavailable"); } + if (!RatelDeskConnectorService.ValidCredential(bearer)) + throw new UnauthorizedAccessException("manual-api-purpose-bearer-required"); + return bearer; + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorAuthorization.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorAuthorization.cs new file mode 100644 index 00000000..f973518c --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorAuthorization.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Identity; +using NetRatel.Infrastructure.Identity.Authorization; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskConnectorAuthorization(IEffectiveAccessService access, NetRatelIdentityDbContext identity) + : IRatelDeskConnectorAuthorization +{ + public async Task CanManageAsync(ClaimsPrincipal principal, int tenantId, CancellationToken cancellationToken) + { + var id = principal.FindFirst("netratel_principal_id")?.Value; + if (string.IsNullOrWhiteSpace(id) || !await IsCurrentPrincipalAsync(id, cancellationToken).ConfigureAwait(false)) return false; + return await access.AuthorizeAsync(principal, NetRatelPermissions.IntegrationManagement, tenantId, cancellationToken).ConfigureAwait(false); + } + + public async Task CanExecuteAsync(string principalId, string? integrationCredentialId, int tenantId, CancellationToken cancellationToken) + { + if (tenantId <= 0 || !await IsCurrentPrincipalAsync(principalId, cancellationToken).ConfigureAwait(false)) return false; + var claims = new List { new("netratel_principal_id", principalId) }; + if (integrationCredentialId is not null) claims.Add(new("netratel_integration_credential_id", integrationCredentialId)); + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "PersistedFlowAuthority")); + return await access.AuthorizeAsync(principal, NetRatelPermissions.IntegrationManagement, tenantId, cancellationToken).ConfigureAwait(false) && + await access.AuthorizeAsync(principal, NetRatelPermissions.SecretUse, tenantId, cancellationToken).ConfigureAwait(false); + } + + private async Task IsCurrentPrincipalAsync(string id, CancellationToken cancellationToken) => + !string.IsNullOrWhiteSpace(id) && await identity.ApplicationPrincipals.AsNoTracking().AnyAsync(p => p.Id == id, cancellationToken).ConfigureAwait(false) && + !await identity.Users.AsNoTracking().AnyAsync(user => user.PrincipalId == id && !user.IsEnabled, cancellationToken).ConfigureAwait(false); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReadiness.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReadiness.cs new file mode 100644 index 00000000..fa08fe05 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReadiness.cs @@ -0,0 +1,64 @@ +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// A bounded authenticated read observation plus current durable authority; never performs HTTP on catalog reads. +public sealed class RatelDeskConnectorReadiness(IRatelDeskConnectorAuthorization authorization, + IRatelDeskConnectorBindingStore bindings, IRatelDeskProducerContinuity source, + ServiceLinkProfileService profiles, RatelDeskReceiverNetworkPolicy network, TimeProvider clock) + : IRatelDeskConnectorReadiness +{ + public static readonly TimeSpan MaximumObservationAge = TimeSpan.FromMinutes(2); + public async Task<(bool Available, string Code)> CurrentAsync(RatelDeskConnectorState state, CancellationToken ct) + { + if (!state.Configuration.Enabled || !await authorization.CanExecuteAsync(state.OwnerPrincipalId, null, state.TenantId, ct)) + return (false, "connector-disabled-or-owner-denied"); + var observed = state.Readiness; + if (observed is null || observed.Peer is null || observed.Capability is null || + observed.Capability.Endpoints is null || observed.Peer.CategoryIds is null || observed.ConnectorRevision != state.Revision) + return (false, "receiver-readiness-required"); + var now = clock.GetUtcNow(); + if (observed.TargetValidatedAtUtc > now || now - observed.TargetValidatedAtUtc > MaximumObservationAge || + observed.Capability.ObservedAtUtc > observed.TargetValidatedAtUtc || + observed.TargetValidatedAtUtc - observed.Capability.ObservedAtUtc > TimeSpan.FromSeconds(20)) + return (false, "receiver-readiness-expired"); + try + { + var authentication = await bindings.GetAuthenticationAsync(state.TenantId, state.Id, ct); + var peer = observed.Peer; + if (authentication.Mode != peer.Mode || authentication.ManagedLinkId != peer.LinkId || + peer.ConnectorId != state.Id || peer.LocalTenantId != state.TenantId || + network.ValidateApprovedApiBase(peer.Mode, state.Configuration.Origin) != peer.ApiBaseUrl || + peer.OrganizationId != state.Configuration.OrganizationId || peer.CustomerId != state.Configuration.CustomerId || + peer.AssignedToId != state.Configuration.AssignedToId || + !state.Configuration.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal) + .SequenceEqual(peer.CategoryIds, StringComparer.Ordinal)) + return (false, "receiver-semantic-target-changed"); + await source.RequireCurrentAsync(peer.SourceInstanceId, ct); + if (peer.Mode == RatelDeskAuthenticationMode.ManagedServiceLink) + { + foreach (var scope in new[] { "rateldesk.incidents.create", "rateldesk.incident-receipts.read", "rateldesk.incident-targets.read" }) + { + var profile = await profiles.ResolveAsync(state.TenantId, peer.LinkId!, scope, ct); + if (profile.LinkRevision != peer.LinkRevision || profile.GrantHash != peer.GrantHash || + profile.PeerInstanceId != peer.ReceiverInstanceId || profile.PeerTenantId != peer.PeerTenantId || + profile.Peer.ApiBaseUrl != peer.ApiBaseUrl || profile.SourceInstanceId != peer.SourceInstanceId.ToString("D") || + profile.SourceNamespaceId != peer.SourceNamespaceId.ToString("D") || + profile.Credential.Issuer != peer.Issuer || profile.Credential.Audience != peer.Audience || + profile.Credential.TokenEndpoint != peer.TokenEndpoint || profile.Credential.ClientId != peer.ClientId || + profile.Grant.DirectionId != peer.DirectionId) + return (false, "receiver-managed-grant-changed"); + } + } + else if (state.ProtectedCredential is null) return (false, "manual-api-bearer-required"); + if (!ReceiverWireValidation.ValidCapability(observed.Capability, peer) || observed.Capability.ContractVersion != ReceiverWireValidation.Contract || + observed.Capability.ReceiverInstanceId != peer.ReceiverInstanceId || + observed.Capability.SourceInstanceId != peer.SourceInstanceId || observed.Capability.SourceNamespaceId != peer.SourceNamespaceId) + return (false, "receiver-readiness-invalid"); + return (true, "receiver-ready"); + } + catch (Exception error) when (error is UnauthorizedAccessException or ServiceLinkProtocolException or ArgumentException or InvalidOperationException) + { return (false, "receiver-current-authority-unavailable"); } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReceiver.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReceiver.cs new file mode 100644 index 00000000..4c67ec2b --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorReceiver.cs @@ -0,0 +1,94 @@ +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskConnectorReceiver(IRatelDeskConnectorStore connectors, + IRatelDeskConnectorBindingStore references, IRatelDeskConnectorReadinessStore observations, + IRatelDeskConnectorReadiness readiness, IRatelDeskOutboundBindingResolver bindings, + IRatelDeskReceiverTransport transport, IFlowSourceIdentityResolver source, + RatelDeskReceiverNetworkPolicy network, TimeProvider clock) +{ + public string ValidateApiBase(RatelDeskConnectorAuthentication authentication, string api) => + network.ValidateApprovedApiBase(authentication.Mode, api); + + public Task<(bool Available, string Code)> CurrentAsync(RatelDeskConnectorState state, CancellationToken ct) => + readiness.CurrentAsync(state, ct); + + public async Task TestAsync(RatelDeskConnectorState state, CancellationToken ct) + { + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(20), clock); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, budget.Token); + var token = linked.Token; + async Task FailureAsync(RatelDeskConnectionTestResult failure) + { + // A failed fresh observation cannot leave an older success presented as current. + // Exact row CAS cannot erase a newer successful observation or concurrent credential/configuration edit. + _ = await observations.ClearReadinessAsync(state, token).ConfigureAwait(false); + return failure; + } + try + { + if (state.Readiness is not null) + { + // Invalidate the previous observation before probing, so inner ten-second + // cancellation or a caller stop cannot expose a retained success afterward. + if (!await observations.ClearReadinessAsync(state, token).ConfigureAwait(false)) + return new(RatelDeskConnectionTestStatus.Unavailable, "connector-changed-during-readiness"); + state = state with { RowVersion = checked(state.RowVersion + 1), Readiness = null }; + } + var authentication = await references.GetAuthenticationAsync(state.TenantId, state.Id, token); + var flowSource = await source.EnsureAsync(token); + var peer = await bindings.CaptureAsync(state, authentication, flowSource, token); + var bearer = await bindings.GetBearerAsync(state, peer, "rateldesk.incident-receipts.read", token); + var capability = await transport.CapabilitiesAsync(peer, bearer, token); + bearer = await bindings.GetBearerAsync(state, peer, "rateldesk.incident-targets.read", token); + await transport.ValidateTargetsAsync(peer, capability, bearer, token); + // The current resolver rechecks local owner/source/profile after every network phase. + _ = await bindings.GetBearerAsync(state, peer, "rateldesk.incidents.create", token); + var after = await connectors.GetAsync(state.TenantId, state.Id, token); + if (after is null || after.Revision != state.Revision || after.RowVersion != state.RowVersion || + after.CredentialRevision != state.CredentialRevision || after.OwnerPrincipalId != state.OwnerPrincipalId) + return await FailureAsync(new(RatelDeskConnectionTestStatus.Unavailable, "connector-changed-during-readiness")); + var observed = new RatelDeskReadinessObservation(state.Revision, peer, capability, clock.GetUtcNow()); + if (!await observations.SaveReadinessAsync(after, observed, token)) + return await FailureAsync(new(RatelDeskConnectionTestStatus.Unavailable, "connector-changed-during-readiness")); + return new(RatelDeskConnectionTestStatus.MappingValidated, "receiver-ready", true); + } + catch (RatelDeskReceiverReadException error) + { + var status = error.HttpStatus switch + { + 401 or 403 => RatelDeskConnectionTestStatus.AuthenticationRejected, + 400 or 422 => RatelDeskConnectionTestStatus.MappingRejected, + _ => RatelDeskConnectionTestStatus.Unavailable + }; + return await FailureAsync(new(status, error.Code, false, error.RetryAfter is null ? null : + (int)Math.Clamp(Math.Ceiling(error.RetryAfter.Value.TotalSeconds), 1, 300))); + } + catch (UnauthorizedAccessException) { return await FailureAsync(new(RatelDeskConnectionTestStatus.AuthenticationRejected, "receiver-current-authority-denied")); } + catch (Exception error) when (error is InvalidDataException or ArgumentException or InvalidOperationException or + HttpRequestException or NetRatel.Infrastructure.ServiceLinks.ServiceLinkProtocolException) + { return await FailureAsync(new(RatelDeskConnectionTestStatus.Unavailable, "receiver-readiness-unverified")); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-readiness-timeout"); } + } + + public static RatelDeskConnectorAuthentication Parse(RatelDeskConnectorAuthenticationDto? value, + RatelDeskConnectorAuthentication? existing = null) + { + if (value is null) return existing ?? new(RatelDeskAuthenticationMode.ManualApiBearer, null); + if (value is { Mode: "api_bearer", ManagedLinkId: null }) return new(RatelDeskAuthenticationMode.ManualApiBearer, null); + if (value is { Mode: "service_link", ManagedLinkId: { } managedLinkId }) + { + try { ServiceLinkValidation.Id(managedLinkId); } + catch (ServiceLinkProtocolException) { throw new ArgumentException("invalid-connector-authentication-reference"); } + return new(RatelDeskAuthenticationMode.ManagedServiceLink, managedLinkId); + } + throw new ArgumentException("invalid-connector-authentication-reference"); + } + + public static RatelDeskConnectorAuthenticationDto ToDto(RatelDeskConnectorAuthentication? value) => + value?.Mode == RatelDeskAuthenticationMode.ManagedServiceLink ? new("service_link", value.ManagedLinkId) : new("api_bearer"); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorRegistration.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorRegistration.cs new file mode 100644 index 00000000..b2d9cdf4 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorRegistration.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static class RatelDeskConnectorRegistration +{ + public static IServiceCollection AddRatelDeskConnector(this IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHttpClient(http => http.Timeout = Timeout.InfiniteTimeSpan) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { AllowAutoRedirect = false, UseCookies = false, ConnectTimeout = TimeSpan.FromSeconds(5), PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + return services; + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorService.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorService.cs new file mode 100644 index 00000000..66c59056 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorService.cs @@ -0,0 +1,103 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskConnectorService(IRatelDeskConnectorStore store, IRatelDeskConnectorAuthorization authorization, + IRatelDeskCredentialProtector protector, IRatelDeskOriginPolicy origins, IRatelDeskConnectionTester tester, + RatelDeskConnectorReceiver? receiver = null) : IRatelDeskConnectorService +{ + public async Task> ListAsync(int tenantId, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + var result = new List(); + foreach (var state in await store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + result.Add(await ToCurrentDtoAsync(state, cancellationToken).ConfigureAwait(false)); + return result; + } + public async Task GetAsync(int tenantId, Guid id, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + var state = await store.GetAsync(tenantId, id, cancellationToken).ConfigureAwait(false); + return state is null ? null : await ToCurrentDtoAsync(state, cancellationToken).ConfigureAwait(false); + } + public async Task SaveAsync(int tenantId, Guid id, SaveRatelDeskConnectorRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + Validate(request.Configuration, validateOrigin: receiver is null); + if (id == Guid.Empty || request.ExpectedRevision < 0) throw new ArgumentException("invalid-connector-id-or-revision"); + var current = await store.GetAsync(tenantId, id, cancellationToken).ConfigureAwait(false); + if ((current?.Revision ?? 0) != request.ExpectedRevision) throw new InvalidOperationException("connector-conflict"); + var owner = current?.OwnerPrincipalId ?? principal.FindFirst("netratel_principal_id")!.Value; + if (!await authorization.CanExecuteAsync(owner, null, tenantId, cancellationToken).ConfigureAwait(false)) throw new UnauthorizedAccessException(); + var authentication = RatelDeskConnectorReceiver.Parse(request.Authentication, current?.Authentication); + var apiBase = receiver is null ? new Uri(request.Configuration.Origin).GetLeftPart(UriPartial.Authority) : + receiver.ValidateApiBase(authentication, request.Configuration.Origin); + var configuration = request.Configuration with { Origin = apiBase, CategoryIds = request.Configuration.CategoryIds.Order().ToArray() }; + var changedMode = current is not null && authentication != (current.Authentication ?? new(RatelDeskAuthenticationMode.ManualApiBearer, null)); + var state = new RatelDeskConnectorState(id, tenantId, checked(request.ExpectedRevision + 1), checked((current?.RowVersion ?? 0) + 1), owner, + configuration, changedMode ? null : current?.ProtectedCredential, + checked((current?.CredentialRevision ?? 0) + (changedMode && current?.ProtectedCredential is not null ? 1 : 0)), + authentication, null); + if (!await store.SaveAsync(state, current?.RowVersion ?? 0, cancellationToken).ConfigureAwait(false)) throw new InvalidOperationException("connector-conflict"); + return await ToCurrentDtoAsync(state, cancellationToken).ConfigureAwait(false); + } + public async Task RotateAsync(int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + if (!ValidCredential(request.Credential)) throw new ArgumentException("api-purpose-rateldesk-credential-required"); + var current = await store.GetAsync(tenantId, id, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException(); + if (!await authorization.CanExecuteAsync(current.OwnerPrincipalId, null, tenantId, cancellationToken).ConfigureAwait(false)) + throw new UnauthorizedAccessException(); + if ((current.Authentication?.Mode ?? RatelDeskAuthenticationMode.ManualApiBearer) != RatelDeskAuthenticationMode.ManualApiBearer) + throw new ArgumentException("managed-credential-rotation-requires-service-link-administration"); + if (current.CredentialRevision != request.ExpectedCredentialRevision) throw new InvalidOperationException("connector-conflict"); + var state = current with { RowVersion = checked(current.RowVersion + 1), CredentialRevision = checked(current.CredentialRevision + 1), + ProtectedCredential = protector.Protect(tenantId, id, request.Credential), Readiness = null }; + if (!await store.SaveAsync(state, current.RowVersion, cancellationToken).ConfigureAwait(false)) throw new InvalidOperationException("connector-conflict"); + return await ToCurrentDtoAsync(state, cancellationToken).ConfigureAwait(false); + } + public async Task TestAsync(int tenantId, Guid id, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + var state = await store.GetAsync(tenantId, id, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException(); + if (receiver is not null) return await receiver.TestAsync(state, cancellationToken).ConfigureAwait(false); + if (state.ProtectedCredential is null) return new(RatelDeskConnectionTestStatus.Unavailable, "credential-required"); + if (!await authorization.CanExecuteAsync(state.OwnerPrincipalId, null, tenantId, cancellationToken).ConfigureAwait(false)) return new(RatelDeskConnectionTestStatus.Unavailable, "connector-owner-denied"); + try { return await tester.TestAsync(tenantId, id, state.Configuration, protector.Unprotect(tenantId, id, state.ProtectedCredential), cancellationToken).ConfigureAwait(false); } + catch (CryptographicException) { return new(RatelDeskConnectionTestStatus.Unavailable, "credential-unavailable"); } + } + public async Task DryRunAsync(int tenantId, Guid id, RatelDeskDryRunRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken) + { + await RequireManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false); + var state = await store.GetAsync(tenantId, id, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException(); + var payload = RatelDeskPayload.Create(state.Configuration, request.Title, request.Description, request.Priority); + return new(payload, RatelDeskPayload.Fingerprint(payload)); + } + + private async Task RequireManageAsync(ClaimsPrincipal principal, int tenantId, CancellationToken cancellationToken) + { if (tenantId <= 0 || !await authorization.CanManageAsync(principal, tenantId, cancellationToken).ConfigureAwait(false)) throw new UnauthorizedAccessException(); } + private void Validate(RatelDeskConnectorConfiguration c, bool validateOrigin = true) + { + static bool Id(string? value) => value is { Length: > 0 and <= 64 } && !value.Any(char.IsControl) && value.Trim() == value; + if (c is null || c.Name is not { Length: > 0 and <= 128 } || c.Name.Any(char.IsControl) || validateOrigin && !origins.TryValidate(c.Origin, out _) || + !Id(c.OrganizationId) || !Id(c.CustomerId) || c.AssignedToId is not null && !Id(c.AssignedToId) || + c.CategoryIds is null || c.CategoryIds.Count > RatelDeskConnectorLimits.MaximumCategories || c.CategoryIds.Any(id => id == Guid.Empty) || c.CategoryIds.Distinct().Count() != c.CategoryIds.Count || + c.Priorities is null || new[] { c.Priorities.Information, c.Priorities.Warning, c.Priorities.Error, c.Priorities.Critical }.Any(p => p is < 0 or > 3)) + throw new ArgumentException("invalid-connector-configuration"); + } + public static bool ValidCredential(string? credential) => credential is { Length: >= 40 and <= 512 } && credential.StartsWith("rdk_", StringComparison.Ordinal) && credential.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-'); + public static RatelDeskConnectorDto ToDto(RatelDeskConnectorState state) => new(state.Id, state.TenantId, state.Revision, + state.Configuration, state.ProtectedCredential is not null, state.CredentialRevision, false, RatelDeskConnectorLimits.ReceiverUnavailableCode, + RatelDeskConnectorReceiver.ToDto(state.Authentication), state.Readiness?.TargetValidatedAtUtc); + private async Task ToCurrentDtoAsync(RatelDeskConnectorState state, CancellationToken ct) + { + var dto = ToDto(state); + if (receiver is null) return dto; + var current = await receiver.CurrentAsync(state, ct).ConfigureAwait(false); + return dto with { AutomaticDeliveryAvailable = current.Available, AvailabilityCode = current.Code }; + } + +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorSetupService.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorSetupService.cs new file mode 100644 index 00000000..0d10908b --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskConnectorSetupService.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// Adopts the existing Flow singleton through the foundation's human-authorized identity operation. +public sealed class RatelDeskConnectorSetupService(IRatelDeskConnectorAuthorization authorization, + IFlowSourceIdentityResolver flow, ServiceLinkIdentityStore identity, ServiceLinkProfileService profiles, + OrchestratorDbContext db) : IRatelDeskConnectorSetupService +{ + public async Task GetAsync(int tenantId, ClaimsPrincipal actor, CancellationToken ct) + { + await RequireAsync(tenantId, actor, ct); + // Apply deployment identity before creating an absent Flow producer; existing Flow IDs always win. + var installed = await identity.GetAsync(ct); + var source = await flow.EnsureAsync(ct); + installed = await identity.GetAsync(ct); + var links = new List(); + var tenant = tenantId.ToString(CultureInfo.InvariantCulture); + var ids = await db.Set().AsNoTracking() + .Where(x => x.LocalTenantId == tenant && x.LinkId != null && x.LocalBusinessSenderEnabled) + .OrderBy(x => x.LinkId).Select(x => x.LinkId!).Take(100).ToListAsync(ct); + foreach (var id in ids) + { + try + { + var profile = await profiles.ResolveAsync(tenantId, id, "rateldesk.incidents.create", ct); + if (profile.SourceInstanceId != source.ToString("D") || + !profile.Grant.Scopes.Contains("rateldesk.incident-receipts.read", StringComparer.Ordinal) || + !profile.Grant.Scopes.Contains("rateldesk.incident-targets.read", StringComparer.Ordinal) || + profile.Grant.ResourceConstraints.CustomerIds.Length != 1) continue; + links.Add(new(id, profile.PeerInstanceId, profile.Peer.ApiBaseUrl, + profile.Grant.ResourceConstraints.OrganizationId!, profile.Grant.ResourceConstraints.CustomerIds[0], profile.LinkRevision)); + } + catch (ServiceLinkProtocolException) { /* Denied/incomplete profiles are not selectable. No discovery or token HTTP. */ } + } + return new(source, installed.InstanceId, installed.SourceInstanceId, installed.Revision, links); + } + + public async Task AdoptAsync(int tenantId, long expectedIdentityRevision, ClaimsPrincipal actor, CancellationToken ct) + { + await RequireAsync(tenantId, actor, ct); + if (actor.Identity?.IsAuthenticated != true || actor.FindFirst("netratel_integration_credential_id") is not null || + actor.FindFirst(ServiceIdentityClaims.PrincipalId) is not null || actor.HasClaim("auth_mode", "service") || + actor.HasClaim("auth_mode", "machine_token")) + throw new UnauthorizedAccessException("interactive-administrator-required"); + _ = await identity.GetAsync(ct); + var source = await flow.EnsureAsync(ct); + // Also requires the foundation's current instance integration-management human authority. + // A previous distinct producer remains a conflict; this never replaces or aliases it. + _ = await identity.AdoptSourceAsync(actor, source, expectedIdentityRevision, ct); + return await GetAsync(tenantId, actor, ct); + } + + private async Task RequireAsync(int tenantId, ClaimsPrincipal actor, CancellationToken ct) + { + if (tenantId <= 0 || !await authorization.CanManageAsync(actor, tenantId, ct)) + throw new UnauthorizedAccessException("connector-management-required"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowConnector.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowConnector.cs new file mode 100644 index 00000000..ac25c75a --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowConnector.cs @@ -0,0 +1,88 @@ +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// Preparation is read-only. The #145 flow store persists its exact result before dispatch acquires any side effect. +public sealed class RatelDeskFlowConnector(IRatelDeskConnectorStore store, IRatelDeskConnectorAuthorization authorization, + IRatelDeskOriginPolicy origins, IRatelDeskConnectorReadiness? readiness = null) : IFlowConnectorCatalog, IFlowIncidentActionDispatcher +{ + public async Task GetAsync(int tenantId, Guid connectorId, FlowExecutionAuthorityDto authority, CancellationToken cancellationToken = default) + { + if (!await AllowedAsync(authority, tenantId, cancellationToken).ConfigureAwait(false)) return null; + var state = await store.GetAsync(tenantId, connectorId, cancellationToken).ConfigureAwait(false); + return state is null ? null : await ReferenceAsync(state, cancellationToken).ConfigureAwait(false); + } + public async Task> ListAsync(int tenantId, FlowExecutionAuthorityDto authority, CancellationToken cancellationToken = default) + { + if (!await AllowedAsync(authority, tenantId, cancellationToken).ConfigureAwait(false)) return []; + var results = new List(); + foreach (var state in await store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + results.Add(await ReferenceAsync(state, cancellationToken).ConfigureAwait(false)); + return results; + } + public async Task PrepareAsync(FlowIncidentActionDraft draft, CancellationToken cancellationToken = default) + { + if (!FlowContractValidation.ValidEnvelope(draft.Event) || draft.Event.TenantId != draft.TenantId || draft.SourceInstanceId == Guid.Empty || draft.RunId == Guid.Empty || draft.ActionNodeId == Guid.Empty || + draft.Event.EventId == Guid.Empty || draft.Event.OccurrenceId == Guid.Empty || draft.Event.FlowVersionId == Guid.Empty || + draft.IdempotencyKey is not { Length: > 0 and <= 256 } || draft.IdempotencyKey.Any(char.IsControl)) + return new(FlowIncidentPreparationStatus.Invalid, Code: "invalid-incident-action"); + if (!await AllowedAsync(draft.Event.Authority, draft.TenantId, cancellationToken).ConfigureAwait(false)) + return new(FlowIncidentPreparationStatus.Denied, Code: "connector-current-authority-denied"); + var state = await store.GetAsync(draft.TenantId, draft.ConnectorId, cancellationToken).ConfigureAwait(false); + if (state is null || state.Revision != draft.ConnectorRevision) return new(FlowIncidentPreparationStatus.Unavailable, Code: "connector-revision-unavailable"); + if (!await IsUsableAsync(state, cancellationToken).ConfigureAwait(false)) return new(FlowIncidentPreparationStatus.Unavailable, Code: "connector-disabled-or-owner-denied"); + try + { + var priority = draft.Fields.Priority ?? MapPriority(state.Configuration.Priorities, draft.Event.Data.Severity); + var description = draft.Fields.Description + "\n\nNetRatel monitoring occurrence\n" + + $"Client: {draft.Event.Data.ClientName} ({draft.Event.Data.AgentId:D})\nResource: {draft.Event.Data.Resource}\n" + + $"Rule: {draft.Event.Data.RuleName} ({draft.Event.Data.RuleId:D})\nSeverity: {draft.Event.Data.Severity}\n" + + $"Metric: {draft.Event.Data.Metric}\nObserved value: {draft.Event.Data.NumericValue?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? draft.Event.Data.ServiceState ?? "Unknown"}\n" + + $"Observed at: {draft.Event.Data.ObservedAtUtc:O}\nOccurred at: {draft.Event.OccurredAtUtc:O}\n" + + $"Source: {draft.SourceInstanceId:D}/{draft.Event.OccurrenceId:D}/{draft.Event.EventId:D}"; + var payload = RatelDeskPayload.Create(state.Configuration, draft.Fields.Title, description, priority); + var target = new FlowIncidentTargetDto(payload.OrganizationId, payload.CustomerId, payload.AssignedToId, payload.CategoryIds); + var action = new FlowIncidentActionRequest(draft.TenantId, draft.RunId, draft.ActionNodeId, draft.ConnectorId, + state.Revision, draft.SourceInstanceId, draft.IdempotencyKey, draft.Event, + new(payload.Title, payload.Description, payload.Priority), target, false, ""); + action = action with { SemanticFingerprint = FlowContractValidation.Fingerprint(action) }; + if (!FlowContractValidation.ValidPrepared(action, draft)) return new(FlowIncidentPreparationStatus.Invalid, Code: "invalid-incident-payload"); + return new(FlowIncidentPreparationStatus.Ready, action, RatelDeskConnectorLimits.ReceiverUnavailableCode); + } + catch (ArgumentException) { return new(FlowIncidentPreparationStatus.Invalid, Code: "invalid-incident-payload"); } + } + public async Task DispatchAsync(FlowIncidentActionRequest action, CancellationToken cancellationToken = default) + { + if (action.TenantId != action.Event.TenantId || !await AllowedAsync(action.Event.Authority, action.TenantId, cancellationToken).ConfigureAwait(false)) + return new(FlowIncidentActionResultKind.Failed, "connector-current-authority-denied"); + var current = await store.GetAsync(action.TenantId, action.ConnectorId, cancellationToken).ConfigureAwait(false); + if (current is null || current.Revision != action.ConnectorRevision) return new(FlowIncidentActionResultKind.Unavailable, "connector-revision-unavailable"); + if (!await IsUsableAsync(current, cancellationToken).ConfigureAwait(false)) return new(FlowIncidentActionResultKind.Unavailable, "connector-disabled-or-owner-denied"); + var payload = new RatelDeskCreateIncidentDto(action.Fields.Title, action.Fields.Description, action.Fields.Priority ?? -1, + action.Target.CustomerId, action.Target.OrganizationId, action.Target.AssignedToId, action.Target.CategoryIds); + if (payload.Priority is < 0 or > 3 || payload.OrganizationId != current.Configuration.OrganizationId || payload.CustomerId != current.Configuration.CustomerId || + payload.AssignedToId != current.Configuration.AssignedToId || !payload.CategoryIds.SequenceEqual(current.Configuration.CategoryIds.Order()) || + FlowContractValidation.Fingerprint(action) != action.SemanticFingerprint) + return new(FlowIncidentActionResultKind.Failed, "persisted-incident-payload-invalid"); + // No HTTP POST, invented header or invented capability route can turn the existing normal-create API into safe replay. + return new(FlowIncidentActionResultKind.Unavailable, RatelDeskConnectorLimits.ReceiverUnavailableCode); + } + + private async Task ReferenceAsync(RatelDeskConnectorState state, CancellationToken cancellationToken) + { + var usable = await IsUsableAsync(state, cancellationToken).ConfigureAwait(false); + var current = readiness is null ? (Available: false, Code: usable ? RatelDeskConnectorLimits.ReceiverUnavailableCode : "connector-disabled-or-owner-denied") : + await readiness.CurrentAsync(state, cancellationToken).ConfigureAwait(false); + return new(state.Id, state.TenantId, state.Configuration.Name, state.Configuration.Enabled, current.Available, current.Code, state.Revision); + } + private Task AllowedAsync(FlowExecutionAuthorityDto authority, int tenantId, CancellationToken cancellationToken) => + authorization.CanExecuteAsync(authority.PrincipalId, authority.IntegrationCredentialId, tenantId, cancellationToken); + private async Task IsUsableAsync(RatelDeskConnectorState state, CancellationToken cancellationToken) => + state.Configuration.Enabled && state.ProtectedCredential is not null && origins.TryValidate(state.Configuration.Origin, out _) && + await authorization.CanExecuteAsync(state.OwnerPrincipalId, null, state.TenantId, cancellationToken).ConfigureAwait(false); + private static int MapPriority(RatelDeskPriorityMapping mapping, string severity) => severity.ToLowerInvariant() switch + { "critical" => mapping.Critical, "error" or "high" => mapping.Error, "warning" or "medium" => mapping.Warning, _ => mapping.Information }; +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowReceiverAdapter.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowReceiverAdapter.cs new file mode 100644 index 00000000..d109f9db --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskFlowReceiverAdapter.cs @@ -0,0 +1,147 @@ +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskFlowReceiverAdapter(IRatelDeskConnectorStore connectors, + IRatelDeskConnectorBindingStore bindings, IRatelDeskConnectorAuthorization authorization, + IRatelDeskOutboundBindingResolver credentials, IRatelDeskReceiverTransport receiver, + ReceiverPreparationBuilder builder, IFlowReceiverEvidenceStore evidenceStore, TimeProvider clock) + : IFlowReceiverDispatcher +{ + public async Task PrepareReceiverAsync(FlowIncidentActionDraft draft, + DateTimeOffset originalCreatedAtUtc, CancellationToken ct) + { + try + { + if (!FlowContractValidation.ValidEnvelope(draft.Event) || draft.TenantId != draft.Event.TenantId || + !await authorization.CanExecuteAsync(draft.Event.Authority.PrincipalId, + draft.Event.Authority.IntegrationCredentialId, draft.TenantId, ct)) + return new(FlowIncidentPreparationStatus.Denied, Code: "connector-current-authority-denied"); + var connector = await CurrentConnector(draft.TenantId, draft.ConnectorId, draft.ConnectorRevision, ct); + var authentication = await bindings.GetAuthenticationAsync(draft.TenantId, draft.ConnectorId, ct); + var peer = await credentials.CaptureAsync(connector, authentication, draft.SourceInstanceId, ct); + var readBearer = await credentials.GetBearerAsync(connector, peer, "rateldesk.incident-receipts.read", ct); + var capability = await receiver.CapabilitiesAsync(peer, readBearer, ct); + var targetBearer = await credentials.GetBearerAsync(connector, peer, "rateldesk.incident-targets.read", ct); + await receiver.ValidateTargetsAsync(peer, capability, targetBearer, ct); + // Reuse the historical stable description mapping exactly, rather than regenerate + // old work from current labels/Flow versions. See captured RatelDeskFlowConnector. + var description = BuildOriginalDescription(draft); + var priority = draft.Fields.Priority ?? Map(connector.Configuration.Priorities, draft.Event.Data.Severity); + var body = RatelDeskPayload.Create(connector.Configuration, draft.Fields.Title, description, priority); + var action = new FlowIncidentActionRequest(draft.TenantId, draft.RunId, draft.ActionNodeId, + draft.ConnectorId, draft.ConnectorRevision, draft.SourceInstanceId, draft.IdempotencyKey, draft.Event, + new(body.Title, body.Description, body.Priority), + new(body.OrganizationId, body.CustomerId, body.AssignedToId, body.CategoryIds), true, ""); + action = action with { SemanticFingerprint = FlowContractValidation.Fingerprint(action) }; + if (!FlowContractValidation.ValidPrepared(action, draft)) + return new(FlowIncidentPreparationStatus.Invalid, Code: "invalid-incident-payload"); + var evidence = builder.Build(draft, action, peer, capability, originalCreatedAtUtc, null); + return new(FlowIncidentPreparationStatus.Ready, action, evidence, "receiver-idempotency-verified"); + } + catch (UnauthorizedAccessException) + { return new(FlowIncidentPreparationStatus.Denied, Code: "connector-current-authority-denied"); } + catch (RatelDeskReceiverReadException error) + { + if (error.HttpStatus is 401 or 403) + return new(FlowIncidentPreparationStatus.Denied, Code: "receiver-current-authority-denied"); + if (error.HttpStatus is 400 or 409 or 422) + return new(FlowIncidentPreparationStatus.Invalid, Code: "receiver-current-target-rejected"); + return new(FlowIncidentPreparationStatus.Unavailable, Code: "receiver-preparation-unavailable", + RetryAfter: error.RetryAfter ?? TimeSpan.FromSeconds(5)); + } + catch (ServiceLinkProtocolException error) + { + return error.StatusCode is 401 or 403 or 409 or 422 + ? new(FlowIncidentPreparationStatus.Denied, Code: "receiver-current-profile-denied") + : new(FlowIncidentPreparationStatus.Unavailable, Code: "receiver-current-profile-unavailable", RetryAfter: TimeSpan.FromSeconds(5)); + } + catch (Exception error) when (error is HttpRequestException or IOException or OperationCanceledException) + { return new(FlowIncidentPreparationStatus.Unavailable, Code: "receiver-preparation-interrupted", RetryAfter: TimeSpan.FromSeconds(5)); } + catch (Exception error) when (error is ArgumentException or InvalidOperationException or System.Text.Json.JsonException) + { return new(FlowIncidentPreparationStatus.Invalid, Code: "receiver-preparation-invalid"); } + } + + public async Task DispatchReceiverAsync(FlowRunLease lease, Guid nodeId, + RatelDeskDispatchEvidence evidence, Func> recheckFlowGuard, + CancellationToken ct) + { + var prepared = evidence.Prepared; + if (ReceiverPreparationBuilder.EvidenceHash(prepared) != prepared.EvidenceFingerprint) + return new(RatelDeskReceiverObservationKind.Unavailable, "persisted-receiver-evidence-invalid"); + var current = await CurrentConnector(prepared.Peer.LocalTenantId, prepared.ConnectorId, + prepared.ConnectorRevision, ct); + if (!await authorization.CanExecuteAsync(lease.Event.Authority.PrincipalId, + lease.Event.Authority.IntegrationCredentialId, lease.Event.TenantId, ct)) + return new(RatelDeskReceiverObservationKind.AuthenticationRejected, "connector-current-authority-denied"); + var readBearer = await credentials.GetBearerAsync(current, prepared.Peer, "rateldesk.incident-receipts.read", ct); + if (evidence.MayHaveCommitted) + { + var lookup = await receiver.LookupAsync(prepared, readBearer, ct); + // A read can outlive a tenant/owner grant or a semantic binding. Re-authorize + // that captured peer after the await before accepting its committed receipt. + current = await CurrentConnector(prepared.Peer.LocalTenantId, prepared.ConnectorId, prepared.ConnectorRevision, ct); + if (!await authorization.CanExecuteAsync(lease.Event.Authority.PrincipalId, + lease.Event.Authority.IntegrationCredentialId, lease.Event.TenantId, ct)) + return new(RatelDeskReceiverObservationKind.AuthenticationRejected, "connector-current-authority-denied"); + _ = await credentials.GetBearerAsync(current, prepared.Peer, "rateldesk.incident-receipts.read", ct); + if (lookup.Kind != RatelDeskReceiverObservationKind.Missing) return lookup; + } + if (clock.GetUtcNow() >= prepared.AutomaticReplayUntilUtc || + evidence.Attempts >= NetRatel.Shared.Contracts.Flows.FlowLimits.MaximumActionAttempts) + return new(RatelDeskReceiverObservationKind.Gone, "automatic-replay-horizon-exhausted"); + var capability = await receiver.CapabilitiesAsync(prepared.Peer, readBearer, ct); + // Original evidence is not rewritten. Fresh observations must support its exact endpoints. + if (capability.Endpoints != prepared.Capability.Endpoints || + capability.SourceNamespaceId != prepared.Peer.SourceNamespaceId || + capability.SourceInstanceId != prepared.Peer.SourceInstanceId || + capability.ReceiverInstanceId != prepared.Peer.ReceiverInstanceId) + return new(RatelDeskReceiverObservationKind.Unavailable, "receiver-capability-drift"); + // Existing committed receipt was already checked above; this creation-time + // validation cannot prevent authorized receipt reconciliation after category changes. + var targetBearer = await credentials.GetBearerAsync(current, prepared.Peer, "rateldesk.incident-targets.read", ct); + await receiver.ValidateTargetsAsync(prepared.Peer, capability, targetBearer, ct); + var gate = await recheckFlowGuard(ct); + if (!gate.Allowed) return new(RatelDeskReceiverObservationKind.AuthenticationRejected, gate.Code); + // Async target probes may have raced disable/rotation/mapping update. + current = await CurrentConnector(prepared.Peer.LocalTenantId, prepared.ConnectorId, prepared.ConnectorRevision, ct); + var createBearer = await credentials.GetBearerAsync(current, prepared.Peer, "rateldesk.incidents.create", ct); + gate = await recheckFlowGuard(ct); + if (!gate.Allowed) return new(RatelDeskReceiverObservationKind.AuthenticationRejected, gate.Code); + if (!await evidenceStore.MarkPostAttemptAsync(lease, nodeId, ct)) + return new(RatelDeskReceiverObservationKind.Unavailable, "action-lease-lost"); + // Persisted MayHaveCommitted and the exact POST-attempt count precede HTTP. A last + // awaited local gate can still stop the send; it cannot clear durable uncertainty. + gate = await recheckFlowGuard(ct); + if (!gate.Allowed) return new(RatelDeskReceiverObservationKind.AuthenticationRejected, gate.Code); + current = await CurrentConnector(prepared.Peer.LocalTenantId, prepared.ConnectorId, prepared.ConnectorRevision, ct); + if (!await authorization.CanExecuteAsync(lease.Event.Authority.PrincipalId, + lease.Event.Authority.IntegrationCredentialId, lease.Event.TenantId, ct)) + return new(RatelDeskReceiverObservationKind.AuthenticationRejected, "connector-current-authority-denied"); + createBearer = await credentials.GetBearerAsync(current, prepared.Peer, "rateldesk.incidents.create", ct); + gate = await recheckFlowGuard(ct); + if (!gate.Allowed) return new(RatelDeskReceiverObservationKind.AuthenticationRejected, gate.Code); + ct.ThrowIfCancellationRequested(); + return await receiver.CreateAsync(prepared, createBearer, ct); + } + + private async Task CurrentConnector(int tenant, Guid id, long revision, CancellationToken ct) + { + var connector = await connectors.GetAsync(tenant, id, ct); + if (connector is null || connector.Revision != revision || !connector.Configuration.Enabled || + !await authorization.CanExecuteAsync(connector.OwnerPrincipalId, null, tenant, ct)) + throw new UnauthorizedAccessException("connector-revision-or-owner-unavailable"); + return connector; + } + private static int Map(RatelDeskPriorityMapping mapping, string severity) => severity.ToLowerInvariant() switch + { "critical" => mapping.Critical, "error" or "high" => mapping.Error, "warning" or "medium" => mapping.Warning, _ => mapping.Information }; + private static string BuildOriginalDescription(FlowIncidentActionDraft draft) => draft.Fields.Description + "\n\nNetRatel monitoring occurrence\n" + + $"Client: {draft.Event.Data.ClientName} ({draft.Event.Data.AgentId:D})\nResource: {draft.Event.Data.Resource}\n" + + $"Rule: {draft.Event.Data.RuleName} ({draft.Event.Data.RuleId:D})\nSeverity: {draft.Event.Data.Severity}\n" + + $"Metric: {draft.Event.Data.Metric}\nObserved value: {draft.Event.Data.NumericValue?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? draft.Event.Data.ServiceState ?? "Unknown"}\n" + + $"Observed at: {draft.Event.Data.ObservedAtUtc:O}\nOccurred at: {draft.Event.OccurredAtUtc:O}\n" + + $"Source: {draft.SourceInstanceId:D}/{draft.Event.OccurrenceId:D}/{draft.Event.EventId:D}"; +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskHttpTransport.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskHttpTransport.cs new file mode 100644 index 00000000..7bd100a9 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskHttpTransport.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// Bounded typed HTTP client. The registered handler disables all redirects and cookies. +public sealed class RatelDeskHttpTransport(HttpClient http, IRatelDeskOriginPolicy origins, TimeProvider time, RatelDeskTransportLimiter limiter) + : IRatelDeskConnectionTester, IRatelDeskIncidentTransport +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public async Task TestAsync(int tenantId, Guid connectorId, RatelDeskConnectorConfiguration configuration, + string credential, CancellationToken cancellationToken) + { + if (tenantId <= 0 || connectorId == Guid.Empty || !origins.TryValidate(configuration.Origin, out var origin) || !RatelDeskConnectorService.ValidCredential(credential)) + return new(RatelDeskConnectionTestStatus.Unavailable, "invalid-origin-or-credential"); + using var operation = await limiter.TryAcquireAsync(tenantId, connectorId, cancellationToken).ConfigureAwait(false); + if (operation is null) return new(RatelDeskConnectionTestStatus.Unavailable, "connector-busy"); + try + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + var lookups = new List<(string Path, string Id, string? Organization)> { + ("api/v1/ticketing/organizations?module=incident", configuration.OrganizationId, null), + ("api/v1/ticketing/customers?module=incident&organizationId=" + Uri.EscapeDataString(configuration.OrganizationId), configuration.CustomerId, configuration.OrganizationId) + }; + if (configuration.AssignedToId is { } assignee) + lookups.Add(("api/v1/ticketing/assignees?module=incident&organizationId=" + Uri.EscapeDataString(configuration.OrganizationId), assignee, configuration.OrganizationId)); + foreach (var lookup in lookups) + { + using var request = Request(HttpMethod.Get, new Uri(origin!, lookup.Path), credential); + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) return new(RatelDeskConnectionTestStatus.AuthenticationRejected, "receiver-auth-or-target-denied"); + if ((int)response.StatusCode == 429) return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-rate-limited", RetryAfterSeconds: RetryAfter(response)); + if (!response.IsSuccessStatusCode) return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-read-unavailable"); + using var document = JsonDocument.Parse(await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false)); + var items = document.RootElement; + if (items.ValueKind != JsonValueKind.Array || items.GetArrayLength() > RatelDeskConnectorLimits.MaximumLookupItems) + return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-read-contract-unverified"); + var found = items.EnumerateArray().Any(item => String(item, "id") == lookup.Id && + (lookup.Organization is null || String(item, "organizationId") == lookup.Organization)); + if (!found) return new(RatelDeskConnectionTestStatus.MappingRejected, "receiver-target-not-authorized"); + } + if (configuration.CategoryIds.Count != 0) + { + using var request = Request(HttpMethod.Get, new Uri(origin!, "api/v1/categories/?type=Incident"), credential); + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return new(RatelDeskConnectionTestStatus.MappingRejected, "receiver-category-not-authorized"); + using var document = JsonDocument.Parse(await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false)); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() > RatelDeskConnectorLimits.MaximumLookupItems || + configuration.CategoryIds.Any(id => !root.EnumerateArray().Any(item => String(item, "id") == id.ToString("D") && + item.TryGetProperty("isActive", out var active) && active.ValueKind == JsonValueKind.True))) + return new(RatelDeskConnectionTestStatus.MappingRejected, "receiver-category-not-authorized"); + } + // Actual current RatelDesk provides normal creation, but no replay/receipt/atomic confirmation capability. + return new(RatelDeskConnectionTestStatus.MappingValidated, RatelDeskConnectorLimits.ReceiverUnavailableCode); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-read-timeout"); } + catch (Exception e) when (e is HttpRequestException or IOException or JsonException or InvalidDataException) + { return new(RatelDeskConnectionTestStatus.Unavailable, "receiver-read-contract-unverified"); } + } + + public async Task CreateAsync(int tenantId, Guid connectorId, string origin, string credential, RatelDeskCreateIncidentDto payload, + CancellationToken cancellationToken) + { + if (tenantId <= 0 || connectorId == Guid.Empty || !origins.TryValidate(origin, out var normalized) || !RatelDeskConnectorService.ValidCredential(credential)) return new(RatelDeskDeliveryStatus.Unavailable, "invalid-origin-or-credential"); + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, Json); + if (bytes.Length > RatelDeskConnectorLimits.MaximumRequestBytes) return new(RatelDeskDeliveryStatus.PayloadRejected, "incident-request-too-large"); + using var operation = await limiter.TryAcquireAsync(tenantId, connectorId, cancellationToken).ConfigureAwait(false); + if (operation is null) return new(RatelDeskDeliveryStatus.Unavailable, "connector-busy"); + var sendStarted = false; + try + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + using var request = Request(HttpMethod.Post, new Uri(normalized!, "api/v1/incidents/"), credential); + request.Content = new ByteArrayContent(bytes); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + cancellationToken.ThrowIfCancellationRequested(); + sendStarted = true; + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) return new(RatelDeskDeliveryStatus.AuthenticationRejected, "receiver-auth-rejected"); + if ((int)response.StatusCode == 429) return new(RatelDeskDeliveryStatus.RateLimited, "receiver-rate-limited", RetryAfterSeconds: RetryAfter(response)); + if ((int)response.StatusCode is >= 300 and < 400) return new(RatelDeskDeliveryStatus.DeliveryUnknown, "receiver-redirect-refused"); + if ((int)response.StatusCode is >= 400 and < 500) return new(RatelDeskDeliveryStatus.PayloadRejected, "receiver-payload-rejected"); + if (response.StatusCode != HttpStatusCode.Created) return new(RatelDeskDeliveryStatus.DeliveryUnknown, "receiver-commit-unconfirmed"); + using var document = JsonDocument.Parse(await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false)); + var receipt = document.RootElement; + var id = String(receipt, "id"); var tracking = String(receipt, "trackingId"); + if (!SafeReceiptValue(id) || !SafeReceiptValue(tracking) || String(receipt, "organizationId") != payload.OrganizationId || String(receipt, "customerId") != payload.CustomerId) + return new(RatelDeskDeliveryStatus.DeliveryUnknown, "receiver-receipt-unverified"); + // The navigation route has not been verified for the intended deployment. Do not invent a browser link. + return new(RatelDeskDeliveryStatus.Succeeded, "incident-created", new(id!, tracking!, null)); + } + catch (OperationCanceledException) when (sendStarted) { return new(RatelDeskDeliveryStatus.DeliveryUnknown, "receiver-commit-unconfirmed"); } + catch (Exception e) when (e is HttpRequestException or IOException or JsonException or InvalidDataException) + { return new(RatelDeskDeliveryStatus.DeliveryUnknown, "receiver-commit-unconfirmed"); } + } + + private static HttpRequestMessage Request(HttpMethod method, Uri uri, string credential) + { + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential); + request.Headers.Accept.Add(new("application/json")); + return request; + } + private static string? String(JsonElement item, string property) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static bool SafeReceiptValue(string? value) => value is { Length: > 0 and <= 128 } && value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); + private int? RetryAfter(HttpResponseMessage response) + { + var retry = response.Headers.RetryAfter; + var delay = retry?.Delta ?? (retry?.Date is { } at ? at - time.GetUtcNow() : null); + return delay is null ? null : (int)Math.Clamp(Math.Ceiling(delay.Value.TotalSeconds), 1d, 300d); + } + private static async Task ReadBoundedAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength > RatelDeskConnectorLimits.MaximumResponseBytes) throw new InvalidDataException("receiver-response-too-large"); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); var buffer = new byte[4_096]; + int count; + while ((count = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + if (output.Length + count > RatelDeskConnectorLimits.MaximumResponseBytes) throw new InvalidDataException("receiver-response-too-large"); + output.Write(buffer, 0, count); + } + return output.ToArray(); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOriginPolicy.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOriginPolicy.cs new file mode 100644 index 00000000..7253e015 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOriginPolicy.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using NetRatel.Application.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// Exact operator-configured HTTPS origins. Unknown origins, path prefixes and URL credentials are denied. +public sealed class RatelDeskOriginPolicy(IConfiguration configuration) : IRatelDeskOriginPolicy +{ + public bool TryValidate(string origin, out Uri? normalizedOrigin) + { + normalizedOrigin = Normalize(origin); + if (normalizedOrigin is null) return false; + var candidate = normalizedOrigin; + return configuration.GetSection("RatelDesk:AllowedOrigins").GetChildren() + .Select(item => Normalize(item.Value)).Any(allowed => allowed == candidate); + } + + private static Uri? Normalize(string? origin) + { + if (origin is null || origin.Length > 2_048 || !Uri.TryCreate(origin, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps || uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0 || + uri.AbsolutePath != "/" || uri.Host.Length == 0 || uri.HostNameType == UriHostNameType.Unknown) + return null; + return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOutboundBindingResolver.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOutboundBindingResolver.cs new file mode 100644 index 00000000..34ee3223 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskOutboundBindingResolver.cs @@ -0,0 +1,32 @@ +using NetRatel.Application.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskOutboundBindingResolver(IRatelDeskConnectorBindingStore bindings, + ManualRatelDeskBindingResolver manual, ManagedRatelDeskBindingResolver managed) + : IRatelDeskOutboundBindingResolver +{ + public async Task CaptureAsync(RatelDeskConnectorState connector, + RatelDeskConnectorAuthentication authentication, Guid source, CancellationToken ct) + { + var current = await bindings.GetAuthenticationAsync(connector.TenantId, connector.Id, ct); + if (current != authentication) throw new UnauthorizedAccessException("connector-authentication-reference-changed"); + return await Select(current).CaptureAsync(connector, current, source, ct); + } + + public async Task GetBearerAsync(RatelDeskConnectorState connector, + RatelDeskSemanticPeer captured, string requiredScope, CancellationToken ct) + { + var current = await bindings.GetAuthenticationAsync(connector.TenantId, connector.Id, ct); + if (current.Mode != captured.Mode || current.ManagedLinkId != captured.LinkId) + throw new UnauthorizedAccessException("captured-authentication-reference-no-longer-authorized"); + return await Select(current).GetBearerAsync(connector, captured, requiredScope, ct); + } + + private IRatelDeskOutboundBindingResolver Select(RatelDeskConnectorAuthentication current) => current switch + { + { Mode: RatelDeskAuthenticationMode.ManualApiBearer, ManagedLinkId: null } => manual, + { Mode: RatelDeskAuthenticationMode.ManagedServiceLink, ManagedLinkId: { Length: > 0 } } => managed, + _ => throw new UnauthorizedAccessException("invalid-connector-authentication-reference") + }; +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskPayload.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskPayload.cs new file mode 100644 index 00000000..629f7ca6 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskPayload.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static partial class RatelDeskPayload +{ + public static RatelDeskCreateIncidentDto Create(RatelDeskConnectorConfiguration configuration, string title, string description, int priority) + { + if (priority is < 0 or > 3) throw new ArgumentException("invalid-incident-priority"); + var safeTitle = Plain(title, RatelDeskConnectorLimits.MaximumTitleCharacters, false); + var safeDescription = Plain(description, RatelDeskConnectorLimits.MaximumDescriptionCharacters, true); + if (safeTitle.Length == 0 || safeDescription.Length == 0) throw new ArgumentException("incident-fields-required"); + return new(safeTitle, safeDescription, priority, configuration.CustomerId, configuration.OrganizationId, + configuration.AssignedToId, configuration.CategoryIds.Order().ToArray()); + } + + public static string Fingerprint(RatelDeskCreateIncidentDto payload) => Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(payload))); + + private static string Plain(string? value, int limit, bool multiline) + { + if (value is null || value.Length > limit * 4) throw new ArgumentException("incident-field-too-large"); + var text = Tags().Replace(System.Net.WebUtility.HtmlDecode(value), string.Empty); + text = new string(text.Where(character => !char.IsControl(character) || multiline && character is '\n' or '\r').ToArray()).Trim(); + if (text.Length > limit) throw new ArgumentException("incident-field-too-large"); + for (var i = 0; i < text.Length; i++) + { + if (char.IsLowSurrogate(text[i])) throw new ArgumentException("invalid-incident-text"); + if (char.IsHighSurrogate(text[i]) && (++i >= text.Length || !char.IsLowSurrogate(text[i]))) throw new ArgumentException("invalid-incident-text"); + } + return text; + } + + [GeneratedRegex("<[^>]*>", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 100)] + private static partial Regex Tags(); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverFingerprint.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverFingerprint.cs new file mode 100644 index 00000000..0e1577a9 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverFingerprint.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Ganss.Xss; +using HtmlAgilityPack; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +// Full projection permits the UNCHANGED receiver conformance fixtures to be tested, +// although the current Flow sender emits only its existing seven-field subset. +public sealed class ReceiverCreateIncidentBody +{ + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public int Priority { get; set; } + public string? CustomerId { get; set; } + public string? OrganizationId { get; set; } + public string? AssignedToId { get; set; } + public List? LinkedAssetIds { get; set; } + public List? Attachments { get; set; } + public DateTime? DueDate { get; set; } + public string? Impact { get; set; } + public List? CcRecipients { get; set; } + public string? RequesterEmail { get; set; } // Receiver derives customer requester; excluded from hash. + public List? CategoryIds { get; set; } +} + +public sealed class RatelDeskReceiverFingerprint : IRatelDeskReceiverFingerprint +{ + private readonly HtmlSanitizer sanitizer = BuildSanitizer(); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public string Compute(RatelDeskCreateIncidentDto body) => ComputeFull(new() + { + Title = body.Title, Description = body.Description, Priority = body.Priority, + CustomerId = body.CustomerId, OrganizationId = body.OrganizationId, + AssignedToId = body.AssignedToId, CategoryIds = body.CategoryIds.ToList() + }); + + public string ComputeFull(ReceiverCreateIncidentBody body) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(new + { + title = body.Title, + description = Plain(sanitizer.Sanitize(body.Description ?? string.Empty)), + priority = body.Priority, + customerId = body.CustomerId, + organizationId = body.OrganizationId, + assignedToId = string.IsNullOrWhiteSpace(body.AssignedToId) ? null : body.AssignedToId, + linkedAssetIds = body.LinkedAssetIds ?? [], + attachments = body.Attachments ?? [], + dueDate = DueUtc(body.DueDate)?.ToString("O"), + impact = body.Impact, + ccRecipients = Cc(body.CcRecipients), + categoryIds = Categories(body.CategoryIds).Select(x => x.ToString("D")).ToArray() + }, Json); + return Convert.ToHexStringLower(SHA256.HashData(bytes)); + } + + private static HtmlSanitizer BuildSanitizer() + { + var value = new HtmlSanitizer(); + foreach (var tag in new[] { "img", "table", "thead", "tbody", "tr", "td", "th" }) value.AllowedTags.Add(tag); + foreach (var attribute in new[] { "src", "style", "class", "alt", "width", "height" }) value.AllowedAttributes.Add(attribute); + value.AllowedSchemes.Add("data"); return value; + } + private static string Plain(string html) + { + if (string.IsNullOrWhiteSpace(html)) return ""; + var document = new HtmlDocument(); document.LoadHtml(html); + var text = WebUtility.HtmlDecode(document.DocumentNode.InnerText); + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)); + return string.Join('\n', lines); + } + private static DateTime? DueUtc(DateTime? value) => value is null ? null : value.Value.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : value.Value.ToUniversalTime(); + private static List Cc(IEnumerable? values) => values?.Select(value => value.Trim().ToLowerInvariant()) + .Where(value => value.Length > 0).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList() ?? []; + private static List Categories(IEnumerable? ids) => ids?.Where(x => x != Guid.Empty) + .Distinct().OrderBy(x => x.ToString("D"), StringComparer.Ordinal).ToList() ?? []; +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverHttpPipeline.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverHttpPipeline.cs new file mode 100644 index 00000000..54284f78 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverHttpPipeline.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Headers; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed record RatelDeskReceiverReply(int Status, byte[] Body, string? Location, + TimeSpan? RetryAfter, string? MediaType, bool NoStore); + +public sealed class RatelDeskReceiverHttpPipeline(IHttpClientFactory clients, + RatelDeskReceiverNetworkPolicy network, TimeProvider time, RatelDeskTransportLimiter limiter) +{ + public const string ManualClient = "RatelDesk.Receiver.ManualApiBearer"; + public const string ManagedClient = "RatelDesk.Receiver.ManagedServiceLink"; + public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(10); + + public async Task ReadAsync(RatelDeskAuthenticationMode mode, + int tenantId, Guid connectorId, string approvedApiBase, Guid source, string bearer, + HttpMethod method, string endpoint, byte[]? body, CancellationToken ct, + string? idempotencyKey = null, bool preserveDotKey = false) + { + if (tenantId <= 0 || connectorId == Guid.Empty || source == Guid.Empty || string.IsNullOrEmpty(bearer)) + throw new UnauthorizedAccessException("receiver-operation-identity-invalid"); + if (idempotencyKey is not null && !RatelDeskReceiverKey.IsConforming(idempotencyKey)) + throw new InvalidDataException("receiver-key-invalid"); + network.ValidateEndpoint(mode, approvedApiBase, endpoint); + using var admission = await limiter.TryAcquireAsync(tenantId, connectorId, ct); + if (admission is null) throw new RatelDeskReceiverReadException("connector-busy", null, null); + using var operationDeadline = new CancellationTokenSource(OperationTimeout, time); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct, operationDeadline.Token); + Uri destination = new(endpoint, UriKind.Absolute); + var isReceipt = RatelDeskReceiverNetworkPolicy.IsReceiptEndpoint( + RatelDeskApiBase.Canonical(approvedApiBase), endpoint); + if (preserveDotKey && !isReceipt) + throw new InvalidDataException("receiver-raw-receipt-path-invalid"); + if (isReceipt) + { + // A conforming durable key can be '.' or '..'. Preserve its exact final segment. + destination = new(endpoint, new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true }); + } + var canonicalApiBase = RatelDeskApiBase.Canonical(approvedApiBase); + var expectedMethod = endpoint == ReceiverWireValidation.Endpoint(canonicalApiBase, ReceiverWireValidation.CreatePath) || + endpoint == ReceiverWireValidation.Endpoint(canonicalApiBase, ReceiverWireValidation.TargetsPath) + ? HttpMethod.Post : HttpMethod.Get; + var isCreate = endpoint == ReceiverWireValidation.Endpoint(canonicalApiBase, ReceiverWireValidation.CreatePath); + if (method != expectedMethod || isCreate != (idempotencyKey is not null) || + expectedMethod == HttpMethod.Get && body is not null || + expectedMethod == HttpMethod.Post && body is null) + throw new InvalidDataException("receiver-operation-endpoint-invalid"); + using var request = new HttpRequestMessage(method, destination); + request.Options.Set(RatelDeskReceiverSafeHttpMessageHandler.ApprovedApiBaseOption, canonicalApiBase); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + request.Headers.Accept.Add(new("application/json")); + request.Headers.Add("X-NetRatel-Source-Instance", source.ToString("D")); + if (idempotencyKey is not null) request.Headers.Add("Idempotency-Key", idempotencyKey); + if (body is not null) + { + if (body.Length > RatelDeskConnectorLimits.MaximumRequestBytes) throw new InvalidDataException("receiver-request-too-large"); + request.Content = new ByteArrayContent(body); + request.Content.Headers.ContentType = new("application/json"); + } + // Options are read again after asynchronous admission and immediately before send. + network.ValidateEndpoint(mode, approvedApiBase, endpoint); + using var client = clients.CreateClient(mode switch + { + RatelDeskAuthenticationMode.ManualApiBearer => ManualClient, + RatelDeskAuthenticationMode.ManagedServiceLink => ManagedClient, + _ => throw new UnauthorizedAccessException("unsupported-connector-authentication-mode") + }); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token); + if (response.Content.Headers.ContentLength > RatelDeskConnectorLimits.MaximumResponseBytes) + throw new InvalidDataException("receiver-response-too-large"); + await using var stream = await response.Content.ReadAsStreamAsync(deadline.Token); + using var output = new MemoryStream(); var buffer = new byte[4096]; + while (true) + { + var count = await stream.ReadAsync(buffer, deadline.Token); if (count == 0) break; + if (output.Length + count > RatelDeskConnectorLimits.MaximumResponseBytes) throw new InvalidDataException("receiver-response-too-large"); + output.Write(buffer, 0, count); + } + var retry = response.Headers.RetryAfter; + var delay = retry?.Delta ?? (retry?.Date is { } at ? at - time.GetUtcNow() : null); + var bounded = delay is null ? (TimeSpan?)null : TimeSpan.FromSeconds(Math.Clamp(Math.Ceiling(delay.Value.TotalSeconds), 1, 300)); + return new((int)response.StatusCode, output.ToArray(), response.Headers.Location?.OriginalString, bounded, + response.Content.Headers.ContentType?.MediaType, response.Headers.CacheControl?.NoStore == true); + } +} + +// Safe diagnostics for read-only capture/preparation. No response body, credential or raw URL. +public sealed class RatelDeskReceiverReadException(string code, int? status, TimeSpan? retryAfter) + : Exception(code) +{ + public string Code { get; } = code; + public int? HttpStatus { get; } = status; + public TimeSpan? RetryAfter { get; } = retryAfter; + + public static void RequireJsonSuccess(RatelDeskReceiverReply reply, string operation) + { + if (reply.Status != 200) + throw new RatelDeskReceiverReadException(reply.Status switch + { + 401 => "receiver-authentication-rejected", 403 => "receiver-current-grant-rejected", + 400 or 422 => "receiver-target-rejected", 429 => "receiver-rate-limited", + >= 300 and < 400 => "receiver-redirect-refused", _ => "receiver-" + operation + "-unavailable" + }, reply.Status, reply.RetryAfter); + if (!string.Equals(reply.MediaType, "application/json", StringComparison.OrdinalIgnoreCase) || !reply.NoStore) + throw new InvalidDataException("receiver-read-headers-unverified"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverKey.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverKey.cs new file mode 100644 index 00000000..44c081ac --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverKey.cs @@ -0,0 +1,42 @@ +using System.Security.Cryptography; +using System.Text; +using NetRatel.Application.Flows; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static class RatelDeskReceiverKey +{ + public const string Algorithm = "netratel-flow-receiver-sha256.v1"; + public static bool IsConforming(string? key) => key is { Length: > 0 and <= 256 } && + key.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '~' or '-'); + + public static string Prepare(FlowIncidentActionDraft draft, string? alreadyPersistedWireKey) + { + // Never change an already durable key, including '.' and '..'. + if (alreadyPersistedWireKey is not null) + return IsConforming(alreadyPersistedWireKey) ? alreadyPersistedWireKey : + throw new InvalidOperationException("historical-wire-key-invalid-readonly-recovery"); + if (draft.SourceInstanceId == Guid.Empty || draft.Event.OccurrenceId == Guid.Empty || + draft.Event.EventId == Guid.Empty || draft.Event.FlowVersionId == Guid.Empty || + draft.ActionNodeId == Guid.Empty || draft.TenantId != draft.Event.TenantId) + throw new ArgumentException("invalid-logical-action-identity"); + var original = $"{draft.SourceInstanceId:N}:{draft.Event.OccurrenceId:N}:{draft.Event.EventId:N}:{draft.Event.FlowVersionId:N}:{draft.ActionNodeId:N}"; + if (draft.IdempotencyKey != original && !IsConforming(draft.IdempotencyKey)) + throw new ArgumentException("invalid-local-action-key"); + if (IsConforming(draft.IdempotencyKey)) return draft.IdempotencyKey; + var projection = string.Join('\n', "NetRatel.Flow.RatelDeskReceiverKey.v1", + draft.SourceInstanceId.ToString("D"), draft.Event.OccurrenceId.ToString("D"), + draft.Event.EventId.ToString("D"), draft.Event.FlowVersionId.ToString("D"), + draft.ActionNodeId.ToString("D")); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(projection))); + } + + public static string ReceiptEndpoint(string template, string key) + { + if (!IsConforming(key) || template.IndexOf("{key}", StringComparison.Ordinal) < 0 || + template.IndexOf("{key}", StringComparison.Ordinal) != template.LastIndexOf("{key}", StringComparison.Ordinal)) + throw new ArgumentException("invalid-receipt-endpoint-or-key"); + // Every permitted character is URI-unreserved. No percent escaping is necessary. + return template.Replace("{key}", key, StringComparison.Ordinal); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverNetworkPolicy.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverNetworkPolicy.cs new file mode 100644 index 00000000..e849f1eb --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverNetworkPolicy.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Infrastructure.ServiceLinks.Network; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskReceiverOptions +{ + public const string SectionName = "RatelDesk"; + // Existing deployment key; a nonempty list remains an authoritative restriction. + public string[] AllowedOrigins { get; set; } = []; + // Additive exact API/path-base restriction. Neither list is populated from discovery. + public string[] AllowedApiBases { get; set; } = []; + public bool RestrictToConfiguredPeers { get; set; } + // Manual bearer mode has its own explicit private-network permission. + public bool AllowPrivateHttp { get; set; } + // Empty optional deployment/env values do not create an enabled restriction. + // Explicit RestrictToConfiguredPeers remains authoritative even when these are empty. + public string[] EffectiveOrigins() => (AllowedOrigins ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); + public string[] EffectiveApiBases() => (AllowedApiBases ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); +} + +public sealed class RatelDeskReceiverOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, RatelDeskReceiverOptions value) + { + if (value.AllowedOrigins is null || value.AllowedApiBases is null || + value.AllowedOrigins.Length > 256 || value.AllowedApiBases.Length > 256) + return ValidateOptionsResult.Fail("RatelDesk peer restrictions exceed the supported bound."); + try + { + foreach (var origin in value.EffectiveOrigins()) + { + var canonical = RatelDeskApiBase.Canonical(origin); + var uri = new Uri(canonical); + // Preserve historical exact root HTTPS AllowedOrigins semantics. + if (uri.Scheme != "https" || uri.AbsolutePath != "/") + throw new ArgumentException("RatelDesk:AllowedOrigins requires root HTTPS origins."); + ServiceLinkEndpointPolicy.Validate(uri, "RatelDesk:AllowedOrigins", allowPrivateHttp: true); + } + foreach (var api in value.EffectiveApiBases()) + ServiceLinkEndpointPolicy.Validate(new Uri(RatelDeskApiBase.Canonical(api)), + "RatelDesk:AllowedApiBases", allowPrivateHttp: true); // Structural restriction; selected mode admits each operation. + if (value.EffectiveOrigins().Select(RatelDeskApiBase.Canonical).Distinct(StringComparer.Ordinal).Count() != value.EffectiveOrigins().Length || + value.EffectiveApiBases().Select(RatelDeskApiBase.Canonical).Distinct(StringComparer.Ordinal).Count() != value.EffectiveApiBases().Length) + throw new ArgumentException("RatelDesk peer restrictions contain duplicate entries."); + return ValidateOptionsResult.Success; + } + catch (Exception error) when (error is ArgumentException or UriFormatException) { return ValidateOptionsResult.Fail("RatelDesk peer restrictions require canonical approved HTTP(S) API identities."); } + } +} + +public static class RatelDeskApiBase +{ + public static string Canonical(string value) + { + if (value is not { Length: > 0 and <= 2048 } || value.Trim() != value || value.Any(char.IsControl) || + value.IndexOfAny(['\\', '%', '?', '#']) >= 0 || !Uri.TryCreate(value, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https") || uri.UserInfo.Length != 0 || uri.Host.Length == 0 || + uri.HostNameType == UriHostNameType.Unknown || + value.TrimEnd('/') != uri.AbsoluteUri.TrimEnd('/') || + uri.AbsolutePath.Contains("//", StringComparison.Ordinal)) + throw new ArgumentException("receiver-api-base-invalid"); + return uri.AbsoluteUri.TrimEnd('/'); + } +} + +/// Policy applies to a durable owner-approved connector or exact approved link, never a browser/discovery URL. +public sealed class RatelDeskReceiverNetworkPolicy(IOptionsMonitor manual, + IOptionsMonitor links, IOptionsMonitor identities) +{ + public bool CurrentAllowPrivateHttp(RatelDeskAuthenticationMode mode) => mode switch + { + RatelDeskAuthenticationMode.ManualApiBearer => manual.CurrentValue.AllowPrivateHttp, + // Matches ServicePublicSettingsResolver's existing effective opt-in rule. + RatelDeskAuthenticationMode.ManagedServiceLink => links.CurrentValue.AllowPrivateHttp || identities.CurrentValue.AllowPrivateHttp, + _ => throw new UnauthorizedAccessException("unsupported-connector-authentication-mode") + }; + + public string ValidateApprovedApiBase(RatelDeskAuthenticationMode mode, string apiBase) + { + var current = manual.CurrentValue; // Fresh options on each operation, no cached allowlist. + if (new RatelDeskReceiverOptionsValidator().Validate(null, current).Failed) + throw new InvalidOperationException("receiver-deployment-policy-invalid"); + var canonical = RatelDeskApiBase.Canonical(apiBase); + var uri = new Uri(canonical); + var origins = current.EffectiveOrigins(); var bases = current.EffectiveApiBases(); + if (origins.Length != 0 && !origins.Any(x => + new Uri(RatelDeskApiBase.Canonical(x)).GetLeftPart(UriPartial.Authority) == uri.GetLeftPart(UriPartial.Authority)) || + bases.Length != 0 && !bases.Any(x => RatelDeskApiBase.Canonical(x) == canonical) || + current.RestrictToConfiguredPeers && origins.Length == 0 && bases.Length == 0) + throw new UnauthorizedAccessException("receiver-deployment-origin-denied"); + ServiceLinkEndpointPolicy.Validate(uri, "Approved RatelDesk API", CurrentAllowPrivateHttp(mode)); + return canonical; + } + + public void ValidateEndpoint(RatelDeskAuthenticationMode mode, string approvedApiBase, string endpoint) + { + var api = ValidateApprovedApiBase(mode, approvedApiBase); + if (endpoint is not { Length: > 0 and <= 4096 } || endpoint.Any(char.IsControl) || + endpoint.IndexOfAny(['\\', '%', '?', '#']) >= 0 || + !(endpoint == ReceiverWireValidation.Endpoint(api, ReceiverWireValidation.CapabilitiesPath) || + endpoint == ReceiverWireValidation.Endpoint(api, ReceiverWireValidation.CreatePath) || + endpoint == ReceiverWireValidation.Endpoint(api, ReceiverWireValidation.TargetsPath) || + IsReceiptEndpoint(api, endpoint))) + throw new InvalidDataException("receiver-endpoint-outside-approved-api-base"); + ServiceLinkEndpointPolicy.Validate(new Uri(endpoint, UriKind.Absolute), "RatelDesk receiver endpoint", CurrentAllowPrivateHttp(mode)); + } + + public static bool IsReceiptEndpoint(string canonicalApiBase, string endpoint) + { + var prefix = ReceiverWireValidation.Endpoint(canonicalApiBase, ReceiverWireValidation.ReceiptPath) + .Replace("{key}", "", StringComparison.Ordinal); + return endpoint.StartsWith(prefix, StringComparison.Ordinal) && + RatelDeskReceiverKey.IsConforming(endpoint[prefix.Length..]); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverSafeHttpMessageHandler.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverSafeHttpMessageHandler.cs new file mode 100644 index 00000000..550cc24e --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverSafeHttpMessageHandler.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Net.Sockets; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.ServiceLinks.Network; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// Every exchange resolves afresh and connects to a policy-checked address. +public static class RatelDeskReceiverSafeHttpMessageHandler +{ + public static readonly HttpRequestOptionsKey ApprovedApiBaseOption = new("NetRatel.RatelDesk.ApprovedApiBase"); + + public static SocketsHttpHandler Create(RatelDeskAuthenticationMode mode, RatelDeskReceiverNetworkPolicy policy) + { + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = false, + UseProxy = false, + UseCookies = false, + ConnectTimeout = TimeSpan.FromSeconds(5), + PooledConnectionLifetime = TimeSpan.Zero, + PooledConnectionIdleTimeout = TimeSpan.Zero + }; + handler.ConnectCallback = (context, cancellationToken) => ConnectAsync(context, mode, policy, cancellationToken); + return handler; + } + + private static async ValueTask ConnectAsync(SocketsHttpConnectionContext context, + RatelDeskAuthenticationMode mode, RatelDeskReceiverNetworkPolicy policy, CancellationToken cancellationToken) + { + var request = context.InitialRequestMessage; + var uri = request.RequestUri ?? throw new HttpRequestException("receiver-operation-endpoint-invalid"); + if (!request.Options.TryGetValue(ApprovedApiBaseOption, out var approvedApiBase)) + throw new HttpRequestException("receiver-approved-api-base-missing"); + policy.ValidateEndpoint(mode, approvedApiBase, uri.OriginalString); + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList addresses = IPAddress.TryParse(context.DnsEndPoint.Host, out var literal) + ? [literal] : await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken); + policy.ValidateEndpoint(mode, approvedApiBase, uri.OriginalString); + ServiceLinkEndpointPolicy.ValidateResolvedAddresses(uri, addresses, "RatelDesk receiver endpoint", + policy.CurrentAllowPrivateHttp(mode)); + SocketException? lastConnectionError = null; + foreach (var address in addresses) + { + cancellationToken.ThrowIfCancellationRequested(); + policy.ValidateEndpoint(mode, approvedApiBase, uri.OriginalString); + ServiceLinkEndpointPolicy.ValidateResolvedAddresses(uri, addresses, "RatelDesk receiver endpoint", + policy.CurrentAllowPrivateHttp(mode)); + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellationToken); + policy.ValidateEndpoint(mode, approvedApiBase, uri.OriginalString); + ServiceLinkEndpointPolicy.ValidateResolvedAddresses(uri, addresses, "RatelDesk receiver endpoint", + policy.CurrentAllowPrivateHttp(mode)); + return new NetworkStream(socket, ownsSocket: true); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + socket.Dispose(); + throw; + } + catch (SocketException error) + { + socket.Dispose(); + lastConnectionError = error; + } + catch + { + socket.Dispose(); + throw; + } + } + throw new HttpRequestException("receiver-endpoint-unreachable", lastConnectionError); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverTransport.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverTransport.cs new file mode 100644 index 00000000..9a28b116 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskReceiverTransport.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Text.Json; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public sealed class RatelDeskReceiverTransport(RatelDeskReceiverHttpPipeline http, TimeProvider time) + : IRatelDeskReceiverTransport +{ + public async Task CapabilitiesAsync(RatelDeskSemanticPeer peer, string bearer, CancellationToken ct) + { + var reply = await Read(peer, bearer, HttpMethod.Get, + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CapabilitiesPath), null, ct); + RatelDeskReceiverReadException.RequireJsonSuccess(reply, "capability"); + return ReceiverWireValidation.Capability(reply.Body, peer, time.GetUtcNow()); + } + public async Task ValidateTargetsAsync(RatelDeskSemanticPeer peer, + RatelDeskVerifiedCapability capability, string bearer, CancellationToken ct) + { + if (!ReceiverWireValidation.ValidCapability(capability, peer)) + throw new InvalidDataException("receiver-capability-binding-unverified"); + var body = JsonSerializer.SerializeToUtf8Bytes(new + { + organizationId = peer.OrganizationId, customerId = peer.CustomerId, + assignedToId = peer.AssignedToId, categoryIds = peer.CategoryIds + }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var reply = await Read(peer, bearer, HttpMethod.Post, capability.Endpoints.TargetValidation, body, ct); + RatelDeskReceiverReadException.RequireJsonSuccess(reply, "target-validation"); + ReceiverWireValidation.Target(reply.Body, peer); + } + public async Task LookupAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { + if (!ReceiverWireValidation.ValidCapability(prepared.Capability, prepared.Peer) || + !RatelDeskReceiverKey.IsConforming(prepared.ReceiverIdempotencyKey)) + return new(RatelDeskReceiverObservationKind.TransientReadFailure, "receiver-preparation-unverified"); + var endpoint = RatelDeskReceiverKey.ReceiptEndpoint(prepared.Capability.Endpoints.ReceiptTemplate, prepared.ReceiverIdempotencyKey); + try + { + var reply = await Read(prepared.Peer, bearer, HttpMethod.Get, endpoint, null, ct, preserveDotKey: true); + if (reply.Status == 200) return Committed(reply, prepared); + if ((reply.Status is 404 or 410) && !reply.NoStore) + throw new InvalidDataException("receiver-receipt-headers-unverified"); + if (reply.Status == 404) return new(RatelDeskReceiverObservationKind.Missing, "receiver-receipt-missing"); + if (reply.Status == 410) return new(RatelDeskReceiverObservationKind.Gone, "receiver-receipt-expired"); + return Classify(reply, false); + } + catch (Exception error) when (error is HttpRequestException or IOException or InvalidDataException or JsonException or OperationCanceledException or RatelDeskReceiverReadException) + { return new(RatelDeskReceiverObservationKind.TransientReadFailure, "receiver-receipt-unverified"); } + } + public async Task CreateAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { + if (!ReceiverWireValidation.ValidCapability(prepared.Capability, prepared.Peer)) + return new(RatelDeskReceiverObservationKind.Unavailable, "receiver-preparation-unverified"); + if (!RatelDeskReceiverKey.IsConforming(prepared.ReceiverIdempotencyKey)) + return new(RatelDeskReceiverObservationKind.Unavailable, "receiver-key-invalid"); + var bytes = Encoding.UTF8.GetBytes(prepared.ExactCreateBodyJson); + if (bytes.Length > RatelDeskConnectorLimits.MaximumRequestBytes) + return new(RatelDeskReceiverObservationKind.PayloadRejected, "incident-request-too-large"); + var mayHaveEnteredHttp = false; + try + { + ct.ThrowIfCancellationRequested(); + // Caller already committed MarkPostAttemptAsync. Conservatively preserve that uncertainty + // even if a later admission/network/read error prevents knowing whether HttpClient sent. + mayHaveEnteredHttp = true; + var reply = await Read(prepared.Peer, bearer, HttpMethod.Post, prepared.Capability.Endpoints.Create, + bytes, ct, prepared.ReceiverIdempotencyKey); + if (reply.Status is 200 or 201) return Committed(reply, prepared); + return Classify(reply, true); + } + catch (Exception error) when (error is HttpRequestException or IOException or InvalidDataException or JsonException or OperationCanceledException or RatelDeskReceiverReadException) + { + return new(mayHaveEnteredHttp ? RatelDeskReceiverObservationKind.PossibleCommit : RatelDeskReceiverObservationKind.Unavailable, + mayHaveEnteredHttp ? "receiver-commit-unconfirmed" : "receiver-create-not-started"); + } + } + private Task Read(RatelDeskSemanticPeer peer, string bearer, + HttpMethod method, string endpoint, byte[]? body, CancellationToken ct, + string? key = null, bool preserveDotKey = false) => http.ReadAsync(peer.Mode, peer.LocalTenantId, + peer.ConnectorId, peer.ApiBaseUrl, peer.SourceInstanceId, bearer, method, endpoint, body, ct, key, preserveDotKey); + private static RatelDeskReceiverObservation Committed(RatelDeskReceiverReply reply, RatelDeskReceiverPreparationV2 prepared) + { + if (!string.Equals(reply.MediaType, "application/json", StringComparison.OrdinalIgnoreCase) || !reply.NoStore) + throw new InvalidDataException("receiver-receipt-headers-unverified"); + return new(RatelDeskReceiverObservationKind.Committed, "receiver-receipt-verified", + ReceiverWireValidation.Receipt(reply.Body, reply.Location, prepared)); + } + private static RatelDeskReceiverObservation Classify(RatelDeskReceiverReply reply, bool sentWrite) + { + if (reply.Status is 401 or 403) return new(RatelDeskReceiverObservationKind.AuthenticationRejected, "receiver-auth-rejected"); + if (reply.Status == 429) return new(RatelDeskReceiverObservationKind.RateLimited, "receiver-rate-limited", RetryAfter: reply.RetryAfter); + string? code = null; + try { code = ReceiverWireValidation.ProblemCode(reply.Body); } + catch (Exception error) when (error is JsonException or InvalidDataException) { } + if (reply.Status == 409 && code == "idempotency-payload-conflict") + return new(RatelDeskReceiverObservationKind.FingerprintConflict, code); + if ((reply.Status is 400 or 422) && (code is "invalid-target-request" or "invalid-incident-target" or "invalid-incident-request" or "invalid-integration-headers")) + return new(RatelDeskReceiverObservationKind.PayloadRejected, code); + return new(sentWrite ? RatelDeskReceiverObservationKind.PossibleCommit : RatelDeskReceiverObservationKind.TransientReadFailure, + sentWrite ? "receiver-commit-unconfirmed" : "receiver-read-unavailable"); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskTransportLimiter.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskTransportLimiter.cs new file mode 100644 index 00000000..799b9965 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/RatelDeskTransportLimiter.cs @@ -0,0 +1,29 @@ +namespace NetRatel.Infrastructure.RatelDesk; + +/// Fixed memory bounds: eight operations globally, two per tenant, one per connector (stripes may conservatively share a slot). +public sealed class RatelDeskTransportLimiter +{ + private readonly SemaphoreSlim _global = new(8, 8); + private readonly SemaphoreSlim[] _tenants = Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(2, 2)).ToArray(); + private readonly SemaphoreSlim[] _connectors = Enumerable.Range(0, 128).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + public async Task TryAcquireAsync(int tenantId, Guid connectorId, CancellationToken cancellationToken) + { + if (!await _global.WaitAsync(0, cancellationToken).ConfigureAwait(false)) return null; + var tenant = _tenants[(uint)tenantId % (uint)_tenants.Length]; + var slot = _connectors[(uint)HashCode.Combine(tenantId, connectorId) % (uint)_connectors.Length]; + var tenantAcquired = false; + try + { + tenantAcquired = await tenant.WaitAsync(0, cancellationToken).ConfigureAwait(false); + if (tenantAcquired && await slot.WaitAsync(0, cancellationToken).ConfigureAwait(false)) return new Lease(slot, tenant, _global); + if (tenantAcquired) tenant.Release(); + _global.Release(); return null; + } + catch { if (tenantAcquired) tenant.Release(); _global.Release(); throw; } + } + private sealed class Lease(SemaphoreSlim slot, SemaphoreSlim tenant, SemaphoreSlim global) : IDisposable + { + private int _disposed; + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) { slot.Release(); tenant.Release(); global.Release(); } } + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverDispatchPolicy.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverDispatchPolicy.cs new file mode 100644 index 00000000..32df5dc8 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverDispatchPolicy.cs @@ -0,0 +1,37 @@ +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.Flows; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static class ReceiverDispatchPolicy +{ + public static FlowIncidentActionResult Complete(RatelDeskDispatchEvidence evidence, + RatelDeskReceiverObservation observation, DateTimeOffset now) + { + if (observation.Kind == RatelDeskReceiverObservationKind.Committed && observation.Receipt is { } receipt) + return new(FlowIncidentActionResultKind.Succeeded, "incident-receipt-verified", + new FlowActionReceiptDto(receipt.IncidentId, receipt.TrackingId, null)); + var withinBudget = now < evidence.Prepared.AutomaticReplayUntilUtc && + evidence.Attempts < FlowLimits.MaximumActionAttempts; + var uncertain = evidence.MayHaveCommitted || observation.Kind == RatelDeskReceiverObservationKind.PossibleCommit; + // The persisted flag permits one final receipt read; it cannot admit another POST. + if (uncertain && evidence.Attempts == FlowLimits.MaximumActionAttempts && + evidence.FinalReconciliationPending && now < evidence.Prepared.AutomaticReplayUntilUtc && + observation.Kind != RatelDeskReceiverObservationKind.Gone) + return new(FlowIncidentActionResultKind.RetryableSafe, "receiver-final-reconciliation-required", + RetryAfter: observation.RetryAfter ?? TimeSpan.FromSeconds(5)); + if (observation.Kind == RatelDeskReceiverObservationKind.Gone || !withinBudget) + return new(uncertain ? FlowIncidentActionResultKind.DeliveryUnknown : FlowIncidentActionResultKind.Failed, + uncertain ? "delivery-horizon-exhausted-unknown" : "action-budget-exhausted"); + if (observation.Kind is RatelDeskReceiverObservationKind.RateLimited or RatelDeskReceiverObservationKind.TransientReadFailure or + RatelDeskReceiverObservationKind.PossibleCommit or RatelDeskReceiverObservationKind.Missing) + return new(FlowIncidentActionResultKind.RetryableSafe, observation.Code, + RetryAfter: observation.RetryAfter ?? TimeSpan.FromSeconds(5)); + // Current denial or malformed selection cannot erase an earlier possible commit. + return new(uncertain ? FlowIncidentActionResultKind.DeliveryUnknown : FlowIncidentActionResultKind.Failed, observation.Code); + } + + public static FlowActionStatus BudgetExhausted(bool mayHaveCommitted) => + mayHaveCommitted ? FlowActionStatus.DeliveryUnknown : FlowActionStatus.Failed; +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparationBuilder.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparationBuilder.cs new file mode 100644 index 00000000..a1faf8e6 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparationBuilder.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using System.Text.Json; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +/// The frozen receiver sanitizer and typed incident projection. +public interface IRatelDeskReceiverFingerprint +{ + string Compute(RatelDeskCreateIncidentDto body); +} + +public sealed class ReceiverPreparationBuilder(IRatelDeskReceiverFingerprint fingerprint) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public RatelDeskReceiverPreparationV2 Build(FlowIncidentActionDraft draft, + FlowIncidentActionRequest action, RatelDeskSemanticPeer peer, + RatelDeskVerifiedCapability capability, DateTimeOffset originalCreatedAtUtc, + string? previouslyPersistedWireKey) + { + if (!FlowContractValidation.ValidPrepared(action, draft) || action.SourceInstanceId != peer.SourceInstanceId || + action.ConnectorId != peer.ConnectorId || action.TenantId != peer.LocalTenantId || + action.Target.OrganizationId != peer.OrganizationId || action.Target.CustomerId != peer.CustomerId || + action.Target.AssignedToId != peer.AssignedToId || + !action.Target.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal) + .SequenceEqual(peer.CategoryIds, StringComparer.Ordinal) || + !ReceiverWireValidation.ValidCapability(capability, peer) || originalCreatedAtUtc == default || + originalCreatedAtUtc.Offset != TimeSpan.Zero) + throw new InvalidOperationException("prepared-receiver-binding-invalid"); + var body = new RatelDeskCreateIncidentDto(action.Fields.Title, action.Fields.Description, + action.Fields.Priority!.Value, action.Target.CustomerId, action.Target.OrganizationId, + action.Target.AssignedToId, action.Target.CategoryIds); + var exactBody = JsonSerializer.Serialize(body, Json); + if (System.Text.Encoding.UTF8.GetByteCount(exactBody) > RatelDeskConnectorLimits.MaximumRequestBytes) + throw new InvalidOperationException("incident-request-too-large"); + var key = RatelDeskReceiverKey.Prepare(draft, previouslyPersistedWireKey); + var replaySeconds = Math.Min((long)NetRatel.Shared.Contracts.Flows.FlowLimits.MaximumRetryAge.TotalSeconds, + capability.MaximumAutomaticReplaySeconds); + var evidence = new RatelDeskReceiverPreparationV2(2, action.ConnectorId, action.ConnectorRevision, + peer, capability, key, previouslyPersistedWireKey is not null || key == draft.IdempotencyKey ? "preserved" : RatelDeskReceiverKey.Algorithm, + exactBody, fingerprint.Compute(body), originalCreatedAtUtc, + originalCreatedAtUtc.AddSeconds(replaySeconds), ""); + evidence = evidence with { EvidenceFingerprint = EvidenceHash(evidence) }; + if (!ReceiverPreparedBinding.Valid(evidence, action, draft, originalCreatedAtUtc, fingerprint)) + throw new InvalidOperationException("prepared-receiver-binding-invalid"); + return evidence; + } + public static string EvidenceHash(RatelDeskReceiverPreparationV2 evidence) => + Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes( + evidence with { EvidenceFingerprint = "" }, Json))); +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparedBinding.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparedBinding.cs new file mode 100644 index 00000000..22b01b52 --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverPreparedBinding.cs @@ -0,0 +1,46 @@ +using System.Text; +using System.Text.Json; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static class ReceiverPreparedBinding +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public static bool Valid(RatelDeskReceiverPreparationV2 evidence, FlowIncidentActionRequest action, + FlowIncidentActionDraft draft, DateTimeOffset runCreatedAtUtc, IRatelDeskReceiverFingerprint receiverHash) + { + if (evidence is null || evidence.Peer is null || evidence.Capability is null || + evidence.Peer.CategoryIds is null || evidence.ExactCreateBodyJson is null || + runCreatedAtUtc == default || runCreatedAtUtc.Offset != TimeSpan.Zero || + !FlowContractValidation.ValidPrepared(action, draft) || !action.SupportsSafeReplay || + evidence.SchemaVersion != 2 || evidence.ConnectorId != action.ConnectorId || + evidence.ConnectorRevision != action.ConnectorRevision || evidence.Peer.ConnectorId != action.ConnectorId || + evidence.Peer.LocalTenantId != action.TenantId || evidence.Peer.SourceInstanceId != action.SourceInstanceId || + evidence.Peer.SourceNamespaceId == Guid.Empty || evidence.Peer.OrganizationId != action.Target.OrganizationId || + evidence.Peer.CustomerId != action.Target.CustomerId || evidence.Peer.AssignedToId != action.Target.AssignedToId || + !action.Target.CategoryIds.Select(x => x.ToString("D")).Order(StringComparer.Ordinal) + .SequenceEqual(evidence.Peer.CategoryIds, StringComparer.Ordinal) || + !ReceiverWireValidation.ValidCapability(evidence.Capability, evidence.Peer) || + evidence.OriginalActionCreatedAtUtc != runCreatedAtUtc || + evidence.AutomaticReplayUntilUtc != runCreatedAtUtc.AddSeconds(Math.Min( + (long)FlowLimits.MaximumRetryAge.TotalSeconds, evidence.Capability.MaximumAutomaticReplaySeconds)) || + evidence.EvidenceFingerprint != ReceiverPreparationBuilder.EvidenceHash(evidence)) return false; + var body = new RatelDeskCreateIncidentDto(action.Fields.Title, action.Fields.Description, + action.Fields.Priority!.Value, action.Target.CustomerId, action.Target.OrganizationId, + action.Target.AssignedToId, action.Target.CategoryIds); + if (evidence.ExactCreateBodyJson != JsonSerializer.Serialize(body, Json) || + Encoding.UTF8.GetByteCount(evidence.ExactCreateBodyJson) > RatelDeskConnectorLimits.MaximumRequestBytes || + evidence.ReceiverFingerprint != receiverHash.Compute(body)) return false; + string expectedKey; + try { expectedKey = RatelDeskReceiverKey.Prepare(draft, null); } + catch (ArgumentException) { return false; } + // A new sidecar cannot invent a different wire key by labeling it preserved. + return evidence.ReceiverIdempotencyKey == expectedKey && + (evidence.KeyAlgorithm == RatelDeskReceiverKey.Algorithm || + evidence.KeyAlgorithm == "preserved" && expectedKey == draft.IdempotencyKey); + } +} diff --git a/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverWireValidation.cs b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverWireValidation.cs new file mode 100644 index 00000000..5dfb433a --- /dev/null +++ b/src/NetRatel/NetRatel.Infrastructure/RatelDesk/ReceiverWireValidation.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Text.Json; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Infrastructure.RatelDesk; + +public static class ReceiverWireValidation +{ + public const string Contract = "rateldesk.incident-create.v1"; + public const string CapabilitiesPath = "/api/v1/integrations/netratel/capabilities"; + public const string CreatePath = "/api/v1/incidents/"; + public const string ReceiptPath = "/api/v1/integrations/netratel/incident-receipts/{key}"; + public const string TargetsPath = "/api/v1/integrations/netratel/targets/validate"; + public const long MinimumRetention = 7_776_000; + public const long MaximumReplay = 2_592_000; + public const string KeyPattern = "^[A-Za-z0-9._~-]{1,256}$"; + + public static string Endpoint(string apiBase, string path) => apiBase.TrimEnd('/') + "/" + path.TrimStart('/'); + + public static bool ValidCapability(RatelDeskVerifiedCapability? capability, RatelDeskSemanticPeer? peer) + { + if (capability is null || peer is null || capability.Endpoints is null) return false; + try { RequirePeer(peer); } + catch (InvalidDataException) { return false; } + return capability.ContractVersion == Contract && + capability.ReceiverInstanceId == peer.ReceiverInstanceId && + capability.SourceInstanceId == peer.SourceInstanceId && + capability.SourceNamespaceId == peer.SourceNamespaceId && + capability.Endpoints.Capabilities == Endpoint(peer.ApiBaseUrl, CapabilitiesPath) && + capability.Endpoints.Create == Endpoint(peer.ApiBaseUrl, CreatePath) && + capability.Endpoints.ReceiptTemplate == Endpoint(peer.ApiBaseUrl, ReceiptPath) && + capability.Endpoints.TargetValidation == Endpoint(peer.ApiBaseUrl, TargetsPath) && + capability.MinimumReceiptRetentionSeconds >= MinimumRetention && + capability.MaximumAutomaticReplaySeconds is >= 86_400 and <= MaximumReplay && + capability.ObservedAtUtc != default && capability.ObservedAtUtc.Offset == TimeSpan.Zero; + } + + private static void RequirePeer(RatelDeskSemanticPeer peer) + { + if (peer.Mode is not (RatelDeskAuthenticationMode.ManualApiBearer or RatelDeskAuthenticationMode.ManagedServiceLink) || + peer.LocalTenantId <= 0 || peer.ConnectorId == Guid.Empty || + peer.SourceInstanceId == Guid.Empty || peer.SourceNamespaceId == Guid.Empty || + peer.OrganizationId is not { Length: > 0 and <= 64 } || string.IsNullOrWhiteSpace(peer.OrganizationId) || peer.OrganizationId.Any(char.IsControl) || + peer.CustomerId is not { Length: > 0 and <= 64 } || string.IsNullOrWhiteSpace(peer.CustomerId) || peer.CustomerId.Any(char.IsControl) || + peer.AssignedToId is { } assignee && (assignee.Length is 0 or > 64 || string.IsNullOrWhiteSpace(assignee) || assignee.Any(char.IsControl)) || + peer.CategoryIds is null || peer.CategoryIds.Length > RatelDeskConnectorLimits.MaximumCategories) + throw new InvalidDataException("receiver-semantic-peer-unverified"); + _ = CanonicalGuid(peer.ReceiverInstanceId); + foreach (var category in peer.CategoryIds) _ = CanonicalGuid(category); + if (!peer.CategoryIds.SequenceEqual(peer.CategoryIds.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal), StringComparer.Ordinal)) + throw new InvalidDataException("receiver-semantic-peer-unverified"); + try + { + if (RatelDeskApiBase.Canonical(peer.ApiBaseUrl) != peer.ApiBaseUrl) + throw new InvalidDataException("receiver-api-base-unverified"); + } + catch (ArgumentException) { throw new InvalidDataException("receiver-api-base-unverified"); } + } + + private static Guid CanonicalGuid(string? value) => Guid.TryParseExact(value, "D", out var id) && + id != Guid.Empty && id.ToString("D") == value ? id : throw new InvalidDataException("receiver-identity-unverified"); + + public static JsonDocument Parse(byte[] body) + { + if (body.Length > RatelDeskConnectorLimits.MaximumResponseBytes) throw new InvalidDataException("receiver-response-too-large"); + var doc = JsonDocument.Parse(body, new JsonDocumentOptions { MaxDepth = 32 }); + try { ValidateDuplicates(doc.RootElement); return doc; } + catch { doc.Dispose(); throw; } + } + private static void ValidateDuplicates(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw new InvalidDataException("duplicate-receiver-json-field"); + ValidateDuplicates(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (var child in element.EnumerateArray()) ValidateDuplicates(child); + } + + // The first identity comes from authenticated capability JSON, before target validation. + public static RatelDeskManualProfileObservation CaptureManual(byte[] body, + RatelDeskConnectorState connector, string canonicalApiBase, Guid source, DateTimeOffset observedAtUtc) + { + if (source == Guid.Empty || observedAtUtc == default || observedAtUtc.Offset != TimeSpan.Zero) + throw new InvalidDataException("flow-source-identity-invalid"); + using var doc = Parse(body); var root = doc.RootElement; + var receiver = CanonicalGuid(Text(root, "receiverInstanceId")); + var sourceNamespace = CanonicalGuid(Text(root, "sourceNamespaceId")); + Equal(Text(root, "sourceInstanceId"), source.ToString("D")); + var peer = new RatelDeskSemanticPeer(RatelDeskAuthenticationMode.ManualApiBearer, connector.TenantId, + connector.Id, null, null, null, receiver.ToString("D"), connector.Configuration.OrganizationId, + canonicalApiBase, null, null, null, null, null, source, sourceNamespace, + connector.Configuration.OrganizationId, connector.Configuration.CustomerId, + connector.Configuration.AssignedToId, connector.Configuration.CategoryIds + .Select(x => x.ToString("D")).Order(StringComparer.Ordinal).ToArray()); + return new(peer, Capability(body, peer, observedAtUtc)); + } + + public static RatelDeskVerifiedCapability Capability(byte[] body, + RatelDeskSemanticPeer peer, DateTimeOffset observedAtUtc) + { + RequirePeer(peer); + if (observedAtUtc == default || observedAtUtc.Offset != TimeSpan.Zero) + throw new InvalidDataException("receiver-observation-time-invalid"); + using var doc = Parse(body); var root = doc.RootElement; + Equal(Text(root, "contractVersion"), Contract); + Equal(Text(root, "receiverInstanceId"), peer.ReceiverInstanceId); + Equal(Text(root, "sourceInstanceId"), peer.SourceInstanceId.ToString("D")); + Equal(Text(root, "sourceNamespaceId"), peer.SourceNamespaceId.ToString("D")); + Equal(Text(root, "keyHeader"), "Idempotency-Key"); + Equal(Text(root, "sourceHeader"), "X-NetRatel-Source-Instance"); + Equal(Text(root, "keyPattern"), KeyPattern); + if (Integer(root, "maxKeyLength") != 256 || + Integer(root, "minimumReceiptRetentionSeconds") < MinimumRetention || + Integer(root, "maximumAutomaticReplaySeconds") is < 86_400 or > MaximumReplay || + Flag(root, "receiptEvictionEnabled") || !Flag(root, "atomicIncidentReceiptAndEffects") || + !Flag(root, "supportsReceiptLookup") || !Flag(root, "supportsSafeSameKeyReplay")) + throw new InvalidDataException("receiver-replay-guarantees-unverified"); + var mode = peer.Mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "oauth_client_credentials" : "api_bearer"; + var modes = Required(root, "authenticationModes", JsonValueKind.Array); + if (modes.GetArrayLength() is < 1 or > 8 || + modes.EnumerateArray().Any(x => x.ValueKind != JsonValueKind.String || + x.GetString() is not ("api_bearer" or "oauth_client_credentials")) || + modes.EnumerateArray().Select(x => x.GetString()).Distinct(StringComparer.Ordinal).Count() != modes.GetArrayLength() || + !modes.EnumerateArray().Any(x => x.GetString() == mode)) + throw new InvalidDataException("receiver-authentication-mode-unverified"); + var endpoints = new RatelDeskReceiverEndpoints(Endpoint(peer.ApiBaseUrl, CapabilitiesPath), + Text(root, "createEndpoint"), Text(root, "receiptEndpointTemplate"), Text(root, "targetValidationEndpoint")); + Equal(endpoints.Create, Endpoint(peer.ApiBaseUrl, CreatePath)); + Equal(endpoints.ReceiptTemplate, Endpoint(peer.ApiBaseUrl, ReceiptPath)); + Equal(endpoints.TargetValidation, Endpoint(peer.ApiBaseUrl, TargetsPath)); + return new(Contract, peer.ReceiverInstanceId, peer.SourceInstanceId, peer.SourceNamespaceId, + endpoints, Integer(root, "minimumReceiptRetentionSeconds"), + Integer(root, "maximumAutomaticReplaySeconds"), observedAtUtc); + } + + public static void Target(byte[] body, RatelDeskSemanticPeer peer) + { + RequirePeer(peer); + using var doc = Parse(body); var root = doc.RootElement; + Equal(Text(root, "contractVersion"), Contract); + Equal(Text(root, "receiverInstanceId"), peer.ReceiverInstanceId); + Equal(Text(root, "sourceInstanceId"), peer.SourceInstanceId.ToString("D")); + Equal(Text(root, "sourceNamespaceId"), peer.SourceNamespaceId.ToString("D")); + if (!Flag(root, "valid")) throw new InvalidDataException("receiver-target-unverified"); + var mapping = Required(root, "mapping", JsonValueKind.Object); + Equal(Text(mapping, "organizationId"), peer.OrganizationId); + Equal(Text(mapping, "customerId"), peer.CustomerId); + if (!mapping.TryGetProperty("assignedToId", out var assignee)) + throw new InvalidDataException("receiver-target-unverified"); + if (peer.AssignedToId is null ? assignee.ValueKind != JsonValueKind.Null : + assignee.ValueKind != JsonValueKind.String || assignee.GetString() != peer.AssignedToId) + throw new InvalidDataException("receiver-target-unverified"); + var categories = Required(mapping, "categoryIds", JsonValueKind.Array); + if (categories.GetArrayLength() != peer.CategoryIds.Length || + !categories.EnumerateArray().Select(x => x.ValueKind == JsonValueKind.String ? x.GetString() : null) + .SequenceEqual(peer.CategoryIds, StringComparer.Ordinal)) + throw new InvalidDataException("receiver-target-unverified"); + } + + public static RatelDeskVerifiedReceipt Receipt(byte[] body, string? locationHeader, + RatelDeskReceiverPreparationV2 prepared) + { + RequirePeer(prepared.Peer); + if (!RatelDeskReceiverKey.IsConforming(prepared.ReceiverIdempotencyKey) || + prepared.ReceiverFingerprint is not { Length: 64 } || + !prepared.ReceiverFingerprint.All(c => c is >= 'a' and <= 'f' or >= '0' and <= '9')) + throw new InvalidDataException("receiver-preparation-unverified"); + using var doc = Parse(body); var root = doc.RootElement; + var receipt = Required(root, "integrationReceipt", JsonValueKind.Object); + Equal(Text(receipt, "contractVersion"), Contract); + Equal(Text(receipt, "receiverInstanceId"), prepared.Peer.ReceiverInstanceId); + Equal(Text(receipt, "sourceInstanceId"), prepared.Peer.SourceInstanceId.ToString("D")); + Equal(Text(receipt, "sourceNamespaceId"), prepared.Peer.SourceNamespaceId.ToString("D")); + Equal(Text(receipt, "key"), prepared.ReceiverIdempotencyKey); + Equal(Text(receipt, "fingerprint"), prepared.ReceiverFingerprint); + Equal(Text(receipt, "outcome"), "committed"); + Equal(Text(receipt, "organizationId"), prepared.Peer.OrganizationId); + Equal(Text(receipt, "customerId"), prepared.Peer.CustomerId); + var incident = SafeId(Text(receipt, "incidentId")); var tracking = SafeId(Text(receipt, "trackingId")); + Equal(Text(root, "id"), incident); Equal(Text(root, "trackingId"), tracking); + Equal(Text(root, "organizationId"), prepared.Peer.OrganizationId); + Equal(Text(root, "customerId"), prepared.Peer.CustomerId); + var location = Text(receipt, "location"); + var api = new Uri(RatelDeskApiBase.Canonical(prepared.Peer.ApiBaseUrl), UriKind.Absolute); + var expectedLocation = api.AbsolutePath.TrimEnd('/') + "/api/v1/incidents/" + incident; + // Capture the actual API resource path, never invent a Web navigation URL. + Equal(location, expectedLocation); Equal(locationHeader, location); + var stamp = Text(receipt, "committedAtUtc"); + // Receiver permits fractional UTC and +00:00. B whole-second parsing is inapplicable. + if (!System.Text.RegularExpressions.Regex.IsMatch(stamp, + @"\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|\+00:00)\z", + System.Text.RegularExpressions.RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)) || + !DateTimeOffset.TryParse(stamp, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var committed) || committed.Offset != TimeSpan.Zero) + throw new InvalidDataException("receiver-commit-time-unverified"); + return new(Contract, prepared.Peer.ReceiverInstanceId, prepared.Peer.SourceNamespaceId, + prepared.Peer.SourceInstanceId, prepared.ReceiverIdempotencyKey, prepared.ReceiverFingerprint, + "committed", incident, tracking, prepared.Peer.OrganizationId, prepared.Peer.CustomerId, + committed, location, System.Text.Encoding.UTF8.GetString(body)); + } + + public static string? ProblemCode(byte[] body) + { + using var doc = Parse(body); + if (doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty("code", out var code) || code.ValueKind != JsonValueKind.String) return null; + var value = code.GetString(); + return value is { Length: > 0 and <= 128 } && value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '.') ? value : null; + } + private static JsonElement Required(JsonElement root, string name, JsonValueKind kind) => + root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var value) && value.ValueKind == kind + ? value : throw new InvalidDataException("receiver-field-unverified"); + private static string Text(JsonElement root, string name) + { + var value = Required(root, name, JsonValueKind.String).GetString(); + return value is { Length: > 0 and <= 4096 } && !value.Any(char.IsControl) + ? value : throw new InvalidDataException("receiver-field-unverified"); + } + private static long Integer(JsonElement root, string name) => + Required(root, name, JsonValueKind.Number).TryGetInt64(out var value) && value >= 0 + ? value : throw new InvalidDataException("receiver-number-unverified"); + private static bool Flag(JsonElement root, string name) + { + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value) || + value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new InvalidDataException("receiver-boolean-unverified"); + return value.GetBoolean(); + } + private static string SafeId(string value) => value.Length <= 128 && + value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_') ? value : + throw new InvalidDataException("receiver-identity-unverified"); + private static void Equal(string? actual, string expected) + { if (!string.Equals(actual, expected, StringComparison.Ordinal)) throw new InvalidDataException("receiver-binding-unverified"); } +} diff --git a/src/NetRatel/NetRatel.Shared/Contracts/RatelDesk/RatelDeskConnectorContracts.cs b/src/NetRatel/NetRatel.Shared/Contracts/RatelDesk/RatelDeskConnectorContracts.cs new file mode 100644 index 00000000..24f51306 --- /dev/null +++ b/src/NetRatel/NetRatel.Shared/Contracts/RatelDesk/RatelDeskConnectorContracts.cs @@ -0,0 +1,53 @@ +namespace NetRatel.Shared.Contracts.RatelDesk; + +public sealed record RatelDeskConnectorTenantDto(int TenantId, string Name); + +public sealed record RatelDeskPriorityMapping(int Information = 0, int Warning = 1, int Error = 2, int Critical = 3); + +public sealed record RatelDeskConnectorConfiguration( + string Name, string Origin, string OrganizationId, string CustomerId, + string? AssignedToId, IReadOnlyList CategoryIds, RatelDeskPriorityMapping Priorities, bool Enabled); + +public sealed record RatelDeskConnectorDto(Guid Id, int TenantId, long Revision, + RatelDeskConnectorConfiguration Configuration, bool HasCredential, long CredentialRevision, + bool AutomaticDeliveryAvailable, string AvailabilityCode, + RatelDeskConnectorAuthenticationDto? Authentication = null, DateTimeOffset? ReadinessObservedAtUtc = null); + +public sealed record SaveRatelDeskConnectorRequest(long ExpectedRevision, RatelDeskConnectorConfiguration Configuration, + RatelDeskConnectorAuthenticationDto? Authentication = null); +public sealed record RotateRatelDeskConnectorCredentialRequest(long ExpectedCredentialRevision, string Credential); +public enum RatelDeskConnectionTestStatus { MappingValidated, AuthenticationRejected, MappingRejected, Unavailable } +public sealed record RatelDeskConnectionTestResult(RatelDeskConnectionTestStatus Status, string Code, + bool AutomaticDeliveryAvailable = false, int? RetryAfterSeconds = null); + +/// Subset of the actual RatelDesk CreateIncidentDto; priority is the numeric TicketPriority (0..3). +public sealed record RatelDeskCreateIncidentDto(string Title, string Description, int Priority, + string CustomerId, string OrganizationId, string? AssignedToId, IReadOnlyList CategoryIds); + +public sealed record RatelDeskDryRunRequest(string Title, string Description, int Priority); +public sealed record RatelDeskDryRunResult(RatelDeskCreateIncidentDto Payload, string SemanticFingerprint, + string FakeIncidentId = "dry-run-only", bool SendsIncident = false, bool AutomaticDeliveryAvailable = false); + +public enum RatelDeskDeliveryStatus { Succeeded, AuthenticationRejected, PayloadRejected, RateLimited, Unavailable, DeliveryUnknown } +public sealed record RatelDeskIncidentReceipt(string IncidentId, string TrackingId, string? SafeLink); +public sealed record RatelDeskDeliveryResult(RatelDeskDeliveryStatus Status, string Code, + RatelDeskIncidentReceipt? Receipt = null, int? RetryAfterSeconds = null); + +public static class RatelDeskConnectorLimits +{ + public const int MaximumConnectorsPerTenant = 256; + public const int MaximumTitleCharacters = 200; + public const int MaximumDescriptionCharacters = 8_000; + public const int MaximumRequestBytes = 32_768; + public const int MaximumResponseBytes = 131_072; + public const int MaximumLookupItems = 512; + public const int MaximumCategories = 16; + public const string ReceiverUnavailableCode = "receiver-idempotency-unverified"; +} + +public sealed record RatelDeskConnectorAuthenticationDto(string Mode, string? ManagedLinkId = null); +public sealed record RatelDeskManagedLinkOptionDto(string LinkId, string ReceiverInstanceId, string ApiBaseUrl, + string OrganizationId, string CustomerId, long LinkRevision); +public sealed record RatelDeskConnectorSetupDto(Guid FlowSourceInstanceId, string InstallationInstanceId, + string? AdoptedSourceInstanceId, long IdentityRevision, IReadOnlyList ManagedLinks); +public sealed record AdoptRatelDeskFlowSourceRequest(long ExpectedIdentityRevision); diff --git a/src/NetRatel/NetRatel.Tests/API/RatelDeskReceiverRegistrationTests.cs b/src/NetRatel/NetRatel.Tests/API/RatelDeskReceiverRegistrationTests.cs new file mode 100644 index 00000000..faa6bb52 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/API/RatelDeskReceiverRegistrationTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using AwesomeAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.API.Bootstrap; +using NetRatel.Infrastructure.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.API; + +public sealed class RatelDeskReceiverRegistrationTests +{ + [Theory] + [InlineData(RatelDeskReceiverHttpPipeline.ManualClient)] + [InlineData(RatelDeskReceiverHttpPipeline.ManagedClient)] + public async Task Receiver_client_removes_global_resilience_replays_without_changing_other_clients(string name) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.ConfigureHttpClientDefaults(client => client.AddStandardResilienceHandler(options => + { options.Retry.Delay = TimeSpan.Zero; options.Retry.UseJitter = false; })); + services.AddRatelDeskReceiverAdapter(new ConfigurationBuilder().Build()); + var transport = new RejectingTransport(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => transport); + var ordinaryTransport = new FailOnceTransport(); + services.AddHttpClient("ordinary-client").ConfigurePrimaryHttpMessageHandler(() => ordinaryTransport); + await using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + using var response = await client.PostAsync("https://receiver.example/api/v2/integration/incidents", + new StringContent("{}"), TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + transport.Calls.Should().Be(1); + client.Timeout.Should().Be(Timeout.InfiniteTimeSpan); + using var ordinaryClient = provider.GetRequiredService().CreateClient("ordinary-client"); + using var ordinaryResponse = await ordinaryClient.GetAsync("https://receiver.example/ordinary", + TestContext.Current.CancellationToken); + ordinaryResponse.StatusCode.Should().Be(HttpStatusCode.OK); + ordinaryTransport.Calls.Should().Be(2); + } + + private sealed class RejectingTransport : HttpMessageHandler + { + public int Calls; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + Calls++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + } + } + + private sealed class FailOnceTransport : HttpMessageHandler + { + public int Calls; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new HttpResponseMessage(++Calls == 1 + ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK)); + } + } +} diff --git a/src/NetRatel/NetRatel.Tests/Flows/FlowPostgreSqlTests.cs b/src/NetRatel/NetRatel.Tests/Flows/FlowPostgreSqlTests.cs index d4e8347e..f4a1c325 100644 --- a/src/NetRatel/NetRatel.Tests/Flows/FlowPostgreSqlTests.cs +++ b/src/NetRatel/NetRatel.Tests/Flows/FlowPostgreSqlTests.cs @@ -293,9 +293,12 @@ private static FlowIncidentActionDraft Draft(FlowRunLease lease) private sealed class Clock : TimeProvider { - private DateTimeOffset _now = new DateTimeOffset(2026, 10, 2, 20, 0, 0, TimeSpan.Zero).AddTicks(7); + private DateTimeOffset _now = CurrentUtc(); public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan duration) => _now += duration; + public void CaptureSystemTime() => _now = CurrentUtc(); + // Retain the existing seven-tick sub-microsecond precision probe on current UTC. + private static DateTimeOffset CurrentUtc() => new(DateTimeOffset.UtcNow.UtcTicks / 10 * 10 + 7, TimeSpan.Zero); } private sealed class Authority : IFlowExecutionAuthorityVerifier { @@ -350,7 +353,7 @@ public static async Task CreateAsync(PostgreSqlPersistenceFixture fixtu { var h = new Harness { Connection = await fixture.CreateDatabaseAsync() }; h.Provider = h.NewProvider(); await using var scope = h.Provider.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); db.Tenants.AddRange(new Tenant { Id = 17, Name = "Flows fixture" }, new Tenant { Id = 18, Name = "Foreign tenant" }); await db.SaveChangesAsync(); return h; + await db.Database.MigrateAsync(); db.Tenants.AddRange(new Tenant { Id = 17, Name = "Flows fixture" }, new Tenant { Id = 18, Name = "Foreign tenant" }); await db.SaveChangesAsync(); h.Clock.CaptureSystemTime(); return h; } public ServiceProvider NewProvider() { diff --git a/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverAdapterTests.cs b/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverAdapterTests.cs new file mode 100644 index 00000000..8c87edfa --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverAdapterTests.cs @@ -0,0 +1,212 @@ +using AwesomeAssertions; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Flows; + +// Isolated component regressions. These doubles do not establish published-peer or physical acceptance. +public sealed class FlowReceiverAdapterTests +{ + [Fact] + public async Task Authority_revoked_during_lookup_does_not_accept_the_returned_committed_receipt() + { + var h = new Harness(); h.Transport.Lookup = new(RatelDeskReceiverObservationKind.Committed, "verified", h.Receipt); + h.Transport.BeforeLookupReturn = () => h.Authority.EventAllowed = false; + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, + h.Evidence with { MayHaveCommitted = true }, h.Admit, default); + (result.Kind).Should().Be(RatelDeskReceiverObservationKind.AuthenticationRejected); + (result.Receipt).Should().BeNull(); + (h.Transport.Operations).Should().Equal(new[] { "lookup" }); + (h.Store.MarkCalls).Should().Be(0); + } + + [Fact] + public async Task Fifth_ambiguous_send_reconciles_original_receipt_before_capability_or_target_checks() + { + var h = new Harness(); h.Transport.Lookup = new(RatelDeskReceiverObservationKind.Committed, "verified", h.Receipt); + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, + h.Evidence with { MayHaveCommitted = true, Attempts = FlowLimits.MaximumActionAttempts }, h.Admit, default); + (result.Kind).Should().Be(RatelDeskReceiverObservationKind.Committed); + (result.Receipt).Should().BeSameAs(h.Receipt); + (h.Transport.Operations).Should().Equal(new[] { "lookup" }); + (h.Store.MarkCalls).Should().Be(0); + } + + [Fact] + public async Task Missing_receipt_at_fifth_send_cap_does_not_permit_sixth_post() + { + var h = new Harness(); h.Transport.Lookup = new(RatelDeskReceiverObservationKind.Missing, "missing"); + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, + h.Evidence with { MayHaveCommitted = true, Attempts = FlowLimits.MaximumActionAttempts }, h.Admit, default); + (result.Kind).Should().Be(RatelDeskReceiverObservationKind.Gone); + (h.Transport.Operations).Should().Equal(new[] { "lookup" }); + (h.Store.MarkCalls).Should().Be(0); + } + + [Theory] + [InlineData(RatelDeskReceiverObservationKind.TransientReadFailure)] + [InlineData(RatelDeskReceiverObservationKind.RateLimited)] + [InlineData(RatelDeskReceiverObservationKind.Gone)] + [InlineData(RatelDeskReceiverObservationKind.AuthenticationRejected)] + public async Task An_unresolved_prior_send_does_not_post_without_a_verified_missing_lookup(RatelDeskReceiverObservationKind lookup) + { + var h = new Harness(); h.Transport.Lookup = new(lookup, "read-unavailable"); + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, + h.Evidence with { MayHaveCommitted = true }, h.Admit, default); + (result.Kind).Should().Be(lookup); + (h.Transport.Operations).Should().Equal(new[] { "lookup" }); + (h.Store.MarkCalls).Should().Be(0); + } + + [Fact] + public async Task Replay_keeps_exact_original_key_and_body_and_commits_uncertainty_before_http() + { + var h = new Harness(); h.Transport.Lookup = new(RatelDeskReceiverObservationKind.Missing, "missing"); + h.Transport.BeforeCreate = prepared => + { + (h.Store.Marked).Should().BeTrue(); + (h.Store.MarkCalls).Should().Be(1); + (prepared).Should().BeSameAs(h.Evidence.Prepared); + (prepared.ReceiverIdempotencyKey).Should().Be(h.Evidence.Prepared.ReceiverIdempotencyKey); + (prepared.ExactCreateBodyJson).Should().Be(h.Evidence.Prepared.ExactCreateBodyJson); + }; + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, + h.Evidence with { MayHaveCommitted = true }, h.Admit, default); + (result.Kind).Should().Be(RatelDeskReceiverObservationKind.PossibleCommit); + (h.Transport.Operations).Should().Equal(new[] { "lookup", "capability", "targets", "create" }); + } + + [Theory] + [InlineData(1, false)] + [InlineData(3, true)] + public async Task Current_guard_can_stop_post_before_or_after_durable_marker(int deniedGate, bool markerCommitted) + { + var h = new Harness(); h.DenyAtGate = deniedGate; + var result = await h.Adapter.DispatchReceiverAsync(h.Lease, h.NodeId, h.Evidence, h.Admit, default); + (result.Kind).Should().Be(RatelDeskReceiverObservationKind.AuthenticationRejected); + (h.Store.Marked).Should().Be(markerCommitted); + (h.Transport.Operations).Should().NotContain("create"); + var classified = ReceiverDispatchPolicy.Complete(h.Evidence with { MayHaveCommitted = h.Store.Marked }, result, h.Clock.GetUtcNow()); + (classified.Kind).Should().Be(markerCommitted ? FlowIncidentActionResultKind.DeliveryUnknown : FlowIncidentActionResultKind.Failed); + } + + [Fact] + public async Task Read_only_preparation_rate_limit_schedules_retry_without_marking_a_post() + { + var h = new Harness(); h.Transport.CapabilityError = new RatelDeskReceiverReadException("receiver-rate-limited", 429, TimeSpan.FromSeconds(17)); + var result = await h.Adapter.PrepareReceiverAsync(h.Draft, h.Clock.GetUtcNow(), default); + (result.Status).Should().Be(FlowIncidentPreparationStatus.Unavailable); + (result.RetryAfter).Should().Be(TimeSpan.FromSeconds(17)); + (result.Action).Should().BeNull(); (result.Evidence).Should().BeNull(); + (h.Store.MarkCalls).Should().Be(0); + (h.Transport.Operations).Should().Equal(new[] { "capability" }); + } + + private sealed class Harness + { + public FlowRunLease Lease { get; } = FlowTestData.Lease("receiver-test", 90); + public Guid NodeId => Draft.ActionNodeId; + public FlowIncidentActionDraft Draft { get; } + public RatelDeskDispatchEvidence Evidence { get; } + public RatelDeskVerifiedReceipt Receipt { get; } + public FixedClock Clock { get; } + public MarkerStore Store { get; } = new(); + public Authorization Authority { get; } = new(); + public Transport Transport { get; } + public RatelDeskFlowReceiverAdapter Adapter { get; } + public int DenyAtGate { get; set; } + private int _gates; + + public Harness() + { + Clock = new(Lease.Event.OccurredAtUtc); + var node = Lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.CreateIncident); + var mapping = Lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.MapIncident).Mapping!; + Draft = new(Lease.Event.TenantId, Lease.RunId, node.Id, node.ConnectorId!.Value, node.ConnectorRevision!.Value, + Lease.SourceInstanceId, FlowActionKeys.Create(Lease, node.Id), Lease.Event, FlowPureEvaluation.Map(mapping, Lease.Event.Data)); + var action = FlowTestData.Prepared(Draft, safeReplay: true); + var peer = new RatelDeskSemanticPeer(RatelDeskAuthenticationMode.ManualApiBearer, 17, Draft.ConnectorId, + null, null, null, Guid.NewGuid().ToString("D"), "organization-17", "https://api.example.test/help", + null, null, null, null, null, Draft.SourceInstanceId, Guid.NewGuid(), "organization-17", "customer-17", null, []); + var capability = new RatelDeskVerifiedCapability(ReceiverWireValidation.Contract, peer.ReceiverInstanceId, + peer.SourceInstanceId, peer.SourceNamespaceId, + new(ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CapabilitiesPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CreatePath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.ReceiptPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.TargetsPath)), + ReceiverWireValidation.MinimumRetention, ReceiverWireValidation.MaximumReplay, Clock.GetUtcNow()); + var builder = new ReceiverPreparationBuilder(new RatelDeskReceiverFingerprint()); + var prepared = builder.Build(Draft, action, peer, capability, Clock.GetUtcNow(), null); + Evidence = new(prepared, false, 1, null); + Receipt = new(ReceiverWireValidation.Contract, peer.ReceiverInstanceId, peer.SourceNamespaceId, peer.SourceInstanceId, + prepared.ReceiverIdempotencyKey, prepared.ReceiverFingerprint, "committed", "incident-test", "INC-test", + peer.OrganizationId, peer.CustomerId, Clock.GetUtcNow(), "/help/api/v1/incidents/incident-test", "{}"); + var connector = new RatelDeskConnectorState(Draft.ConnectorId, 17, 1, 1, "test-owner", + new("Test", peer.ApiBaseUrl, peer.OrganizationId, peer.CustomerId, null, [], new(), true), "protected-unit-value", 1); + Transport = new(capability); + Adapter = new(new ConnectorStore(connector), new BindingStore(), Authority, + new Credentials(peer), Transport, builder, Store, Clock); + } + public Task Admit(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var denied = ++_gates == DenyAtGate; + return Task.FromResult(new FlowDispatchDecision(!denied, denied ? "current-test-denial" : "current-test-allow")); + } + } + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { public override DateTimeOffset GetUtcNow() => now; } + private sealed class ConnectorStore(RatelDeskConnectorState connector) : IRatelDeskConnectorStore + { + public Task GetAsync(int tenantId, Guid id, CancellationToken ct) => Task.FromResult( + tenantId == connector.TenantId && id == connector.Id ? connector : null); + public Task> ListAsync(int tenantId, CancellationToken ct) => Task.FromResult>([connector]); + public Task SaveAsync(RatelDeskConnectorState state, long expected, CancellationToken ct) => throw new NotSupportedException(); + } + private sealed class BindingStore : IRatelDeskConnectorBindingStore + { public Task GetAuthenticationAsync(int tenantId, Guid id, CancellationToken ct) => Task.FromResult(new RatelDeskConnectorAuthentication(RatelDeskAuthenticationMode.ManualApiBearer, null)); } + private sealed class Authorization : IRatelDeskConnectorAuthorization + { + public bool EventAllowed { get; set; } = true; + public Task CanManageAsync(System.Security.Claims.ClaimsPrincipal actor, int tenantId, CancellationToken ct) => throw new NotSupportedException(); + public Task CanExecuteAsync(string principalId, string? credentialId, int tenantId, CancellationToken ct) => Task.FromResult(principalId == "test-owner" || EventAllowed); + } + private sealed class Credentials(RatelDeskSemanticPeer peer) : IRatelDeskOutboundBindingResolver + { + public Task CaptureAsync(RatelDeskConnectorState connector, RatelDeskConnectorAuthentication authentication, Guid source, CancellationToken ct) => Task.FromResult(peer); + public Task GetBearerAsync(RatelDeskConnectorState current, RatelDeskSemanticPeer captured, string scope, CancellationToken ct) => Task.FromResult("unit-only-bearer"); + } + private sealed class Transport(RatelDeskVerifiedCapability capability) : IRatelDeskReceiverTransport + { + public List Operations { get; } = []; + public RatelDeskReceiverObservation Lookup { get; set; } = new(RatelDeskReceiverObservationKind.Missing, "missing"); + public Action? BeforeCreate { get; set; } + public Action? BeforeLookupReturn { get; set; } + public Exception? CapabilityError { get; set; } + public Task CapabilitiesAsync(RatelDeskSemanticPeer peer, string bearer, CancellationToken ct) + { Operations.Add("capability"); return CapabilityError is null ? Task.FromResult(capability) : Task.FromException(CapabilityError); } + public Task ValidateTargetsAsync(RatelDeskSemanticPeer peer, RatelDeskVerifiedCapability current, string bearer, CancellationToken ct) + { Operations.Add("targets"); return Task.CompletedTask; } + public Task LookupAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { Operations.Add("lookup"); BeforeLookupReturn?.Invoke(); return Task.FromResult(Lookup); } + public Task CreateAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { Operations.Add("create"); BeforeCreate?.Invoke(prepared); return Task.FromResult(new RatelDeskReceiverObservation(RatelDeskReceiverObservationKind.PossibleCommit, "response-lost")); } + } + private sealed class MarkerStore : IFlowReceiverEvidenceStore + { + public bool Marked { get; private set; } + public int MarkCalls { get; private set; } + public Task MarkPostAttemptAsync(FlowRunLease lease, Guid node, CancellationToken ct) + { MarkCalls++; Marked = true; return Task.FromResult(true); } + public Task GetOriginalCreatedAtAsync(FlowRunLease lease, CancellationToken ct) => throw new NotSupportedException(); + public Task ReadAsync(FlowRunLease lease, Guid node, CancellationToken ct) => throw new NotSupportedException(); + public Task SavePreparedAsync(FlowRunLease lease, Guid node, RatelDeskReceiverPreparationV2 evidence, CancellationToken ct) => throw new NotSupportedException(); + public Task SavePreparedActionAsync(FlowRunLease lease, Guid node, FlowIncidentActionRequest action, RatelDeskReceiverPreparationV2 evidence, CancellationToken ct) => throw new NotSupportedException(); + public Task SchedulePreparationRetryAsync(FlowRunLease lease, Guid node, TimeSpan? retryAfter, CancellationToken ct) => throw new NotSupportedException(); + public Task SaveReceiptAsync(FlowRunLease lease, Guid node, RatelDeskVerifiedReceipt receipt, CancellationToken ct) => throw new NotSupportedException(); + } +} diff --git a/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverEvidenceTests.cs b/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverEvidenceTests.cs new file mode 100644 index 00000000..cc0ae794 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Flows/FlowReceiverEvidenceTests.cs @@ -0,0 +1,373 @@ +using System.Text.Json; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using Xunit; + +namespace NetRatel.Tests.Flows; + +// Isolated store regressions; PostgreSQL locks/triggers and published-peer acceptance remain separate checks. +public sealed class FlowReceiverEvidenceTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Returned_fifth_send_ambiguity_completes_run_then_claims_exactly_one_read_only_reconciliation(bool receiptAvailable) + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + await using (var scope = h.Provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var action = await db.FlowActions.SingleAsync(row => row.RunId == h.Lease.RunId); + var evidence = await db.Set().SingleAsync(row => row.RunId == h.Lease.RunId); + // Arrange the durable state immediately after the fifth POST returned an ambiguous response. + action.Status = FlowActionStatus.Dispatching; action.Attempts = FlowLimits.MaximumActionAttempts; + evidence.MayHaveCommitted = true; evidence.FirstPostAttemptAtUtc = h.Clock.GetUtcNow(); + evidence.LastPostLeaseFence = h.Lease.Fence; + await db.SaveChangesAsync(); + } + var postState = (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!; + (postState.FinalReconciliationPending).Should().BeTrue(); + var result = ReceiverDispatchPolicy.Complete(postState, + new(RatelDeskReceiverObservationKind.PossibleCommit, "response-lost"), h.Clock.GetUtcNow()); + (result.Kind).Should().Be(FlowIncidentActionResultKind.RetryableSafe); + (await h.Store.CompleteActionAsync(h.Lease, h.NodeId, result)).Should().BeTrue(); + (await h.Store.CompleteRunAsync(h.Lease, new(FlowRunStatus.RetryWaiting, "retry-waiting"))).Should().BeTrue(); + ((await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!.Run.Status).Should().Be(FlowRunStatus.RetryWaiting); + h.Receiver.Response = receiptAvailable ? RatelDeskReceiverObservationKind.Committed : RatelDeskReceiverObservationKind.TransientReadFailure; + h.Clock.Advance(result.RetryAfter!.Value); + (await h.Provider.GetRequiredService().ProcessOneAsync()).Should().BeTrue(); + var run = (await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!; + (run.Run.Status).Should().Be(receiptAvailable ? FlowRunStatus.Succeeded : FlowRunStatus.DeliveryUnknown); + (run.Actions.Single().Attempts).Should().Be(FlowLimits.MaximumActionAttempts); + (h.Receiver.Lookups).Should().Be(1); + (await h.Provider.GetRequiredService().ProcessOneAsync()).Should().BeFalse(); + await using var finalScope = h.Provider.CreateAsyncScope(); + var stored = await finalScope.ServiceProvider.GetRequiredService() + .Set().SingleAsync(row => row.RunId == h.Lease.RunId); + (stored.FinalReconciliationAttempted).Should().BeTrue(); + } + + [Fact] + public async Task Authority_revoked_while_receipt_lookup_is_awaited_denies_the_receipt_commit() + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + await using (var scope = h.Provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.FlowActions.SingleAsync(row => row.RunId == h.Lease.RunId)).Status = FlowActionStatus.Dispatching; + (await db.Set().SingleAsync(row => row.RunId == h.Lease.RunId)).MayHaveCommitted = true; + await db.SaveChangesAsync(); + } + h.Clock.Advance(FlowLimits.LeaseDuration + TimeSpan.FromSeconds(1)); + h.Receiver.BeforeReturn = () => h.Authority.Allowed = false; + (await h.Provider.GetRequiredService().ProcessOneAsync()).Should().BeTrue(); + var run = (await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!; + (run.Run.Status).Should().Be(FlowRunStatus.DeliveryUnknown); + (run.Actions.Single().Receipt).Should().BeNull(); + (h.Receiver.Lookups).Should().Be(1); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Recovered_fifth_send_can_reconcile_after_disable_but_current_authority_is_still_required(bool authorized) + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + await using (var scope = h.Provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var action = await db.FlowActions.SingleAsync(row => row.RunId == h.Lease.RunId); + var evidence = await db.Set().SingleAsync(row => row.RunId == h.Lease.RunId); + action.Status = FlowActionStatus.Dispatching; action.Attempts = FlowLimits.MaximumActionAttempts; + evidence.MayHaveCommitted = true; evidence.FirstPostAttemptAtUtc = h.Clock.GetUtcNow(); + (await db.FlowDefinitions.SingleAsync(row => row.Id == h.Lease.Version.FlowId)).Enabled = false; + await db.SaveChangesAsync(); + } + h.Authority.Allowed = authorized; + h.Clock.Advance(FlowLimits.LeaseDuration + TimeSpan.FromSeconds(1)); + (await h.Provider.GetRequiredService().ProcessOneAsync()).Should().BeTrue(); + var run = (await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!; + (run.Run.Status).Should().Be(authorized ? FlowRunStatus.Succeeded : FlowRunStatus.DeliveryUnknown); + (h.Receiver.Lookups).Should().Be(authorized ? 1 : 0); + (run.Actions.Single().Attempts).Should().Be(FlowLimits.MaximumActionAttempts); + (run.Actions.Single().IdempotencyKey).Should().Be(h.Draft.IdempotencyKey); + (run.Actions.Single().Receipt?.IncidentId).Should().Be(authorized ? "incident-test" : null); + } + + [Fact] + public async Task Interrupted_atomic_preparation_leaves_neither_V1_prepared_body_nor_V2_sidecar() + { + await using var h = await Harness.CreateAsync(prepareAction: false); + var before = await h.ReadOriginalAsync(); + h.Interruption.FailOneEvidenceSave = true; + await ((Func)(() => h.Evidence.SavePreparedActionAsync(h.Lease, h.NodeId, + FlowTestData.Prepared(h.Draft, safeReplay: true), h.Prepared, default))).Should().ThrowExactlyAsync(); + (await h.ReadOriginalAsync()).Should().Be(before); + (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default)).Should().BeNull(); + (await h.Evidence.SavePreparedActionAsync(h.Lease, h.NodeId, + FlowTestData.Prepared(h.Draft, safeReplay: true), h.Prepared, default)).Should().BeTrue(); + ((await h.ReadOriginalAsync()).PreparedJson).Should().NotBeNull(); + (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default)).Should().NotBeNull(); + } + + [Fact] + public async Task Adding_receiver_evidence_preserves_original_V1_event_draft_prepared_bytes_and_hashes() + { + await using var h = await Harness.CreateAsync(); + var before = await h.ReadOriginalAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + (await h.ReadOriginalAsync()).Should().Be(before); + var read = (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!; + (read).Should().NotBeNull(); + (read.Prepared.EvidenceFingerprint).Should().Be(h.Prepared.EvidenceFingerprint); + (read.Prepared.ExactCreateBodyJson).Should().Be(h.Prepared.ExactCreateBodyJson); + (read.Attempts).Should().Be(0); + (read.MayHaveCommitted).Should().BeFalse(); + } + + [Fact] + public async Task A_self_consistent_new_sidecar_cannot_replace_the_original_wire_key_or_body() + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + var before = await h.ReadOriginalAsync(); + foreach (var changed in new[] + { + h.Prepared with { ReceiverIdempotencyKey = new string('a', 64) }, + h.Prepared with { ExactCreateBodyJson = "{}" }, + h.Prepared with { Peer = h.Prepared.Peer with { CustomerId = "foreign-customer" } } + }) + { + var selfConsistent = changed with { EvidenceFingerprint = ReceiverPreparationBuilder.EvidenceHash(changed) }; + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, selfConsistent, default)).Should().BeFalse(); + } + (await h.ReadOriginalAsync()).Should().Be(before); + ((await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!.Prepared.EvidenceFingerprint).Should().Be(h.Prepared.EvidenceFingerprint); + } + + [Fact] + public async Task A_stale_or_cross_tenant_lease_cannot_read_or_replace_receiver_evidence() + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + foreach (var wrong in new[] + { + h.Lease with { Token = Guid.NewGuid() }, + h.Lease with { Fence = h.Lease.Fence + 1 }, + h.Lease with { Event = h.Lease.Event with { TenantId = 18 } } + }) + { + (await h.Evidence.ReadAsync(wrong, h.NodeId, default)).Should().BeNull(); + (await h.Evidence.SavePreparedAsync(wrong, h.NodeId, h.Prepared, default)).Should().BeFalse(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Original_run_age_or_attempt_budget_preserves_uncertain_retry_as_unknown(bool exhaustedAge) + { + await using var h = await Harness.CreateAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + await using (var scope = h.Provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.FlowRuns.SingleAsync(row => row.Id == h.Lease.RunId); + var action = await db.FlowActions.SingleAsync(row => row.RunId == h.Lease.RunId); + var evidence = await db.Set().SingleAsync(row => row.RunId == h.Lease.RunId); + action.Status = FlowActionStatus.RetryWaiting; action.Attempts = 1; + evidence.MayHaveCommitted = true; evidence.FirstPostAttemptAtUtc = h.Clock.GetUtcNow(); + run.Status = FlowRunStatus.RetryWaiting; run.LeaseExpiresAtUtc = null; + if (exhaustedAge) h.Clock.Advance(FlowLimits.MaximumRetryAge + TimeSpan.FromSeconds(1)); + else run.Attempts = FlowLimits.MaximumActionAttempts * 2; + await db.SaveChangesAsync(); + } + (await h.Store.ClaimAsync(Guid.NewGuid())).Should().BeNull(); + ((await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!.Run.Status).Should().Be(FlowRunStatus.DeliveryUnknown); + ((await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!.Actions.Single().Status).Should().Be(FlowActionStatus.DeliveryUnknown); + h.Clock.Advance(FlowLimits.HistoryRetention + TimeSpan.FromDays(1)); + (await h.Store.PruneHistoryAsync()).Should().Be(0); + } + + [Fact] + public async Task Preparation_retry_keeps_original_action_and_consumes_no_post_attempt() + { + await using var h = await Harness.CreateAsync(prepareAction: false); + (await h.Evidence.SchedulePreparationRetryAsync(h.Lease, h.NodeId, TimeSpan.FromSeconds(17), default)).Should().BeTrue(); + var action = (await h.Definitions.GetRunByIdAsync(17, h.Lease.RunId))!.Actions.Single(); + (action.Status).Should().Be(FlowActionStatus.RetryWaiting); + (action.Attempts).Should().Be(0); + (action.IdempotencyKey).Should().Be(h.Draft.IdempotencyKey); + (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default)).Should().BeNull(); + (await h.Evidence.SchedulePreparationRetryAsync(h.Lease, h.NodeId, TimeSpan.FromHours(2), default)).Should().BeFalse(); + } + + private sealed record OriginalBytes(string EventJson, string EventFingerprint, string DraftJson, + string? PreparedJson, string? SemanticFingerprint, string IdempotencyKey); + private sealed class Clock(DateTimeOffset now) : TimeProvider + { + private DateTimeOffset _now = now; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan age) => _now += age; + } + private sealed class PreparationInterruption : SaveChangesInterceptor + { + public bool FailOneEvidenceSave { get; set; } + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + if (FailOneEvidenceSave && eventData.Context!.ChangeTracker.Entries() + .Any(entry => entry.State == EntityState.Added)) + { + FailOneEvidenceSave = false; + throw new DbUpdateException("injected-before-atomic-preparation-save"); + } + return ValueTask.FromResult(result); + } + } + private sealed class Authority : IFlowExecutionAuthorityVerifier + { + public bool Allowed { get; set; } = true; + public Task AuthorizeAsync(int tenantId, FlowExecutionAuthorityDto authority, CancellationToken ct = default) => Task.FromResult(Allowed); + } + private sealed class SuppressedGuard : IFlowDispatchGuard + { public Task CanDispatchAsync(FlowEventEnvelope input, CancellationToken ct = default) => Task.FromResult(new FlowDispatchDecision(false, "test-occurrence-cleared")); } + private sealed class OneActionRuntime : IFlowRuntimeAdapter + { + public async Task ExecuteAsync(FlowRunLease lease, + Func> execute, CancellationToken ct = default) + { + var node = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.CreateIncident); + var mapping = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.MapIncident).Mapping!; + var draft = new FlowIncidentActionDraft(lease.Event.TenantId, lease.RunId, node.Id, node.ConnectorId!.Value, + node.ConnectorRevision!.Value, lease.SourceInstanceId, FlowActionKeys.Create(lease, node.Id), lease.Event, + FlowPureEvaluation.Map(mapping, lease.Event.Data)); + var result = await execute(draft, ct); + return new(result.Kind == FlowIncidentActionResultKind.Succeeded ? FlowRunStatus.Succeeded : FlowRunStatus.Failed, "test-runtime-completed"); + } + } + private sealed class ReceiptReceiver(RatelDeskReceiverPreparationV2 prepared, TimeProvider clock) : IFlowReceiverDispatcher + { + public int Lookups { get; private set; } + public Action? BeforeReturn { get; set; } + public RatelDeskReceiverObservationKind Response { get; set; } = RatelDeskReceiverObservationKind.Committed; + public Task PrepareReceiverAsync(FlowIncidentActionDraft draft, DateTimeOffset createdAt, CancellationToken ct) => throw new NotSupportedException(); + public Task DispatchReceiverAsync(FlowRunLease lease, Guid nodeId, + RatelDeskDispatchEvidence evidence, Func> recheckGuard, CancellationToken ct) + { + Lookups++; (evidence.MayHaveCommitted).Should().BeTrue(); + (evidence.Prepared.EvidenceFingerprint).Should().Be(prepared.EvidenceFingerprint); + if (Response != RatelDeskReceiverObservationKind.Committed) + return Task.FromResult(new RatelDeskReceiverObservation(Response, "test-lookup-unavailable")); + var location = "/help/api/v1/incidents/incident-test"; + var body = JsonSerializer.SerializeToUtf8Bytes(new + { + id = "incident-test", trackingId = "INC-test", organizationId = prepared.Peer.OrganizationId, customerId = prepared.Peer.CustomerId, + integrationReceipt = new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = prepared.Peer.ReceiverInstanceId, + sourceNamespaceId = prepared.Peer.SourceNamespaceId.ToString("D"), sourceInstanceId = prepared.Peer.SourceInstanceId.ToString("D"), + key = prepared.ReceiverIdempotencyKey, fingerprint = prepared.ReceiverFingerprint, outcome = "committed", + incidentId = "incident-test", trackingId = "INC-test", organizationId = prepared.Peer.OrganizationId, + customerId = prepared.Peer.CustomerId, committedAtUtc = clock.GetUtcNow(), location + } + }); + var receipt = ReceiverWireValidation.Receipt(body, location, evidence.Prepared); + BeforeReturn?.Invoke(); + return Task.FromResult(new RatelDeskReceiverObservation(RatelDeskReceiverObservationKind.Committed, "test-verified", receipt)); + } + } + private sealed class Harness : IAsyncDisposable + { + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public required ServiceProvider Provider { get; init; } + public required FlowRunLease Lease { get; init; } + public required FlowIncidentActionDraft Draft { get; init; } + public required RatelDeskReceiverPreparationV2 Prepared { get; init; } + public required Clock Clock { get; init; } + public required PreparationInterruption Interruption { get; init; } + public required Authority Authority { get; init; } + public required ReceiptReceiver Receiver { get; init; } + public Guid NodeId => Draft.ActionNodeId; + public IFlowExecutionStore Store => Provider.GetRequiredService(); + public IFlowDefinitionService Definitions => Provider.GetRequiredService(); + public IFlowReceiverEvidenceStore Evidence => Provider.GetRequiredService(); + + public static async Task CreateAsync(bool prepareAction = true) + { + var lease = FlowTestData.Lease("receiver-evidence", 91); var clock = new Clock(lease.Event.OccurredAtUtc); + var node = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.CreateIncident); + var map = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.MapIncident).Mapping!; + var draft = new FlowIncidentActionDraft(17, lease.RunId, node.Id, node.ConnectorId!.Value, node.ConnectorRevision!.Value, + lease.SourceInstanceId, FlowActionKeys.Create(lease, node.Id), lease.Event, FlowPureEvaluation.Map(map, lease.Event.Data)); + var action = FlowTestData.Prepared(draft, safeReplay: true); + var peer = new RatelDeskSemanticPeer(RatelDeskAuthenticationMode.ManualApiBearer, 17, draft.ConnectorId, + null, null, null, Guid.NewGuid().ToString("D"), "organization-17", "https://api.example.test/help", + null, null, null, null, null, lease.SourceInstanceId, Guid.NewGuid(), "organization-17", "customer-17", null, []); + var capability = new RatelDeskVerifiedCapability(ReceiverWireValidation.Contract, peer.ReceiverInstanceId, + peer.SourceInstanceId, peer.SourceNamespaceId, + new(ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CapabilitiesPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CreatePath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.ReceiptPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.TargetsPath)), + ReceiverWireValidation.MinimumRetention, ReceiverWireValidation.MaximumReplay, clock.GetUtcNow()); + var fingerprint = new RatelDeskReceiverFingerprint(); + var prepared = new ReceiverPreparationBuilder(fingerprint).Build(draft, action, peer, capability, clock.GetUtcNow(), null); + var services = new ServiceCollection(); services.AddLogging(); + var databaseName = "receiver-evidence-" + Guid.NewGuid().ToString("N"); + var interruption = new PreparationInterruption(); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName).AddInterceptors(interruption)); + services.AddSingleton(clock); services.AddSingleton(); + services.AddSingleton(fingerprint); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + var authority = new Authority(); var receiver = new ReceiptReceiver(prepared, clock); + services.AddSingleton(authority); services.AddSingleton(); + services.AddSingleton(receiver); services.AddSingleton(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var h = new Harness { Provider = provider, Lease = lease, Draft = draft, Prepared = prepared, Clock = clock, + Interruption = interruption, Authority = authority, Receiver = receiver }; + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Tenants.Add(new Tenant { Id = 17, Name = "Evidence fixture" }); + db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = lease.SourceInstanceId }); + db.FlowDefinitions.Add(new() { Id = lease.Version.FlowId, TenantId = 17, Name = "Evidence fixture", Enabled = true, + Revision = 1, DraftJson = JsonSerializer.Serialize(lease.Version.Graph, Json) }); + db.FlowVersions.Add(new() { Id = lease.Version.Id, FlowId = lease.Version.FlowId, TenantId = 17, VersionNumber = 1, + GraphJson = JsonSerializer.Serialize(lease.Version.Graph, Json), ConfigurationHash = lease.Version.ConfigurationHash, + PublishedBy = lease.Event.Authority.PrincipalId, PublishedAtUtc = clock.GetUtcNow() }); + db.FlowRuns.Add(new() { Id = lease.RunId, FlowId = lease.Version.FlowId, FlowVersionId = lease.Version.Id, TenantId = 17, + EventId = lease.Event.EventId, OccurrenceId = lease.Event.OccurrenceId, EventJson = JsonSerializer.Serialize(lease.Event, Json), + EventFingerprint = FlowContractValidation.Hash(JsonSerializer.SerializeToUtf8Bytes(lease.Event, Json)), + Status = FlowRunStatus.Running, Fence = lease.Fence, LeaseToken = lease.Token, LeaseOwner = lease.WorkerId, + LeaseExpiresAtUtc = lease.ExpiresAtUtc, CreatedAtUtc = clock.GetUtcNow(), Attempts = lease.Attempts }); + await db.SaveChangesAsync(); + } + (await h.Store.GetOrCreateActionAsync(lease, draft)).Should().NotBeNull(); + if (prepareAction) (await h.Store.SavePreparedActionAsync(lease, node.Id, action)).Should().BeTrue(); + return h; + } + public async Task ReadOriginalAsync() + { + await using var scope = Provider.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.FlowRuns.AsNoTracking().SingleAsync(row => row.Id == Lease.RunId); + var action = await db.FlowActions.AsNoTracking().SingleAsync(row => row.RunId == Lease.RunId); + return new(run.EventJson, run.EventFingerprint, action.DraftJson, action.PreparedJson, action.SemanticFingerprint, action.IdempotencyKey); + } + public ValueTask DisposeAsync() => Provider.DisposeAsync(); + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedConnectionOwnershipMigrationPostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedConnectionOwnershipMigrationPostgresTests.cs index 44f0550e..20e85a2c 100644 --- a/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedConnectionOwnershipMigrationPostgresTests.cs +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedConnectionOwnershipMigrationPostgresTests.cs @@ -113,8 +113,8 @@ public async Task Incremental_owner_down_and_reupgrade_preserve_services_and_rei var firstGuardDefinitions = await GuardDefinitionsAsync(connection, ct); var firstSchema = await OwnershipSchemaAsync(connection, ct); - // Down crosses registration and ownership only. The registered lower-ID - // Monitoring and Flow migrations stay in the chronological prefix alongside all 67 baseline IDs. + // Down crosses receiver, registration and ownership. The registered lower-ID + // Monitoring, Flow and Connector migrations stay alongside all 67 baseline IDs. await MigrateAsync(database, plan.PreviousMigration, ct); (await AppliedAsync(database, ct)).Should().Equal(plan.PreOwnerMigrationIds); @@ -175,12 +175,13 @@ private static MigrationPlan CurrentPlan(string database, Baseline services) var owner = migrations.Where(id => id.EndsWith("_AddCommittedConnectionOwnership", StringComparison.Ordinal)) .Should().ContainSingle().Which; owner.Should().Be(PrecedingOwnerMigration); - var expected = services.MigrationIds.Append(HistoricalMonitoringMigration).Append(FlowPreceding149PendingMigration).Append(owner) - .Append(CommittedRegistrationMigration).Order(StringComparer.Ordinal).ToArray(); + var expected = services.MigrationIds.Append(HistoricalMonitoringMigration).Append(FlowPreceding149PendingMigration) + .Append(HistoricalConnectorMigration).Append(owner).Append(CommittedRegistrationMigration) + .Append(ReceiverEvidenceMigration).Order(StringComparer.Ordinal).ToArray(); migrations.Should().Equal(expected, - "current history retains the exact Services baseline, lower-ID Monitoring and Flow, genuine owner and genuine registration delta"); + "current history retains the exact Services baseline, lower-ID Monitoring, Flow and Connector, genuine owner and registration, and genuine receiver delta"); var previous = services.MigrationIds.Last(); - return new(owner, previous, CommittedRegistrationMigration, + return new(owner, previous, ReceiverEvidenceMigration, migrations.Where(id => StringComparer.Ordinal.Compare(id, previous) <= 0).ToArray(), migrations); } diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedMonitoringRegistrationMigrationPostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedMonitoringRegistrationMigrationPostgresTests.cs index 755cfe28..91d32c6c 100644 --- a/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedMonitoringRegistrationMigrationPostgresTests.cs +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/CommittedMonitoringRegistrationMigrationPostgresTests.cs @@ -18,6 +18,9 @@ public sealed partial class CommittedConnectionOwnershipMigrationPostgresTests private const string PrecedingOwnerMigration = "20261006174907_AddCommittedConnectionOwnership"; private const string HistoricalMonitoringMigration = "20261002195600_AddMonitoringRuntime"; private const string CommittedRegistrationMigration = "20261006182927_AddCommittedMonitoringRegistration"; + private const string HistoricalConnectorMigration = "20261002200000_AddRatelDeskConnectors"; + private const string ReceiverEvidenceMigration = "20261006195456_AddFlowReceiverEvidence"; + private const string Preceding151Sha256 = "d254a6270c98f16886e0d9b25d102f0fec71ad1f507fd2db527ec34ec1379726"; private const string Preceding148Sha256 = "9877f2b6ba9bcc678da0d248aa903f262dbe9ef4f1459e3e7958742e227938c4"; private static readonly string[] MonitoringTables = [ @@ -79,14 +82,15 @@ public async Task Incremental_registration_down_and_reupgrade_keep_lower_monitor await SeedPassiveMonitoringAsync(connection, prior.History, ct); await AssertRegistrationDeltaAsync(connection, prior.History, ct); var firstSchema = await CompleteSchemaAsync(connection, ct); - var retainedIds = prior.Plan.CurrentMigrationIds.Where(id => id != CommittedRegistrationMigration).ToArray(); + var retainedIds = prior.Plan.CurrentMigrationIds.Where(id => id != CommittedRegistrationMigration && + id != ReceiverEvidenceMigration).ToArray(); var retainedHistory = await MigrationHistorySnapshotAsync(connection, retainedIds, ct); var monitoring = MonitoringTables.Select(table => new HistoricalTable(table, table == "MonitoringEvidenceStreams" ? ["CommittedRegistrationOrdinal"] : [])).ToArray(); var monitoringRows = await HistoricalSnapshotsAsync(connection, monitoring, ct); - // Only the higher-ID registration migration is removed. Older Monitoring and Flow - // were pending on true148 and now remain installed below the owner. + // The newer receiver and registration migrations are removed. Lower-ID Monitoring, Flow + // and Connector remain installed below the owner; the original registration intent remains unchanged. await MigrateAsync(prior.Database, PrecedingOwnerMigration, ct); (await AppliedAsync(prior.Database, ct)).Should().Equal(retainedIds); @@ -127,8 +131,9 @@ private async Task PreparePreceding148Async(CancellationToken ct) (await AppliedAsync(database, ct)).Should().Equal(preceding.MigrationIds); await using (var db = Context(database)) (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal( - [HistoricalMonitoringMigration, FlowPreceding149PendingMigration, CommittedRegistrationMigration], - "the lower-ID historical Monitoring and Flow migrations and registration delta are genuinely unapplied on the authentic preceding schema"); + [HistoricalMonitoringMigration, FlowPreceding149PendingMigration, HistoricalConnectorMigration, + CommittedRegistrationMigration, ReceiverEvidenceMigration], + "the lower-ID Monitoring, Flow and Connector migrations plus registration and receiver are genuinely unapplied on the authentic preceding schema"); await using var connection = new NpgsqlConnection(database); await connection.OpenAsync(ct); await AssertMonitoringPresenceAsync(connection, present: false, ct); @@ -363,11 +368,12 @@ private async Task PrepareFlowPreceding149Async(CancellationTo await ApplyBaselineAsync(database, preceding, ct); var plan = CurrentPlan(database, services); plan.CurrentMigrationIds.Should().Equal(preceding.MigrationIds.Append(FlowPreceding149PendingMigration) - .Order(StringComparer.Ordinal)); + .Append(HistoricalConnectorMigration).Append(ReceiverEvidenceMigration).Order(StringComparer.Ordinal)); (await AppliedAsync(database, ct)).Should().Equal(preceding.MigrationIds); await using (var db = Context(database)) - (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal([FlowPreceding149PendingMigration], - "only the retained lower-ID Flow migration is unapplied on the authentic current149 schema"); + (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal( + [FlowPreceding149PendingMigration, HistoricalConnectorMigration, ReceiverEvidenceMigration], + "retained lower-ID Flow and Connector plus genuine receiver are unapplied on authentic current149"); await using var connection = new NpgsqlConnection(database); await connection.OpenAsync(ct); await AssertMonitoringPresenceAsync(connection, present: true, ct); @@ -440,56 +446,61 @@ public async Task Scoped_historical_flow_down_and_up_preserve_newer_owner_regist { using var budget = new CancellationTokenSource(TimeSpan.FromMinutes(3)); var ct = budget.Token; - // These exact range bytes come from the installed EF tool and actual compiled - // current source. They are not a chronological downgrade of the current database. var down = await ReadScopedFlowRangeAsync(ScopedFlowDownResource, ScopedFlowDownSha256, down: true, ct); var up = await ReadScopedFlowRangeAsync(ScopedFlowUpResource, ScopedFlowUpSha256, down: false, ct); - var prior = await PrepareFlowPreceding149Async(ct); + // Use the genuine preceding151 assembly's 71-ID full SQL. Current Migrate(owner/ + // registration) would also install the lower Connector ID, and receiver's FK + // cannot remain installed while the historical Flow Down drops FlowActions. + var prior = await PreparePreceding151Async(populateFlow: false, ct); await using var connection = new NpgsqlConnection(prior.Database); await connection.OpenAsync(ct); - var precedingSchema = await CompleteSchemaAsync(connection, ct); - - await MigrateAsync(prior.Database, prior.Plan.CurrentMigration, ct); - await AssertCurrentModelAsync(prior.Database, prior.Plan, ct); - await AssertFlowPreceding149PreservedAsync(connection, prior, ct); + var currentSchema = await CompleteSchemaAsync(connection, ct); + var precedingBaseline = await ReadBaselineAsync("Current14970.sql", FlowPreceding149Sha256, 70, ct); + var precedingDatabase = await _fixture.CreateDatabaseAsync(ct); + await ApplyBaselineAsync(precedingDatabase, precedingBaseline, ct); + await using var precedingConnection = new NpgsqlConnection(precedingDatabase); + await precedingConnection.OpenAsync(ct); + var precedingSchema = await CompleteSchemaAsync(precedingConnection, ct); + await AssertPreceding151MigrationStateAsync(prior.Database, prior, flowPresent: true, ct); + await AssertPreceding151PreservedAsync(connection, prior, ct); await SeedScopedPassiveFlowRowsAsync(prior.Database, prior.History.TenantId, ct); await AssertScopedFlowPresenceAsync(connection, present: true, ct); await AssertScopedFlowCountsAsync(connection, expected: 1, ct); - var currentSchema = await CompleteSchemaAsync(connection, ct); var flowPrograms = await ScopedFlowProgramObjectsAsync(connection, flowOnly: true, ct); var retainedPrograms = await ScopedFlowProgramObjectsAsync(connection, flowOnly: false, ct); - var retainedIds = prior.Plan.CurrentMigrationIds.Where(id => id != FlowPreceding149PendingMigration).ToArray(); - retainedIds.Should().Equal(prior.History.MigrationIds); + var retainedIds = prior.History.MigrationIds.Where(id => id != FlowPreceding149PendingMigration).ToArray(); + retainedIds.Should().Equal(precedingBaseline.MigrationIds); var retainedHistory = await MigrationHistorySnapshotAsync(connection, retainedIds, ct); - var retainedRows = await HistoricalSnapshotsAsync(connection, prior.Tables, ct); + var retainedTables = prior.Tables.Where(table => !ScopedFlowTables.Contains(table.Name, StringComparer.Ordinal)).ToArray(); + var retainedRows = await HistoricalSnapshotsAsync(connection, retainedTables, ct); await ApplyScopedFlowRangeAsync(connection, down, ct); - (await AppliedAsync(prior.Database, ct)).Should().Equal(retainedIds); + await AssertPreceding151MigrationStateAsync(prior.Database, prior, flowPresent: false, ct); (await MigrationHistorySnapshotAsync(connection, retainedIds, ct)).Should().Equal(retainedHistory); - await using (var db = Context(prior.Database)) - (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal([FlowPreceding149PendingMigration]); await AssertScopedFlowPresenceAsync(connection, present: false, ct); (await CompleteSchemaAsync(connection, ct)).Should().Equal(precedingSchema, - "the scoped historical Flow Down leaves the genuine preceding149 schema intact despite four newer installed IDs"); - await AssertScopedRetainedRowsAsync(connection, prior.Tables, retainedRows, ct); - await AssertFlowPreceding149PreservedAsync(connection, prior, ct); + "the scoped historical Flow Down leaves the genuine preceding149 schema intact despite newer installed owner and registration IDs"); + await AssertScopedRetainedRowsAsync(connection, retainedTables, retainedRows, ct); + await AssertScopedRetainedRowsAsync(connection, retainedTables, prior.Rows, ct); (await ScopedFlowProgramObjectsAsync(connection, flowOnly: false, ct)).Should().Equal(retainedPrograms); await AssertMonitoringPresenceAsync(connection, present: true, ct); await AssertRegistrationPresenceAsync(connection, present: true, ct); await AssertGuardsAsync(connection, present: true, ct); + var downGuards = await GuardDefinitionsAsync(connection, ct); + foreach (var function in GuardFunctions) downGuards[function].Should().Equal(prior.GuardDefinitions[function]); await ApplyScopedFlowRangeAsync(connection, up, ct); - await AssertCurrentModelAsync(prior.Database, prior.Plan, ct); + await AssertPreceding151MigrationStateAsync(prior.Database, prior, flowPresent: true, ct); + await AssertPreceding151PreservedAsync(connection, prior, ct); await AssertScopedFlowPresenceAsync(connection, present: true, ct); (await CompleteSchemaAsync(connection, ct)).Should().Equal(currentSchema); (await ScopedFlowProgramObjectsAsync(connection, flowOnly: true, ct)).Should().Equal(flowPrograms, "the exact historical Up reinstalls the same Flow immutability functions and triggers"); (await ScopedFlowProgramObjectsAsync(connection, flowOnly: false, ct)).Should().Equal(retainedPrograms); (await MigrationHistorySnapshotAsync(connection, retainedIds, ct)).Should().Equal(retainedHistory); - await AssertScopedRetainedRowsAsync(connection, prior.Tables, retainedRows, ct); - await AssertFlowPreceding149PreservedAsync(connection, prior, ct); + await AssertScopedRetainedRowsAsync(connection, retainedTables, retainedRows, ct); await AssertMonitoringPresenceAsync(connection, present: true, ct); await AssertRegistrationPresenceAsync(connection, present: true, ct); await AssertGuardsAsync(connection, present: true, ct); @@ -497,8 +508,9 @@ public async Task Scoped_historical_flow_down_and_up_preserve_newer_owner_regist await AssertScopedFlowCountsAsync(connection, expected: 0, ct); var fresh = await _fixture.CreateDatabaseAsync(ct); - await MigrateAsync(fresh, prior.Plan.CurrentMigration, ct); - await AssertCurrentModelAsync(fresh, prior.Plan, ct); + var freshBaseline = await ReadBaselineAsync("Current15171.sql", Preceding151Sha256, 71, ct); + await ApplyBaselineAsync(fresh, freshBaseline, ct); + await AssertPreceding151MigrationStateAsync(fresh, prior, flowPresent: true, ct); await using var freshConnection = new NpgsqlConnection(fresh); await freshConnection.OpenAsync(ct); (await CompleteSchemaAsync(freshConnection, ct)).Should().Equal(currentSchema); @@ -627,4 +639,334 @@ SELECT convert_to(jsonb_build_object( Add(query, ("functions", ScopedFlowFunctions), ("tables", ScopedFlowTables), ("flow_only", flowOnly)); return (byte[])(await query.ExecuteScalarAsync(ct))!; } + + private sealed record Preceding151(string Database, MigrationPlan Plan, HistoricalRows History, + HistoricalTable[] Tables, IReadOnlyDictionary Rows, byte[] MigrationHistory, + IReadOnlyDictionary GuardDefinitions, byte[] RetainedPrograms); + + private async Task PreparePreceding151Async(bool populateFlow, CancellationToken ct) + { + var preceding = await ReadBaselineAsync("Current15171.sql", Preceding151Sha256, 71, ct); + var services = await ReadBaselineAsync("HistoricalServices.sql", ServicesSha256, 67, ct); + preceding.MigrationIds.Should().Equal(services.MigrationIds.Append(HistoricalMonitoringMigration) + .Append(FlowPreceding149PendingMigration).Append(PrecedingOwnerMigration) + .Append(CommittedRegistrationMigration).Order(StringComparer.Ordinal)); + var database = await _fixture.CreateDatabaseAsync(ct); + await ApplyBaselineAsync(database, preceding, ct); + var plan = CurrentPlan(database, services); + plan.CurrentMigrationIds.Should().Equal(preceding.MigrationIds.Append(HistoricalConnectorMigration) + .Append(ReceiverEvidenceMigration).Order(StringComparer.Ordinal)); + (await AppliedAsync(database, ct)).Should().Equal(preceding.MigrationIds); + await using (var db = Context(database)) + (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal( + [HistoricalConnectorMigration, ReceiverEvidenceMigration]); + await using var connection = new NpgsqlConnection(database); + await connection.OpenAsync(ct); + await AssertMonitoringPresenceAsync(connection, present: true, ct); + await AssertRegistrationPresenceAsync(connection, present: true, ct); + await AssertGuardsAsync(connection, present: true, ct); + var history = await SeedHistoricalRowsAsync(connection, preceding.MigrationIds, hasServices: true, ct); + await SeedPassiveMonitoringAsync(connection, history, ct); + await AssertRegistrationDeltaAsync(connection, history, ct); + // These legacy/Monitoring/registration rows are passive migration fixtures. + // Authority comes only from production reservation and first-heartbeat commit. + await using (var provider = new ServiceCollection() + .AddDbContext(options => options.UseNpgsql(database)) + .AddSingleton(TimeProvider.System) + .AddSingleton(new OwnershipPolicy(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(10))) + .AddNetRatelClientServicesPersistence().BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true })) + { + var store = provider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var reservation = await store.ReserveAsync(new(new(history.TenantId, history.AgentId), Guid.NewGuid(), + Guid.NewGuid(), 500, now, now.AddSeconds(30), now.AddMinutes(10), + new("migration-owner", ["presence"], new string('a', 64))), ct); + reservation.Disposition.Should().Be(OwnershipDisposition.Accepted); + var accepted = await store.CommitAsync(reservation.Reservation!, + new(reservation.Reservation!.Owner, 1, DateTimeOffset.UtcNow), ct); + accepted.Disposition.Should().Be(OwnershipDisposition.Accepted); + accepted.Current!.AcceptanceGuardAtUtc.Should().NotBeNull(); + accepted.Current.Owner.Should().Be(reservation.Reservation.Owner); + accepted.Current.IsEffective(DateTimeOffset.UtcNow).Should().BeTrue(); + } + if (populateFlow) await SeedScopedPassiveFlowRowsAsync(database, history.TenantId, ct); + var tables = new List(); + await using (var query = new NpgsqlCommand("SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public' AND tablename<>'__EFMigrationsHistory' ORDER BY tablename COLLATE \"C\"", connection)) + await using (var reader = await query.ExecuteReaderAsync(ct)) + while (await reader.ReadAsync(ct)) tables.Add(new(reader.GetString(0), [])); + return new(database, plan, history, tables.ToArray(), await HistoricalSnapshotsAsync(connection, tables, ct), + await MigrationHistorySnapshotAsync(connection, preceding.MigrationIds, ct), + await GuardDefinitionsAsync(connection, ct), await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: false, ct)); + } + + private static async Task AssertPreceding151MigrationStateAsync(string database, Preceding151 prior, + bool flowPresent, CancellationToken ct) + { + await using var db = Context(database); + var expected = flowPresent ? prior.History.MigrationIds : prior.History.MigrationIds + .Where(id => id != FlowPreceding149PendingMigration).ToArray(); + (await db.Database.GetAppliedMigrationsAsync(ct)).Should().Equal(expected); + var pending = flowPresent ? new[] { HistoricalConnectorMigration, ReceiverEvidenceMigration } + : new[] { FlowPreceding149PendingMigration, HistoricalConnectorMigration, ReceiverEvidenceMigration }; + (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal(pending); + // This checks source/snapshot agreement; this staged schema deliberately has pending IDs. + db.Database.HasPendingModelChanges().Should().BeFalse(); + } + + private static async Task AssertPreceding151PreservedAsync(NpgsqlConnection connection, Preceding151 prior, + CancellationToken ct) + { + await AssertScopedRetainedRowsAsync(connection, prior.Tables, prior.Rows, ct); + (await MigrationHistorySnapshotAsync(connection, prior.History.MigrationIds, ct)).Should().Equal(prior.MigrationHistory); + var guards = await GuardDefinitionsAsync(connection, ct); + foreach (var function in GuardFunctions) guards[function].Should().Equal(prior.GuardDefinitions[function]); + (await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: false, ct)).Should().Equal(prior.RetainedPrograms); + } + + private const string ReceiverDownResource = "ReceiverEvidenceOnlyDown.sql"; + private const string ReceiverUpResource = "ReceiverEvidenceOnlyUp.sql"; + private const string ReceiverDownSha256 = "a702c2517bf3bfdefedfd4c36a31160e5d70ebce0ab799cf5ae077cbe1285793"; + private const string ReceiverUpSha256 = "9340a0328834ef436d57529d73192246966571720590ff3cb55db905202c4690"; + + [Fact] + public async Task True_preceding_flow_upgrade_and_receiver_only_down_reup_preserve_prior_rows_history_and_guards() + { + using var budget = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + var ct = budget.Token; + var down = await ReadScopedReceiverRangeAsync(ReceiverDownResource, ReceiverDownSha256, down: true, ct); + var up = await ReadScopedReceiverRangeAsync(ReceiverUpResource, ReceiverUpSha256, down: false, ct); + var prior = await PreparePreceding151Async(populateFlow: true, ct); + prior.Plan.CurrentMigration.Should().Be(ReceiverEvidenceMigration); + prior.Plan.CurrentMigrationIds.Should().Equal(prior.History.MigrationIds + .Append(HistoricalConnectorMigration).Append(ReceiverEvidenceMigration).Order(StringComparer.Ordinal)); + await using var connection = new NpgsqlConnection(prior.Database); + await connection.OpenAsync(ct); + + await MigrateAsync(prior.Database, prior.Plan.CurrentMigration, ct); + + await AssertCurrentModelAsync(prior.Database, prior.Plan, ct); + await AssertPreceding151PreservedAsync(connection, prior, ct); + await AssertScopedReceiverSchemaAsync(connection, present: true, ct); + foreach (var table in new[] { "RatelDeskConnectors", "FlowReceiverEvidence" }) + { + await using var count = new NpgsqlCommand($"SELECT count(*) FROM \"{table}\"", connection); + ((long)(await count.ExecuteScalarAsync(ct))!).Should().Be(0, + "schema upgrade must not manufacture connector configuration or receiver evidence from prior owner and Flow rows"); + } + var fullSchema = await CompleteSchemaAsync(connection, ct); + var receiverPrograms = await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: true, ct); + var retainedPrograms = await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: false, ct); + retainedPrograms.Should().Equal(prior.RetainedPrograms); + + await SeedScopedReceiverFixturesAndAssertGuardsAsync(connection, ct); + HistoricalTable[] connectorBase = + [ new("RatelDeskConnectors", ["AuthenticationJson", "ReadinessJson"]) ]; + var connectorRows = await HistoricalSnapshotsAsync(connection, connectorBase, ct); + var retainedIds = prior.Plan.CurrentMigrationIds.Where(id => id != ReceiverEvidenceMigration).ToArray(); + retainedIds.Should().Equal(prior.History.MigrationIds.Append(HistoricalConnectorMigration).Order(StringComparer.Ordinal)); + var retainedHistory = await MigrationHistorySnapshotAsync(connection, retainedIds, ct); + + // Execute only the genuine receiver range. The lower Connector and Flow + // migrations, their data, and all original owner/Monitoring guards remain. + await ApplyScopedFlowRangeAsync(connection, down, ct); + + (await AppliedAsync(prior.Database, ct)).Should().Equal(retainedIds); + (await MigrationHistorySnapshotAsync(connection, retainedIds, ct)).Should().Equal(retainedHistory); + await using (var db = Context(prior.Database)) + (await db.Database.GetPendingMigrationsAsync(ct)).Should().Equal([ReceiverEvidenceMigration]); + await AssertScopedReceiverSchemaAsync(connection, present: false, ct); + await AssertPreceding151PreservedAsync(connection, prior, ct); + await AssertScopedRetainedRowsAsync(connection, connectorBase, connectorRows, ct); + (await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: false, ct)).Should().Equal(retainedPrograms); + + await ApplyScopedFlowRangeAsync(connection, up, ct); + + await AssertCurrentModelAsync(prior.Database, prior.Plan, ct); + await AssertScopedReceiverSchemaAsync(connection, present: true, ct); + await AssertPreceding151PreservedAsync(connection, prior, ct); + await AssertScopedRetainedRowsAsync(connection, connectorBase, connectorRows, ct); + (await MigrationHistorySnapshotAsync(connection, retainedIds, ct)).Should().Equal(retainedHistory); + (await CompleteSchemaAsync(connection, ct)).Should().Equal(fullSchema); + (await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: true, ct)).Should().Equal(receiverPrograms); + (await ScopedReceiverProgramObjectsAsync(connection, receiverOnly: false, ct)).Should().Equal(retainedPrograms); + await using (var count = new NpgsqlCommand("SELECT count(*) FROM \"FlowReceiverEvidence\"", connection)) + ((long)(await count.ExecuteScalarAsync(ct))!).Should().Be(0, + "receiver Down destroys sidecar data; re-Up restores schema and history, not lost evidence"); + await using (var nullable = new NpgsqlCommand("SELECT count(*) FROM \"RatelDeskConnectors\" WHERE \"AuthenticationJson\" IS NULL AND \"ReadinessJson\" IS NULL", connection)) + ((long)(await nullable.ExecuteScalarAsync(ct))!).Should().Be(1, + "the retained historical connector gains nullable fields without invented authentication or readiness"); + + var fresh = await _fixture.CreateDatabaseAsync(ct); + await MigrateAsync(fresh, prior.Plan.CurrentMigration, ct); + await AssertCurrentModelAsync(fresh, prior.Plan, ct); + await using var freshConnection = new NpgsqlConnection(fresh); + await freshConnection.OpenAsync(ct); + await AssertScopedReceiverSchemaAsync(freshConnection, present: true, ct); + (await CompleteSchemaAsync(freshConnection, ct)).Should().Equal(fullSchema); + (await ScopedReceiverProgramObjectsAsync(freshConnection, receiverOnly: true, ct)).Should().Equal(receiverPrograms); + (await ScopedReceiverProgramObjectsAsync(freshConnection, receiverOnly: false, ct)).Should().Equal(retainedPrograms); + } + + private static async Task ReadScopedReceiverRangeAsync(string resource, string expectedSha256, + bool down, CancellationToken ct) + { + await using var stream = typeof(CommittedConnectionOwnershipMigrationPostgresTests).Assembly + .GetManifestResourceStream(ResourcePrefix + resource) + ?? throw new InvalidOperationException($"Missing genuine embedded receiver range: {resource}"); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, ct); + var bytes = buffer.ToArray(); + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant().Should().Be(expectedSha256); + var sql = Encoding.UTF8.GetString(bytes); + if (sql.Length > 0 && sql[0] == '\uFEFF') sql = sql[1..]; + var inserts = Regex.Matches(sql, + """INSERT INTO "__EFMigrationsHistory" \("MigrationId", "ProductVersion"\)\s*VALUES \('([^']+)', '([^']+)'\);"""); + var deletes = Regex.Matches(sql, + """DELETE FROM "__EFMigrationsHistory"\s+WHERE "MigrationId"\s*=\s*'([^']+)'\s*;"""); + if (down) + { + inserts.Should().BeEmpty(); + deletes.Select(match => match.Groups[1].Value).Should().Equal([ReceiverEvidenceMigration]); + Regex.Matches(sql, """DROP TABLE "([^"]+)";""") + .Select(match => match.Groups[1].Value).Should().Equal(["FlowReceiverEvidence"]); + } + else + { + deletes.Should().BeEmpty(); + inserts.Select(match => match.Groups[1].Value).Should().Equal([ReceiverEvidenceMigration]); + inserts.Select(match => match.Groups[2].Value).Should().OnlyContain(version => version == "10.0.12"); + } + return sql; + } + + private static async Task AssertScopedReceiverSchemaAsync(NpgsqlConnection connection, bool present, + CancellationToken ct) + { + await using var query = new NpgsqlCommand(""" + SELECT to_regclass('public."FlowReceiverEvidence"') IS NOT NULL, + to_regprocedure('public.nr_receiver_evidence_immutable_inputs()') IS NOT NULL, + (SELECT count(*) FROM pg_catalog.pg_trigger tr JOIN pg_catalog.pg_class rel ON rel.oid=tr.tgrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid=rel.relnamespace + WHERE ns.nspname='public' AND rel.relname='FlowReceiverEvidence' + AND tr.tgname='TR_FlowReceiverEvidence_ImmutableInputs' AND NOT tr.tgisinternal + AND tr.tgenabled='O' AND tr.tgtype=19), + (SELECT count(*) FROM information_schema.columns WHERE table_schema='public' + AND table_name='RatelDeskConnectors' AND column_name IN ('AuthenticationJson','ReadinessJson') + AND data_type='jsonb' AND is_nullable='YES' AND column_default IS NULL), + (SELECT count(*) FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid=con.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid=rel.relnamespace WHERE ns.nspname='public' + AND rel.relname='FlowActions' AND con.conname='AK_FlowActions_TenantId_RunId_NodeId' AND con.contype='u'), + (SELECT count(*) FROM pg_catalog.pg_indexes WHERE schemaname='public' AND tablename='FlowActions' + AND indexname='IX_FlowActions_TenantId_RunId'), + (SELECT count(*) FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid=con.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid=rel.relnamespace WHERE ns.nspname='public' + AND rel.relname='FlowReceiverEvidence' AND con.convalidated + AND con.conname IN ('CK_FlowReceiverEvidence_V2','FK_FlowReceiverEvidence_FlowActions_TenantId_RunId_NodeId')) + """, connection); + await using var reader = await query.ExecuteReaderAsync(ct); + (await reader.ReadAsync(ct)).Should().BeTrue(); + reader.GetBoolean(0).Should().Be(present); + reader.GetBoolean(1).Should().Be(present); + reader.GetInt64(2).Should().Be(present ? 1 : 0); + reader.GetInt64(3).Should().Be(present ? 2 : 0); + reader.GetInt64(4).Should().Be(present ? 1 : 0); + reader.GetInt64(5).Should().Be(present ? 0 : 1); + reader.GetInt64(6).Should().Be(present ? 2 : 0); + } + + private sealed record ScopedReceiverAction(int TenantId, Guid RunId, Guid NodeId); + + private static async Task SeedScopedReceiverFixturesAndAssertGuardsAsync(NpgsqlConnection connection, + CancellationToken ct) + { + ScopedReceiverAction action; + await using (var query = new NpgsqlCommand("SELECT \"TenantId\",\"RunId\",\"NodeId\" FROM \"FlowActions\"", connection)) + await using (var reader = await query.ExecuteReaderAsync(ct)) + { + (await reader.ReadAsync(ct)).Should().BeTrue(); + action = new(reader.GetInt32(0), reader.GetGuid(1), reader.GetGuid(2)); + (await reader.ReadAsync(ct)).Should().BeFalse("the preceding helper seeds one passive relational Flow action"); + } + // Explicitly inert bytes populate only the eight historical Connector fields. + // They are not usable credentials, executable configuration or receiver proof. + await using (var connector = new NpgsqlCommand(""" + INSERT INTO "RatelDeskConnectors" ("TenantId","Id","Revision","RowVersion","OwnerPrincipalId", + "ConfigurationJson","ProtectedCredential","CredentialRevision") + VALUES (@tenant,@connector,1,1,@principal,'{"passiveMigrationFixture":{"unknown":[null,true,1]}}'::jsonb, + 'passive-migration-not-a-protected-credential',1) + """, connection)) + { + Add(connector, ("tenant", action.TenantId), ("connector", Guid.NewGuid()), ("principal", Guid.NewGuid().ToString("N"))); + (await connector.ExecuteNonQueryAsync(ct)).Should().Be(1); + } + await using (var foreign = ScopedReceiverInsertCommand(connection, action, checked(action.TenantId + 1))) + await AssertScopedReceiverRejectedAsync(foreign, PostgresErrorCodes.ForeignKeyViolation, + "FK_FlowReceiverEvidence_FlowActions_TenantId_RunId_NodeId", guardMessage: false, ct); + await using (var wrongVersion = ScopedReceiverInsertCommand(connection, action, action.TenantId, schemaVersion: 1)) + await AssertScopedReceiverRejectedAsync(wrongVersion, PostgresErrorCodes.CheckViolation, + "CK_FlowReceiverEvidence_V2", guardMessage: false, ct); + await using (var replay = ScopedReceiverInsertCommand(connection, action, action.TenantId, replayHours: 25)) + await AssertScopedReceiverRejectedAsync(replay, PostgresErrorCodes.CheckViolation, + "CK_FlowReceiverEvidence_V2", guardMessage: false, ct); + await using (var insert = ScopedReceiverInsertCommand(connection, action, action.TenantId)) + (await insert.ExecuteNonQueryAsync(ct)).Should().Be(1); + await AssertScopedReceiverRejectedUpdateAsync(connection, action, + "\"PreparationJson\"='{\"tampered\":true}'::jsonb,\"RowVersion\"=2", ct); + } + + private static NpgsqlCommand ScopedReceiverInsertCommand(NpgsqlConnection connection, ScopedReceiverAction action, + int tenant, int schemaVersion = 2, int replayHours = 1) + { + var command = new NpgsqlCommand(""" + INSERT INTO "FlowReceiverEvidence" ("RunId","NodeId","TenantId","SchemaVersion","PreparationJson", + "EvidenceFingerprint","ReceiverIdempotencyKey","ReceiverFingerprint","OriginalCreatedAtUtc", + "AutomaticReplayUntilUtc","MayHaveCommitted","FinalReconciliationAttempted","RowVersion") + VALUES (@run,@node,@tenant,@schema,'{"passiveReceiverMigrationFixture":true}'::jsonb, + @evidence,@key,@receiver,@created,@replay,FALSE,FALSE,1) + """, connection); + var created = new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.Zero); + Add(command, ("run", action.RunId), ("node", action.NodeId), ("tenant", tenant), ("schema", schemaVersion), + ("evidence", new string('a', 64)), ("key", "passive-receiver-migration-" + action.RunId.ToString("N")), + ("receiver", new string('b', 64)), ("created", created), ("replay", created.AddHours(replayHours))); + return command; + } + + private static async Task AssertScopedReceiverRejectedUpdateAsync(NpgsqlConnection connection, + ScopedReceiverAction action, string assignments, CancellationToken ct) + { + // Assignments are fixed literals above; identities remain parameters. + await using var update = new NpgsqlCommand($"UPDATE \"FlowReceiverEvidence\" SET {assignments} WHERE \"RunId\"=@run AND \"NodeId\"=@node", connection); + Add(update, ("run", action.RunId), ("node", action.NodeId)); + await AssertScopedReceiverRejectedAsync(update, PostgresErrorCodes.CheckViolation, + "receiver-evidence-immutable-input-conflict", guardMessage: true, ct); + } + + private static async Task AssertScopedReceiverRejectedAsync(NpgsqlCommand command, string sqlState, + string expectedIdentity, bool guardMessage, CancellationToken ct) + { + var exception = await Assert.ThrowsAsync(async () => { await command.ExecuteNonQueryAsync(ct); }); + exception.SqlState.Should().Be(sqlState); + if (guardMessage) exception.MessageText.Should().Be(expectedIdentity); + else exception.ConstraintName.Should().Be(expectedIdentity); + } + + private static async Task ScopedReceiverProgramObjectsAsync(NpgsqlConnection connection, + bool receiverOnly, CancellationToken ct) + { + await using var query = new NpgsqlCommand(""" + SELECT convert_to(jsonb_build_object( + 'functions',(SELECT jsonb_agg(jsonb_build_array(p.proname,pg_get_function_identity_arguments(p.oid),pg_get_functiondef(p.oid)) + ORDER BY p.proname COLLATE "C",pg_get_function_identity_arguments(p.oid) COLLATE "C") + FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace + WHERE n.nspname='public' AND p.prokind IN ('f','p') AND + (p.proname='nr_receiver_evidence_immutable_inputs' AND pg_get_function_identity_arguments(p.oid)='')=@receiver_only), + 'triggers',(SELECT jsonb_agg(jsonb_build_array(rel.relname,tr.tgname,pg_get_triggerdef(tr.oid),tr.tgenabled) + ORDER BY rel.relname COLLATE "C",tr.tgname COLLATE "C") FROM pg_catalog.pg_trigger tr + JOIN pg_catalog.pg_class rel ON rel.oid=tr.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid=rel.relnamespace + WHERE n.nspname='public' AND NOT tr.tgisinternal AND (rel.relname='FlowReceiverEvidence')=@receiver_only) + )::text,'UTF8') + """, connection); + query.Parameters.AddWithValue("receiver_only", receiverOnly); + return (byte[])(await query.ExecuteScalarAsync(ct))!; + } } diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/FlowReceiverEvidencePostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/FlowReceiverEvidencePostgresTests.cs new file mode 100644 index 00000000..b92e0d44 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/FlowReceiverEvidencePostgresTests.cs @@ -0,0 +1,489 @@ +using System.Text.Json; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Tests.Flows; +using Npgsql; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +// Real PostgreSQL migration/store regressions. Publication policy is an explicit +// test seam. Historical PossibleCommit SQL and constructed receipt DTOs below +// exercise persistence guards; they prove no fresh Monitoring admission, POST, +// remote receiver, JWT, published companion or physical acceptance. +[Collection(PostgreSqlPersistenceCollection.Name)] +public sealed class FlowReceiverEvidencePostgresTests(PostgreSqlPersistenceFixture postgres) +{ + private const string MigrationId = "20261006195456_AddFlowReceiverEvidence"; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Atomic_V2_preparation_rolls_back_on_real_database_constraint_failure_and_retains_original_V1_bytes(bool existingV1Preparation) + { + await using var h = await Harness.CreateAsync(postgres); + if (existingV1Preparation) + (await h.Store.SavePreparedActionAsync(h.Lease, h.NodeId, h.Request)).Should().BeTrue(); + var before = await h.ReadOriginalAsync(); + var snapshot = await h.ReadSnapshotAsync(); + + // The interceptor changes only this newly added row before EF sends its + // SQL. The actual migration CHECK rejects the insert in the store's real + // transaction, including any simultaneously written V1 preparation. + h.InsertFailure.RejectOneEvidenceInsert = true; + Func save = async () => + await h.Evidence.SavePreparedActionAsync(h.Lease, h.NodeId, h.Request, h.Prepared, default); + var error = (await save.Should().ThrowAsync()).Which; + var providerError = error.InnerException.Should().BeOfType().Which; + providerError.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + providerError.ConstraintName.Should().Be("CK_FlowReceiverEvidence_V2"); + (await h.ReadSnapshotAsync()).Should().Be(snapshot); + (await h.ReadOriginalAsync()).Should().Be(before); + (await h.CountEvidenceAsync()).Should().Be(0); + + (await h.Evidence.SavePreparedActionAsync(h.Lease, h.NodeId, h.Request, h.Prepared, default)).Should().BeTrue(); + var after = await h.ReadOriginalAsync(); + (after with { PreparedJson = before.PreparedJson, SemanticFingerprint = before.SemanticFingerprint }).Should().Be(before); + after.PreparedJson.Should().Be(await h.CanonicalJsonAsync(JsonSerializer.Serialize(h.Request, Harness.Json))); + after.SemanticFingerprint.Should().Be(h.Request.SemanticFingerprint); + if (existingV1Preparation) after.Should().Be(before); + var stored = await h.ReadEvidenceRowAsync(); + stored.SchemaVersion.Should().Be(2); + stored.RowVersion.Should().Be(1); + stored.ReceiverIdempotencyKey.Should().Be(h.Prepared.ReceiverIdempotencyKey); + stored.EvidenceFingerprint.Should().Be(h.Prepared.EvidenceFingerprint); + stored.ReceiverFingerprint.Should().Be(h.Prepared.ReceiverFingerprint); + stored.OriginalCreatedAtUtc.Should().Be(h.Prepared.OriginalActionCreatedAtUtc); + stored.AutomaticReplayUntilUtc.Should().Be(h.Prepared.AutomaticReplayUntilUtc); + stored.MayHaveCommitted.Should().BeFalse(); + stored.FirstPostAttemptAtUtc.Should().BeNull(); + (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!.Prepared.Should().BeEquivalentTo(h.Prepared); + var committed = await h.ReadSnapshotAsync(); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + (await h.ReadSnapshotAsync()).Should().Be(committed); + (await h.CountEvidenceAsync()).Should().Be(1); + } + + [Fact] + public async Task Actual_migration_trigger_rejects_material_immutable_and_monotonic_mutations_with_exact_durable_rows_preserved() + { + await using var h = await Harness.CreateAsync(postgres); + await h.PrepareAsync(); + // Every material mutation advances RowVersion, so its own trigger arm + // must reject it. These are material JSONB changes, not key reordering. + var immutable = new (string Name, string Assignment)[] + { + ("tenant", "\"TenantId\" = \"TenantId\" + 1"), + ("run", "\"RunId\" = '00000000-0000-0000-0000-000000000001'::uuid"), + ("node", "\"NodeId\" = '00000000-0000-0000-0000-000000000002'::uuid"), + ("schema", "\"SchemaVersion\" = 3"), + ("preparation", "\"PreparationJson\" = jsonb_set(\"PreparationJson\", '{schemaVersion}', '3'::jsonb)"), + ("evidence fingerprint", "\"EvidenceFingerprint\" = left(\"EvidenceFingerprint\",63) || CASE right(\"EvidenceFingerprint\",1) WHEN '0' THEN '1' ELSE '0' END"), + ("wire key", "\"ReceiverIdempotencyKey\" = 'changed-provider-fixture-key'"), + ("receiver fingerprint", "\"ReceiverFingerprint\" = left(\"ReceiverFingerprint\",63) || CASE right(\"ReceiverFingerprint\",1) WHEN '0' THEN '1' ELSE '0' END"), + ("original timestamp", "\"OriginalCreatedAtUtc\" = \"OriginalCreatedAtUtc\" + INTERVAL '1 microsecond'"), + ("replay horizon", "\"AutomaticReplayUntilUtc\" = \"AutomaticReplayUntilUtc\" - INTERVAL '1 minute'") + }; + foreach (var mutation in immutable) + await h.AssertTriggerDeniedAsync(mutation.Assignment + ", \"RowVersion\" = \"RowVersion\" + 1", mutation.Name); + await h.AssertTriggerDeniedAsync("\"RowVersion\" = \"RowVersion\"", "unchanged version"); + await h.AssertTriggerDeniedAsync("\"RowVersion\" = \"RowVersion\" - 1", "decreased version"); + + await h.ArrangeHistoricalPossibleCommitAsync(postAttempts: 1); + foreach (var mutation in new (string Name, string Assignment)[] + { + ("clear first POST time", "\"FirstPostAttemptAtUtc\" = NULL"), + ("change first POST time", "\"FirstPostAttemptAtUtc\" = \"FirstPostAttemptAtUtc\" + INTERVAL '1 microsecond'"), + ("clear POST fence", "\"LastPostLeaseFence\" = NULL"), + ("decrease POST fence", "\"LastPostLeaseFence\" = \"LastPostLeaseFence\" - 1"), + ("erase uncertainty without receipt", "\"MayHaveCommitted\" = FALSE") + }) + await h.AssertTriggerDeniedAsync(mutation.Assignment + ", \"RowVersion\" = \"RowVersion\" + 1", mutation.Name); + (await h.ExecuteEvidenceUpdateAsync("\"FinalReconciliationAttempted\" = TRUE, \"RowVersion\" = \"RowVersion\" + 1")).Should().Be(1); + await h.AssertTriggerDeniedAsync("\"FinalReconciliationAttempted\" = FALSE, \"RowVersion\" = \"RowVersion\" + 1", "reopen consumed final read"); + } + + [Fact] + public async Task Historical_uncertainty_can_progress_through_real_lease_fenced_reconciliation_and_constructed_receipt_persistence() + { + await using var h = await Harness.CreateAsync(postgres); + await h.PrepareAsync(); + var v1 = await h.ReadOriginalAsync(); + await h.ArrangeHistoricalPossibleCommitAsync(postAttempts: 1); + var uncertain = (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!; + uncertain.MayHaveCommitted.Should().BeTrue(); + uncertain.Attempts.Should().Be(1); + uncertain.FirstPostAttemptAtUtc.Should().NotBeNull(); + (await h.Store.StartActionAsync(h.Lease, h.NodeId))!.Status.Should().Be(FlowActionStatus.Dispatching); + // The body is explicitly constructed fixture data, independently parsed + // by the production wire validator and revalidated by SaveReceiptAsync. + var receipt = h.ConstructedReceipt(); + (await h.Evidence.SaveReceiptAsync(h.Lease, h.NodeId, receipt, default)).Should().BeTrue(); + (await h.ReadOriginalAsync()).Should().Be(v1); + var row = await h.ReadEvidenceRowAsync(); + row.MayHaveCommitted.Should().BeFalse(); + row.FirstPostAttemptAtUtc.Should().Be(uncertain.FirstPostAttemptAtUtc); + row.LastPostLeaseFence.Should().Be(h.Lease.Fence); + row.RowVersion.Should().Be(3); + JsonSerializer.Deserialize(row.FullReceiptJson!, Harness.Json)!.ExactAcceptedBodyJson + .Should().Be(receipt.ExactAcceptedBodyJson); + var action = (await h.Definitions.GetRunByIdAsync(Harness.TenantId, h.Lease.RunId))!.Actions.Single(); + action.Status.Should().Be(FlowActionStatus.Succeeded); + action.Attempts.Should().Be(1); + action.Receipt!.IncidentId.Should().Be("provider-synthetic-incident"); + await h.AssertTriggerDeniedAsync("\"FullReceiptJson\" = '{}'::jsonb, \"RowVersion\" = \"RowVersion\" + 1", "replace full original receipt"); + await h.AssertTriggerDeniedAsync("\"FullReceiptJson\" = NULL, \"RowVersion\" = \"RowVersion\" + 1", "remove full original receipt"); + (await h.Store.CompleteRunAsync(h.Lease, new(FlowRunStatus.Succeeded, "provider-fixture-completed"))).Should().BeTrue(); + (await h.Definitions.GetRunByIdAsync(Harness.TenantId, h.Lease.RunId))!.Run.Status.Should().Be(FlowRunStatus.Succeeded); + } + + [Fact] + public async Task Wrong_tenant_token_owner_fence_source_and_genuinely_reclaimed_lease_cannot_mutate_or_read_evidence() + { + await using var h = await Harness.CreateAsync(postgres); + await h.PrepareAsync(); + var original = await h.ReadOriginalAsync(); + foreach (var wrong in new[] + { + h.Lease with { Event = h.Lease.Event with { TenantId = 18 } }, + h.Lease with { Token = Guid.NewGuid() }, + h.Lease with { WorkerId = Guid.NewGuid() }, + h.Lease with { Fence = h.Lease.Fence + 1 }, + h.Lease with { SourceInstanceId = Guid.NewGuid() } + }) + { + var unchanged = await h.ReadSnapshotAsync(); + (await h.Evidence.GetOriginalCreatedAtAsync(wrong, default)).Should().BeNull(); + (await h.Evidence.ReadAsync(wrong, h.NodeId, default)).Should().BeNull(); + (await h.Evidence.SavePreparedActionAsync(wrong, h.NodeId, h.Request, h.Prepared, default)).Should().BeFalse(); + (await h.Evidence.SchedulePreparationRetryAsync(wrong, h.NodeId, TimeSpan.FromSeconds(5), default)).Should().BeFalse(); + (await h.ReadSnapshotAsync()).Should().Be(unchanged); + } + var stale = h.Lease; + h.Clock.Advance(FlowLimits.LeaseDuration + TimeSpan.FromSeconds(1)); + await h.ReclaimAsync(); + h.Lease.Fence.Should().BeGreaterThan(stale.Fence); + h.Lease.Token.Should().NotBe(stale.Token); + (await h.ReadOriginalAsync()).Should().Be(original); + var reclaimed = await h.ReadSnapshotAsync(); + (await h.Evidence.ReadAsync(stale, h.NodeId, default)).Should().BeNull(); + (await h.Evidence.SavePreparedAsync(stale, h.NodeId, h.Prepared, default)).Should().BeFalse(); + (await h.Evidence.SchedulePreparationRetryAsync(stale, h.NodeId, TimeSpan.FromSeconds(5), default)).Should().BeFalse(); + (await h.ReadSnapshotAsync()).Should().Be(reclaimed); + (await h.Evidence.SavePreparedAsync(h.Lease, h.NodeId, h.Prepared, default)).Should().BeTrue(); + await h.ArrangeHistoricalPossibleCommitAsync(postAttempts: 1); + (await h.Store.StartActionAsync(h.Lease, h.NodeId))!.Status.Should().Be(FlowActionStatus.Dispatching); + var beforeReceipt = await h.ReadSnapshotAsync(); + (await h.Evidence.SaveReceiptAsync(stale, h.NodeId, h.ConstructedReceipt(), default)).Should().BeFalse(); + (await h.ReadSnapshotAsync()).Should().Be(beforeReceipt); + (await h.Evidence.SaveReceiptAsync(h.Lease, h.NodeId, h.ConstructedReceipt(), default)).Should().BeTrue(); + (await h.ReadOriginalAsync()).Should().Be(original); + } + + [Fact] + public async Task Concurrent_actual_SQL_CAS_consumes_one_final_read_marker_and_cannot_reopen_it() + { + await using var h = await Harness.CreateAsync(postgres); + await h.PrepareAsync(); + await h.ArrangeHistoricalPossibleCommitAsync(FlowLimits.MaximumActionAttempts); + var before = await h.ReadEvidenceRowAsync(); + var v1 = await h.ReadOriginalAsync(); + var ready1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = h.ConsumeFinalReadAsync(before.RowVersion, ready1, release.Task); + var second = h.ConsumeFinalReadAsync(before.RowVersion, ready2, release.Task); + await Task.WhenAll(ready1.Task, ready2.Task); + release.SetResult(true); + var writes = await Task.WhenAll(first, second); + writes.Order().Should().Equal(0, 1); + var after = await h.ReadEvidenceRowAsync(); + after.FinalReconciliationAttempted.Should().BeTrue(); + after.RowVersion.Should().Be(before.RowVersion + 1); + after.LastPostLeaseFence.Should().Be(before.LastPostLeaseFence); + after.FirstPostAttemptAtUtc.Should().Be(before.FirstPostAttemptAtUtc); + after.PreparationJson.Should().Be(before.PreparationJson); + (await h.ReadOriginalAsync()).Should().Be(v1); + (await h.Evidence.ReadAsync(h.Lease, h.NodeId, default))!.FinalReconciliationPending.Should().BeFalse(); + await h.AssertTriggerDeniedAsync("\"FinalReconciliationAttempted\" = FALSE, \"RowVersion\" = \"RowVersion\" + 1", "concurrent final read cannot reopen"); + } + + private sealed record OriginalV1Bytes(string EventJson, string EventFingerprint, string DraftJson, + string? PreparedJson, string? SemanticFingerprint, string IdempotencyKey, string GraphJson, string ConfigurationHash); + + private sealed class FixtureClock : TimeProvider + { + private DateTimeOffset now = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => now; + public void Advance(TimeSpan duration) => now += duration; + public void CaptureSystemTime() => now = DateTimeOffset.UtcNow; + } + + private sealed class PublicationPolicySeam : IFlowExecutionAuthorityVerifier + { + public Task AuthorizeAsync(int tenantId, FlowExecutionAuthorityDto authority, CancellationToken ct = default) => + Task.FromResult(tenantId == Harness.TenantId && authority == FlowTestData.Authority); + } + + private sealed class ConnectorCatalogSeam : IFlowConnectorCatalog + { + public Task GetAsync(int tenantId, Guid connectorId, FlowExecutionAuthorityDto authority, CancellationToken ct = default) => + Task.FromResult(tenantId == Harness.TenantId && connectorId == FlowTestData.ConnectorId + ? new(connectorId, tenantId, "Provider fixture connector", true, true, null, 1) : null); + public async Task> ListAsync(int tenantId, FlowExecutionAuthorityDto authority, CancellationToken ct = default) => + (await GetAsync(tenantId, FlowTestData.ConnectorId, authority, ct)) is { } connector ? [connector] : []; + } + + private sealed class InvalidEvidenceInsert : SaveChangesInterceptor + { + public bool RejectOneEvidenceInsert { get; set; } + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + var evidence = eventData.Context?.ChangeTracker.Entries() + .SingleOrDefault(entry => entry.State == EntityState.Added); + if (RejectOneEvidenceInsert && evidence is not null) + { + RejectOneEvidenceInsert = false; + evidence.Entity.SchemaVersion = 1; + } + return ValueTask.FromResult(result); + } + } + + private sealed class Harness : IAsyncDisposable + { + public const int TenantId = 17; + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public required string Connection { get; init; } + public FixtureClock Clock { get; } = new(); + public InvalidEvidenceInsert InsertFailure { get; } = new(); + public ServiceProvider Provider { get; private set; } = null!; + public FlowRunLease Lease { get; private set; } = null!; + public FlowIncidentActionDraft Draft { get; private set; } = null!; + public FlowIncidentActionRequest Request { get; private set; } = null!; + public RatelDeskReceiverPreparationV2 Prepared { get; private set; } = null!; + public Guid NodeId => Draft.ActionNodeId; + public IFlowExecutionStore Store => Provider.GetRequiredService(); + public IFlowDefinitionService Definitions => Provider.GetRequiredService(); + public IFlowReceiverEvidenceStore Evidence => Provider.GetRequiredService(); + + public static async Task CreateAsync(PostgreSqlPersistenceFixture postgres) + { + var h = new Harness { Connection = await postgres.CreateDatabaseAsync() }; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseNpgsql(h.Connection).AddInterceptors(h.InsertFailure)); + services.AddSingleton(h.Clock); + services.AddNetRatelFlows(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + h.Provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + await using (var scope = h.Provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + (await db.Database.GetAppliedMigrationsAsync()).Should().Contain(MigrationId); + (await db.Database.GetPendingMigrationsAsync()).Should().BeEmpty(); + db.Tenants.AddRange(new Tenant { Id = TenantId, Name = "Receiver provider fixture" }, new Tenant { Id = 18, Name = "Foreign tenant" }); + await db.SaveChangesAsync(); + } + h.Clock.CaptureSystemTime(); + var created = (await h.Definitions.CreateAsync(TenantId, new("Receiver provider fixture"), "fixture-editor")).Definition!; + var saved = (await h.Definitions.SaveDraftAsync(TenantId, created.Id, new(created.Revision, created.Name, FlowTestData.Graph()), "fixture-editor")).Definition!; + var publication = await h.Definitions.PublishAsync(TenantId, saved.Id, new(saved.Revision), FlowTestData.Authority); + publication.Disposition.Should().Be(FlowWriteDisposition.Stored); + var version = publication.Version!; + var input = new FlowEventEnvelope(TenantId, Guid.NewGuid(), Guid.NewGuid(), version.Id, h.Clock.GetUtcNow(), + FlowTestData.EventData("receiver-provider-fixture", 90) with { ObservedAtUtc = h.Clock.GetUtcNow() }, FlowTestData.Authority); + var queued = await h.Provider.GetRequiredService().EnqueueAsync(input); + queued.Disposition.Should().Be(FlowIngressDisposition.Enqueued); + h.Lease = (await h.Store.ClaimAsync(Guid.NewGuid()))!; + h.Lease.RunId.Should().Be(queued.RunId!.Value); + h.Draft = BuildDraft(h.Lease); + (await h.Store.GetOrCreateActionAsync(h.Lease, h.Draft)).Should().NotBeNull(); + h.Request = FlowTestData.Prepared(h.Draft, safeReplay: true); + var original = (await h.Evidence.GetOriginalCreatedAtAsync(h.Lease, default))!.Value; + var peer = new RatelDeskSemanticPeer(RatelDeskAuthenticationMode.ManualApiBearer, TenantId, h.Draft.ConnectorId, + null, null, null, Guid.NewGuid().ToString("D"), "organization-17", "https://api.example.test/help", + null, null, null, null, null, h.Lease.SourceInstanceId, Guid.NewGuid(), "organization-17", "customer-17", null, []); + var capability = new RatelDeskVerifiedCapability(ReceiverWireValidation.Contract, peer.ReceiverInstanceId, peer.SourceInstanceId, + peer.SourceNamespaceId, new(ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CapabilitiesPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.CreatePath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.ReceiptPath), + ReceiverWireValidation.Endpoint(peer.ApiBaseUrl, ReceiverWireValidation.TargetsPath)), + ReceiverWireValidation.MinimumRetention, ReceiverWireValidation.MaximumReplay, h.Clock.GetUtcNow()); + h.Prepared = new ReceiverPreparationBuilder(h.Provider.GetRequiredService()) + .Build(h.Draft, h.Request, peer, capability, original, null); + return h; + } + + public async Task PrepareAsync() => + (await Evidence.SavePreparedActionAsync(Lease, NodeId, Request, Prepared, default)).Should().BeTrue(); + + public async Task ReclaimAsync() + { + Lease = (await Store.ClaimAsync(Guid.NewGuid()))!; + Draft = BuildDraft(Lease); + (await Store.GetOrCreateActionAsync(Lease, Draft)).Should().NotBeNull(); + } + + private static FlowIncidentActionDraft BuildDraft(FlowRunLease lease) + { + var node = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.CreateIncident); + var mapping = lease.Version.Graph.Nodes.Single(item => item.Kind == FlowNodeKind.MapIncident).Mapping!; + return new(lease.Event.TenantId, lease.RunId, node.Id, node.ConnectorId!.Value, node.ConnectorRevision!.Value, + lease.SourceInstanceId, FlowActionKeys.Create(lease, node.Id), lease.Event, FlowPureEvaluation.Map(mapping, lease.Event.Data)); + } + + public async Task ReadOriginalAsync() + { + await using var scope = Provider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.FlowRuns.AsNoTracking().SingleAsync(row => row.Id == Lease.RunId); + var action = await db.FlowActions.AsNoTracking().SingleAsync(row => row.RunId == Lease.RunId && row.NodeId == NodeId); + var version = await db.FlowVersions.AsNoTracking().SingleAsync(row => row.Id == Lease.Version.Id); + return new(run.EventJson, run.EventFingerprint, action.DraftJson, action.PreparedJson, action.SemanticFingerprint, + action.IdempotencyKey, version.GraphJson, version.ConfigurationHash); + } + + public async Task ReadEvidenceRowAsync() + { + await using var scope = Provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Set() + .AsNoTracking().SingleAsync(row => row.TenantId == TenantId && row.RunId == Lease.RunId && row.NodeId == NodeId); + } + + public async Task CountEvidenceAsync() + { + await using var scope = Provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Set() + .CountAsync(row => row.TenantId == TenantId && row.RunId == Lease.RunId && row.NodeId == NodeId); + } + + public async Task CanonicalJsonAsync(string raw) + { + await using var connection = new NpgsqlConnection(Connection); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand("SELECT @json::jsonb::text", connection); + command.Parameters.AddWithValue("json", raw); + return (string)(await command.ExecuteScalarAsync())!; + } + + public async Task ReadSnapshotAsync() + { + await using var connection = new NpgsqlConnection(Connection); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(""" + SELECT json_build_object('run',to_jsonb(r),'action',to_jsonb(a),'evidence',to_jsonb(e))::text + FROM "FlowRuns" r JOIN "FlowActions" a ON a."RunId"=r."Id" AND a."TenantId"=r."TenantId" + LEFT JOIN "FlowReceiverEvidence" e ON e."RunId"=a."RunId" AND e."NodeId"=a."NodeId" + WHERE r."Id"=@run AND r."TenantId"=@tenant AND a."NodeId"=@node + """, connection); + BindIdentity(command); + return (string)(await command.ExecuteScalarAsync())!; + } + + public async Task AssertTriggerDeniedAsync(string assignments, string because) + { + var before = await ReadSnapshotAsync(); + // Standalone autocommit connection per probe: a 23514 cannot poison + // subsequent probes with a retained failed PostgreSQL transaction. + Func mutation = async () => await ExecuteEvidenceUpdateAsync(assignments); + var error = (await mutation.Should().ThrowAsync(because)).Which; + error.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + error.MessageText.Should().Be("receiver-evidence-immutable-input-conflict"); + (await ReadSnapshotAsync()).Should().Be(before, because); + } + + public async Task ExecuteEvidenceUpdateAsync(string fixedAssignments) + { + await using var connection = new NpgsqlConnection(Connection); + await connection.OpenAsync(); + // Assignments are fixed source literals from this test, never input. + await using var command = new NpgsqlCommand("UPDATE \"FlowReceiverEvidence\" SET " + fixedAssignments + + " WHERE \"TenantId\"=@tenant AND \"RunId\"=@run AND \"NodeId\"=@node", connection); + BindIdentity(command); + return await command.ExecuteNonQueryAsync(); + } + + public async Task ArrangeHistoricalPossibleCommitAsync(int postAttempts) + { + // A constructed historical state for the migration guard, not a POST + // or receipt claim. No Agent, owner, evidence stream or outbox is seeded. + postAttempts.Should().BeInRange(1, FlowLimits.MaximumActionAttempts); + await using var connection = new NpgsqlConnection(Connection); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using var command = new NpgsqlCommand(""" + UPDATE "FlowActions" SET "Attempts"=@attempts + WHERE "TenantId"=@tenant AND "RunId"=@run AND "NodeId"=@node; + UPDATE "FlowReceiverEvidence" SET "MayHaveCommitted"=TRUE, + "FirstPostAttemptAtUtc"=@first, "LastPostLeaseFence"=@fence, "RowVersion"="RowVersion"+1 + WHERE "TenantId"=@tenant AND "RunId"=@run AND "NodeId"=@node + """, connection, transaction); + BindIdentity(command); + command.Parameters.AddWithValue("attempts", postAttempts); + command.Parameters.AddWithValue("first", Clock.GetUtcNow()); + command.Parameters.AddWithValue("fence", Lease.Fence); + (await command.ExecuteNonQueryAsync()).Should().Be(2); + await transaction.CommitAsync(); + } + + public async Task ConsumeFinalReadAsync(long expectedVersion, TaskCompletionSource ready, Task release) + { + await using var connection = new NpgsqlConnection(Connection); + try { await connection.OpenAsync(); } + finally { ready.TrySetResult(true); } + await release; + await using var command = new NpgsqlCommand(""" + UPDATE "FlowReceiverEvidence" SET "FinalReconciliationAttempted"=TRUE,"RowVersion"="RowVersion"+1 + WHERE "TenantId"=@tenant AND "RunId"=@run AND "NodeId"=@node AND "RowVersion"=@version + AND "MayHaveCommitted" AND NOT "FinalReconciliationAttempted" AND "LastPostLeaseFence"=@fence + """, connection); + BindIdentity(command); + command.Parameters.AddWithValue("version", expectedVersion); + command.Parameters.AddWithValue("fence", Lease.Fence); + return await command.ExecuteNonQueryAsync(); + } + + private void BindIdentity(NpgsqlCommand command) + { + command.Parameters.AddWithValue("tenant", TenantId); + command.Parameters.AddWithValue("run", Lease.RunId); + command.Parameters.AddWithValue("node", NodeId); + } + + public RatelDeskVerifiedReceipt ConstructedReceipt() + { + const string incident = "provider-synthetic-incident"; + const string tracking = "SYNTHETIC-provider"; + const string location = "/help/api/v1/incidents/provider-synthetic-incident"; + var raw = JsonSerializer.SerializeToUtf8Bytes(new + { + id = incident, trackingId = tracking, organizationId = Prepared.Peer.OrganizationId, customerId = Prepared.Peer.CustomerId, + integrationReceipt = new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = Prepared.Peer.ReceiverInstanceId, + sourceNamespaceId = Prepared.Peer.SourceNamespaceId.ToString("D"), sourceInstanceId = Prepared.Peer.SourceInstanceId.ToString("D"), + key = Prepared.ReceiverIdempotencyKey, fingerprint = Prepared.ReceiverFingerprint, outcome = "committed", + incidentId = incident, trackingId = tracking, organizationId = Prepared.Peer.OrganizationId, + customerId = Prepared.Peer.CustomerId, committedAtUtc = Clock.GetUtcNow(), location + } + }); + return ReceiverWireValidation.Receipt(raw, location, Prepared); + } + + public ValueTask DisposeAsync() => Provider.DisposeAsync(); + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/ManagedRatelDeskTokenRacePostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/ManagedRatelDeskTokenRacePostgresTests.cs new file mode 100644 index 00000000..8945dd40 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/ManagedRatelDeskTokenRacePostgresTests.cs @@ -0,0 +1,520 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using AwesomeAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Identity; +using NetRatel.Infrastructure.Identity.Authorization; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Shared.ServiceIdentity; +using NetRatel.Shared.ServiceLinks; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +[Collection(PostgreSqlPersistenceCollection.Name)] +public sealed class ManagedRatelDeskTokenRacePostgresTests(PostgreSqlPersistenceFixture postgres) +{ + [Theory] + [InlineData("sender-disabled")] + [InlineData("unlinked")] + [InlineData("inbound-revoked")] + [InlineData("owner-disabled")] + [InlineData("owner-permission-revoked")] + [InlineData("connector-disabled")] + [InlineData("connector-mode-changed")] + [InlineData("connector-binding-changed")] + [InlineData("connector-revision-changed")] + [InlineData("approved-semantic-drift")] + [InlineData("profile-only-revision")] + public async Task Completed_current_state_change_during_token_HTTP_denies_bearer_and_business_send(string change) + { + await using var rig = await Rig.CreateAsync(postgres); + var send = rig.SendCapturedAsync(); + await rig.WaitForTokenRequestAsync(1); + await rig.ChangeAsync(change); + rig.ReleaseTokenResponse(1); + + Func complete = () => send; + var error = (await complete.Should().ThrowAsync()).Which; + if (change.StartsWith("owner-", StringComparison.Ordinal) || change.StartsWith("connector-", StringComparison.Ordinal)) + error.Should().BeOfType(); + else + { + var protocol = error.Should().BeOfType().Subject; + protocol.StatusCode.Should().Be(403); + } + rig.TokenRequests.Should().Be(1); + rig.BusinessRequests.Should().Be(0); + rig.InvalidTokenRequests.Should().Be(0); + rig.Peer.Should().BeEquivalentTo(rig.OriginalPeer); + if (!change.StartsWith("owner-", StringComparison.Ordinal) && !change.StartsWith("connector-", StringComparison.Ordinal)) + (await rig.ResolveTokenCacheKeysAsync()).Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Credential_successor_during_token_HTTP_uses_one_refresh_for_identical_capture_body_and_key(bool reorderEquivalentScopes) + { + await using var rig = await Rig.CreateAsync(postgres); + var send = rig.SendCapturedAsync(); + await rig.WaitForTokenRequestAsync(1); + await rig.RotateSecretAsync(reorderEquivalentScopes); + rig.ReleaseTokenResponse(1); + await rig.WaitForTokenRequestAsync(2); + rig.ReleaseTokenResponse(2); + await send; + + rig.TokenRequests.Should().Be(2); + rig.InvalidTokenRequests.Should().Be(0); + rig.BusinessRequests.Should().Be(1); + rig.CredentialRevisionsSent.Should().Equal(1L, 2L); + rig.BusinessBearer.Should().Be("synthetic-token-2"); + rig.BusinessBody.Should().Be(rig.OriginalBody); + rig.BusinessKey.Should().Be(rig.OriginalKey); + rig.Peer.Should().BeEquivalentTo(rig.OriginalPeer); + (await rig.CurrentConnectorAsync()).Revision.Should().Be(rig.Connector.Revision); + } + + [Fact] + public async Task Second_credential_successor_during_refresh_cannot_create_a_third_token_request_or_business_send() + { + await using var rig = await Rig.CreateAsync(postgres); + var send = rig.SendCapturedAsync(); + await rig.WaitForTokenRequestAsync(1); + await rig.RotateSecretAsync(); + rig.ReleaseTokenResponse(1); + await rig.WaitForTokenRequestAsync(2); + await rig.RotateSecretAsync(); + rig.ReleaseTokenResponse(2); + + Func complete = () => send; + var error = (await complete.Should().ThrowAsync()).Which; + error.StatusCode.Should().Be(409); + error.Code.Should().Be("profile-revision-conflict"); + rig.TokenRequests.Should().Be(2); + rig.BusinessRequests.Should().Be(0); + rig.InvalidTokenRequests.Should().Be(0); + rig.CredentialRevisionsSent.Should().Equal(1L, 2L); + } + + [Theory] + [InlineData("sender-disabled")] + [InlineData("unlinked")] + [InlineData("inbound-revoked")] + [InlineData("approved-semantic-drift")] + public async Task Foundation_token_consumer_rechecks_durable_profile_after_HTTP_without_managed_wrapper(string change) + { + await using var rig = await Rig.CreateAsync(postgres); + var token = rig.GetProfileTokenAsync(); + await rig.WaitForTokenRequestAsync(1); + await rig.ChangeAsync(change); + rig.ReleaseTokenResponse(1); + Func complete = () => token; + (await complete.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + rig.TokenRequests.Should().Be(1); + rig.BusinessRequests.Should().Be(0); + (await rig.ResolveTokenCacheKeysAsync()).Should().BeEmpty(); + } + + // Synthetic approval, real production profile/transport/connector authorization, + // migrated PostgreSQL and TestServer HTTP. This is not published-peer, Flow + // attempt-marker or physical-client acceptance. No mocked profile store exists. + private sealed class Rig : IAsyncDisposable + { + private const int TenantId = 81; + private const string OwnerId = "synthetic-connector-owner"; + private const string LocalTenant = "81"; + private const string PeerTenant = "synthetic-organization"; + private const string PeerApi = "https://peer.example.test"; + private const string Scope = "rateldesk.incidents.create"; + private static readonly string[] ReceiverScopes = [Scope, "rateldesk.incident-receipts.read", "rateldesk.incident-targets.read"]; + private readonly WebApplication app; + private readonly HttpClient client; + private readonly AsyncServiceScope receiverScope; + private readonly string keyDirectory; + private readonly ServiceLinkProfileService profiles; + private readonly ManagedRatelDeskBindingResolver binding; + private readonly MemoryCache cache; + private readonly TaskCompletionSource[] entered = [Barrier(), Barrier(), Barrier()]; + private readonly TaskCompletionSource[] release = [Barrier(), Barrier(), Barrier()]; + private readonly ConcurrentQueue revisionsSent = new(); + private readonly FixedClock clock; + private readonly ServicePublicSettingsEffective settings; + private ServiceDirectionalCredential outbound = null!; + private ServiceLinkResolvedProfile profile = null!; + private Guid inboundPrincipal; + private string attemptId = ""; + private int tokenRequests; + private int invalidTokenRequests; + private int businessRequests; + + private Rig(WebApplication app, HttpClient client, string directory, FixedClock clock, + ServicePublicSettingsEffective settings) + { + this.app = app; this.client = client; keyDirectory = directory; this.clock = clock; this.settings = settings; + receiverScope = app.Services.CreateAsyncScope(); + var services = receiverScope.ServiceProvider; + cache = (MemoryCache)app.Services.GetRequiredService(); + profiles = new(services.GetRequiredService(), new PublicSettings(settings), + app.Services.GetRequiredService(), new ServiceLinkTransport(client, Options.Create(settings.Linking)), cache, clock); + var identity = services.GetRequiredService(); + var authority = new RatelDeskConnectorAuthorization(new EffectiveAccessService(identity, new ConfigurationBuilder().Build()), identity); + binding = new(profiles, services.GetRequiredService(), + app.Services.GetRequiredService(), authority, + new RatelDeskConnectorStore(services.GetRequiredService())); + } + + public RatelDeskConnectorState Connector { get; private set; } = null!; + public RatelDeskSemanticPeer Peer { get; private set; } = null!; + public RatelDeskSemanticPeer OriginalPeer { get; private set; } = null!; + public string OriginalBody { get; private set; } = ""; + public string OriginalKey { get; private set; } = ""; + public string? BusinessBody { get; private set; } + public string? BusinessKey { get; private set; } + public string? BusinessBearer { get; private set; } + public int TokenRequests => Volatile.Read(ref tokenRequests); + public int InvalidTokenRequests => Volatile.Read(ref invalidTokenRequests); + public int BusinessRequests => Volatile.Read(ref businessRequests); + public long[] CredentialRevisionsSent => revisionsSent.ToArray(); + + public static async Task CreateAsync(PostgreSqlPersistenceFixture postgres) + { + var connection = await postgres.CreateDatabaseAsync(); + var directory = Path.Combine(Path.GetTempPath(), "netratel-managed-token-race-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var clock = new FixedClock(); + var instance = Guid.NewGuid(); var source = Guid.NewGuid(); var peerInstance = Guid.NewGuid(); + var settings = new ServicePublicSettingsEffective( + new ServiceIdentityOptions { Enabled = true, InstanceId = instance.ToString("D"), WebBaseUrl = "https://web.example.test", + ApiBaseUrl = "https://api.example.test", Issuer = "https://api.example.test/services", Audience = "netratel.services" }, + new ServiceLinkOptions { Enabled = true, WebBaseUrl = "https://web.example.test", ApiBaseUrl = "https://api.example.test", + GatewayBaseUrl = "https://gateway.example.test", SourceInstanceId = source.ToString("D") }, 1, []); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddDbContext(o => o.UseNpgsql(connection)); + builder.Services.AddDbContext(o => o.UseNpgsql(connection)); + builder.Services.AddSingleton(clock); + builder.Services.AddSingleton(clock); + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton>(new CurrentOptions(settings.Identity)); + builder.Services.AddSingleton>(new CurrentOptions(settings.Linking)); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(directory)).SetApplicationName("NetRatel.ManagedTokenRace.Tests"); + var app = builder.Build(); + Rig? rig = null; + app.MapPost("/connect/token", (HttpRequest request) => rig!.TokenResponseAsync(request)); + app.MapPost("/api/v1/incidents/", (HttpRequest request) => rig!.BusinessResponseAsync(request)); + try + { + await app.StartAsync(); + var client = app.GetTestClient(); client.BaseAddress = new Uri(PeerApi); + rig = new(app, client, directory, clock, settings); + await rig.SeedAsync(settings, instance, source, peerInstance); + return rig; + } + catch + { + if (rig is not null) await rig.DisposeAsync(); + else { await app.DisposeAsync(); Directory.Delete(directory, true); } + throw; + } + } + + private async Task SeedAsync(ServicePublicSettingsEffective settings, Guid instance, Guid source, Guid peerInstance) + { + var db = receiverScope.ServiceProvider.GetRequiredService(); + // Requires the coordinated append-only receiver migration. Never use EnsureCreated. + await db.Database.MigrateAsync(); + var identity = receiverScope.ServiceProvider.GetRequiredService(); + await identity.Database.MigrateAsync(); + var now = clock.GetUtcNow(); var agent = Guid.NewGuid(); + db.Tenants.Add(new() { Id = TenantId, Name = "Synthetic receiver tenant", CreatedAtUtc = now, UpdatedAtUtc = now }); + db.Agents.Add(new() { Id = agent, TenantId = TenantId, Name = "Synthetic current resource", Status = AgentStatus.Active, CreatedAtUtc = now }); + db.Set().Add(new() { InstanceId = instance, SourceInstanceId = source, + SourceAdoptedBy = OwnerId, SourceAdoptedAtUnixSeconds = now.ToUnixTimeSeconds() }); + db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = source }); + await db.SaveChangesAsync(); + identity.ApplicationPrincipals.Add(new() { Id = OwnerId, LocalUserId = "synthetic-owner-user" }); + identity.Users.Add(new() { Id = "synthetic-owner-user", PrincipalId = OwnerId, UserName = "synthetic-owner", IsEnabled = true }); + var role = new AccessRole { Name = "Synthetic tenant receiver authority" }; + role.Permissions.Add(new() { RoleId = role.Id, Permission = NetRatelPermissions.IntegrationManagement }); + role.Permissions.Add(new() { RoleId = role.Id, Permission = NetRatelPermissions.SecretUse }); + identity.AccessRoles.Add(role); + identity.PrincipalRoleAssignments.Add(new() { PrincipalId = OwnerId, RoleId = role.Id, TenantId = TenantId }); + await identity.SaveChangesAsync(); + var local = Metadata("netratel", instance.ToString("D"), settings.Identity.WebBaseUrl, settings.Identity.ApiBaseUrl, + settings.Identity.Issuer, settings.Identity.Audience, source.ToString("D"), settings.Linking.GatewayBaseUrl, + [new("orchestration", [ServiceIdentityScopes.OrchestrationRead], [new("GET", "/api/internal/orchestration/tenants", ServiceIdentityScopes.OrchestrationRead)])]); + var peer = Metadata("rateldesk", peerInstance.ToString("D"), "https://peer-web.example.test", PeerApi, + PeerApi + "/services", "rateldesk.services", null, null, + [new("incident-delivery", ReceiverScopes, [new("POST", "/api/v1/incidents/", ReceiverScopes[0]), + new("GET", "/api/v1/integrations/netratel/incident-receipts/{key}", ReceiverScopes[1]), + new("POST", "/api/v1/integrations/netratel/targets/validate", ReceiverScopes[2])])]); + var inbound = new ServiceLinkGrant + { + DirectionId = ServiceLinkContract.ResponderToInitiator, CallerSnapshot = "responder", TargetSnapshot = "initiator", + CallerProduct = "rateldesk", CallerInstanceId = peer.InstanceId, CallerTenantId = PeerTenant, + TargetProduct = "netratel", TargetInstanceId = local.InstanceId, TargetTenantId = LocalTenant, + Issuer = local.OauthIssuer, Audience = local.Audience, Capabilities = ["orchestration"], Scopes = [ServiceIdentityScopes.OrchestrationRead], + ResourceConstraints = new() { TenantId = LocalTenant, ResourceIds = [agent.ToString("D")] } + }; + var outgoing = new ServiceLinkGrant + { + DirectionId = ServiceLinkContract.InitiatorToResponder, CallerSnapshot = "initiator", TargetSnapshot = "responder", + CallerProduct = "netratel", CallerInstanceId = local.InstanceId, CallerTenantId = LocalTenant, + TargetProduct = "rateldesk", TargetInstanceId = peer.InstanceId, TargetTenantId = PeerTenant, + Issuer = peer.OauthIssuer, Audience = peer.Audience, Capabilities = ["incident-delivery"], Scopes = ReceiverScopes, + ResourceConstraints = new() { OrganizationId = PeerTenant, CustomerIds = ["synthetic-customer"] }, + SourceInstanceId = source.ToString("D"), SourceNamespaceId = Guid.NewGuid().ToString("D") + }; + var summary = new ServiceLinkGrantSummary + { + AttemptId = ServiceLinkValidation.NewId(), LinkId = ServiceLinkValidation.NewId(), + DescriptorHash = ServiceLinkValidation.Digest("synthetic-approved-receiver"), + ExpiresAt = ServiceLinkValidation.Timestamp(now.AddMinutes(15).ToUnixTimeSeconds()), + InitiatorInstanceId = local.InstanceId, ResponderInstanceId = peer.InstanceId, + InitiatorEndpointSnapshot = ServiceLinkValidation.Metadata(local, false), ResponderEndpointSnapshot = ServiceLinkValidation.Metadata(peer, false), + Grants = ServiceLinkValidation.Grants([outgoing, inbound], local, peer) + }; + await ServiceLinkGrantAuthority.ValidateLocalGrantAsync(db, inbound, null, null, CancellationToken.None); + var principal = new ServicePrincipalRegistration + { + ClientId = "synthetic-inbound-client", NormalizedClientId = "synthetic-inbound-client", AliasKey = "synthetic-inbound-client", + Name = "Synthetic active linked principal", TenantId = TenantId, PeerInstanceId = peer.InstanceId, PeerTenantId = PeerTenant, + AllowedScopesJson = Serialize(inbound.Scopes), ResourceConstraintsJson = Serialize(inbound.ResourceConstraints), + LinkId = summary.LinkId, AttemptId = summary.AttemptId, GrantHash = ServiceLinkCanonicalJson.HashObject(summary), + DescriptorHash = summary.DescriptorHash, DirectionId = inbound.DirectionId, Status = "active", + CreatedBy = OwnerId, ApprovedBy = OwnerId, CreatedAtUtc = now, UpdatedAtUtc = now + }; + inboundPrincipal = principal.Id; + db.Set().Add(principal); + db.Set().Add(new() { ServicePrincipalId = principal.Id, CredentialRevision = 1, Status = "active", + SecretHash = new string('a', 64), Salt = new string('b', 64), CreatedAtUtc = now, ExpiresAtUtc = now.AddDays(1) }); + var attempt = new ServiceLinkAttempt + { + AttemptId = summary.AttemptId, Role = "initiator", LocalTenantId = LocalTenant, LocalActorId = OwnerId, + PeerInstanceId = peer.InstanceId, PeerTenantId = PeerTenant, LinkId = summary.LinkId, + LifecycleState = "active", Decision = "commit", CommitId = ServiceLinkValidation.NewId(), + DescriptorJson = "{}", DescriptorHash = summary.DescriptorHash, GrantSummaryJson = Serialize(summary), GrantHash = principal.GrantHash, + ConsentId = ServiceLinkValidation.NewId(), InboundPrincipalId = principal.Id, OutboundProfileRevision = 1, + LocalInboundActive = true, LocalBusinessSenderEnabled = true, PeerActiveAcknowledged = true, LocalActiveAcknowledged = true, + PeerPreparedAcknowledged = true, LocalPreparedAcknowledged = true, CreatedAtUnixSeconds = now.ToUnixTimeSeconds(), + UpdatedAtUnixSeconds = now.ToUnixTimeSeconds(), ExpiresAtUnixSeconds = now.AddMinutes(15).ToUnixTimeSeconds() + }; + outbound = new() + { + ClientId = "synthetic-peer-client", ClientSecret = ServiceLinkValidation.Proof(), Issuer = peer.OauthIssuer, + TokenEndpoint = peer.TokenEndpoint, Audience = peer.Audience, Scopes = outgoing.Scopes, + CallerInstanceId = local.InstanceId, CallerTenantId = LocalTenant, TargetInstanceId = peer.InstanceId, TargetTenantId = PeerTenant + }; + attempt.ProtectedOutboundCredential = Protect(attempt, outbound); attemptId = attempt.AttemptId; + db.Set().Add(attempt); + Connector = new(Guid.NewGuid(), TenantId, 1, 1, OwnerId, + new("Synthetic managed receiver", PeerApi, PeerTenant, "synthetic-customer", null, [], new(), true), null, 0, + new(RatelDeskAuthenticationMode.ManagedServiceLink, summary.LinkId)); + db.RatelDeskConnectors.Add(new() { TenantId = TenantId, Id = Connector.Id, Revision = 1, RowVersion = 1, OwnerPrincipalId = OwnerId, + ConfigurationJson = JsonSerializer.Serialize(Connector.Configuration), AuthenticationJson = JsonSerializer.Serialize(Connector.Authentication) }); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); identity.ChangeTracker.Clear(); + profile = await profiles.ResolveAsync(TenantId, summary.LinkId, Scope, CancellationToken.None); + Peer = await binding.CaptureAsync(Connector, Connector.Authentication!, source, CancellationToken.None); + OriginalPeer = Peer with { CategoryIds = [.. Peer.CategoryIds] }; + OriginalBody = JsonSerializer.Serialize(new RatelDeskCreateIncidentDto("Immutable incident", "Immutable body", 1, + Connector.Configuration.CustomerId, Connector.Configuration.OrganizationId, null, [])); + // The canonical key golden vector is covered by receiver conformance tests. + OriginalKey = "8ba5abac281f0a20cf5034acd7e1e1a6e4ac703ff567ab90d105aadf4488d683"; + } + + public Task GetProfileTokenAsync() => profiles.GetAccessTokenAsync(profile, Scope, CancellationToken.None); + + public async Task SendCapturedAsync() + { + var bearer = await binding.GetBearerAsync(Connector, Peer, Scope, CancellationToken.None); + using var request = new HttpRequestMessage(HttpMethod.Post, PeerApi + "/api/v1/incidents/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + request.Headers.Add("Idempotency-Key", OriginalKey); + request.Content = new StringContent(OriginalBody, Encoding.UTF8, "application/json"); + using var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + + public Task WaitForTokenRequestAsync(int request) => entered[request - 1].Task.WaitAsync(TimeSpan.FromSeconds(10)); + public void ReleaseTokenResponse(int request) => release[request - 1].TrySetResult(); + public Task ResolveTokenCacheKeysAsync() => Task.FromResult(cache.Keys.OfType() + .Where(key => key.StartsWith("netratel-service/", StringComparison.Ordinal)).ToArray()); + public async Task CurrentConnectorAsync() + { + await using var scope = app.Services.CreateAsyncScope(); + return (await new RatelDeskConnectorStore(scope.ServiceProvider.GetRequiredService()) + .GetAsync(TenantId, Connector.Id, CancellationToken.None))!; + } + + public async Task RotateSecretAsync(bool reorderEquivalentScopes = false) + { + await using var scope = app.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var attempt = await db.Set().SingleAsync(x => x.AttemptId == attemptId); + var successor = outbound with { ClientSecret = ServiceLinkValidation.Proof(), CredentialRevision = outbound.CredentialRevision + 1, + Scopes = reorderEquivalentScopes ? outbound.Scopes.Reverse().ToArray() : outbound.Scopes }; + attempt.ProtectedOutboundCredential = Protect(attempt, successor); + // Matches the committed StageAsync + SetSenderAsync rotation switch. + attempt.OutboundProfileRevision += 2; attempt.Revision++; + await db.SaveChangesAsync(); + outbound = successor; + } + + public async Task ChangeAsync(string change) + { + await using var scope = app.Services.CreateAsyncScope(); + if (change.StartsWith("owner-", StringComparison.Ordinal)) + { + var identity = scope.ServiceProvider.GetRequiredService(); + if (change == "owner-disabled") + { + var owner = await identity.Users.SingleAsync(x => x.PrincipalId == OwnerId); + owner.IsEnabled = false; owner.AuthorizationRevision++; + } + else identity.PrincipalRoleAssignments.Remove(await identity.PrincipalRoleAssignments.SingleAsync(x => x.PrincipalId == OwnerId)); + await identity.SaveChangesAsync(); return; + } + var db = scope.ServiceProvider.GetRequiredService(); + if (change.StartsWith("connector-", StringComparison.Ordinal)) + { + var store = new RatelDeskConnectorStore(db); + var current = (await store.GetAsync(TenantId, Connector.Id, CancellationToken.None))!; + var next = current with { Revision = current.Revision + 1, RowVersion = current.RowVersion + 1 }; + next = change switch + { + "connector-disabled" => next with { Configuration = next.Configuration with { Enabled = false } }, + "connector-mode-changed" => next with { Authentication = new(RatelDeskAuthenticationMode.ManualApiBearer, null) }, + "connector-binding-changed" => next with { Authentication = new(RatelDeskAuthenticationMode.ManagedServiceLink, "different-approved-link") }, + _ => next with { Configuration = next.Configuration with { CustomerId = "different-customer" } } + }; + (await store.SaveAsync(next, current.RowVersion, CancellationToken.None)).Should().BeTrue(); return; + } + var attempt = await db.Set().SingleAsync(x => x.AttemptId == attemptId); + switch (change) + { + case "sender-disabled": attempt.LocalBusinessSenderEnabled = false; attempt.OutboundProfileRevision++; break; + case "unlinked": + attempt.LifecycleState = "revocation_pending"; attempt.LocalBusinessSenderEnabled = false; + attempt.LocalInboundActive = false; attempt.OutboundProfileRevision++; break; + case "inbound-revoked": + var principal = await db.Set().SingleAsync(x => x.Id == inboundPrincipal); + principal.Status = "revoked"; principal.Version++; principal.Revision++; break; + case "approved-semantic-drift": + var summary = ServiceLinkCanonicalJson.Deserialize(attempt.GrantSummaryJson!); + var changed = summary with { ProposedLinkRevision = summary.ProposedLinkRevision + 1, + Grants = summary.Grants.Select(g => g.DirectionId == ServiceLinkContract.InitiatorToResponder + ? g with { ResourceConstraints = g.ResourceConstraints with { CustomerIds = ["different-customer"] } } : g).ToArray() }; + // A self-consistent synthetic successor approval remains currently usable. + // Its changed target must still deny the original captured work. + attempt.LinkRevision = changed.ProposedLinkRevision; + attempt.GrantSummaryJson = Serialize(changed); attempt.GrantHash = ServiceLinkCanonicalJson.HashObject(changed); + attempt.OutboundProfileRevision++; + var approvedPrincipal = await db.Set().SingleAsync(x => x.Id == inboundPrincipal); + approvedPrincipal.LinkRevision = attempt.LinkRevision; approvedPrincipal.GrantHash = attempt.GrantHash; + approvedPrincipal.Version++; approvedPrincipal.Revision++; + attempt.ProtectedOutboundCredential = Protect(attempt, outbound); + break; + case "profile-only-revision": attempt.OutboundProfileRevision++; break; + default: throw new ArgumentOutOfRangeException(nameof(change)); + } + attempt.Revision++; await db.SaveChangesAsync(); + if (change == "approved-semantic-drift") + { + // Validate in a different context while the token response is still held. + await using var check = app.Services.CreateAsyncScope(); + var durable = check.ServiceProvider.GetRequiredService(); + var currentProfile = new ServiceLinkProfileService(durable, new PublicSettings(settings), + app.Services.GetRequiredService(), new ServiceLinkTransport(client, + Options.Create(app.Services.GetRequiredService>().CurrentValue)), cache, clock); + (await currentProfile.ResolveAsync(TenantId, attempt.LinkId!, Scope, CancellationToken.None)).LinkRevision.Should().Be(attempt.LinkRevision); + } + } + + private async Task TokenResponseAsync(HttpRequest request) + { + var index = Interlocked.Increment(ref tokenRequests); + if (index > release.Length) return Results.StatusCode(500); + var expected = outbound; + var form = await request.ReadFormAsync(); + if (form.Count != 4 || form["grant_type"] != "client_credentials" || form["client_id"] != expected.ClientId || + form["client_secret"] != expected.ClientSecret || form["scope"] != Scope) + { + Interlocked.Increment(ref invalidTokenRequests); return Results.BadRequest(); + } + revisionsSent.Enqueue(expected.CredentialRevision); + entered[index - 1].TrySetResult(); + await release[index - 1].Task.WaitAsync(request.HttpContext.RequestAborted); + return Results.Json(new { access_token = "synthetic-token-" + index, token_type = "Bearer", expires_in = 900 }); + } + + private async Task BusinessResponseAsync(HttpRequest request) + { + Interlocked.Increment(ref businessRequests); + BusinessBearer = request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.Ordinal); + BusinessKey = request.Headers["Idempotency-Key"].ToString(); + using var reader = new StreamReader(request.Body, Encoding.UTF8); + BusinessBody = await reader.ReadToEndAsync(); + return Results.Ok(); + } + + private string Protect(ServiceLinkAttempt attempt, ServiceDirectionalCredential credential) => app.Services.GetRequiredService() + .CreateProtector("NetRatel.ServiceLink.v1", attempt.AttemptId, attempt.PeerInstanceId, "outbound-credential", + attempt.LocalTenantId + "/" + attempt.LinkId + "/" + attempt.GrantHash + "/" + attempt.LinkRevision).Protect(Serialize(credential)); + private static TaskCompletionSource Barrier() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static string Serialize(T value) => JsonSerializer.Serialize(value, ServiceLinkCanonicalJson.Json); + private static ServiceLinkMetadata Metadata(string product, string instance, string web, string api, string issuer, + string audience, string? source, string? gateway, ServiceLinkPermissionProfile[] profiles) => new() + { + Product = product, ProductVersion = "synthetic-fixture", InstanceId = instance, SourceInstanceId = source, + WebBaseUrl = web, ApiBaseUrl = api, GatewayBaseUrl = gateway, OauthIssuer = issuer, Audience = audience, + OauthMetadataUrl = api + "/.well-known/oauth-authorization-server", TokenEndpoint = api + "/connect/token", + JwksUri = api + "/.well-known/service-jwks.json", ServiceLinkEndpoint = api + ServiceLinkContract.EndpointPath, + ApprovalEndpoint = web + "/account/integration-credentials/link/approve", CallbackEndpoint = web + "/account/integration-credentials/link/callback", + PermissionProfiles = profiles + }; + public async ValueTask DisposeAsync() + { + foreach (var barrier in release) barrier.TrySetResult(); + await receiverScope.DisposeAsync(); client.Dispose(); await app.DisposeAsync(); Directory.Delete(keyDirectory, true); + } + } + + private sealed class PublicSettings(ServicePublicSettingsEffective settings) : IServicePublicSettingsResolver + { + public Task ResolveAsync(CancellationToken ct = default) => Task.FromResult(settings); + public Task UpdateAsync(ServicePublicSettingsUpdate update, string actorId, CancellationToken ct = default) => + throw new NotSupportedException("The fixture has fixed synthetic approved public settings."); + } + private sealed class CurrentOptions(T current) : IOptionsMonitor + { + public T CurrentValue => current; public T Get(string? name) => current; + public IDisposable? OnChange(Action listener) => null; + } + private sealed class FixedClock : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero); + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/ManualRatelDeskBindingResolverTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/ManualRatelDeskBindingResolverTests.cs new file mode 100644 index 00000000..6696979f --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/ManualRatelDeskBindingResolverTests.cs @@ -0,0 +1,306 @@ +using AwesomeAssertions; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class ManualRatelDeskBindingResolverTests +{ + [Fact] + public async Task Authenticated_capture_keeps_the_Flow_GUID_and_exact_receiver_namespace_without_OAuth_fields() + { + var f = new Fixture(); + var peer = await f.Resolver.CaptureAsync(f.State, Fixture.Manual, Fixture.Source, default); + (peer.SourceInstanceId).Should().Be(Fixture.Source); + (peer.SourceNamespaceId).Should().Be(Fixture.Namespace); + (peer.ReceiverInstanceId).Should().Be(Fixture.Receiver.ToString("D")); + (peer.ClientId).Should().BeNull(); (peer.Issuer).Should().BeNull(); (peer.TokenEndpoint).Should().BeNull(); (peer.LinkId).Should().BeNull(); + (f.Probe.Calls).Should().Be(1); (f.Continuity.Calls).Should().Be(2); + } + + [Fact] + public async Task Secret_only_rotation_uses_latest_bearer_for_the_same_prepared_peer() + { + var f = new Fixture(); + var peer = await f.Resolver.CaptureAsync(f.State, Fixture.Manual, Fixture.Source, default); + var capturedConnector = f.State; + f.Store.Current = f.State with { CredentialRevision = 2, RowVersion = 2, ProtectedCredential = "second" }; + var bearer = await f.Resolver.GetBearerAsync(capturedConnector, peer, "rateldesk.incident-receipts.read", default); + (bearer).Should().Be(Fixture.Bearer2); + f.State.Revision.Should().Be(capturedConnector.Revision); + (peer.SourceNamespaceId).Should().Be(Fixture.Namespace); + (f.Probe.Calls).Should().Be(1); // GetBearer never invents a new target capture after rotation. + } + + [Theory] + [InlineData("tenant")] + [InlineData("connector")] + [InlineData("revision")] + [InlineData("disabled")] + [InlineData("owner")] + [InlineData("mode")] + [InlineData("source")] + [InlineData("mapping")] + [InlineData("api-base")] + [InlineData("oauth-field")] + public async Task Every_operation_rejects_current_authority_or_captured_target_drift(string mutation) + { + var f = new Fixture(); var peer = f.Peer; var original = f.State; + switch (mutation) + { + case "tenant": peer = peer with { LocalTenantId = 99 }; break; + case "connector": peer = peer with { ConnectorId = Guid.NewGuid() }; break; + case "revision": f.Store.Current = f.State with { Revision = 2 }; break; + case "disabled": f.Store.Current = f.State with { Configuration = f.State.Configuration with { Enabled = false } }; break; + case "owner": f.Authorization.Allowed = false; break; + case "mode": f.Bindings.Current = new(RatelDeskAuthenticationMode.ManagedServiceLink, "bound-link"); break; + case "source": f.Continuity.Allowed = false; break; + case "mapping": peer = peer with { CustomerId = "another-customer" }; break; + case "api-base": peer = peer with { ApiBaseUrl = "https://other.example" }; break; + case "oauth-field": peer = peer with { ClientId = "must-never-be-used-in-manual-mode" }; break; + } + await ((Func)(() => f.Resolver.GetBearerAsync(original, peer, "rateldesk.incidents.create", default))).Should().ThrowAsync(); + (f.Protector.Reads).Should().Be(0); + } + + [Theory] + [InlineData("jwt")] + [InlineData("absent")] + [InlineData("unprotect")] + public async Task Manual_mode_never_substitutes_a_managed_secret_or_other_credential(string failure) + { + var f = new Fixture(); + if (failure == "absent") f.Store.Current = f.State with { ProtectedCredential = null }; + if (failure == "jwt") f.Protector.First = "ey.not.an.api.bearer"; + if (failure == "unprotect") f.Protector.Fail = true; + await ((Func)(() => f.Resolver.CaptureAsync(f.State, Fixture.Manual, Fixture.Source, default))).Should().ThrowAsync(); + (f.Probe.Calls).Should().Be(0); + } + + [Fact] + public async Task Capture_rechecks_credential_after_remote_reads_and_rejects_mixed_revision_observations() + { + var f = new Fixture(); + f.Probe.OnCapture = () => f.Store.Current = f.State with { CredentialRevision = 2, ProtectedCredential = "second" }; + var error = (await ((Func)(() => f.Resolver.CaptureAsync(f.State, Fixture.Manual, Fixture.Source, default))).Should().ThrowAsync()).Which; + (error.Message).Should().Be("manual-credential-changed-during-capture"); + } + + [Theory] + [InlineData("rateldesk.orchestration.callback")] + [InlineData("bostec.service-link.control")] + [InlineData("rateldesk.incidents.create other.scope")] + public async Task Manual_resolver_denies_non_incident_and_mixed_scope_requests(string scope) + { + var f = new Fixture(); + await ((Func)(() => f.Resolver.GetBearerAsync(f.State, f.Peer, scope, default))).Should().ThrowAsync(); + (f.Protector.Reads).Should().Be(0); (f.Probe.Calls).Should().Be(0); + } + + [Theory] + [InlineData(401, "receiver-authentication-rejected")] + [InlineData(403, "receiver-current-grant-rejected")] + [InlineData(422, "receiver-target-rejected")] + [InlineData(429, "receiver-rate-limited")] + [InlineData(302, "receiver-redirect-refused")] + [InlineData(503, "receiver-capability-unavailable")] + public void Capability_failures_remain_typed_and_never_bootstrap_identity(int status, string expected) + { + var error = ((Action)(() => RatelDeskReceiverReadException.RequireJsonSuccess( + new(status, [], null, TimeSpan.FromSeconds(999), "application/json", true), "capability"))).Should().Throw().Which; + (error.Code).Should().Be(expected); (error.HttpStatus).Should().Be(status); + } + + [Theory] + [InlineData("sourceInstanceId")] + [InlineData("sourceNamespaceId")] + [InlineData("receiverInstanceId")] + [InlineData("createEndpoint")] + [InlineData("targetValidationEndpoint")] + [InlineData("receiptEndpointTemplate")] + [InlineData("authenticationModes")] + [InlineData("minimumReceiptRetentionSeconds")] + [InlineData("maximumAutomaticReplaySeconds")] + [InlineData("supportsSafeSameKeyReplay")] + public void Authenticated_capability_cannot_waive_identity_path_base_mode_or_replay_guarantees(string field) + { + var f = new Fixture(); var cap = Capability(); + cap[field] = field switch + { + "sourceInstanceId" => Guid.NewGuid().ToString("D"), + "sourceNamespaceId" or "receiverInstanceId" => Guid.Empty.ToString("D"), + "authenticationModes" => new[] { "oauth_client_credentials" }, + "minimumReceiptRetentionSeconds" => 60, + "maximumAutomaticReplaySeconds" => 60, + "supportsSafeSameKeyReplay" => false, + _ => "https://redirect.example/api/v1/incidents/" + }; + ((Action)(() => ReceiverWireValidation.CaptureManual( + JsonSerializer.SerializeToUtf8Bytes(cap), f.State, Fixture.Api, Fixture.Source, DateTimeOffset.UnixEpoch))).Should().Throw(); + } + + [Fact] + public void Target_response_must_confirm_the_same_authenticated_namespace_and_exact_approved_mapping() + { + var f = new Fixture(); + var body = JsonSerializer.SerializeToUtf8Bytes(new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = Fixture.Receiver.ToString("D"), + sourceInstanceId = Fixture.Source.ToString("D"), sourceNamespaceId = Guid.NewGuid().ToString("D"), valid = true, + mapping = new { organizationId = "org", customerId = "customer", assignedToId = (string?)null, categoryIds = Array.Empty() } + }); + ((Action)(() => ReceiverWireValidation.Target(body, f.Peer))).Should().Throw(); + } + + [Fact] + public async Task Empty_optional_deployment_values_allow_only_an_existing_owner_approved_connector() + { + var f = new Fixture(); + (f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, Fixture.Api)).Should().Be(Fixture.Api); + // Resolver still performs current owner/mode/source checks; policy alone is not a grant. + f.Authorization.Allowed = false; + await ((Func)(() => f.Resolver.CaptureAsync(f.State, Fixture.Manual, Fixture.Source, default))).Should().ThrowAsync(); + (f.Probe.Calls).Should().Be(0); + } + + [Fact] + public void Blank_optional_deployment_entries_do_not_enable_restrictions_but_invalid_nonempty_entries_fail() + { + var f = new Fixture(); f.Options.Value.AllowedOrigins = ["", " "]; f.Options.Value.AllowedApiBases = [""]; + (new RatelDeskReceiverOptionsValidator().Validate(null, f.Options.Value).Succeeded).Should().BeTrue(); + (f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, Fixture.Api)).Should().Be(Fixture.Api); + f.Options.Value.RestrictToConfiguredPeers = true; + ((Action)(() => f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, Fixture.Api))).Should().Throw(); + f.Options.Value.AllowedOrigins = ["https://valid.example?query=not-allowed"]; + (new RatelDeskReceiverOptionsValidator().Validate(null, f.Options.Value).Failed).Should().BeTrue(); + } + + [Fact] + public void Existing_explicit_origin_restriction_applies_to_manual_and_managed_modes() + { + var f = new Fixture(); f.Options.Value.AllowedOrigins = ["https://another.example"]; + ((Action)(() => f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, Fixture.Api))).Should().Throw(); + ((Action)(() => f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManagedServiceLink, Fixture.Api))).Should().Throw(); + } + + [Fact] + public void Restriction_and_private_permission_removal_are_observed_without_recreating_the_policy() + { + var f = new Fixture(); f.Options.Value.AllowPrivateHttp = true; + (f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, "http://127.0.0.1:9000/helpdesk")).Should().Be("http://127.0.0.1:9000/helpdesk"); + f.Options.Value.AllowPrivateHttp = false; + ((Action)(() => f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, "http://127.0.0.1:9000/helpdesk"))).Should().Throw(); + f.Options.Value.RestrictToConfiguredPeers = true; + ((Action)(() => f.Network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, Fixture.Api))).Should().Throw(); + } + + [Fact] + public void Managed_opt_in_does_not_enable_manual_private_HTTP() + { + var f = new Fixture(); f.Links.Value.AllowPrivateHttp = true; + (f.Network.CurrentAllowPrivateHttp(RatelDeskAuthenticationMode.ManagedServiceLink)).Should().BeTrue(); + (f.Network.CurrentAllowPrivateHttp(RatelDeskAuthenticationMode.ManualApiBearer)).Should().BeFalse(); + } + + public static Dictionary Capability() => new() + { + ["contractVersion"] = ReceiverWireValidation.Contract, ["receiverInstanceId"] = Fixture.Receiver.ToString("D"), + ["sourceInstanceId"] = Fixture.Source.ToString("D"), ["sourceNamespaceId"] = Fixture.Namespace.ToString("D"), + ["createEndpoint"] = Fixture.Api + ReceiverWireValidation.CreatePath, + ["receiptEndpointTemplate"] = Fixture.Api + ReceiverWireValidation.ReceiptPath, + ["targetValidationEndpoint"] = Fixture.Api + ReceiverWireValidation.TargetsPath, + ["keyHeader"] = "Idempotency-Key", ["sourceHeader"] = "X-NetRatel-Source-Instance", ["maxKeyLength"] = 256, + ["keyPattern"] = ReceiverWireValidation.KeyPattern, ["minimumReceiptRetentionSeconds"] = ReceiverWireValidation.MinimumRetention, + ["maximumAutomaticReplaySeconds"] = ReceiverWireValidation.MaximumReplay, ["receiptEvictionEnabled"] = false, + ["atomicIncidentReceiptAndEffects"] = true, ["supportsReceiptLookup"] = true, ["supportsSafeSameKeyReplay"] = true, + ["authenticationModes"] = new[] { "api_bearer", "oauth_client_credentials" } + }; + + private sealed class Fixture + { + public const string Api = "https://receiver.example/helpdesk"; + public static readonly Guid Source = Guid.Parse("11111111-1111-4111-8111-111111111111"); + public static readonly Guid Namespace = Guid.Parse("22222222-2222-4222-8222-222222222222"); + public static readonly Guid Receiver = Guid.Parse("33333333-3333-4333-8333-333333333333"); + public const string Bearer1 = "rdk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + public const string Bearer2 = "rdk_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + public static readonly RatelDeskConnectorAuthentication Manual = new(RatelDeskAuthenticationMode.ManualApiBearer, null); + public RatelDeskConnectorState State => Store.Current!; + public Store Store { get; } = new(); + public Bindings Bindings { get; } = new(); + public Authorization Authorization { get; } = new(); + public Protector Protector { get; } = new(); + public Continuity Continuity { get; } = new(); + public Probe Probe { get; } + public Monitor Options { get; } = new(new()); + public Monitor Links { get; } = new(new()); + public RatelDeskReceiverNetworkPolicy Network { get; } + public ManualRatelDeskBindingResolver Resolver { get; } + public RatelDeskSemanticPeer Peer { get; } + public Fixture() + { + Store.Current = new(Guid.Parse("44444444-4444-4444-8444-444444444444"), 1, 1, 1, "owner", + new("manual", Api, "org", "customer", null, Array.Empty(), new(), true), "first", 1); + Peer = ReceiverWireValidation.CaptureManual(JsonSerializer.SerializeToUtf8Bytes(Capability()), State, Api, Source, DateTimeOffset.UnixEpoch).Peer; + Probe = new(new(Peer, ReceiverWireValidation.Capability(JsonSerializer.SerializeToUtf8Bytes(Capability()), Peer, DateTimeOffset.UnixEpoch))); + Network = new(Options, Links, new Monitor(new())); + Resolver = new(Store, Bindings, Authorization, Protector, Continuity, Probe, Network); + } + } + private sealed class Store : IRatelDeskConnectorStore + { + public RatelDeskConnectorState? Current { get; set; } + public Task GetAsync(int tenantId, Guid id, CancellationToken ct) => Task.FromResult(Current); + public Task> ListAsync(int tenantId, CancellationToken ct) => throw new NotSupportedException(); + public Task SaveAsync(RatelDeskConnectorState state, long version, CancellationToken ct) => throw new NotSupportedException(); + } + private sealed class Bindings : IRatelDeskConnectorBindingStore + { + public RatelDeskConnectorAuthentication Current { get; set; } = Fixture.Manual; + public Task GetAuthenticationAsync(int tenant, Guid connector, CancellationToken ct) => Task.FromResult(Current); + } + private sealed class Authorization : IRatelDeskConnectorAuthorization + { + public bool Allowed { get; set; } = true; + public Task CanExecuteAsync(string owner, string? credential, int tenant, CancellationToken ct) => Task.FromResult(Allowed); + public Task CanManageAsync(ClaimsPrincipal actor, int tenant, CancellationToken ct) => Task.FromResult(Allowed); + } + private sealed class Protector : IRatelDeskCredentialProtector + { + public int Reads { get; private set; } + public bool Fail { get; set; } + public string First { get; set; } = Fixture.Bearer1; + public string Protect(int tenant, Guid connector, string value) => throw new NotSupportedException(); + public string Unprotect(int tenant, Guid connector, string value) + { Reads++; if (Fail) throw new CryptographicException(); return value == "second" ? Fixture.Bearer2 : First; } + } + private sealed class Continuity : IRatelDeskProducerContinuity + { + public int Calls { get; private set; } + public bool Allowed { get; set; } = true; + public Task RequireCurrentAsync(Guid expected, CancellationToken ct) + { Calls++; if (!Allowed || expected != Fixture.Source) throw new UnauthorizedAccessException("source-denied"); return Task.CompletedTask; } + } + private sealed class Probe(RatelDeskManualProfileObservation observation) : IRatelDeskManualProfileProbe + { + public int Calls { get; private set; } + public Action? OnCapture { get; set; } + public Task CaptureAsync(RatelDeskConnectorState connector, Guid source, string bearer, CancellationToken ct) + { Calls++; OnCapture?.Invoke(); return Task.FromResult(observation); } + } + private sealed class Monitor(T value) : IOptionsMonitor + { + public T Value { get; set; } = value; + public T CurrentValue => Value; + public T Get(string? name) => Value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedMonitoringRegistrationCurrent151.sql b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedMonitoringRegistrationCurrent151.sql new file mode 100644 index 00000000..05735f51 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedMonitoringRegistrationCurrent151.sql @@ -0,0 +1,3388 @@ +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" character varying(150) NOT NULL, + "ProductVersion" character varying(32) NOT NULL, + CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") +); + +START TRANSACTION; +CREATE TABLE "M2MConnectivitySettings" ( + "Id" uuid NOT NULL, + "Enabled" boolean NOT NULL, + "RemoteBaseUrl" text, + "RemoteAudience" text, + "RemoteSystemName" text, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_M2MConnectivitySettings" PRIMARY KEY ("Id") +); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260224160156_InitialOrchestratorPostgres', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Agents" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "Name" text, + "Status" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CreatedBy" text, + "LastSeenUtc" timestamp with time zone, + CONSTRAINT "PK_Agents" PRIMARY KEY ("Id") +); + +CREATE TABLE "EnrollmentCodes" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "Code" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL DEFAULT (now()), + "CreatedBy" text, + "ValidFromUtc" timestamp with time zone NOT NULL, + "ValidToUtc" timestamp with time zone NOT NULL, + "MaxUses" integer, + "Uses" integer NOT NULL DEFAULT 0, + "RevokedAtUtc" timestamp with time zone, + "RevokedBy" text, + "Notes" text, + CONSTRAINT "PK_EnrollmentCodes" PRIMARY KEY ("Id") +); + +CREATE TABLE "OidcSigningKeys" ( + "Id" uuid NOT NULL, + "KeyId" text NOT NULL, + "PrivateKeyPem" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "IsActive" boolean NOT NULL, + CONSTRAINT "PK_OidcSigningKeys" PRIMARY KEY ("Id") +); + +CREATE TABLE "AgentCredentials" ( + "Id" uuid NOT NULL, + "AgentId" uuid NOT NULL, + "RefreshTokenHash" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone, + "RevokedAtUtc" timestamp with time zone, + "LastUsedUtc" timestamp with time zone, + CONSTRAINT "PK_AgentCredentials" PRIMARY KEY ("Id"), + CONSTRAINT "FK_AgentCredentials_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE CASCADE +); + +CREATE INDEX "IX_AgentCredentials_AgentId" ON "AgentCredentials" ("AgentId"); + +CREATE UNIQUE INDEX "IX_AgentCredentials_RefreshTokenHash" ON "AgentCredentials" ("RefreshTokenHash"); + +CREATE INDEX "IX_Agents_Status" ON "Agents" ("Status"); + +CREATE INDEX "IX_Agents_TenantId" ON "Agents" ("TenantId"); + +CREATE UNIQUE INDEX "IX_EnrollmentCodes_Code" ON "EnrollmentCodes" ("Code"); + +CREATE INDEX "IX_EnrollmentCodes_TenantId_ValidToUtc" ON "EnrollmentCodes" ("TenantId", "ValidToUtc"); + +CREATE INDEX "IX_EnrollmentCodes_TenantId_ValidToUtc_Active" ON "EnrollmentCodes" ("TenantId", "ValidToUtc") WHERE "RevokedAtUtc" IS NULL; + +CREATE INDEX "IX_OidcSigningKeys_IsActive" ON "OidcSigningKeys" ("IsActive"); + +CREATE UNIQUE INDEX "IX_OidcSigningKeys_IsActive_True" ON "OidcSigningKeys" ("IsActive") WHERE "IsActive" = TRUE; + +CREATE UNIQUE INDEX "IX_OidcSigningKeys_KeyId" ON "OidcSigningKeys" ("KeyId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260224213234_AddAgentAuthBroker', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Agents" ADD "AllowedScopesJson" text; + +ALTER TABLE "Agents" ADD "DeviceInfoJson" text; + +ALTER TABLE "Agents" ADD "KeyAlgorithm" text NOT NULL DEFAULT 'ecdsa-p256'; + +ALTER TABLE "Agents" ADD "KeyRegisteredAtUtc" timestamp with time zone; + +ALTER TABLE "Agents" ADD "MtlsThumbprint" text; + +ALTER TABLE "Agents" ADD "PublicKey" text; + +CREATE TABLE "AgentNonceLogs" ( + "Id" uuid NOT NULL, + "AgentId" uuid NOT NULL, + "Nonce" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_AgentNonceLogs" PRIMARY KEY ("Id") +); + +CREATE TABLE "AgentRefreshTokens" ( + "Id" uuid NOT NULL, + "AgentId" uuid NOT NULL, + "TokenHash" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone, + "RevokedAtUtc" timestamp with time zone, + "ReplacedByTokenId" uuid, + "LastUsedUtc" timestamp with time zone, + CONSTRAINT "PK_AgentRefreshTokens" PRIMARY KEY ("Id"), + CONSTRAINT "FK_AgentRefreshTokens_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "IX_AgentNonceLogs_AgentId_Nonce" ON "AgentNonceLogs" ("AgentId", "Nonce"); + +CREATE INDEX "IX_AgentNonceLogs_CreatedAtUtc" ON "AgentNonceLogs" ("CreatedAtUtc"); + +CREATE INDEX "IX_AgentRefreshTokens_AgentId" ON "AgentRefreshTokens" ("AgentId"); + +CREATE INDEX "IX_AgentRefreshTokens_AgentId_CreatedAtUtc" ON "AgentRefreshTokens" ("AgentId", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_AgentRefreshTokens_TokenHash" ON "AgentRefreshTokens" ("TokenHash"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260226194753_AddPhase8SecurityHardening', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Agents" ADD COLUMN IF NOT EXISTS "IsEnabled" boolean NOT NULL DEFAULT TRUE; +ALTER TABLE "Agents" ADD COLUMN IF NOT EXISTS "DisabledReason" text; +ALTER TABLE "Agents" ADD COLUMN IF NOT EXISTS "LastTokenIssuedAtUtc" timestamp with time zone; +ALTER TABLE "Agents" ADD COLUMN IF NOT EXISTS "RevokedAtUtc" timestamp with time zone; + +UPDATE "Agents" +SET "IsEnabled" = CASE WHEN "Status" = 0 THEN TRUE ELSE FALSE END +WHERE "IsEnabled" IS DISTINCT FROM CASE WHEN "Status" = 0 THEN TRUE ELSE FALSE END; + +CREATE INDEX IF NOT EXISTS "IX_Agents_TenantId_IsEnabled" + ON "Agents" ("TenantId", "IsEnabled"); +CREATE INDEX IF NOT EXISTS "IX_Agents_TenantId_CreatedAtUtc" + ON "Agents" ("TenantId", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260227204349_RepairAgentLifecycleColumns', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "OutboxMessages" ( + "Id" uuid NOT NULL, + "OccurredUtc" timestamp with time zone NOT NULL, + "Type" text NOT NULL, + "PayloadJson" text NOT NULL, + "Source" text NOT NULL, + "CorrelationId" text NOT NULL, + "TenantId" text, + "EntityId" text, + "Severity" text, + "Message" text, + "Status" text NOT NULL, + "Attempts" integer NOT NULL, + "NextAttemptUtc" timestamp with time zone, + "LockedUntilUtc" timestamp with time zone, + "LockOwner" text, + "LastError" text, + CONSTRAINT "PK_OutboxMessages" PRIMARY KEY ("Id") +); + +CREATE TABLE "OutboxProcessedEvents" ( + "EventId" uuid NOT NULL, + "ConsumerName" text NOT NULL, + "ProcessedUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_OutboxProcessedEvents" PRIMARY KEY ("EventId", "ConsumerName") +); + +CREATE TABLE "OutboxReadReceipts" ( + "EventId" uuid NOT NULL, + "UserId" text NOT NULL, + "ReadUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_OutboxReadReceipts" PRIMARY KEY ("EventId", "UserId") +); + +CREATE INDEX "IX_OutboxMessages_CorrelationId" ON "OutboxMessages" ("CorrelationId"); + +CREATE INDEX "IX_OutboxMessages_EntityId" ON "OutboxMessages" ("EntityId"); + +CREATE INDEX "IX_OutboxMessages_OccurredUtc" ON "OutboxMessages" ("OccurredUtc"); + +CREATE INDEX "IX_OutboxMessages_Status_NextAttemptUtc_LockedUntilUtc" ON "OutboxMessages" ("Status", "NextAttemptUtc", "LockedUntilUtc"); + +CREATE INDEX "IX_OutboxMessages_Type" ON "OutboxMessages" ("Type"); + +CREATE INDEX "IX_OutboxProcessedEvents_ProcessedUtc" ON "OutboxProcessedEvents" ("ProcessedUtc"); + +CREATE INDEX "IX_OutboxReadReceipts_UserId_ReadUtc" ON "OutboxReadReceipts" ("UserId", "ReadUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260304174023_AddOutboxNotifications', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Tenants" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "Description" text, + "Location" text, + "Domains" text[] NOT NULL, + "ContactPerson" text, + "ContactEmail" text, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Tenants" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_Tenants_CreatedAtUtc" ON "Tenants" ("CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_Tenants_Name" ON "Tenants" ("Name"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307142009_AddTenantsTable', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Scripts" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "FolderPath" text NOT NULL, + "Description" text NOT NULL, + "Content" text NOT NULL, + "ManifestJson" text, + "ScriptType" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Scripts" PRIMARY KEY ("Id") +); + +CREATE TABLE "ScriptParameters" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "ScriptId" bigint NOT NULL, + "Name" text NOT NULL, + "Type" text NOT NULL, + "Required" boolean NOT NULL, + "Default" text, + "Description" text, + "OptionsJson" text, + CONSTRAINT "PK_ScriptParameters" PRIMARY KEY ("Id"), + CONSTRAINT "FK_ScriptParameters_Scripts_ScriptId" FOREIGN KEY ("ScriptId") REFERENCES "Scripts" ("Id") ON DELETE CASCADE +); + +CREATE INDEX "IX_ScriptParameters_ScriptId" ON "ScriptParameters" ("ScriptId"); + +CREATE INDEX "IX_Scripts_CreatedAtUtc" ON "Scripts" ("CreatedAtUtc"); + +CREATE INDEX "IX_Scripts_FolderPath_Name" ON "Scripts" ("FolderPath", "Name"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307144511_AddScriptsTables', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Secrets" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "TenantId" integer, + "ClientIdentity" text, + "Value" text NOT NULL, + "Description" text, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Secrets" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_Secrets_ClientIdentity" ON "Secrets" ("ClientIdentity"); + +CREATE INDEX "IX_Secrets_CreatedAtUtc" ON "Secrets" ("CreatedAtUtc"); + +CREATE INDEX "IX_Secrets_TenantId" ON "Secrets" ("TenantId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307145928_AddSecretsTable', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Jobs" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "FolderPath" text NOT NULL, + "Description" text, + "TenantId" integer, + "ClientIdentity" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Jobs" PRIMARY KEY ("Id") +); + +CREATE TABLE "JobParameters" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "JobId" bigint NOT NULL, + "Name" text NOT NULL, + "Type" text NOT NULL, + "Required" boolean NOT NULL, + "DefaultValue" text, + "Description" text, + "OptionsJson" text, + CONSTRAINT "PK_JobParameters" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobParameters_Jobs_JobId" FOREIGN KEY ("JobId") REFERENCES "Jobs" ("Id") ON DELETE CASCADE +); + +CREATE TABLE "JobSteps" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "JobId" bigint NOT NULL, + "Ordinal" integer NOT NULL, + "Type" integer NOT NULL, + "Runner" text, + "Command" text, + "ScriptId" bigint, + "PayloadJson" text, + "Enabled" boolean NOT NULL, + CONSTRAINT "PK_JobSteps" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobSteps_Jobs_JobId" FOREIGN KEY ("JobId") REFERENCES "Jobs" ("Id") ON DELETE CASCADE +); + +CREATE INDEX "IX_JobParameters_JobId" ON "JobParameters" ("JobId"); + +CREATE INDEX "IX_Jobs_ClientIdentity" ON "Jobs" ("ClientIdentity"); + +CREATE INDEX "IX_Jobs_CreatedAtUtc" ON "Jobs" ("CreatedAtUtc"); + +CREATE INDEX "IX_Jobs_FolderPath_Name" ON "Jobs" ("FolderPath", "Name"); + +CREATE INDEX "IX_Jobs_TenantId" ON "Jobs" ("TenantId"); + +CREATE INDEX "IX_JobSteps_JobId" ON "JobSteps" ("JobId"); + +CREATE INDEX "IX_JobSteps_JobId_Ordinal" ON "JobSteps" ("JobId", "Ordinal"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307150553_AddJobDefinitionTables', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "JobRuns" ( + "Id" bigint NOT NULL, + "JobId" bigint NOT NULL, + "TenantId" integer, + "ClientIdentity" text NOT NULL, + "StartedBy" text NOT NULL, + "Status" integer NOT NULL, + "CurrentStepOrdinal" integer NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "StartedAtUtc" timestamp with time zone, + "CompletedAtUtc" timestamp with time zone, + "Error" text, + "InputsJson" text, + "OptionsJson" text, + CONSTRAINT "PK_JobRuns" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobRuns_Jobs_JobId" FOREIGN KEY ("JobId") REFERENCES "Jobs" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "JobStepRuns" ( + "Id" bigint NOT NULL, + "JobRunId" bigint NOT NULL, + "JobStepId" bigint NOT NULL, + "Status" integer NOT NULL, + "Ordinal" integer NOT NULL, + "TaskRequestId" text, + "Error" text, + "StartedAtUtc" timestamp with time zone, + "CompletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_JobStepRuns" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobStepRuns_JobRuns_JobRunId" FOREIGN KEY ("JobRunId") REFERENCES "JobRuns" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_JobStepRuns_JobSteps_JobStepId" FOREIGN KEY ("JobStepId") REFERENCES "JobSteps" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "JobTaskActivities" ( + "Id" bigint NOT NULL, + "RequestId" text NOT NULL, + "JobRunId" bigint NOT NULL, + "JobStepId" bigint NOT NULL, + "ClientIdentity" text NOT NULL, + "TenantId" integer, + "TaskType" text NOT NULL, + "Status" text NOT NULL, + "Error" text, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_JobTaskActivities" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobTaskActivities_JobRuns_JobRunId" FOREIGN KEY ("JobRunId") REFERENCES "JobRuns" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_JobTaskActivities_JobSteps_JobStepId" FOREIGN KEY ("JobStepId") REFERENCES "JobSteps" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_JobRuns_ClientIdentity" ON "JobRuns" ("ClientIdentity"); + +CREATE INDEX "IX_JobRuns_CreatedAtUtc" ON "JobRuns" ("CreatedAtUtc"); + +CREATE INDEX "IX_JobRuns_JobId" ON "JobRuns" ("JobId"); + +CREATE INDEX "IX_JobRuns_TenantId" ON "JobRuns" ("TenantId"); + +CREATE INDEX "IX_JobStepRuns_JobRunId" ON "JobStepRuns" ("JobRunId"); + +CREATE INDEX "IX_JobStepRuns_JobRunId_Ordinal" ON "JobStepRuns" ("JobRunId", "Ordinal"); + +CREATE INDEX "IX_JobStepRuns_JobStepId" ON "JobStepRuns" ("JobStepId"); + +CREATE INDEX "IX_JobTaskActivities_CreatedAtUtc" ON "JobTaskActivities" ("CreatedAtUtc"); + +CREATE INDEX "IX_JobTaskActivities_JobRunId" ON "JobTaskActivities" ("JobRunId"); + +CREATE INDEX "IX_JobTaskActivities_JobStepId" ON "JobTaskActivities" ("JobStepId"); + +CREATE UNIQUE INDEX "IX_JobTaskActivities_RequestId" ON "JobTaskActivities" ("RequestId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307151818_AddJobRunTables', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "Requests" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "SourceSystem" text NOT NULL, + "TargetClientIdentity" text NOT NULL, + "RundeckJobDefinitionId" text NOT NULL, + "RundeckExecutionId" text, + "Status" text NOT NULL, + "ResultMessage" text, + "ResultData" text, + "JobInputs" text, + "Logs" text[] NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Requests" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_Requests_CreatedAtUtc" ON "Requests" ("CreatedAtUtc"); + +CREATE INDEX "IX_Requests_Status" ON "Requests" ("Status"); + +CREATE INDEX "IX_Requests_TargetClientIdentity" ON "Requests" ("TargetClientIdentity"); + +CREATE INDEX "IX_Requests_UpdatedAtUtc" ON "Requests" ("UpdatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307153208_AddRequestsTable', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "JobTaskLogs" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY, + "RequestId" text NOT NULL, + "JobTaskActivityId" bigint, + "ClientIdentity" text NOT NULL, + "TenantId" integer, + "Stream" text NOT NULL, + "Message" text NOT NULL, + "Sequence" bigint NOT NULL, + "TimestampUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_JobTaskLogs" PRIMARY KEY ("Id"), + CONSTRAINT "FK_JobTaskLogs_JobTaskActivities_JobTaskActivityId" FOREIGN KEY ("JobTaskActivityId") REFERENCES "JobTaskActivities" ("Id") ON DELETE CASCADE +); + +CREATE INDEX "IX_JobTaskLogs_JobTaskActivityId" ON "JobTaskLogs" ("JobTaskActivityId"); + +CREATE INDEX "IX_JobTaskLogs_RequestId" ON "JobTaskLogs" ("RequestId"); + +CREATE UNIQUE INDEX "IX_JobTaskLogs_RequestId_Sequence_Stream" ON "JobTaskLogs" ("RequestId", "Sequence", "Stream"); + +CREATE INDEX "IX_JobTaskLogs_TimestampUtc" ON "JobTaskLogs" ("TimestampUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307173338_AddJobTaskLogsTable', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "JobTaskActivities" ADD "LegacyClientTaskId" integer; + +CREATE INDEX "IX_JobTaskActivities_LegacyClientTaskId" ON "JobTaskActivities" ("LegacyClientTaskId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307175825_AddLegacyClientTaskIdToJobTaskActivity', '10.0.12'); + +COMMIT; + +START TRANSACTION; +DROP INDEX "IX_JobTaskActivities_LegacyClientTaskId"; + +ALTER TABLE "JobTaskActivities" DROP COLUMN "LegacyClientTaskId"; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260307201907_RemoveLegacyClientTaskIdFromJobTaskActivity', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "JobTaskActivities" ALTER COLUMN "JobStepId" DROP NOT NULL; + +ALTER TABLE "JobTaskActivities" ALTER COLUMN "JobRunId" DROP NOT NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260308203544_AllowAdHocTaskActivities', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "JobStepRuns" DROP CONSTRAINT "FK_JobStepRuns_JobSteps_JobStepId"; + +ALTER TABLE "JobStepRuns" ALTER COLUMN "JobStepId" DROP NOT NULL; + +ALTER TABLE "JobStepRuns" ADD CONSTRAINT "FK_JobStepRuns_JobSteps_JobStepId" FOREIGN KEY ("JobStepId") REFERENCES "JobSteps" ("Id") ON DELETE SET NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260309135159_AllowNullJobStepRunJobStepId', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Tenants" ADD "AutoUpdate" boolean NOT NULL DEFAULT FALSE; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260523102251_AddTenantAutoUpdate', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Jobs" ADD "OptionsJson" text; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260524143108_AddJobExecutionRuntimePolicy', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "ClientWindowsSessionInventoryRefreshes" ( + "Id" uuid NOT NULL, + "RefreshRequestId" text NOT NULL, + "TenantId" integer NOT NULL, + "ClientIdentity" text NOT NULL, + "RequesterIdentity" text NOT NULL, + "RequestedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "Completed" boolean NOT NULL, + "InventorySequence" numeric(20,0), + "ObservedAtUtc" timestamp with time zone, + "Source" text NOT NULL, + "Status" text NOT NULL, + "Error" text, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientWindowsSessionInventoryRefreshes" PRIMARY KEY ("Id") +); + +CREATE TABLE "ClientWindowsSessionSnapshots" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "ClientIdentity" text NOT NULL, + "WindowsSessionId" integer NOT NULL, + "State" text NOT NULL, + "Username" text, + "Domain" text, + "DisplayLabel" text, + "UserSidHash" text, + "IsConsoleSession" boolean NOT NULL, + "IsActive" boolean NOT NULL, + "IsConnected" boolean NOT NULL, + "IsLocked" boolean NOT NULL, + "IsWinlogon" boolean NOT NULL, + "IsAssistable" boolean NOT NULL, + "SessionType" text, + "Provider" text, + "HelperConnected" boolean NOT NULL, + "HelperVersionMatches" boolean NOT NULL, + "HelperLaunchable" boolean NOT NULL, + "HelperRepairable" boolean NOT NULL, + "HelperPid" integer, + "HelperVersion" text, + "InventorySequence" numeric(20,0) NOT NULL, + "ObservedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "Source" text NOT NULL, + "Stale" boolean NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientWindowsSessionSnapshots" PRIMARY KEY ("Id") +); + +CREATE TABLE "RemoteSupportTargetSelectionEvents" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "RequesterIdentity" text NOT NULL, + "ClientIdentity" text NOT NULL, + "SessionId" text, + "TargetMode" text NOT NULL, + "TargetWindowsSessionId" integer, + "TargetUserSidHash" text, + "TargetDisplayLabel" text, + "SelectedProvider" text, + "InventorySequence" numeric(20,0), + "RequestedAtUtc" timestamp with time zone NOT NULL, + "Result" text NOT NULL, + "FailureReason" text, + CONSTRAINT "PK_RemoteSupportTargetSelectionEvents" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_ClientWindowsSessionInventoryRefreshes_ClientIdentity_Compl~" ON "ClientWindowsSessionInventoryRefreshes" ("ClientIdentity", "Completed"); + +CREATE UNIQUE INDEX "IX_ClientWindowsSessionInventoryRefreshes_RefreshRequestId" ON "ClientWindowsSessionInventoryRefreshes" ("RefreshRequestId"); + +CREATE INDEX "IX_ClientWindowsSessionInventoryRefreshes_TenantId_ClientIdent~" ON "ClientWindowsSessionInventoryRefreshes" ("TenantId", "ClientIdentity", "RequestedAtUtc"); + +CREATE INDEX "IX_ClientWindowsSessionSnapshots_ClientIdentity_InventorySeque~" ON "ClientWindowsSessionSnapshots" ("ClientIdentity", "InventorySequence"); + +CREATE UNIQUE INDEX "IX_ClientWindowsSessionSnapshots_ClientIdentity_WindowsSession~" ON "ClientWindowsSessionSnapshots" ("ClientIdentity", "WindowsSessionId"); + +CREATE INDEX "IX_ClientWindowsSessionSnapshots_ExpiresAtUtc" ON "ClientWindowsSessionSnapshots" ("ExpiresAtUtc"); + +CREATE INDEX "IX_ClientWindowsSessionSnapshots_TenantId_ClientIdentity" ON "ClientWindowsSessionSnapshots" ("TenantId", "ClientIdentity"); + +CREATE INDEX "IX_RemoteSupportTargetSelectionEvents_SessionId" ON "RemoteSupportTargetSelectionEvents" ("SessionId"); + +CREATE INDEX "IX_RemoteSupportTargetSelectionEvents_TenantId_ClientIdentity_~" ON "RemoteSupportTargetSelectionEvents" ("TenantId", "ClientIdentity", "RequestedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260619101901_AddRemoteSupportWindowsSessionInventoryCache', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "CommandInboxReceipts" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "ClientId" uuid NOT NULL, + "CommandId" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "Version" numeric(20,0) NOT NULL, + "Sequence" numeric(20,0) NOT NULL, + "FirstReceivedAtUtc" timestamp with time zone NOT NULL, + "LastReceivedAtUtc" timestamp with time zone NOT NULL, + "DuplicateCount" bigint NOT NULL, + CONSTRAINT "PK_CommandInboxReceipts" PRIMARY KEY ("Id") +); + +CREATE TABLE "CommandIntentEvents" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "ClientId" uuid NOT NULL, + "CommandId" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "RequestTimestamp" timestamp with time zone NOT NULL, + "StatusTimestamp" timestamp with time zone NOT NULL, + "Version" numeric(20,0) NOT NULL, + "Sequence" numeric(20,0) NOT NULL, + "Status" smallint NOT NULL, + "Source" character varying(64) NOT NULL, + "IsAuthoritative" boolean NOT NULL, + "RecordedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_CommandIntentEvents" PRIMARY KEY ("Id") +); + +CREATE TABLE "CommandOutbox" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "ClientId" uuid NOT NULL, + "CommandId" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "RequestTimestamp" timestamp with time zone NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "LastObservedAtUtc" timestamp with time zone NOT NULL, + "TerminalAtUtc" timestamp with time zone, + "LastAcceptedVersion" numeric(20,0) NOT NULL, + "LastAcceptedSequence" numeric(20,0) NOT NULL, + "CurrentStatus" smallint NOT NULL, + "ObservedDispatchCount" integer NOT NULL, + "Mode" character varying(32) NOT NULL, + "IsAuthoritative" boolean NOT NULL, + CONSTRAINT "PK_CommandOutbox" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_CommandInboxReceipts_FirstReceivedAtUtc" ON "CommandInboxReceipts" ("FirstReceivedAtUtc"); + +CREATE INDEX "IX_CommandInboxReceipts_TenantId_CorrelationId" ON "CommandInboxReceipts" ("TenantId", "CorrelationId"); + +CREATE UNIQUE INDEX "UX_CommandInbox_Idempotency" ON "CommandInboxReceipts" ("TenantId", "CommandId", "Version", "Sequence"); + +CREATE INDEX "IX_CommandIntentEvents_RecordedAtUtc" ON "CommandIntentEvents" ("RecordedAtUtc"); + +CREATE INDEX "IX_CommandIntentEvents_TenantId_CorrelationId" ON "CommandIntentEvents" ("TenantId", "CorrelationId"); + +CREATE UNIQUE INDEX "UX_CommandIntentHistory_Order" ON "CommandIntentEvents" ("TenantId", "CommandId", "Version", "Sequence"); + +CREATE INDEX "IX_CommandOutbox_TenantId_CorrelationId" ON "CommandOutbox" ("TenantId", "CorrelationId"); + +CREATE INDEX "IX_CommandOutbox_TerminalAtUtc_CreatedAtUtc" ON "CommandOutbox" ("TerminalAtUtc", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "UX_CommandOutbox_Command" ON "CommandOutbox" ("TenantId", "CommandId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260807102017_AddCommandPersistenceFoundation', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "JobShadowObservations" ( + "Id" uuid NOT NULL, + "SourceSystem" character varying(64) NOT NULL, + "SourceEventId" bigint NOT NULL, + "JobRunId" numeric(20,0) NOT NULL, + "JobId" numeric(20,0) NOT NULL, + "TenantId" integer, + "ClientIdentity" character varying(256) NOT NULL, + "Kind" smallint NOT NULL, + "StartedBy" character varying(256), + "RunStatus" smallint, + "CurrentStepOrdinal" integer, + "RunCreatedAtUtc" timestamp with time zone, + "JobStepRunId" numeric(20,0), + "JobStepId" numeric(20,0), + "StepStatus" smallint, + "StepOrdinal" integer, + "TaskRequestId" character varying(256), + "CommandCorrelationStatus" smallint NOT NULL, + "CorrelatedCommandStatus" smallint, + "StartedAtUtc" timestamp with time zone, + "CompletedAtUtc" timestamp with time zone, + "ObservedAtUtc" timestamp with time zone NOT NULL, + "IsAuthoritative" boolean NOT NULL, + "RecordedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_JobShadowObservations" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_JobShadowObservations_JobRunId_SourceEventId" ON "JobShadowObservations" ("JobRunId", "SourceEventId"); + +CREATE INDEX "IX_JobShadowObservations_RecordedAtUtc" ON "JobShadowObservations" ("RecordedAtUtc"); + +CREATE INDEX "IX_JobShadowObservations_TenantId_TaskRequestId" ON "JobShadowObservations" ("TenantId", "TaskRequestId"); + +CREATE UNIQUE INDEX "UX_JobShadowObservation_Source" ON "JobShadowObservations" ("SourceSystem", "SourceEventId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260807105643_AddJobShadowObservations', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "PrimaryClientAgentBindings" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid, + "EnrollmentCodeId" uuid, + "PrimaryClientIdentity" text NOT NULL, + "Status" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CreatedBy" text NOT NULL, + "BoundAtUtc" timestamp with time zone, + "BoundBy" text, + "RevokedAtUtc" timestamp with time zone, + "RevokedBy" text, + "BindingSource" text NOT NULL, + "Notes" text, + "Version" bigint NOT NULL, + CONSTRAINT "PK_PrimaryClientAgentBindings" PRIMARY KEY ("Id"), + CONSTRAINT "FK_PrimaryClientAgentBindings_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_PrimaryClientAgentBindings_EnrollmentCodes_EnrollmentCodeId" FOREIGN KEY ("EnrollmentCodeId") REFERENCES "EnrollmentCodes" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_PrimaryClientAgentBindings_AgentId" ON "PrimaryClientAgentBindings" ("AgentId") WHERE "AgentId" IS NOT NULL AND "Status" <> 3; + +CREATE UNIQUE INDEX "IX_PrimaryClientAgentBindings_EnrollmentCodeId" ON "PrimaryClientAgentBindings" ("EnrollmentCodeId"); + +CREATE UNIQUE INDEX "IX_PrimaryClientAgentBindings_TenantId_PrimaryClientIdentity" ON "PrimaryClientAgentBindings" ("TenantId", "PrimaryClientIdentity") WHERE "Status" <> 3; + +CREATE INDEX "IX_PrimaryClientAgentBindings_TenantId_Status_CreatedAtUtc" ON "PrimaryClientAgentBindings" ("TenantId", "Status", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260809064936_AddPrimaryClientAgentBindings', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Requests" ADD "TargetAgentId" uuid; + +ALTER TABLE "Requests" ADD "TargetTenantId" integer; + +ALTER TABLE "JobTaskActivities" ADD "AgentId" uuid; + +ALTER TABLE "Jobs" ADD "AgentId" uuid; + +ALTER TABLE "JobRuns" ADD "AgentId" uuid; + +CREATE INDEX "IX_Requests_TargetTenantId_TargetAgentId" ON "Requests" ("TargetTenantId", "TargetAgentId"); + +CREATE INDEX "IX_JobTaskActivities_TenantId_AgentId_CreatedAtUtc" ON "JobTaskActivities" ("TenantId", "AgentId", "CreatedAtUtc"); + +CREATE INDEX "IX_Jobs_TenantId_AgentId" ON "Jobs" ("TenantId", "AgentId"); + +CREATE INDEX "IX_JobRuns_TenantId_AgentId_CreatedAtUtc" ON "JobRuns" ("TenantId", "AgentId", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260815123451_AddAkkaOrchestrationTargets', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS "IX_Tenants_GlobalSearch_Name_trgm" ON "Tenants" USING gin ("Name" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Tenants_GlobalSearch_Description_trgm" ON "Tenants" USING gin ("Description" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Tenants_GlobalSearch_Location_trgm" ON "Tenants" USING gin ("Location" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Tenants_GlobalSearch_ContactPerson_trgm" ON "Tenants" USING gin ("ContactPerson" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Tenants_GlobalSearch_ContactEmail_trgm" ON "Tenants" USING gin ("ContactEmail" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Scripts_GlobalSearch_Name_trgm" ON "Scripts" USING gin ("Name" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Scripts_GlobalSearch_FolderPath_trgm" ON "Scripts" USING gin ("FolderPath" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Scripts_GlobalSearch_Description_trgm" ON "Scripts" USING gin ("Description" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Scripts_GlobalSearch_ScriptType_trgm" ON "Scripts" USING gin ("ScriptType" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Jobs_GlobalSearch_Name_trgm" ON "Jobs" USING gin ("Name" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Jobs_GlobalSearch_FolderPath_trgm" ON "Jobs" USING gin ("FolderPath" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Jobs_GlobalSearch_Description_trgm" ON "Jobs" USING gin ("Description" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Jobs_GlobalSearch_ClientIdentity_trgm" ON "Jobs" USING gin ("ClientIdentity" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_SourceSystem_trgm" ON "Requests" USING gin ("SourceSystem" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_TargetClientIdentity_trgm" ON "Requests" USING gin ("TargetClientIdentity" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_RundeckJobDefinitionId_trgm" ON "Requests" USING gin ("RundeckJobDefinitionId" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_RundeckExecutionId_trgm" ON "Requests" USING gin ("RundeckExecutionId" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_Status_trgm" ON "Requests" USING gin ("Status" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_Requests_GlobalSearch_ResultMessage_trgm" ON "Requests" USING gin ("ResultMessage" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_JobTaskActivities_GlobalSearch_RequestId_trgm" ON "JobTaskActivities" USING gin ("RequestId" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_JobTaskActivities_GlobalSearch_TaskType_trgm" ON "JobTaskActivities" USING gin ("TaskType" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_JobTaskActivities_GlobalSearch_Status_trgm" ON "JobTaskActivities" USING gin ("Status" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_JobTaskActivities_GlobalSearch_Error_trgm" ON "JobTaskActivities" USING gin ("Error" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "IX_JobTaskActivities_GlobalSearch_ClientIdentity_trgm" ON "JobTaskActivities" USING gin ("ClientIdentity" gin_trgm_ops); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260815141614_RepairGlobalSearchIndexes', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX "IX_Agents_GlobalSearch_DeviceInfoJson_trgm" ON "Agents" USING gin ("DeviceInfoJson" gin_trgm_ops); + +CREATE INDEX "IX_Agents_GlobalSearch_Name_trgm" ON "Agents" USING gin ("Name" gin_trgm_ops); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260817100237_AddAgentGlobalSearchIndexes', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Agents" ADD "PublicKeyFingerprint" text; + +ALTER TABLE "Agents" ADD "SupersededAtUtc" timestamp with time zone; + +ALTER TABLE "Agents" ADD "SupersededByAgentId" uuid; + +ALTER TABLE "AgentRefreshTokens" ADD "RecoveryUsedAtUtc" timestamp with time zone; + +UPDATE "Agents" +SET "PublicKeyFingerprint" = encode(sha256(decode(trim("PublicKey"), 'base64')), 'hex') +WHERE "PublicKey" IS NOT NULL + AND trim("PublicKey") <> '' + AND lower("KeyAlgorithm") = 'ecdsa-p256'; + +CREATE TEMP TABLE "AgentIdentityConsolidation" ON COMMIT DROP AS +WITH ranked AS ( + SELECT + agent."Id" AS "AgentId", + agent."TenantId", + first_value(agent."Id") OVER ( + PARTITION BY agent."TenantId", agent."PublicKeyFingerprint" + ORDER BY + agent."IsEnabled" DESC, + (agent."Status" = 0) DESC, + EXISTS ( + SELECT 1 + FROM "AgentRefreshTokens" refresh + WHERE refresh."AgentId" = agent."Id" + AND refresh."RevokedAtUtc" IS NULL + AND (refresh."ExpiresAtUtc" IS NULL OR refresh."ExpiresAtUtc" > now())) DESC, + agent."LastTokenIssuedAtUtc" DESC NULLS LAST, + agent."LastSeenUtc" DESC NULLS LAST, + agent."CreatedAtUtc" DESC, + agent."Id" + ) AS "CanonicalAgentId", + count(*) OVER ( + PARTITION BY agent."TenantId", agent."PublicKeyFingerprint") AS "IdentityCount" + FROM "Agents" agent + WHERE agent."PublicKeyFingerprint" IS NOT NULL + AND agent."SupersededAtUtc" IS NULL +) +SELECT "AgentId", "TenantId", "CanonicalAgentId" +FROM ranked +WHERE "IdentityCount" > 1 + AND "AgentId" <> "CanonicalAgentId"; + +UPDATE "Jobs" target +SET + "AgentId" = map."CanonicalAgentId", + "TenantId" = coalesce(target."TenantId", map."TenantId") +FROM "AgentIdentityConsolidation" map +WHERE target."AgentId" = map."AgentId" + AND (target."TenantId" IS NULL OR target."TenantId" = map."TenantId"); + +UPDATE "Requests" target +SET + "TargetAgentId" = map."CanonicalAgentId", + "TargetTenantId" = coalesce(target."TargetTenantId", map."TenantId") +FROM "AgentIdentityConsolidation" map +WHERE target."TargetAgentId" = map."AgentId" + AND (target."TargetTenantId" IS NULL OR target."TargetTenantId" = map."TenantId"); + +UPDATE "JobRuns" target +SET + "AgentId" = map."CanonicalAgentId", + "TenantId" = coalesce(target."TenantId", map."TenantId") +FROM "AgentIdentityConsolidation" map +WHERE target."AgentId" = map."AgentId" + AND (target."TenantId" IS NULL OR target."TenantId" = map."TenantId"); + +UPDATE "JobTaskActivities" target +SET + "AgentId" = map."CanonicalAgentId", + "TenantId" = coalesce(target."TenantId", map."TenantId") +FROM "AgentIdentityConsolidation" map +WHERE target."AgentId" = map."AgentId" + AND (target."TenantId" IS NULL OR target."TenantId" = map."TenantId"); + +CREATE TEMP TABLE "AgentIdentityBindingKeep" ON COMMIT DROP AS +SELECT DISTINCT ON (map."CanonicalAgentId") + binding."Id" AS "BindingId", + map."CanonicalAgentId" +FROM "AgentIdentityConsolidation" map +JOIN "PrimaryClientAgentBindings" binding + ON binding."TenantId" = map."TenantId" + AND binding."AgentId" IN (map."AgentId", map."CanonicalAgentId") + AND binding."Status" <> 3 +ORDER BY + map."CanonicalAgentId", + (binding."AgentId" = map."CanonicalAgentId") DESC, + (binding."Status" = 1) DESC, + binding."BoundAtUtc" DESC NULLS LAST, + binding."CreatedAtUtc" DESC, + binding."Id"; + +UPDATE "PrimaryClientAgentBindings" binding +SET + "Status" = 3, + "RevokedAtUtc" = now(), + "RevokedBy" = 'agent-identity-migration', + "Notes" = concat_ws(E'\n', nullif(binding."Notes", ''), 'Revoked while consolidating an exact cryptographic Agent identity match.'), + "Version" = binding."Version" + 1 +FROM "AgentIdentityConsolidation" map +WHERE binding."TenantId" = map."TenantId" + AND binding."AgentId" = map."AgentId" + AND binding."Status" <> 3 + AND NOT EXISTS ( + SELECT 1 + FROM "AgentIdentityBindingKeep" keep + WHERE keep."BindingId" = binding."Id"); + +UPDATE "PrimaryClientAgentBindings" binding +SET + "AgentId" = keep."CanonicalAgentId", + "BoundBy" = 'agent-identity-migration', + "Notes" = concat_ws(E'\n', nullif(binding."Notes", ''), 'Moved to the canonical Agent after an exact cryptographic identity match.'), + "Version" = binding."Version" + 1 +FROM "AgentIdentityBindingKeep" keep +WHERE binding."Id" = keep."BindingId" + AND binding."AgentId" <> keep."CanonicalAgentId"; + +UPDATE "AgentCredentials" credential +SET "RevokedAtUtc" = coalesce(credential."RevokedAtUtc", now()) +FROM "AgentIdentityConsolidation" map +WHERE credential."AgentId" = map."AgentId"; + +UPDATE "AgentRefreshTokens" refresh +SET "RevokedAtUtc" = coalesce(refresh."RevokedAtUtc", now()) +FROM "AgentIdentityConsolidation" map +WHERE refresh."AgentId" = map."AgentId"; + +UPDATE "Agents" agent +SET + "IsEnabled" = false, + "Status" = 1, + "DisabledReason" = 'Superseded after exact cryptographic installation identity match.', + "RevokedAtUtc" = coalesce(agent."RevokedAtUtc", now()), + "SupersededByAgentId" = map."CanonicalAgentId", + "SupersededAtUtc" = now() +FROM "AgentIdentityConsolidation" map +WHERE agent."Id" = map."AgentId"; + +INSERT INTO "OutboxMessages" ( + "Id", "OccurredUtc", "Type", "PayloadJson", "Source", "CorrelationId", + "TenantId", "EntityId", "Severity", "Message", "Status", "Attempts", "NextAttemptUtc") +SELECT + md5('agent-superseded:' || map."AgentId"::text)::uuid, + now(), + 'DomainEvent.Orchestration.Agent.Superseded', + jsonb_build_object( + 'agentId', map."AgentId", + 'tenantId', map."TenantId", + 'supersededByAgentId', map."CanonicalAgentId", + 'actor', 'agent-identity-migration')::text, + 'Agent', + 'netratel-agent-' || replace(map."CanonicalAgentId"::text, '-', ''), + map."TenantId"::text, + map."AgentId"::text, + 'Info', + 'Agent superseded after exact cryptographic installation identity match.', + 'Pending', + 0, + now() +FROM "AgentIdentityConsolidation" map; + +CREATE INDEX "IX_Agents_SupersededByAgentId" ON "Agents" ("SupersededByAgentId"); + +CREATE UNIQUE INDEX "IX_Agents_TenantId_PublicKeyFingerprint" ON "Agents" ("TenantId", "PublicKeyFingerprint") WHERE "PublicKeyFingerprint" IS NOT NULL AND "SupersededAtUtc" IS NULL; + +ALTER TABLE "Agents" ADD CONSTRAINT "FK_Agents_Agents_SupersededByAgentId" FOREIGN KEY ("SupersededByAgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260817121734_CanonicalizeAgentInstallationIdentity', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "AgentClientUpdateStates" ( + "AgentId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "SuspendedAtUtc" timestamp with time zone, + "SuspensionReason" character varying(256), + "SuspensionAttemptId" uuid, + "SuppressedReleaseId" uuid, + "PolicyRevision" bigint NOT NULL, + "ResumedAtUtc" timestamp with time zone, + "ResumedBy" text, + CONSTRAINT "PK_AgentClientUpdateStates" PRIMARY KEY ("AgentId"), + CONSTRAINT "FK_AgentClientUpdateStates_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE CASCADE +); + +CREATE TABLE "ClientUpdateCatalogRevision" ( + "Id" integer NOT NULL, + "Revision" bigint NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientUpdateCatalogRevision" PRIMARY KEY ("Id") +); + +CREATE TABLE "ClientUpdateReleases" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "PublicId" uuid NOT NULL, + "Revision" bigint NOT NULL, + "RuntimeId" character varying(32) NOT NULL, + "Version" character varying(64) NOT NULL, + "Channel" character varying(16) NOT NULL, + "ArtifactKey" character varying(512) NOT NULL, + "Sha256" character varying(64) NOT NULL, + "SizeBytes" bigint NOT NULL, + "ManifestJson" jsonb NOT NULL, + "Enabled" boolean NOT NULL, + "PublishedAtUtc" timestamp with time zone NOT NULL, + "PublishedBy" text, + "DisabledAtUtc" timestamp with time zone, + "DisabledBy" text, + CONSTRAINT "PK_ClientUpdateReleases" PRIMARY KEY ("Id") +); + +CREATE TABLE "ClientUpdateAttempts" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "PublicId" uuid NOT NULL, + "ReleaseId" integer NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "FromVersion" character varying(64) NOT NULL, + "TargetVersion" character varying(64) NOT NULL, + "RuntimeId" character varying(32) NOT NULL, + "State" smallint NOT NULL, + "AdmissionNonceHash" character varying(64) NOT NULL, + "GatewayConnectionId" uuid, + "GatewayConnectionEpoch" bigint, + "ConfirmationId" uuid, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "ReadmittedAtUtc" timestamp with time zone, + "ConfirmedAtUtc" timestamp with time zone, + "FailureCode" character varying(64), + "Message" text, + CONSTRAINT "PK_ClientUpdateAttempts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_ClientUpdateAttempts_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_ClientUpdateAttempts_ClientUpdateReleases_ReleaseId" FOREIGN KEY ("ReleaseId") REFERENCES "ClientUpdateReleases" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_AgentClientUpdateStates_TenantId_SuspendedAtUtc" ON "AgentClientUpdateStates" ("TenantId", "SuspendedAtUtc"); + +CREATE UNIQUE INDEX "IX_ClientUpdateAttempts_AgentId_ReleaseId" ON "ClientUpdateAttempts" ("AgentId", "ReleaseId"); + +CREATE UNIQUE INDEX "IX_ClientUpdateAttempts_PublicId" ON "ClientUpdateAttempts" ("PublicId"); + +CREATE INDEX "IX_ClientUpdateAttempts_ReleaseId" ON "ClientUpdateAttempts" ("ReleaseId"); + +CREATE INDEX "IX_ClientUpdateAttempts_TenantId_AgentId_UpdatedAtUtc" ON "ClientUpdateAttempts" ("TenantId", "AgentId", "UpdatedAtUtc"); + +CREATE INDEX "IX_ClientUpdateReleases_Enabled_RuntimeId_Channel_PublishedAtU~" ON "ClientUpdateReleases" ("Enabled", "RuntimeId", "Channel", "PublishedAtUtc"); + +CREATE UNIQUE INDEX "IX_ClientUpdateReleases_PublicId" ON "ClientUpdateReleases" ("PublicId"); + +CREATE UNIQUE INDEX "IX_ClientUpdateReleases_Revision" ON "ClientUpdateReleases" ("Revision"); + +CREATE UNIQUE INDEX "IX_ClientUpdateReleases_RuntimeId_Version" ON "ClientUpdateReleases" ("RuntimeId", "Version"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260818180104_AddAkkaClientUpdates', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "RemoteSupportSessions" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "OpenRequestId" uuid NOT NULL, + "ContractVersion" integer NOT NULL DEFAULT 1, + "InitiatingOperatorId" character varying(256) NOT NULL, + "TargetKind" character varying(32) NOT NULL, + "TargetWindowsSessionId" integer, + "TargetUserSidHash" character varying(256), + "TargetInventorySequence" numeric(20,0), + "RequestedCapabilitiesJson" text NOT NULL, + "GrantedCapabilitiesJson" text NOT NULL, + "State" character varying(64) NOT NULL, + "LifecycleRevision" numeric(20,0) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone, + "TerminalAtUtc" timestamp with time zone, + "TerminalReasonCode" character varying(128), + CONSTRAINT "PK_RemoteSupportSessions" PRIMARY KEY ("Id") +); + +CREATE TABLE "RemoteSupportAuditEvents" ( + "Id" uuid NOT NULL, + "RemoteSupportSessionId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "ContractVersion" integer NOT NULL, + "AuditSequence" numeric(20,0) NOT NULL, + "LifecycleRevision" numeric(20,0) NOT NULL, + "EventType" character varying(128) NOT NULL, + "ActorKind" character varying(64) NOT NULL, + "ActorId" character varying(256) NOT NULL, + "RequestId" uuid, + "Outcome" character varying(64) NOT NULL, + "FailureCode" character varying(128), + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_RemoteSupportAuditEvents" PRIMARY KEY ("Id"), + CONSTRAINT "FK_RemoteSupportAuditEvents_RemoteSupportSessions_RemoteSuppor~" FOREIGN KEY ("RemoteSupportSessionId") REFERENCES "RemoteSupportSessions" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_RemoteSupportAuditEvents_RemoteSupportSessionId_AuditSequen~" ON "RemoteSupportAuditEvents" ("RemoteSupportSessionId", "AuditSequence"); + +CREATE INDEX "IX_RemoteSupportAuditEvents_RemoteSupportSessionId_RequestId" ON "RemoteSupportAuditEvents" ("RemoteSupportSessionId", "RequestId"); + +CREATE INDEX "IX_RemoteSupportAuditEvents_TenantId_AgentId_OccurredAtUtc" ON "RemoteSupportAuditEvents" ("TenantId", "AgentId", "OccurredAtUtc"); + +CREATE INDEX "IX_RemoteSupportSessions_ExpiresAtUtc" ON "RemoteSupportSessions" ("ExpiresAtUtc"); + +CREATE INDEX "IX_RemoteSupportSessions_TenantId_AgentId_CreatedAtUtc" ON "RemoteSupportSessions" ("TenantId", "AgentId", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_RemoteSupportSessions_TenantId_AgentId_OpenRequestId" ON "RemoteSupportSessions" ("TenantId", "AgentId", "OpenRequestId"); + +CREATE INDEX "IX_RemoteSupportSessions_TenantId_InitiatingOperatorId_Updated~" ON "RemoteSupportSessions" ("TenantId", "InitiatingOperatorId", "UpdatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260820171115_AddRemoteSupportV2LifecycleContracts', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Tenants" ADD "AutoUpdateChannel" text NOT NULL DEFAULT ''; + +ALTER TABLE "Tenants" ADD "AutoUpdateTargetVersion" text; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260823110407_AddTenantApprovedClientRollout', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "DevelopmentOperatorTargetGrants" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Classification" smallint NOT NULL, + "AllowedOperations" integer NOT NULL, + "EvidenceReference" character varying(512) NOT NULL, + "GrantedBy" character varying(256) NOT NULL, + "GrantedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "RevokedAtUtc" timestamp with time zone, + "RevokedBy" character varying(256), + "RevocationReason" character varying(256), + CONSTRAINT "PK_DevelopmentOperatorTargetGrants" PRIMARY KEY ("Id"), + CONSTRAINT "FK_DevelopmentOperatorTargetGrants_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "DevelopmentOperatorAcceptedAudits" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "TargetGrantId" uuid NOT NULL, + "Operation" smallint NOT NULL, + "ActorId" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_DevelopmentOperatorAcceptedAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_DevelopmentOperatorAcceptedAudits_DevelopmentOperatorTarget~" FOREIGN KEY ("TargetGrantId") REFERENCES "DevelopmentOperatorTargetGrants" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_DevelopmentOperatorAcceptedAudits_TargetGrantId_OccurredAtU~" ON "DevelopmentOperatorAcceptedAudits" ("TargetGrantId", "OccurredAtUtc"); + +CREATE INDEX "IX_DevelopmentOperatorAcceptedAudits_TenantId_AgentId_Occurred~" ON "DevelopmentOperatorAcceptedAudits" ("TenantId", "AgentId", "OccurredAtUtc"); + +CREATE UNIQUE INDEX "IX_DevelopmentOperatorTargetGrants_AgentId_RevokedAtUtc" ON "DevelopmentOperatorTargetGrants" ("AgentId", "RevokedAtUtc") WHERE "RevokedAtUtc" IS NULL; + +CREATE INDEX "IX_DevelopmentOperatorTargetGrants_TenantId_AgentId_RevokedAtU~" ON "DevelopmentOperatorTargetGrants" ("TenantId", "AgentId", "RevokedAtUtc", "ExpiresAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260825170134_AddDevelopmentOperatorTargetAuthority', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "DevelopmentOperatorTargetGrants" ADD "FileFixtureRoot" character varying(4096); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260825175518_AddDevelopmentOperatorFileFixtureRoot', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "DevelopmentMcpFileArtifacts" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "TargetGrantId" uuid NOT NULL, + "FileName" character varying(512) NOT NULL, + "SizeBytes" bigint NOT NULL, + "Sha256" character varying(64) NOT NULL, + "MarkerOwned" boolean NOT NULL, + "Content" bytea NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_DevelopmentMcpFileArtifacts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_DevelopmentMcpFileArtifacts_DevelopmentOperatorTargetGrants~" FOREIGN KEY ("TargetGrantId") REFERENCES "DevelopmentOperatorTargetGrants" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_DevelopmentMcpFileArtifacts_TargetGrantId_CreatedAtUtc" ON "DevelopmentMcpFileArtifacts" ("TargetGrantId", "CreatedAtUtc"); + +CREATE INDEX "IX_DevelopmentMcpFileArtifacts_TenantId_AgentId_ExpiresAtUtc" ON "DevelopmentMcpFileArtifacts" ("TenantId", "AgentId", "ExpiresAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260825180742_AddDevelopmentMcpFileArtifacts', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "DevelopmentMcpScripts" ( + "Id" uuid NOT NULL, + "ScriptId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "TargetGrantId" uuid NOT NULL, + "Marker" character varying(128) NOT NULL, + "Shell" character varying(32) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_DevelopmentMcpScripts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_DevelopmentMcpScripts_DevelopmentOperatorTargetGrants_Targe~" FOREIGN KEY ("TargetGrantId") REFERENCES "DevelopmentOperatorTargetGrants" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_DevelopmentMcpScripts_ScriptId" ON "DevelopmentMcpScripts" ("ScriptId"); + +CREATE INDEX "IX_DevelopmentMcpScripts_TargetGrantId_CreatedAtUtc" ON "DevelopmentMcpScripts" ("TargetGrantId", "CreatedAtUtc"); + +CREATE INDEX "IX_DevelopmentMcpScripts_TenantId_AgentId_DeletedAtUtc_Created~" ON "DevelopmentMcpScripts" ("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260825183341_AddDevelopmentMcpMarkerScripts', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "DevelopmentMcpMarkerJobs" ( + "Id" uuid NOT NULL, + "JobId" bigint NOT NULL, + "ScriptId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "TargetGrantId" uuid NOT NULL, + "Marker" character varying(128) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_DevelopmentMcpMarkerJobs" PRIMARY KEY ("Id"), + CONSTRAINT "FK_DevelopmentMcpMarkerJobs_DevelopmentOperatorTargetGrants_TargetGrantId" FOREIGN KEY ("TargetGrantId") REFERENCES "DevelopmentOperatorTargetGrants" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_DevelopmentMcpMarkerJobs_JobId" ON "DevelopmentMcpMarkerJobs" ("JobId"); + +CREATE UNIQUE INDEX "IX_DevelopmentMcpMarkerJobs_ScriptId" ON "DevelopmentMcpMarkerJobs" ("ScriptId"); + +CREATE INDEX "IX_DevelopmentMcpMarkerJobs_TargetGrantId_CreatedAtUtc" ON "DevelopmentMcpMarkerJobs" ("TargetGrantId", "CreatedAtUtc"); + +CREATE INDEX "IX_DevelopmentMcpMarkerJobs_TenantId_AgentId_DeletedAtUtc_CreatedAtUtc" ON "DevelopmentMcpMarkerJobs" ("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260826001500_AddDevelopmentMcpMarkerJobs', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "DevelopmentMcpScripts" ADD "ExecutionMode" character varying(32) NOT NULL DEFAULT 'standard'; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260828080023_AddDevelopmentMcpCancellationProbe', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "EnrollmentCodes" ADD "DevelopmentMcpTargetAgentId" uuid; + +ALTER TABLE "EnrollmentCodes" ADD "DevelopmentMcpMarker" text; + +CREATE INDEX "IX_EnrollmentCodes_TenantId_DevelopmentMcpTargetAgentId_DevelopmentMcpMarker" ON "EnrollmentCodes" ("TenantId", "DevelopmentMcpTargetAgentId", "DevelopmentMcpMarker"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260828170000_AddDevelopmentMcpEnrollmentOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorPolicies" ( + "Id" uuid NOT NULL, + "Name" character varying(160) NOT NULL, + "Environment" smallint NOT NULL, + "Effect" smallint NOT NULL, + "Priority" integer NOT NULL, + "PrincipalSelectorKind" smallint NOT NULL, + "PrincipalSelectorValue" character varying(256) NOT NULL, + "TargetSelectorKind" smallint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid, + "ClientTag" character varying(128), + "TargetClassification" smallint, + "OperationFamily" integer NOT NULL, + "Operation" character varying(256), + "ConstraintsJson" jsonb NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CreatedBy" character varying(256) NOT NULL, + "ExpiresAtUtc" timestamp with time zone, + "ReviewByUtc" timestamp with time zone, + "DisabledAtUtc" timestamp with time zone, + "DisabledBy" character varying(256), + "Version" bigint NOT NULL, + "AuditReference" character varying(512), + CONSTRAINT "PK_McpOperatorPolicies" PRIMARY KEY ("Id") +); + +CREATE TABLE "McpOperatorAcceptedAudits" ( + "Id" uuid NOT NULL, + "PolicyId" uuid NOT NULL, + "Environment" smallint NOT NULL, + "ServicePrincipal" character varying(256) NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256), + "TenantId" integer NOT NULL, + "AgentId" uuid, + "OperationFamily" integer NOT NULL, + "Operation" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "RequestId" character varying(256) NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorAcceptedAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorAcceptedAudits_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorAcceptedAudits_PolicyId_OccurredAtUtc" ON "McpOperatorAcceptedAudits" ("PolicyId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorAcceptedAudits_RequestId_OccurredAtUtc" ON "McpOperatorAcceptedAudits" ("RequestId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorAcceptedAudits_TenantId_AgentId_OccurredAtUtc" ON "McpOperatorAcceptedAudits" ("TenantId", "AgentId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorPolicies_Environment_PrincipalSelectorKind_Princ~" ON "McpOperatorPolicies" ("Environment", "PrincipalSelectorKind", "PrincipalSelectorValue"); + +CREATE INDEX "IX_McpOperatorPolicies_Environment_TargetSelectorKind_TenantId~" ON "McpOperatorPolicies" ("Environment", "TargetSelectorKind", "TenantId", "AgentId"); + +CREATE INDEX "IX_McpOperatorPolicies_Environment_TenantId_DisabledAtUtc_Expi~" ON "McpOperatorPolicies" ("Environment", "TenantId", "DisabledAtUtc", "ExpiresAtUtc", "Effect", "Priority"); + +INSERT INTO "McpOperatorPolicies" ( + "Id", "Name", "Environment", "Effect", "Priority", + "PrincipalSelectorKind", "PrincipalSelectorValue", + "TargetSelectorKind", "TenantId", "AgentId", "ClientTag", + "TargetClassification", "OperationFamily", "Operation", + "ConstraintsJson", "CreatedAtUtc", "CreatedBy", "ExpiresAtUtc", + "ReviewByUtc", "DisabledAtUtc", "DisabledBy", "Version", "AuditReference") +SELECT + md5('legacy-dev-mcp-policy:' || legacy_grant."Id"::text)::uuid, + left('Legacy Dev target grant ' || legacy_grant."Id"::text, 160), + 1, 2, 0, + 5, 'development-compatibility', + 1, legacy_grant."TenantId", legacy_grant."AgentId", NULL, + CASE legacy_grant."Classification" WHEN 1 THEN 1 WHEN 2 THEN 2 ELSE NULL END, + (CASE WHEN (legacy_grant."AllowedOperations" & 1) <> 0 THEN 2816 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 2) <> 0 THEN 1024 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 4) <> 0 THEN 16 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 8) <> 0 THEN 6 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 16) <> 0 THEN 24 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 32) <> 0 THEN 96 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 64) <> 0 THEN 4096 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 128) <> 0 THEN 65536 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 256) <> 0 THEN 131072 ELSE 0 END) | + (CASE WHEN (legacy_grant."AllowedOperations" & 512) <> 0 THEN 1 ELSE 0 END), + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'readRoots', CASE WHEN legacy_grant."FileFixtureRoot" IS NULL THEN NULL ELSE jsonb_build_array(legacy_grant."FileFixtureRoot") END, + 'writeRoots', CASE WHEN legacy_grant."FileFixtureRoot" IS NULL THEN NULL ELSE jsonb_build_array(legacy_grant."FileFixtureRoot") END)), + legacy_grant."GrantedAtUtc", legacy_grant."GrantedBy", legacy_grant."ExpiresAtUtc", + NULL, NULL, NULL, 1, 'legacy-dev-grant:' || legacy_grant."Id"::text +FROM "DevelopmentOperatorTargetGrants" AS legacy_grant +WHERE legacy_grant."RevokedAtUtc" IS NULL +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829051150_AddMcpOperatorPolicyFoundation', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "McpOperatorAcceptedAudits" ADD "AuthorizedParty" character varying(256); + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "GroupsJson" jsonb NOT NULL DEFAULT '[]'; + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "McpInstance" character varying(32); + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "McpResource" character varying(512); + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "RolesJson" jsonb NOT NULL DEFAULT '[]'; + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "ScopesJson" jsonb NOT NULL DEFAULT '[]'; + +ALTER TABLE "McpOperatorAcceptedAudits" ADD "Tool" character varying(128); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829062048_AddMcpOperatorDelegatedIdentityAuditFacts', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorTargetProfiles" ( + "AgentId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "Classification" smallint NOT NULL, + "TagsJson" jsonb NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedBy" character varying(256) NOT NULL, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorTargetProfiles" PRIMARY KEY ("AgentId"), + CONSTRAINT "FK_McpOperatorTargetProfiles_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorTargetProfiles_TenantId_Classification" ON "McpOperatorTargetProfiles" ("TenantId", "Classification"); + +INSERT INTO "McpOperatorTargetProfiles" ( + "AgentId", "TenantId", "Classification", "TagsJson", + "UpdatedAtUtc", "UpdatedBy", "Version") +SELECT + legacy_grant."AgentId", + legacy_grant."TenantId", + CASE legacy_grant."Classification" + WHEN 1 THEN 1 + WHEN 2 THEN 2 + ELSE 3 + END, + '[]'::jsonb, + legacy_grant."GrantedAtUtc", + legacy_grant."GrantedBy", + 1 +FROM "DevelopmentOperatorTargetGrants" AS legacy_grant +WHERE legacy_grant."RevokedAtUtc" IS NULL +ON CONFLICT ("AgentId") DO NOTHING; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829063129_AddMcpOperatorTargetProfiles', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorPolicyChangeAudits" ( + "Id" uuid NOT NULL, + "Action" character varying(64) NOT NULL, + "PolicyId" uuid, + "AgentId" uuid, + "TenantId" integer NOT NULL, + "ActorId" character varying(256) NOT NULL, + "Version" bigint NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorPolicyChangeAudits" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_McpOperatorPolicyChangeAudits_AgentId_OccurredAtUtc" ON "McpOperatorPolicyChangeAudits" ("AgentId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorPolicyChangeAudits_PolicyId_OccurredAtUtc" ON "McpOperatorPolicyChangeAudits" ("PolicyId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorPolicyChangeAudits_TenantId_OccurredAtUtc" ON "McpOperatorPolicyChangeAudits" ("TenantId", "OccurredAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829064731_AddMcpOperatorPolicyChangeAudits', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorConfirmationPlans" ( + "Id" uuid NOT NULL, + "TokenHash" character varying(64) NOT NULL, + "Environment" smallint NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512), + "McpInstance" character varying(32), + "TenantId" integer NOT NULL, + "AgentId" uuid, + "TargetSetDigest" character varying(64) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "OperationFamily" integer NOT NULL, + "Operation" character varying(256) NOT NULL, + "ConfirmationClass" smallint NOT NULL, + "PayloadHash" character varying(64) NOT NULL, + "IdempotencyKey" character varying(128) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "ConsumedAtUtc" timestamp with time zone, + "ConsumedIdempotencyId" uuid, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorConfirmationPlans" PRIMARY KEY ("Id") +); + +CREATE TABLE "McpOperatorIdempotencyRecords" ( + "Id" uuid NOT NULL, + "Environment" smallint NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid, + "TargetSetDigest" character varying(64) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "OperationFamily" integer NOT NULL, + "Operation" character varying(256) NOT NULL, + "IdempotencyKey" character varying(128) NOT NULL, + "PayloadHash" character varying(64) NOT NULL, + "Outcome" smallint NOT NULL, + "ResultReference" character varying(512), + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorIdempotencyRecords" PRIMARY KEY ("Id") +); + +CREATE INDEX "IX_McpOperatorConfirmationPlans_ExpiresAtUtc_ConsumedAtUtc" ON "McpOperatorConfirmationPlans" ("ExpiresAtUtc", "ConsumedAtUtc"); + +CREATE INDEX "IX_McpOperatorConfirmationPlans_TenantId_AgentId_CreatedAtUtc" ON "McpOperatorConfirmationPlans" ("TenantId", "AgentId", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_McpOperatorConfirmationPlans_TokenHash" ON "McpOperatorConfirmationPlans" ("TokenHash"); + +CREATE UNIQUE INDEX "IX_McpOperatorIdempotencyRecords_Environment_Subject_ClientId_~" ON "McpOperatorIdempotencyRecords" ("Environment", "Subject", "ClientId", "TenantId", "OperationFamily", "Operation", "IdempotencyKey"); + +CREATE INDEX "IX_McpOperatorIdempotencyRecords_TenantId_AgentId_CreatedAtUtc" ON "McpOperatorIdempotencyRecords" ("TenantId", "AgentId", "CreatedAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829070648_AddMcpOperatorConfirmationAndIdempotency', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "McpOperatorPolicies" ADD "LifecycleState" smallint NOT NULL DEFAULT 1; + +UPDATE "McpOperatorPolicies" SET "LifecycleState" = 2 WHERE "DisabledAtUtc" IS NOT NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829123000_AddMcpOperatorPolicyLifecycleState', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorTerminalSessions" ( + "Id" uuid NOT NULL, + "SessionId" character varying(64) NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Generation" numeric(20,0) NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid, + "ShellType" character varying(32) NOT NULL, + "WorkingDirectory" character varying(4096), + "Columns" integer NOT NULL, + "Rows" integer NOT NULL, + "EffectiveConstraintsJson" jsonb NOT NULL, + "State" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "LastActivityAtUtc" timestamp with time zone NOT NULL, + "IdleExpiresAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "CloseRequestedAtUtc" timestamp with time zone, + "CloseReason" character varying(128), + "ClosedAtUtc" timestamp with time zone, + "FailureCode" character varying(64), + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorTerminalSessions" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorTerminalSessions_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTerminalSessions_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTerminalSessions_McpOperatorIdempotencyRecords_IdempotencyId" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTerminalSessions_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorTerminalSessionAudits" ( + "Id" uuid NOT NULL, + "SessionRecordId" uuid NOT NULL, + "State" smallint NOT NULL, + "Action" character varying(64) NOT NULL, + "Reason" character varying(128), + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorTerminalSessionAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorTerminalSessionAudits_McpOperatorTerminalSessions_SessionRecordId" FOREIGN KEY ("SessionRecordId") REFERENCES "McpOperatorTerminalSessions" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorTerminalSessionAudits_SessionRecordId_OccurredAtUtc" ON "McpOperatorTerminalSessionAudits" ("SessionRecordId", "OccurredAtUtc"); + +CREATE INDEX "IX_McpOperatorTerminalSessions_AcceptedAuditId" ON "McpOperatorTerminalSessions" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorTerminalSessions_AgentId" ON "McpOperatorTerminalSessions" ("AgentId"); + +CREATE UNIQUE INDEX "IX_McpOperatorTerminalSessions_IdempotencyId" ON "McpOperatorTerminalSessions" ("IdempotencyId") WHERE "IdempotencyId" IS NOT NULL; + +CREATE INDEX "IX_McpOperatorTerminalSessions_PolicyId" ON "McpOperatorTerminalSessions" ("PolicyId"); + +CREATE UNIQUE INDEX "IX_McpOperatorTerminalSessions_SessionId" ON "McpOperatorTerminalSessions" ("SessionId"); + +CREATE INDEX "IX_McpOperatorTerminalSessions_TenantId_AgentId_State_ExpiresAtUtc" ON "McpOperatorTerminalSessions" ("TenantId", "AgentId", "State", "ExpiresAtUtc"); + +CREATE INDEX "IX_McpOperatorTerminalSessions_TenantId_AgentId_Subject_ClientId_State" ON "McpOperatorTerminalSessions" ("TenantId", "AgentId", "Subject", "ClientId", "State"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829133000_AddMcpOperatorTerminalSessionOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorCommands" ( + "Id" uuid NOT NULL, + "CommandId" character varying(64) NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid, + "CorrelationId" character varying(128) NOT NULL, + "ShellType" character varying(32) NOT NULL, + "WorkingDirectory" character varying(4096), + "CommandHash" character varying(64) NOT NULL, + "CommandLength" integer NOT NULL, + "EnvironmentReferencesJson" jsonb NOT NULL, + "TimeoutSeconds" integer NOT NULL, + "MaximumOutputBytes" integer NOT NULL, + "EffectiveConstraintsJson" jsonb NOT NULL, + "State" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "LastUpdatedAtUtc" timestamp with time zone NOT NULL, + "FailureCode" character varying(64), + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorCommands" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorCommands_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorCommands_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorCommands_McpOperatorIdempotencyRecords_IdempotencyId" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorCommands_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorCommands_AcceptedAuditId" ON "McpOperatorCommands" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorCommands_AgentId" ON "McpOperatorCommands" ("AgentId"); + +CREATE UNIQUE INDEX "IX_McpOperatorCommands_CommandId" ON "McpOperatorCommands" ("CommandId"); + +CREATE UNIQUE INDEX "IX_McpOperatorCommands_IdempotencyId" ON "McpOperatorCommands" ("IdempotencyId") WHERE "IdempotencyId" IS NOT NULL; + +CREATE INDEX "IX_McpOperatorCommands_PolicyId" ON "McpOperatorCommands" ("PolicyId"); + +CREATE INDEX "IX_McpOperatorCommands_TenantId_AgentId_Subject_ClientId_State" ON "McpOperatorCommands" ("TenantId", "AgentId", "Subject", "ClientId", "State"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829143000_AddMcpOperatorCommandOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorScripts" ( + "Id" uuid NOT NULL, + "ScriptId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "Name" character varying(120) NOT NULL, + "Description" character varying(512) NOT NULL, + "ShellType" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "ContentHash" character varying(64) NOT NULL, + "ManifestHash" character varying(64) NOT NULL, + "ParametersJson" jsonb NOT NULL, + "TimeoutSeconds" integer NOT NULL, + "WorkingDirectory" character varying(4096) NOT NULL, + "DeclaredSideEffectsJson" jsonb NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorScripts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorScripts_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorScripts_Scripts_ScriptId" FOREIGN KEY ("ScriptId") REFERENCES "Scripts" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorScriptVersions" ( + "Id" uuid NOT NULL, + "ScriptRecordId" uuid NOT NULL, + "ScriptId" bigint NOT NULL, + "ScriptVersion" bigint NOT NULL, + "Action" character varying(32) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "Name" character varying(120) NOT NULL, + "Description" character varying(512) NOT NULL, + "ShellType" character varying(32) NOT NULL, + "ContentHash" character varying(64) NOT NULL, + "ManifestHash" character varying(64) NOT NULL, + "ParametersJson" jsonb NOT NULL, + "TimeoutSeconds" integer NOT NULL, + "WorkingDirectory" character varying(4096) NOT NULL, + "DeclaredSideEffectsJson" jsonb NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorScriptVersions" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorScriptVersions_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorScriptVersions_McpOperatorScripts_ScriptRecordId" FOREIGN KEY ("ScriptRecordId") REFERENCES "McpOperatorScripts" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorScripts_PolicyId" ON "McpOperatorScripts" ("PolicyId"); + +CREATE UNIQUE INDEX "IX_McpOperatorScripts_ScriptId" ON "McpOperatorScripts" ("ScriptId"); + +CREATE INDEX "IX_McpOperatorScripts_TenantId_Subject_ClientId_McpResource_McpInstance_DeletedAtUtc_UpdatedAtUtc" ON "McpOperatorScripts" ("TenantId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + +CREATE INDEX "IX_McpOperatorScriptVersions_AcceptedAuditId" ON "McpOperatorScriptVersions" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorScriptVersions_ScriptId_OccurredAtUtc" ON "McpOperatorScriptVersions" ("ScriptId", "OccurredAtUtc"); + +CREATE UNIQUE INDEX "IX_McpOperatorScriptVersions_ScriptRecordId_ScriptVersion" ON "McpOperatorScriptVersions" ("ScriptRecordId", "ScriptVersion"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829150000_AddMcpOperatorScriptOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorJobs" ( + "Id" uuid NOT NULL, + "JobId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "TargetSetDigest" character varying(64) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorJobs" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorJobs_Jobs_JobId" FOREIGN KEY ("JobId") REFERENCES "Jobs" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorJobs_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorJobAudits" ( + "Id" uuid NOT NULL, + "JobRecordId" uuid NOT NULL, + "JobId" bigint NOT NULL, + "JobVersion" bigint NOT NULL, + "Action" character varying(32) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorJobAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorJobAudits_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorJobAudits_McpOperatorJobs_JobRecordId" FOREIGN KEY ("JobRecordId") REFERENCES "McpOperatorJobs" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorJobRuns" ( + "Id" uuid NOT NULL, + "JobRunId" numeric(20,0) NOT NULL, + "JobRecordId" uuid NOT NULL, + "JobId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid, + "CorrelationId" character varying(256) NOT NULL, + "TargetSetDigest" character varying(64) NOT NULL, + "CancellationRequested" boolean NOT NULL, + "CancellationRequestedAtUtc" timestamp with time zone, + "DeletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorJobRuns" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorJobRuns_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorJobRuns_McpOperatorIdempotencyRecords_IdempotencyId" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorJobRuns_McpOperatorJobs_JobRecordId" FOREIGN KEY ("JobRecordId") REFERENCES "McpOperatorJobs" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorJobRunAudits" ( + "Id" uuid NOT NULL, + "JobRunRecordId" uuid NOT NULL, + "Action" character varying(32) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorJobRunAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorJobRunAudits_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorJobRunAudits_McpOperatorJobRuns_JobRunRecordId" FOREIGN KEY ("JobRunRecordId") REFERENCES "McpOperatorJobRuns" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_McpOperatorJobs_JobId" ON "McpOperatorJobs" ("JobId"); + +CREATE INDEX "IX_McpOperatorJobs_PolicyId" ON "McpOperatorJobs" ("PolicyId"); + +CREATE INDEX "IX_McpOperatorJobs_TenantId_AgentId_Subject_ClientId_McpResource_McpInstance_DeletedAtUtc_UpdatedAtUtc" ON "McpOperatorJobs" ("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "DeletedAtUtc", "UpdatedAtUtc"); + +CREATE INDEX "IX_McpOperatorJobAudits_AcceptedAuditId" ON "McpOperatorJobAudits" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorJobAudits_JobId_OccurredAtUtc" ON "McpOperatorJobAudits" ("JobId", "OccurredAtUtc"); + +CREATE UNIQUE INDEX "IX_McpOperatorJobAudits_JobRecordId_JobVersion" ON "McpOperatorJobAudits" ("JobRecordId", "JobVersion"); + +CREATE INDEX "IX_McpOperatorJobRuns_AcceptedAuditId" ON "McpOperatorJobRuns" ("AcceptedAuditId"); + +CREATE UNIQUE INDEX "IX_McpOperatorJobRuns_IdempotencyId" ON "McpOperatorJobRuns" ("IdempotencyId") WHERE "IdempotencyId" IS NOT NULL; + +CREATE INDEX "IX_McpOperatorJobRuns_JobRecordId" ON "McpOperatorJobRuns" ("JobRecordId"); + +CREATE UNIQUE INDEX "IX_McpOperatorJobRuns_JobRunId" ON "McpOperatorJobRuns" ("JobRunId"); + +CREATE INDEX "IX_McpOperatorJobRuns_TenantId_AgentId_DeletedAtUtc_CreatedAtUtc" ON "McpOperatorJobRuns" ("TenantId", "AgentId", "DeletedAtUtc", "CreatedAtUtc"); + +CREATE INDEX "IX_McpOperatorJobRunAudits_AcceptedAuditId" ON "McpOperatorJobRunAudits" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorJobRunAudits_JobRunRecordId_OccurredAtUtc" ON "McpOperatorJobRunAudits" ("JobRunRecordId", "OccurredAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829160000_AddMcpOperatorJobOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorTasks" ( + "Id" uuid NOT NULL, + "TaskActivityId" bigint NOT NULL, + "CommandId" character varying(64) NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "TargetSetDigest" character varying(64) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "TaskType" character varying(64) NOT NULL, + "ShellType" character varying(32), + "CommandHash" character varying(64), + "CommandLength" integer NOT NULL, + "ScriptId" bigint, + "ScriptVersion" bigint, + "ScriptContentHash" character varying(64), + "TimeoutSeconds" integer NOT NULL, + "MaximumOutputBytes" integer NOT NULL, + "State" character varying(32) NOT NULL, + "ResultSummary" character varying(49152), + "CancellationRequested" boolean NOT NULL, + "CancellationRequestedAtUtc" timestamp with time zone, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorTasks" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorTasks_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTasks_JobTaskActivities_TaskActivityId" FOREIGN KEY ("TaskActivityId") REFERENCES "JobTaskActivities" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTasks_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTasks_McpOperatorIdempotencyRecords_IdempotencyId" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTasks_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorTaskAudits" ( + "Id" uuid NOT NULL, + "TaskRecordId" uuid NOT NULL, + "Action" character varying(32) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorTaskAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorTaskAudits_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTaskAudits_McpOperatorTasks_TaskRecordId" FOREIGN KEY ("TaskRecordId") REFERENCES "McpOperatorTasks" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorTasks_AcceptedAuditId" ON "McpOperatorTasks" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorTasks_AgentId" ON "McpOperatorTasks" ("AgentId"); + +CREATE UNIQUE INDEX "IX_McpOperatorTasks_CommandId" ON "McpOperatorTasks" ("CommandId"); + +CREATE UNIQUE INDEX "IX_McpOperatorTasks_IdempotencyId" ON "McpOperatorTasks" ("IdempotencyId"); + +CREATE INDEX "IX_McpOperatorTasks_PolicyId" ON "McpOperatorTasks" ("PolicyId"); + +CREATE UNIQUE INDEX "IX_McpOperatorTasks_TaskActivityId" ON "McpOperatorTasks" ("TaskActivityId"); + +CREATE INDEX "IX_McpOperatorTasks_TenantId_AgentId_Subject_ClientId_McpResource_McpInstance_State_UpdatedAtUtc" ON "McpOperatorTasks" ("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + +CREATE INDEX "IX_McpOperatorTaskAudits_AcceptedAuditId" ON "McpOperatorTaskAudits" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorTaskAudits_TaskRecordId_OccurredAtUtc" ON "McpOperatorTaskAudits" ("TaskRecordId", "OccurredAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829170000_AddMcpOperatorTaskOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorRequests" ( + "Id" uuid NOT NULL, + "RequestId" integer NOT NULL, + "JobId" bigint NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "PolicyId" uuid NOT NULL, + "PolicyVersion" bigint NOT NULL, + "TargetSetDigest" character varying(64) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "State" character varying(32) NOT NULL, + "Summary" character varying(4096) NOT NULL, + "ResultSummary" character varying(49152), + "ClaimReferenceHash" character varying(64), + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorRequests" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorRequests_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequests_Jobs_JobId" FOREIGN KEY ("JobId") REFERENCES "Jobs" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequests_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequests_McpOperatorIdempotencyRecords_IdempotencyId" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequests_McpOperatorPolicies_PolicyId" FOREIGN KEY ("PolicyId") REFERENCES "McpOperatorPolicies" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequests_Requests_RequestId" FOREIGN KEY ("RequestId") REFERENCES "Requests" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "McpOperatorRequestAudits" ( + "Id" uuid NOT NULL, + "RequestRecordId" uuid NOT NULL, + "Action" character varying(32) NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "OccurredAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_McpOperatorRequestAudits" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorRequestAudits_McpOperatorAcceptedAudits_AcceptedAuditId" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorRequestAudits_McpOperatorRequests_RequestRecordId" FOREIGN KEY ("RequestRecordId") REFERENCES "McpOperatorRequests" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorRequests_AcceptedAuditId" ON "McpOperatorRequests" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorRequests_AgentId" ON "McpOperatorRequests" ("AgentId"); + +CREATE UNIQUE INDEX "IX_McpOperatorRequests_IdempotencyId" ON "McpOperatorRequests" ("IdempotencyId"); + +CREATE INDEX "IX_McpOperatorRequests_JobId_UpdatedAtUtc" ON "McpOperatorRequests" ("JobId", "UpdatedAtUtc"); + +CREATE INDEX "IX_McpOperatorRequests_PolicyId" ON "McpOperatorRequests" ("PolicyId"); + +CREATE UNIQUE INDEX "IX_McpOperatorRequests_RequestId" ON "McpOperatorRequests" ("RequestId"); + +CREATE INDEX "IX_McpOperatorRequests_TenantId_AgentId_Subject_ClientId_McpResource_McpInstance_State_UpdatedAtUtc" ON "McpOperatorRequests" ("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "State", "UpdatedAtUtc"); + +CREATE INDEX "IX_McpOperatorRequestAudits_AcceptedAuditId" ON "McpOperatorRequestAudits" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorRequestAudits_RequestRecordId_OccurredAtUtc" ON "McpOperatorRequestAudits" ("RequestRecordId", "OccurredAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829180000_AddMcpOperatorRequestOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Agents" ADD "DeletedAtUtc" timestamp with time zone; + +ALTER TABLE "Agents" ADD "DeletedBy" text; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829183612_AddAgentDecommissionState', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Tenants" ADD "Version" bigint NOT NULL DEFAULT 1; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260829190000_AddTenantOperatorConcurrency', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorTerminalActions" ( + "Id" uuid NOT NULL, + "SessionRecordId" uuid NOT NULL, + "Operation" character varying(64) NOT NULL, + "DelegationRequestId" character varying(128) NOT NULL, + "PayloadHash" character varying(64) NOT NULL, + "Outcome" smallint NOT NULL, + "ResultReference" character varying(128), + "AcceptedAuditId" uuid, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "Version" bigint NOT NULL, + CONSTRAINT "PK_McpOperatorTerminalActions" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorTerminalActions_McpOperatorAcceptedAudits_Accept~" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorTerminalActions_McpOperatorTerminalSessions_Sess~" FOREIGN KEY ("SessionRecordId") REFERENCES "McpOperatorTerminalSessions" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorTerminalActions_AcceptedAuditId" ON "McpOperatorTerminalActions" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorTerminalActions_SessionRecordId_CreatedAtUtc" ON "McpOperatorTerminalActions" ("SessionRecordId", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_McpOperatorTerminalActions_SessionRecordId_Operation_Delega~" ON "McpOperatorTerminalActions" ("SessionRecordId", "Operation", "DelegationRequestId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260830122359_AddMcpOperatorTerminalActionIdempotency', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "McpOperatorFileArtifacts" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "Subject" character varying(256) NOT NULL, + "ClientId" character varying(256) NOT NULL, + "McpResource" character varying(512) NOT NULL, + "McpInstance" character varying(32) NOT NULL, + "ReadRootFingerprint" character varying(64) NOT NULL, + "FileName" character varying(512) NOT NULL, + "SizeBytes" bigint NOT NULL, + "Sha256" character varying(64) NOT NULL, + "MimeType" character varying(256) NOT NULL, + "Content" bytea NOT NULL, + "AcceptedAuditId" uuid NOT NULL, + "IdempotencyId" uuid NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "DeletedAtUtc" timestamp with time zone, + CONSTRAINT "PK_McpOperatorFileArtifacts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_McpOperatorFileArtifacts_Agents_AgentId" FOREIGN KEY ("AgentId") REFERENCES "Agents" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorFileArtifacts_McpOperatorAcceptedAudits_Accepted~" FOREIGN KEY ("AcceptedAuditId") REFERENCES "McpOperatorAcceptedAudits" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_McpOperatorFileArtifacts_McpOperatorIdempotencyRecords_Idem~" FOREIGN KEY ("IdempotencyId") REFERENCES "McpOperatorIdempotencyRecords" ("Id") ON DELETE RESTRICT +); + +CREATE INDEX "IX_McpOperatorFileArtifacts_AcceptedAuditId" ON "McpOperatorFileArtifacts" ("AcceptedAuditId"); + +CREATE INDEX "IX_McpOperatorFileArtifacts_AgentId" ON "McpOperatorFileArtifacts" ("AgentId"); + +CREATE UNIQUE INDEX "IX_McpOperatorFileArtifacts_IdempotencyId" ON "McpOperatorFileArtifacts" ("IdempotencyId"); + +CREATE INDEX "IX_McpOperatorFileArtifacts_TenantId_AgentId_Subject_ClientId_~" ON "McpOperatorFileArtifacts" ("TenantId", "AgentId", "Subject", "ClientId", "McpResource", "McpInstance", "ExpiresAtUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260830160317_AddMcpOperatorFileArtifacts', '10.0.12'); + +COMMIT; + +START TRANSACTION; +INSERT INTO "McpOperatorPolicies" ( + "Id", "Name", "Environment", "Effect", "Priority", + "PrincipalSelectorKind", "PrincipalSelectorValue", + "TargetSelectorKind", "TenantId", "AgentId", "ClientTag", + "TargetClassification", "OperationFamily", "Operation", + "ConstraintsJson", "CreatedAtUtc", "CreatedBy", "ExpiresAtUtc", + "ReviewByUtc", "DisabledAtUtc", "DisabledBy", "Version", "AuditReference", + "LifecycleState") +SELECT + md5('dev-mcp-policy-bootstrap:netratel-mcp-dev-policy-bootstrap-administrator:v1')::uuid, + 'Dev QA PolicyAdministration bootstrap', + 1, 2, 100, + 3, 'netratel-mcp-dev-policy-bootstrap-administrator', + 1, legacy_grant."TenantId", legacy_grant."AgentId", NULL, + 1, 262144, NULL, + jsonb_build_object( + 'maxFanOut', 1, + 'allowedTargetClassifications', jsonb_build_array(1), + 'destructiveOperationsAllowed', false), + clock_timestamp(), 'dev-mcp-policy-bootstrap-migration', legacy_grant."ExpiresAtUtc", + LEAST(legacy_grant."ExpiresAtUtc", clock_timestamp() + INTERVAL '7 days'), + NULL, NULL, 1, + 'dev-policy-bootstrap:netratel-mcp-dev-policy-bootstrap-administrator', 1 +FROM "DevelopmentOperatorTargetGrants" AS legacy_grant +WHERE legacy_grant."TenantId" = 1 + AND legacy_grant."AgentId" = '1641ed2c-26f7-4277-abfb-81a56c26cee9'::uuid + AND legacy_grant."Classification" = 1 + AND legacy_grant."RevokedAtUtc" IS NULL + AND legacy_grant."ExpiresAtUtc" > clock_timestamp() +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260831065009_SeedDevMcpPolicyAdministrationBootstrap', '10.0.12'); + +COMMIT; + +START TRANSACTION; +UPDATE "McpOperatorPolicies" +SET "TargetClassification" = NULL, + "ConstraintsJson" = jsonb_build_object( + 'maxFanOut', 1, + 'destructiveOperationsAllowed', false), + "Version" = "Version" + 1 +WHERE "Id" = md5('dev-mcp-policy-bootstrap:netratel-mcp-dev-policy-bootstrap-administrator:v1')::uuid + AND "Environment" = 1 + AND "Effect" = 2 + AND "Priority" = 100 + AND "PrincipalSelectorKind" = 3 + AND "PrincipalSelectorValue" = 'netratel-mcp-dev-policy-bootstrap-administrator' + AND "TargetSelectorKind" = 1 + AND "TenantId" = 1 + AND "AgentId" = '1641ed2c-26f7-4277-abfb-81a56c26cee9'::uuid + AND "TargetClassification" = 1 + AND "OperationFamily" = 262144 + AND "Operation" IS NULL + AND "LifecycleState" = 1 + AND "ConstraintsJson" @> jsonb_build_object( + 'maxFanOut', 1, + 'allowedTargetClassifications', jsonb_build_array(1), + 'destructiveOperationsAllowed', false) + AND NOT EXISTS ( + SELECT 1 + FROM "McpOperatorAcceptedAudits" AS audit + WHERE audit."PolicyId" = "McpOperatorPolicies"."Id"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260831083000_RemoveUnresolvableDevBootstrapClassificationPredicate', '10.0.12'); + +COMMIT; + +START TRANSACTION; +INSERT INTO "McpOperatorPolicies" ( + "Id", "Name", "Environment", "Effect", "Priority", + "PrincipalSelectorKind", "PrincipalSelectorValue", + "TargetSelectorKind", "TenantId", "AgentId", "ClientTag", + "TargetClassification", "OperationFamily", "Operation", + "ConstraintsJson", "CreatedAtUtc", "CreatedBy", "ExpiresAtUtc", + "ReviewByUtc", "DisabledAtUtc", "DisabledBy", "Version", "AuditReference", + "LifecycleState") +SELECT + md5('dev-mcp-policy-bootstrap:netratel-mcp-dev-policy-administrator:tenant-provisioning:v1')::uuid, + 'Dev tenant PolicyAdministration provisioning admission', + 1, 2, 80, + 3, 'netratel-mcp-dev-policy-administrator', + 2, normal_admission."TenantId", NULL, NULL, + NULL, 262144, NULL, + jsonb_build_object( + 'maxFanOut', 1, + 'destructiveOperationsAllowed', false), + clock_timestamp(), 'dev-mcp-policy-provisioning-migration', + LEAST(legacy_grant."ExpiresAtUtc", normal_admission."ExpiresAtUtc"), + LEAST(legacy_grant."ExpiresAtUtc", normal_admission."ReviewByUtc"), + NULL, NULL, 1, + 'dev-policy-bootstrap:netratel-mcp-dev-policy-administrator:tenant-provisioning', 1 +FROM "DevelopmentOperatorTargetGrants" AS legacy_grant +JOIN "McpOperatorPolicies" AS normal_admission + ON normal_admission."Environment" = 1 + AND normal_admission."Effect" = 2 + AND normal_admission."PrincipalSelectorKind" = 3 + AND normal_admission."PrincipalSelectorValue" = 'netratel-mcp-dev-policy-administrator' + AND normal_admission."TargetSelectorKind" = 1 + AND normal_admission."TenantId" = 1 + AND normal_admission."AgentId" = '1641ed2c-26f7-4277-abfb-81a56c26cee9'::uuid + AND normal_admission."OperationFamily" = 262144 + AND normal_admission."Operation" IS NULL + AND normal_admission."ExpiresAtUtc" > clock_timestamp() + AND normal_admission."ReviewByUtc" IS NOT NULL + AND normal_admission."LifecycleState" = 1 +WHERE legacy_grant."TenantId" = 1 + AND legacy_grant."AgentId" = '1641ed2c-26f7-4277-abfb-81a56c26cee9'::uuid + AND legacy_grant."Classification" = 1 + AND legacy_grant."RevokedAtUtc" IS NULL + AND legacy_grant."ExpiresAtUtc" > clock_timestamp() +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260831133310_SeedDevMcpPolicyAdminTenantProvisioning', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "JobTaskActivities" ADD "ResultJson" text; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260831181727_AddTaskActivityResultJson', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "McpOperatorCommands" ADD "OutputJson" jsonb; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260905194254_AddMcpCommandOutput', '10.0.12'); + +COMMIT; + +START TRANSACTION; +INSERT INTO "McpOperatorPolicies" ( + "Id", "Name", "Environment", "Effect", "Priority", + "PrincipalSelectorKind", "PrincipalSelectorValue", + "TargetSelectorKind", "TenantId", "AgentId", "ClientTag", + "TargetClassification", "OperationFamily", "Operation", "ConstraintsJson", + "CreatedAtUtc", "CreatedBy", "Version", "AuditReference", "LifecycleState") +VALUES ( + md5('dev-mcp-feature-testing:netratel-mcp-dev-feature-testers:v1')::uuid, 'Full Dev MCP feature testing', 1, 2, 1000, + 3, 'netratel-mcp-dev-feature-testers', 5, 0, NULL, NULL, NULL, 524287, NULL, + jsonb_build_object( + 'readRoots', jsonb_build_array('/', 'A:/', 'B:/', 'C:/', 'D:/', 'E:/', 'F:/', 'G:/', 'H:/', 'I:/', 'J:/', 'K:/', 'L:/', 'M:/', 'N:/', 'O:/', 'P:/', 'Q:/', 'R:/', 'S:/', 'T:/', 'U:/', 'V:/', 'W:/', 'X:/', 'Y:/', 'Z:/'), + 'writeRoots', jsonb_build_array('/', 'A:/', 'B:/', 'C:/', 'D:/', 'E:/', 'F:/', 'G:/', 'H:/', 'I:/', 'J:/', 'K:/', 'L:/', 'M:/', 'N:/', 'O:/', 'P:/', 'Q:/', 'R:/', 'S:/', 'T:/', 'U:/', 'V:/', 'W:/', 'X:/', 'Y:/', 'Z:/'), + 'allowedShells', jsonb_build_array('bash', 'sh', 'pwsh', 'powershell', 'cmd', 'zsh', 'fish'), + 'workingDirectories', jsonb_build_array('*'), + 'maxCommandDurationSeconds', 3600, + 'maxTerminalIdleSeconds', 3600, + 'maxTerminalLifetimeSeconds', 86400, + 'maxConcurrentTerminalSessions', 100, + 'maxConcurrentCommands', 100, + 'maxOutputBytes', 16777216, + 'maxArtifactBytes', 268435456, + 'maxScriptBytes', 1048576, + 'maxJobTargetCount', 1000, + 'maxTaskTargetCount', 1000, + 'maxFanOut', 1000, + 'maxOnboardingCodeLifetimeSeconds', 2592000, + 'maxOnboardingCodeUses', 10000, + 'destructiveOperationsAllowed', true, + 'allowActiveTenantDeletion', true), + clock_timestamp(), 'dev-mcp-feature-testing-migration', 1, + 'public-source-migration', 1) +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260905195000_SeedDevMcpFeatureTesting', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "Scripts" ADD "DeletedAtUtc" timestamp with time zone; + +ALTER TABLE "Scripts" ADD "SourceRevision" bigint NOT NULL DEFAULT 1; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260906090619_AddScriptSourceRevision', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "BootstrapInitializations" ( + "Id" integer NOT NULL, + "BootstrapInstanceId" uuid NOT NULL, + "OperationId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AdministratorUserId" character varying(450) NOT NULL, + "CompletedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_BootstrapInitializations" PRIMARY KEY ("Id") +); + +CREATE UNIQUE INDEX "IX_BootstrapInitializations_BootstrapInstanceId" ON "BootstrapInitializations" ("BootstrapInstanceId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260921103721_AddBootstrapInitializationRecord', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "ClientReleaseImportOperations" ( + "Id" uuid NOT NULL, + "GitHubReleaseId" bigint NOT NULL, + "SourceRepository" character varying(128) NOT NULL, + "Tag" character varying(128) NOT NULL, + "Version" character varying(64) NOT NULL, + "BuildCommit" character varying(40), + "PublicationSha256" character varying(64), + "State" smallint NOT NULL, + "RequestedBy" character varying(256) NOT NULL, + "IsAutomatic" boolean NOT NULL, + "CancellationRequested" boolean NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "ImportedAtUtc" timestamp with time zone, + "PublishedAtUtc" timestamp with time zone, + "PublishedBy" character varying(256), + "TotalBytes" bigint, + "DownloadedBytes" bigint NOT NULL, + "AttemptCount" integer NOT NULL, + "Error" character varying(2048), + "LeaseOwner" uuid, + "LeaseUntilUtc" timestamp with time zone, + "LeaseGeneration" bigint NOT NULL, + CONSTRAINT "PK_ClientReleaseImportOperations" PRIMARY KEY ("Id") +); + +CREATE TABLE "ClientReleaseImportAssets" ( + "OperationId" uuid NOT NULL, + "RuntimeId" character varying(32) NOT NULL, + "GitHubAssetId" bigint NOT NULL, + "SourceName" character varying(256) NOT NULL, + "SourceSha256" character varying(64) NOT NULL, + "SourceSizeBytes" bigint NOT NULL, + "LocalSha256" character varying(64), + "LocalSizeBytes" bigint, + "ConversionContract" character varying(128), + "State" smallint NOT NULL, + "DownloadedBytes" bigint NOT NULL, + "Error" character varying(2048), + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientReleaseImportAssets" PRIMARY KEY ("OperationId", "RuntimeId"), + CONSTRAINT "FK_ClientReleaseImportAssets_ClientReleaseImportOperations_Ope~" FOREIGN KEY ("OperationId") REFERENCES "ClientReleaseImportOperations" ("Id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "IX_ClientReleaseImportOperations_GitHubReleaseId" ON "ClientReleaseImportOperations" ("GitHubReleaseId"); + +CREATE INDEX "IX_ClientReleaseImportOperations_State_LeaseUntilUtc_CreatedAt~" ON "ClientReleaseImportOperations" ("State", "LeaseUntilUtc", "CreatedAtUtc"); + +CREATE INDEX "IX_ClientReleaseImportOperations_Version" ON "ClientReleaseImportOperations" ("Version"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260924181300_AddClientReleaseImportOperations', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "EnrollmentCodes" ADD "CodeHash" character varying(64); + +CREATE TABLE "ClientInstallGrants" ( + "Id" uuid NOT NULL, + "TokenHash" character varying(64) NOT NULL, + "ProtectedToken" text NOT NULL, + "ProtectedScript" text NOT NULL, + "RequestKey" character varying(128) NOT NULL, + "RequestFingerprint" character varying(64) NOT NULL, + "EnrollmentCodeId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "RuntimeId" character varying(32) NOT NULL, + "ArtifactVersion" character varying(64) NOT NULL, + "ArtifactSha256" character varying(64) NOT NULL, + "InstallAsService" boolean NOT NULL, + "SilentInstall" boolean NOT NULL, + "PublicWebBaseUrl" character varying(2048) NOT NULL, + "PublicApiBaseUrl" character varying(2048) NOT NULL, + "CreatedBy" character varying(256) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "MaxUses" integer NOT NULL, + "RevokedAtUtc" timestamp with time zone, + "RevokedBy" character varying(256), + CONSTRAINT "PK_ClientInstallGrants" PRIMARY KEY ("Id"), + CONSTRAINT "FK_ClientInstallGrants_EnrollmentCodes_EnrollmentCodeId" FOREIGN KEY ("EnrollmentCodeId") REFERENCES "EnrollmentCodes" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_EnrollmentCodes_CodeHash" ON "EnrollmentCodes" ("CodeHash"); + +CREATE INDEX "IX_ClientInstallGrants_EnrollmentCodeId" ON "ClientInstallGrants" ("EnrollmentCodeId"); + +CREATE UNIQUE INDEX "IX_ClientInstallGrants_RequestKey" ON "ClientInstallGrants" ("RequestKey"); + +CREATE INDEX "IX_ClientInstallGrants_TenantId_CreatedAtUtc" ON "ClientInstallGrants" ("TenantId", "CreatedAtUtc"); + +CREATE UNIQUE INDEX "IX_ClientInstallGrants_TokenHash" ON "ClientInstallGrants" ("TokenHash"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260924185042_AddProtectedClientInstallLinks', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "ClientReleaseImportOperations" ADD "AutomaticPublishAttemptAtUtc" timestamp with time zone; + +ALTER TABLE "ClientReleaseImportOperations" ADD "AutomaticPublishError" character varying(2048); + +CREATE TABLE "ClientReleaseAutomationSettings" ( + "Id" integer NOT NULL, + "CheckEveryHours" integer NOT NULL, + "DownloadStable" boolean NOT NULL, + "DownloadPrerelease" boolean NOT NULL, + "PublishAutomatically" boolean NOT NULL, + "DeployPrereleaseAutomatically" boolean NOT NULL, + "NextCheckAtUtc" timestamp with time zone, + "LastAttemptAtUtc" timestamp with time zone, + "LastSuccessAtUtc" timestamp with time zone, + "LastError" character varying(2048), + "LeaseOwner" uuid, + "LeaseUntilUtc" timestamp with time zone, + "Revision" bigint NOT NULL, + "UpdatedBy" character varying(256) NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientReleaseAutomationSettings" PRIMARY KEY ("Id") +); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260924194413_AddClientReleaseAutomation', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "ClientInstallGrants" ADD "EffectiveGatewayBaseUrl" character varying(2048); + +ALTER TABLE "ClientInstallGrants" ADD "PublicWebSource" character varying(64); + +ALTER TABLE "ClientInstallGrants" ADD "PublicApiSource" character varying(64); + +ALTER TABLE "ClientInstallGrants" ADD "GatewaySource" character varying(64); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261002160000_AddClientInstallEndpointSnapshot', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "ClientConnectionEpochs" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "LastIssuedEpoch" bigint NOT NULL, + CONSTRAINT "PK_ClientConnectionEpochs" PRIMARY KEY ("TenantId", "AgentId") +); + +CREATE TABLE "ClientServicesSnapshots" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "ConnectionEpoch" bigint NOT NULL, + "LastAcceptedSequence" numeric(20,0) NOT NULL, + "Revision" bigint NOT NULL, + "StateJson" jsonb NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientServicesSnapshots" PRIMARY KEY ("TenantId", "AgentId") +); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261002170000_AddClientServicesSnapshots', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "MonitoringAudits" ( + "AuditId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "EntityKind" character varying(32) NOT NULL, + "EntityId" uuid NOT NULL, + "Operation" character varying(64) NOT NULL, + "OperatorId" uuid NOT NULL, + "Reason" character varying(512) NOT NULL, + "AtUtc" timestamp with time zone NOT NULL, + "ConfigurationRevision" numeric(20,0) NOT NULL, + "DetailsJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringAudits" PRIMARY KEY ("AuditId") +); + +CREATE TABLE "MonitoringBypasses" ( + "TenantId" integer NOT NULL, + "BypassId" uuid NOT NULL, + "ExpiresAtUtc" timestamp with time zone, + "DefinitionJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringBypasses" PRIMARY KEY ("TenantId", "BypassId") +); + +CREATE TABLE "MonitoringEvidenceStreams" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "ConnectionId" uuid NOT NULL, + "ConnectionEpoch" bigint NOT NULL, + "EvidenceStreamId" uuid NOT NULL, + "Active" boolean NOT NULL, + "RegisteredAtUtc" timestamp with time zone NOT NULL, + "Revision" numeric(20,0) NOT NULL, + CONSTRAINT "PK_MonitoringEvidenceStreams" PRIMARY KEY ("TenantId", "AgentId") +); + +CREATE TABLE "MonitoringGroups" ( + "TenantId" integer NOT NULL, + "GroupId" uuid NOT NULL, + "Revision" numeric(20,0) NOT NULL, + "DefinitionJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringGroups" PRIMARY KEY ("TenantId", "GroupId") +); + +CREATE TABLE "MonitoringOccurrences" ( + "OccurrenceId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "RuleId" uuid NOT NULL, + "AgentId" uuid NOT NULL, + "ResourceKey" character varying(512) NOT NULL, + "RaisedEventId" uuid NOT NULL, + "RaisedAtUtc" timestamp with time zone NOT NULL, + "EndedAtUtc" timestamp with time zone, + "OccurrenceJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringOccurrences" PRIMARY KEY ("OccurrenceId") +); + +CREATE TABLE "MonitoringRules" ( + "TenantId" integer NOT NULL, + "RuleId" uuid NOT NULL, + "Revision" numeric(20,0) NOT NULL, + "DefinitionJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringRules" PRIMARY KEY ("TenantId", "RuleId") +); + +CREATE TABLE "MonitoringSeries" ( + "TenantId" integer NOT NULL, + "RuleId" uuid NOT NULL, + "AgentId" uuid NOT NULL, + "ResourceKey" character varying(512) NOT NULL, + "StateRevision" numeric(20,0) NOT NULL, + "Phase" smallint NOT NULL, + "EvidenceQuality" smallint NOT NULL, + "ActiveOccurrenceId" uuid, + "LatestOccurrenceId" uuid, + "Acknowledged" boolean NOT NULL, + "Suppressed" boolean NOT NULL, + "StateJson" jsonb NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_MonitoringSeries" PRIMARY KEY ("TenantId", "RuleId", "AgentId", "ResourceKey") +); + +CREATE TABLE "MonitoringTenantConfigurations" ( + "TenantId" integer NOT NULL, + "Revision" numeric(20,0) NOT NULL, + CONSTRAINT "PK_MonitoringTenantConfigurations" PRIMARY KEY ("TenantId") +); + +CREATE TABLE "MonitoringEvents" ( + "EventId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "OccurrenceId" uuid NOT NULL, + "AtUtc" timestamp with time zone NOT NULL, + "EventJson" jsonb NOT NULL, + CONSTRAINT "PK_MonitoringEvents" PRIMARY KEY ("EventId"), + CONSTRAINT "FK_MonitoringEvents_MonitoringOccurrences_OccurrenceId" FOREIGN KEY ("OccurrenceId") REFERENCES "MonitoringOccurrences" ("OccurrenceId") ON DELETE RESTRICT +); + +CREATE TABLE "MonitoringFlowOutbox" ( + "OutboxId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "OccurrenceId" uuid NOT NULL, + "EventId" uuid NOT NULL, + "StableFlowDispatchKey" character varying(256) NOT NULL, + "IntentJson" jsonb NOT NULL, + "Status" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "NextAttemptAtUtc" timestamp with time zone, + "Attempts" integer NOT NULL, + "WorkerId" uuid, + "LeaseId" uuid, + "LeaseFence" bigint NOT NULL, + "LeaseExpiresAtUtc" timestamp with time zone, + "OutcomeJson" jsonb, + "FlowRunId" uuid, + "HandedOffAtUtc" timestamp with time zone, + "Code" character varying(64), + CONSTRAINT "PK_MonitoringFlowOutbox" PRIMARY KEY ("OutboxId"), + CONSTRAINT "FK_MonitoringFlowOutbox_MonitoringEvents_EventId" FOREIGN KEY ("EventId") REFERENCES "MonitoringEvents" ("EventId") ON DELETE RESTRICT +); + +CREATE INDEX "IX_MonitoringAudits_TenantId_AtUtc" ON "MonitoringAudits" ("TenantId", "AtUtc"); + +CREATE INDEX "IX_MonitoringBypasses_TenantId_ExpiresAtUtc" ON "MonitoringBypasses" ("TenantId", "ExpiresAtUtc"); + +CREATE INDEX "IX_MonitoringEvents_OccurrenceId" ON "MonitoringEvents" ("OccurrenceId"); + +CREATE INDEX "IX_MonitoringEvents_TenantId_AtUtc_EventId" ON "MonitoringEvents" ("TenantId", "AtUtc", "EventId"); + +CREATE INDEX "IX_MonitoringFlowOutbox_EventId" ON "MonitoringFlowOutbox" ("EventId"); + +CREATE UNIQUE INDEX "IX_MonitoringFlowOutbox_FlowRunId" ON "MonitoringFlowOutbox" ("FlowRunId") WHERE "FlowRunId" IS NOT NULL; + +CREATE UNIQUE INDEX "IX_MonitoringFlowOutbox_StableFlowDispatchKey" ON "MonitoringFlowOutbox" ("StableFlowDispatchKey"); + +CREATE INDEX "IX_MonitoringFlowOutbox_TenantId_Status_NextAttemptAtUtc_Lease~" ON "MonitoringFlowOutbox" ("TenantId", "Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + +CREATE UNIQUE INDEX "IX_MonitoringOccurrences_RaisedEventId" ON "MonitoringOccurrences" ("RaisedEventId"); + +CREATE INDEX "IX_MonitoringOccurrences_TenantId_EndedAtUtc" ON "MonitoringOccurrences" ("TenantId", "EndedAtUtc"); + +CREATE UNIQUE INDEX "UX_MonitoringOccurrence_OpenSeries" ON "MonitoringOccurrences" ("TenantId", "RuleId", "AgentId", "ResourceKey") WHERE "EndedAtUtc" IS NULL; + +CREATE INDEX "IX_MonitoringSeries_TenantId_AgentId" ON "MonitoringSeries" ("TenantId", "AgentId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261002195600_AddMonitoringRuntime', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "FlowAudits" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "FlowId" uuid NOT NULL, + "Revision" bigint NOT NULL, + "ActorId" character varying(256) NOT NULL, + "Operation" character varying(64) NOT NULL, + "AtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_FlowAudits" PRIMARY KEY ("Id") +); + +CREATE TABLE "FlowDefinitions" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "Name" character varying(128) NOT NULL, + "Revision" bigint NOT NULL, + "Enabled" boolean NOT NULL, + "DraftJson" jsonb NOT NULL, + "PublishedVersionId" uuid, + "PublishedVersionNumber" integer NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_FlowDefinitions" PRIMARY KEY ("Id"), + CONSTRAINT "AK_FlowDefinitions_TenantId_Id" UNIQUE ("TenantId", "Id") +); + +CREATE TABLE "FlowRuntimeIdentity" ( + "Id" integer NOT NULL, + "SourceInstanceId" uuid NOT NULL, + CONSTRAINT "PK_FlowRuntimeIdentity" PRIMARY KEY ("Id") +); + +CREATE TABLE "FlowVersions" ( + "Id" uuid NOT NULL, + "FlowId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "VersionNumber" integer NOT NULL, + "GraphJson" jsonb NOT NULL, + "ConfigurationHash" character varying(64) NOT NULL, + "PublishedBy" character varying(256) NOT NULL, + "PublishedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_FlowVersions" PRIMARY KEY ("Id"), + CONSTRAINT "AK_FlowVersions_TenantId_Id" UNIQUE ("TenantId", "Id"), + CONSTRAINT "FK_FlowVersions_FlowDefinitions_TenantId_FlowId" FOREIGN KEY ("TenantId", "FlowId") REFERENCES "FlowDefinitions" ("TenantId", "Id") ON DELETE RESTRICT +); + +CREATE TABLE "FlowRuns" ( + "Id" uuid NOT NULL, + "TenantId" integer NOT NULL, + "FlowId" uuid NOT NULL, + "FlowVersionId" uuid NOT NULL, + "EventId" uuid NOT NULL, + "OccurrenceId" uuid NOT NULL, + "EventJson" jsonb NOT NULL, + "EventFingerprint" character varying(64) NOT NULL, + "Status" integer NOT NULL, + "Fence" bigint NOT NULL, + "LeaseToken" uuid, + "LeaseOwner" uuid, + "LeaseExpiresAtUtc" timestamp with time zone, + "Attempts" integer NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "CompletedAtUtc" timestamp with time zone, + "NextAttemptAtUtc" timestamp with time zone, + "Code" character varying(128), + CONSTRAINT "PK_FlowRuns" PRIMARY KEY ("Id"), + CONSTRAINT "AK_FlowRuns_TenantId_Id" UNIQUE ("TenantId", "Id"), + CONSTRAINT "FK_FlowRuns_FlowVersions_TenantId_FlowVersionId" FOREIGN KEY ("TenantId", "FlowVersionId") REFERENCES "FlowVersions" ("TenantId", "Id") ON DELETE RESTRICT +); + +CREATE TABLE "FlowActions" ( + "RunId" uuid NOT NULL, + "NodeId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "IdempotencyKey" character varying(200) NOT NULL, + "Status" integer NOT NULL, + "DraftJson" jsonb NOT NULL, + "PreparedJson" jsonb, + "ConnectorRevision" bigint, + "SemanticFingerprint" character varying(64), + "ReceiptJson" jsonb, + "LeaseFence" bigint NOT NULL, + "Attempts" integer NOT NULL, + "NextAttemptAtUtc" timestamp with time zone, + "Code" character varying(128), + CONSTRAINT "PK_FlowActions" PRIMARY KEY ("RunId", "NodeId"), + CONSTRAINT "FK_FlowActions_FlowRuns_TenantId_RunId" FOREIGN KEY ("TenantId", "RunId") REFERENCES "FlowRuns" ("TenantId", "Id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "IX_FlowActions_TenantId_IdempotencyKey" ON "FlowActions" ("TenantId", "IdempotencyKey"); + +CREATE INDEX "IX_FlowActions_TenantId_RunId" ON "FlowActions" ("TenantId", "RunId"); + +CREATE INDEX "IX_FlowAudits_TenantId_FlowId_AtUtc" ON "FlowAudits" ("TenantId", "FlowId", "AtUtc"); + +CREATE INDEX "IX_FlowDefinitions_TenantId_UpdatedAtUtc" ON "FlowDefinitions" ("TenantId", "UpdatedAtUtc"); + +CREATE INDEX "IX_FlowRuns_Status_NextAttemptAtUtc_LeaseExpiresAtUtc" ON "FlowRuns" ("Status", "NextAttemptAtUtc", "LeaseExpiresAtUtc"); + +CREATE UNIQUE INDEX "IX_FlowRuns_TenantId_EventId_FlowVersionId" ON "FlowRuns" ("TenantId", "EventId", "FlowVersionId"); + +CREATE INDEX "IX_FlowRuns_TenantId_FlowId_CreatedAtUtc" ON "FlowRuns" ("TenantId", "FlowId", "CreatedAtUtc"); + +CREATE INDEX "IX_FlowRuns_TenantId_FlowVersionId" ON "FlowRuns" ("TenantId", "FlowVersionId"); + +CREATE UNIQUE INDEX "IX_FlowVersions_TenantId_FlowId_VersionNumber" ON "FlowVersions" ("TenantId", "FlowId", "VersionNumber"); + +CREATE FUNCTION netratel_flow_version_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW IS DISTINCT FROM OLD THEN + RAISE EXCEPTION 'flow-version-immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER flow_version_immutable BEFORE UPDATE ON "FlowVersions" + FOR EACH ROW EXECUTE FUNCTION netratel_flow_version_immutable(); + +CREATE FUNCTION netratel_flow_action_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF ROW(NEW."RunId", NEW."NodeId", NEW."TenantId", NEW."IdempotencyKey", NEW."DraftJson") + IS DISTINCT FROM ROW(OLD."RunId", OLD."NodeId", OLD."TenantId", OLD."IdempotencyKey", OLD."DraftJson") + OR (OLD."PreparedJson" IS NOT NULL AND + ROW(NEW."PreparedJson", NEW."ConnectorRevision", NEW."SemanticFingerprint") IS DISTINCT FROM + ROW(OLD."PreparedJson", OLD."ConnectorRevision", OLD."SemanticFingerprint")) + OR (OLD."ReceiptJson" IS NOT NULL AND NEW."ReceiptJson" IS DISTINCT FROM OLD."ReceiptJson") THEN + RAISE EXCEPTION 'flow-action-immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER flow_action_immutable BEFORE UPDATE ON "FlowActions" + FOR EACH ROW EXECUTE FUNCTION netratel_flow_action_immutable(); + +CREATE FUNCTION netratel_flow_event_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF ROW(NEW."TenantId", NEW."FlowId", NEW."FlowVersionId", NEW."EventId", NEW."OccurrenceId", NEW."EventJson", NEW."EventFingerprint") + IS DISTINCT FROM ROW(OLD."TenantId", OLD."FlowId", OLD."FlowVersionId", OLD."EventId", OLD."OccurrenceId", OLD."EventJson", OLD."EventFingerprint") THEN + RAISE EXCEPTION 'flow-event-immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER flow_event_immutable BEFORE UPDATE ON "FlowRuns" + FOR EACH ROW EXECUTE FUNCTION netratel_flow_event_immutable(); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261002195852_AddDurableFlows', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "ManagedOrchestrationRequestBindings" ( + "RequestId" integer NOT NULL, + "ServicePrincipalId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "JobDefinitionId" character varying(256) NOT NULL, + "ExecutionId" character varying(256), + "ParentRequestId" character varying(256) NOT NULL, + "RequestTaskId" character varying(256) NOT NULL, + "CorrelationId" character varying(256) NOT NULL, + "IngestFingerprint" character varying(64) NOT NULL, + "LinkId" character varying(256), + "LinkRevision" bigint NOT NULL, + "GrantHash" character varying(64), + "PeerInstanceId" character varying(256) NOT NULL, + "PeerTenantId" character varying(256) NOT NULL, + "CallbackUrl" character varying(2048), + "CreatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ManagedOrchestrationRequestBindings" PRIMARY KEY ("RequestId"), + CONSTRAINT "FK_ManagedOrchestrationRequestBindings_Requests_RequestId" FOREIGN KEY ("RequestId") REFERENCES "Requests" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "OrchestrationCallbackDeliveries" ( + "RequestId" integer NOT NULL, + "Phase" character varying(32) NOT NULL, + "Revision" bigint NOT NULL, + "LeaseId" uuid, + "LeaseExpiresAtUtc" timestamp with time zone, + "NextAttemptAtUtc" timestamp with time zone NOT NULL, + "DeliveredAtUtc" timestamp with time zone, + "Attempts" integer NOT NULL, + CONSTRAINT "PK_OrchestrationCallbackDeliveries" PRIMARY KEY ("RequestId", "Phase"), + CONSTRAINT "FK_OrchestrationCallbackDeliveries_Requests_RequestId" FOREIGN KEY ("RequestId") REFERENCES "Requests" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "ServiceIdentityConfiguration" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Revision" bigint NOT NULL, + "Enabled" boolean NOT NULL, + "WebBaseUrl" character varying(2048) NOT NULL, + "ApiBaseUrl" character varying(2048) NOT NULL, + "Issuer" character varying(2048) NOT NULL, + "Audience" character varying(256) NOT NULL, + "UpdatedBy" character varying(256) NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ServiceIdentityConfiguration" PRIMARY KEY ("Id"), + CONSTRAINT "CK_ServiceIdentityConfiguration_Singleton" CHECK ("Id" = 1) +); + +CREATE TABLE "ServiceLinkAttempts" ( + "AttemptId" character varying(128) NOT NULL, + "Role" text NOT NULL, + "LocalTenantId" text NOT NULL, + "LocalActorId" text NOT NULL, + "PeerInstanceId" text NOT NULL, + "PeerTenantId" text, + "LinkId" text, + "ActiveRelationshipKey" text, + "LinkRevision" bigint NOT NULL, + "LifecycleState" text NOT NULL, + "Decision" text NOT NULL, + "CommitId" text, + "AbortId" text, + "RevocationId" text, + "DescriptorJson" text NOT NULL, + "DescriptorHash" text NOT NULL, + "GrantSummaryJson" text, + "GrantHash" text, + "ConsentId" text, + "ProtectedVerifier" text, + "ProtectedBrowserState" text, + "SessionBindingHash" text, + "PairingCodeHash" text, + "ProtectedPairingCode" text, + "ProtectedInboundEscrow" text, + "ProtectedExchangeResponse" text, + "ExchangeFingerprint" text, + "ExchangeResponseHash" text, + "ExchangeDispatched" boolean NOT NULL, + "InboundPrincipalId" uuid, + "ProtectedOutboundCredential" text, + "OutboundProfileRevision" integer, + "PeerPreparedAcknowledged" boolean NOT NULL, + "LocalPreparedAcknowledged" boolean NOT NULL, + "LocalInboundActive" boolean NOT NULL, + "LocalBusinessSenderEnabled" boolean NOT NULL, + "PeerActiveAcknowledged" boolean NOT NULL, + "LocalActiveAcknowledged" boolean NOT NULL, + "PeerRevocationAcknowledged" boolean NOT NULL, + "InitiatorVerificationReceiptId" text, + "ResponderVerificationReceiptId" text, + "LastErrorCode" text, + "ExpiresAtUnixSeconds" bigint NOT NULL, + "TerminalControlExpiresAtUnixSeconds" bigint, + "CreatedAtUnixSeconds" bigint NOT NULL, + "UpdatedAtUnixSeconds" bigint NOT NULL, + "NextWorkAtUnixSeconds" bigint NOT NULL, + "Revision" bigint NOT NULL, + CONSTRAINT "PK_ServiceLinkAttempts" PRIMARY KEY ("AttemptId") +); + +CREATE TABLE "ServiceLinkOperations" ( + "LinkId" character varying(128) NOT NULL, + "OperationId" character varying(128) NOT NULL, + "Kind" text NOT NULL, + "RequestFingerprint" text NOT NULL, + "ResponseJson" text NOT NULL, + "ProtectedRequestJson" text, + "Outbound" boolean NOT NULL, + "Completed" boolean NOT NULL, + "CreatedAtUnixSeconds" bigint NOT NULL, + CONSTRAINT "PK_ServiceLinkOperations" PRIMARY KEY ("LinkId", "OperationId") +); + +CREATE TABLE "ServiceLinkRotations" ( + "RotationId" text NOT NULL, + "LinkId" text NOT NULL, + "DirectionId" text NOT NULL, + "IsIssuer" boolean NOT NULL, + "RotationState" text NOT NULL, + "ActiveRotationKey" text, + "ExpectedCurrentCredentialRevision" bigint NOT NULL, + "SuccessorCredentialRevision" bigint, + "ProtectedOffer" text, + "ProtectedCandidate" text, + "OfferExpiresAtUnixSeconds" bigint, + "SuccessorVerificationReceiptId" text, + "ActivateDecisionId" text, + "CallerSwitchRevision" bigint, + "PredecessorRetireAtUnixSeconds" bigint, + "LastErrorCode" text, + "CreatedAtUnixSeconds" bigint NOT NULL, + "Revision" bigint NOT NULL, + CONSTRAINT "PK_ServiceLinkRotations" PRIMARY KEY ("RotationId") +); + +CREATE TABLE "ServiceLinkRuntimeIdentity" ( + "Id" integer NOT NULL, + "InstanceId" uuid NOT NULL, + "SourceInstanceId" uuid, + "Revision" bigint NOT NULL, + "SourceAdoptedBy" text, + "SourceAdoptedAtUnixSeconds" bigint, + CONSTRAINT "PK_ServiceLinkRuntimeIdentity" PRIMARY KEY ("Id") +); + +CREATE TABLE "ServiceLinkVerificationReceipts" ( + "VerificationReceiptId" text NOT NULL, + "LinkId" text NOT NULL, + "AttemptId" text NOT NULL, + "GrantHash" text NOT NULL, + "ServicePrincipalId" uuid NOT NULL, + "DirectionId" text NOT NULL, + "CredentialRevision" bigint NOT NULL, + "RotationId" text, + "VerifiedAtUnixSeconds" bigint NOT NULL, + CONSTRAINT "PK_ServiceLinkVerificationReceipts" PRIMARY KEY ("VerificationReceiptId") +); + +CREATE TABLE "ServicePrincipalRegistrations" ( + "Id" uuid NOT NULL, + "ClientId" character varying(256) NOT NULL, + "NormalizedClientId" character varying(256) NOT NULL, + "AliasKey" character varying(128) NOT NULL, + "Name" character varying(256) NOT NULL, + "TenantId" integer NOT NULL, + "PeerInstanceId" character varying(256) NOT NULL, + "PeerTenantId" character varying(256) NOT NULL, + "AllowedScopesJson" text NOT NULL, + "ResourceConstraintsJson" text NOT NULL, + "LinkId" character varying(256), + "AttemptId" character varying(256), + "GrantHash" character varying(64), + "DescriptorHash" character varying(64), + "DirectionId" character varying(64), + "LinkRevision" bigint NOT NULL, + "Revision" bigint NOT NULL, + "Version" bigint NOT NULL, + "CurrentCredentialRevision" bigint NOT NULL, + "Status" text NOT NULL, + "Source" text NOT NULL, + "DeploymentFingerprint" text, + "CreatedBy" character varying(256) NOT NULL, + "ApprovedBy" character varying(256) NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "UpdatedAtUtc" timestamp with time zone NOT NULL, + "RevokedAtUtc" timestamp with time zone, + "TerminalControlUntilUtc" timestamp with time zone, + CONSTRAINT "PK_ServicePrincipalRegistrations" PRIMARY KEY ("Id"), + CONSTRAINT "CK_ServicePrincipal_Status" CHECK ("Status" IN ('pending','prepared','verified','in_doubt','active','revoked','expired','failed')), + CONSTRAINT "FK_ServicePrincipalRegistrations_Tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "Tenants" ("Id") ON DELETE RESTRICT +); + +CREATE TABLE "ServiceSigningKeys" ( + "Kid" character varying(64) NOT NULL, + "Issuer" character varying(2048) NOT NULL, + "ProtectedPrivateKey" text NOT NULL, + "PublicModulus" text NOT NULL, + "PublicExponent" text NOT NULL, + "ActiveSlot" integer, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ValidateUntilUtc" timestamp with time zone, + CONSTRAINT "PK_ServiceSigningKeys" PRIMARY KEY ("Kid") +); + +CREATE TABLE "ServicePrincipalSecrets" ( + "ServicePrincipalId" uuid NOT NULL, + "CredentialRevision" bigint NOT NULL, + "SecretHash" character varying(64) NOT NULL, + "Salt" character varying(64) NOT NULL, + "Status" text NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "RetireAtUtc" timestamp with time zone, + CONSTRAINT "PK_ServicePrincipalSecrets" PRIMARY KEY ("ServicePrincipalId", "CredentialRevision"), + CONSTRAINT "CK_ServiceSecret_Status" CHECK ("Status" IN ('pending','active','retiring','revoked')), + CONSTRAINT "FK_ServicePrincipalSecrets_ServicePrincipalRegistrations_Servi~" FOREIGN KEY ("ServicePrincipalId") REFERENCES "ServicePrincipalRegistrations" ("Id") ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX "IX_ManagedOrchestrationRequestBindings_ExecutionId" ON "ManagedOrchestrationRequestBindings" ("ExecutionId") WHERE "ExecutionId" IS NOT NULL; + +CREATE UNIQUE INDEX "IX_ManagedOrchestrationRequestBindings_ServicePrincipalId_Pare~" ON "ManagedOrchestrationRequestBindings" ("ServicePrincipalId", "ParentRequestId", "RequestTaskId"); + +CREATE INDEX "IX_OrchestrationCallbackDeliveries_DeliveredAtUtc_NextAttemptA~" ON "OrchestrationCallbackDeliveries" ("DeliveredAtUtc", "NextAttemptAtUtc"); + +CREATE UNIQUE INDEX "IX_ServiceLinkAttempts_ActiveRelationshipKey" ON "ServiceLinkAttempts" ("ActiveRelationshipKey"); + +CREATE INDEX "IX_ServiceLinkAttempts_LifecycleState_NextWorkAtUnixSeconds" ON "ServiceLinkAttempts" ("LifecycleState", "NextWorkAtUnixSeconds"); + +CREATE UNIQUE INDEX "IX_ServiceLinkAttempts_LinkId" ON "ServiceLinkAttempts" ("LinkId"); + +CREATE INDEX "IX_ServiceLinkAttempts_PeerInstanceId_LocalTenantId_PeerTenant~" ON "ServiceLinkAttempts" ("PeerInstanceId", "LocalTenantId", "PeerTenantId", "LinkRevision", "Role"); + +CREATE INDEX "IX_ServiceLinkOperations_Outbound_Completed" ON "ServiceLinkOperations" ("Outbound", "Completed"); + +CREATE UNIQUE INDEX "IX_ServiceLinkRotations_ActiveRotationKey" ON "ServiceLinkRotations" ("ActiveRotationKey"); + +CREATE INDEX "IX_ServiceLinkRotations_LinkId_DirectionId_ExpectedCurrentCred~" ON "ServiceLinkRotations" ("LinkId", "DirectionId", "ExpectedCurrentCredentialRevision"); + +CREATE INDEX "IX_ServiceLinkVerificationReceipts_LinkId_ServicePrincipalId_C~" ON "ServiceLinkVerificationReceipts" ("LinkId", "ServicePrincipalId", "CredentialRevision", "RotationId"); + +CREATE UNIQUE INDEX "IX_ServicePrincipalRegistrations_AliasKey" ON "ServicePrincipalRegistrations" ("AliasKey"); + +CREATE UNIQUE INDEX "IX_ServicePrincipalRegistrations_LinkId_DirectionId" ON "ServicePrincipalRegistrations" ("LinkId", "DirectionId") WHERE "LinkId" IS NOT NULL; + +CREATE UNIQUE INDEX "IX_ServicePrincipalRegistrations_NormalizedClientId" ON "ServicePrincipalRegistrations" ("NormalizedClientId"); + +CREATE INDEX "IX_ServicePrincipalRegistrations_TenantId" ON "ServicePrincipalRegistrations" ("TenantId"); + +CREATE UNIQUE INDEX "IX_ServicePrincipalSecrets_ServicePrincipalId" ON "ServicePrincipalSecrets" ("ServicePrincipalId") WHERE "Status" = 'pending'; + +CREATE UNIQUE INDEX "IX_ServiceSigningKeys_ActiveSlot" ON "ServiceSigningKeys" ("ActiveSlot") WHERE "ActiveSlot" IS NOT NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261005050831_AddHelpdeskReciprocalServiceIdentity', '10.0.12'); + +COMMIT; + +START TRANSACTION; +CREATE TABLE "JobRunControls" ( + "RunId" bigint NOT NULL, + "Revision" bigint NOT NULL, + "DispatchPreparedAtUtc" timestamp with time zone, + "DispatchEnqueuedAtUtc" timestamp with time zone, + "NativeDeadlineUtc" timestamp with time zone, + "CancellationRequestedAtUtc" timestamp with time zone, + "CancellationReason" character varying(256), + "CancellationEnqueuedAtUtc" timestamp with time zone, + "TerminalReadyAtUtc" timestamp with time zone, + "TerminalResultHash" character varying(64), + CONSTRAINT "PK_JobRunControls" PRIMARY KEY ("RunId"), + CONSTRAINT "FK_JobRunControls_JobRuns_RunId" FOREIGN KEY ("RunId") REFERENCES "JobRuns" ("Id") ON DELETE CASCADE +); + +CREATE INDEX "IX_JobRunControls_TerminalReadyAtUtc_NativeDeadlineUtc" ON "JobRunControls" ("TerminalReadyAtUtc", "NativeDeadlineUtc"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261005113424_AddDurableJobRunControlBoundary', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "JobRunControls" ADD "DispatchOwnerConnectionId" uuid; + +ALTER TABLE "JobRunControls" ADD "DispatchOwnerEpoch" bigint; + +ALTER TABLE "ClientConnectionEpochs" ADD "CancellationBarrierUntilUtc" timestamp with time zone; + +CREATE TABLE "ClientConnectionAdmissions" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "ConnectionId" uuid NOT NULL, + "ConnectionEpoch" bigint, + "OperationId" uuid, + "PayloadHash" character varying(64), + "ReceivedAtUtc" timestamp with time zone, + "AdmissionExpiresAtUtc" timestamp with time zone, + "AuthenticationExpiresAtUtc" timestamp with time zone, + "MetadataJson" jsonb, + "Status" smallint NOT NULL, + "RetainUntilUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_ClientConnectionAdmissions" PRIMARY KEY ("TenantId", "AgentId", "ConnectionId"), + CONSTRAINT "CK_ClientConnectionAdmissions_Epoch" CHECK ("ConnectionEpoch" IS NULL OR "ConnectionEpoch">0), + CONSTRAINT "CK_ClientConnectionAdmissions_Identity" CHECK ("TenantId" > 0 AND "AgentId" <> '00000000-0000-0000-0000-000000000000'::uuid AND "ConnectionId" <> '00000000-0000-0000-0000-000000000000'::uuid), + CONSTRAINT "CK_ClientConnectionAdmissions_Pending" CHECK ("Status" = 2 OR ( "ConnectionEpoch" IS NOT NULL AND "ConnectionEpoch">0 AND "OperationId" IS NOT NULL AND "OperationId"<>'00000000-0000-0000-0000-000000000000'::uuid AND "PayloadHash" IS NOT NULL AND "ReceivedAtUtc" IS NOT NULL AND "AdmissionExpiresAtUtc" IS NOT NULL AND "AuthenticationExpiresAtUtc" IS NOT NULL AND "AdmissionExpiresAtUtc">"ReceivedAtUtc" AND "AuthenticationExpiresAtUtc">"ReceivedAtUtc" AND "MetadataJson" IS NOT NULL)), + CONSTRAINT "CK_ClientConnectionAdmissions_Status" CHECK ("Status" IN (1,2,3)) +); + +CREATE TABLE "ClientConnectionOwners" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "ConnectionEpoch" bigint NOT NULL DEFAULT 0, + "ConnectionId" uuid, + "Active" boolean NOT NULL DEFAULT FALSE, + "LastHeartbeatSequence" numeric(20,0) NOT NULL DEFAULT 0.0, + "LastReceivedAtUtc" timestamp with time zone, + "PresenceExpiresAtUtc" timestamp with time zone, + "AuthenticationExpiresAtUtc" timestamp with time zone, + "OwnerRevision" bigint NOT NULL DEFAULT 0, + "StartOperationId" uuid, + "AdmissionPayloadHash" character varying(64), + "MetadataJson" jsonb NOT NULL DEFAULT ('{}'::jsonb), + "AcceptanceClockFloorUtc" timestamp with time zone, + "AcceptanceGuardAtUtc" timestamp with time zone, + CONSTRAINT "PK_ClientConnectionOwners" PRIMARY KEY ("TenantId", "AgentId"), + CONSTRAINT "CK_ClientConnectionOwners_Active" CHECK ("AcceptanceGuardAtUtc" IS NULL OR NOT "Active" OR ( "ConnectionEpoch" > 0 AND "ConnectionId" IS NOT NULL AND "ConnectionId" <> '00000000-0000-0000-0000-000000000000'::uuid AND "StartOperationId" IS NOT NULL AND "AdmissionPayloadHash" IS NOT NULL AND "LastReceivedAtUtc" IS NOT NULL AND "PresenceExpiresAtUtc" IS NOT NULL AND "AuthenticationExpiresAtUtc" IS NOT NULL AND "LastHeartbeatSequence" > 0)), + CONSTRAINT "CK_ClientConnectionOwners_CommittedShape" CHECK (("ConnectionEpoch"=0 AND "ConnectionId" IS NULL) OR ("ConnectionEpoch">0 AND "ConnectionId" IS NOT NULL AND "StartOperationId" IS NOT NULL AND "AdmissionPayloadHash" IS NOT NULL AND "LastReceivedAtUtc" IS NOT NULL AND "PresenceExpiresAtUtc" IS NOT NULL AND "AuthenticationExpiresAtUtc" IS NOT NULL)), + CONSTRAINT "CK_ClientConnectionOwners_Epoch" CHECK ("ConnectionEpoch" >= 0 AND "OwnerRevision" >= 0), + CONSTRAINT "CK_ClientConnectionOwners_Identity" CHECK ("TenantId" > 0 AND "AgentId" <> '00000000-0000-0000-0000-000000000000'::uuid), + CONSTRAINT "CK_ClientConnectionOwners_Sequence" CHECK ("LastHeartbeatSequence" BETWEEN 0 AND 18446744073709551615) +); + +ALTER TABLE "JobRunControls" ADD CONSTRAINT "CK_JobRunControls_DispatchOwner" CHECK (("DispatchOwnerConnectionId" IS NULL AND "DispatchOwnerEpoch" IS NULL) OR ("DispatchOwnerConnectionId" IS NOT NULL AND "DispatchOwnerConnectionId" <> '00000000-0000-0000-0000-000000000000'::uuid AND "DispatchOwnerEpoch" IS NOT NULL AND "DispatchOwnerEpoch" > 0)); + +CREATE INDEX "IX_ClientConnectionAdmissions_RetainUntilUtc_TenantId_AgentId" ON "ClientConnectionAdmissions" ("RetainUntilUtc", "TenantId", "AgentId"); + +CREATE UNIQUE INDEX "UX_ClientConnectionAdmissions_Epoch" ON "ClientConnectionAdmissions" ("TenantId", "AgentId", "ConnectionEpoch") WHERE "ConnectionEpoch" IS NOT NULL; + +-- Reserve fixes the original admission body once. Commit/cancel may change +-- status and bounded retention, never those signed deadlines or grant fields. +CREATE FUNCTION public.nr_check_admission_immutable() +RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public +AS $immutable$ +BEGIN + IF OLD."TenantId" IS DISTINCT FROM NEW."TenantId" + OR OLD."AgentId" IS DISTINCT FROM NEW."AgentId" + OR OLD."ConnectionId" IS DISTINCT FROM NEW."ConnectionId" + OR OLD."ConnectionEpoch" IS DISTINCT FROM NEW."ConnectionEpoch" + OR OLD."OperationId" IS DISTINCT FROM NEW."OperationId" + OR OLD."PayloadHash" IS DISTINCT FROM NEW."PayloadHash" + OR OLD."ReceivedAtUtc" IS DISTINCT FROM NEW."ReceivedAtUtc" + OR OLD."AdmissionExpiresAtUtc" IS DISTINCT FROM NEW."AdmissionExpiresAtUtc" + OR OLD."AuthenticationExpiresAtUtc" IS DISTINCT FROM NEW."AuthenticationExpiresAtUtc" + OR OLD."MetadataJson" IS DISTINCT FROM NEW."MetadataJson" + OR (OLD."Status" = 2 AND NEW."Status" <> 2) + OR (OLD."Status" = 3 AND NEW."Status" = 1) + THEN + RAISE EXCEPTION USING ERRCODE = 'NR004', + CONSTRAINT = 'CK_ClientConnectionAdmissions_Immutable', + MESSAGE = 'ownership-admission-body-is-immutable'; + END IF; + RETURN NEW; +END; +$immutable$; +CREATE TRIGGER nr_admission_body_immutable +BEFORE UPDATE ON public."ClientConnectionAdmissions" +FOR EACH ROW EXECUTE FUNCTION public.nr_check_admission_immutable(); + +-- This is a commit-processing validation, NOT a final WAL-flush deadline. +-- The exact standalone COMMIT check deliberately matches Npgsql CommitAsync. +-- It rejects early constraint draining, implicit/autocommit DML, PREPARE, +-- END/COMMIT WORK, SQL comments, and batched COMMIT commands. +CREATE FUNCTION public.nr_check_owner_acceptance() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $guard$ +DECLARE + admission public."ClientConnectionAdmissions"%ROWTYPE; + owner_now public."ClientConnectionOwners"%ROWTYPE; + guard_at timestamp with time zone; + stamped integer; +BEGIN + IF pg_catalog.current_query() IS DISTINCT FROM 'COMMIT' THEN + RAISE EXCEPTION USING + ERRCODE = 'NR003', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-requires-standalone-commit'; + END IF; + + -- Identity change is a replacement, never a same-epoch mutation. + IF TG_OP = 'UPDATE' AND NEW."ConnectionEpoch" <= OLD."ConnectionEpoch" THEN + RAISE EXCEPTION USING + ERRCODE = 'NR002', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-invalid-authority'; + END IF; + + -- CommitAsync already holds the exact per-client counter/admission and + -- owner locks. A durable cancel uses the same counter before changing the + -- admission. Cancellation committed first cannot pass this exact status. + SELECT * INTO admission FROM public."ClientConnectionAdmissions" + WHERE "TenantId" = NEW."TenantId" AND "AgentId" = NEW."AgentId" + AND "ConnectionId" = NEW."ConnectionId"; + IF NOT FOUND THEN + RAISE EXCEPTION USING ERRCODE = 'NR002', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-invalid-authority'; + END IF; + SELECT * INTO owner_now FROM public."ClientConnectionOwners" + WHERE "TenantId" = NEW."TenantId" AND "AgentId" = NEW."AgentId"; + IF NOT FOUND THEN + RAISE EXCEPTION USING ERRCODE = 'NR002', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-invalid-authority'; + END IF; + + -- A second replacement in this transaction, a changed immutable grant, + -- absent proof inputs, or an invented marker rejects the WHOLE transaction. + -- IS DISTINCT FROM is intentional: null never satisfies an exact binding. + IF admission."Status" IS DISTINCT FROM 3::smallint + OR admission."ConnectionEpoch" IS DISTINCT FROM NEW."ConnectionEpoch" + OR admission."OperationId" IS DISTINCT FROM NEW."StartOperationId" + OR admission."PayloadHash" IS DISTINCT FROM NEW."AdmissionPayloadHash" + OR admission."MetadataJson" IS DISTINCT FROM NEW."MetadataJson" + OR owner_now."ConnectionEpoch" IS DISTINCT FROM NEW."ConnectionEpoch" + OR owner_now."ConnectionId" IS DISTINCT FROM NEW."ConnectionId" + OR owner_now."StartOperationId" IS DISTINCT FROM NEW."StartOperationId" + OR owner_now."AdmissionPayloadHash" IS DISTINCT FROM NEW."AdmissionPayloadHash" + OR owner_now."MetadataJson" IS DISTINCT FROM NEW."MetadataJson" + OR owner_now."OwnerRevision" IS DISTINCT FROM NEW."OwnerRevision" + OR owner_now."LastHeartbeatSequence" IS DISTINCT FROM NEW."LastHeartbeatSequence" + OR owner_now."LastReceivedAtUtc" IS DISTINCT FROM NEW."LastReceivedAtUtc" + OR owner_now."PresenceExpiresAtUtc" IS DISTINCT FROM NEW."PresenceExpiresAtUtc" + OR owner_now."AuthenticationExpiresAtUtc" IS DISTINCT FROM NEW."AuthenticationExpiresAtUtc" + OR owner_now."Active" IS DISTINCT FROM TRUE + OR owner_now."AcceptanceGuardAtUtc" IS NOT NULL + OR NEW."AcceptanceGuardAtUtc" IS NOT NULL + OR owner_now."AcceptanceClockFloorUtc" IS NULL + OR NEW."AcceptanceClockFloorUtc" IS NULL + OR owner_now."AcceptanceClockFloorUtc" < NEW."AcceptanceClockFloorUtc" + OR admission."ReceivedAtUtc" IS NULL + OR admission."AdmissionExpiresAtUtc" IS NULL + OR admission."AuthenticationExpiresAtUtc" IS NULL + OR NEW."LastHeartbeatSequence" <= 0 + OR NEW."LastReceivedAtUtc" < admission."ReceivedAtUtc" + OR NEW."LastReceivedAtUtc" >= admission."AdmissionExpiresAtUtc" + OR NEW."AuthenticationExpiresAtUtc" < admission."AuthenticationExpiresAtUtc" + THEN + RAISE EXCEPTION USING ERRCODE = 'NR002', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-invalid-authority'; + END IF; + + -- Sample after the exact durable-authority reads. CURRENT_TIMESTAMP and + -- statement_timestamp are unsuitable: neither advances during a blocked + -- transaction/COMMIT. The caller's trusted clock floor cannot extend time. + guard_at := GREATEST(pg_catalog.clock_timestamp(), owner_now."AcceptanceClockFloorUtc"); + IF admission."AdmissionExpiresAtUtc" <= guard_at + OR admission."AuthenticationExpiresAtUtc" <= guard_at + OR NEW."PresenceExpiresAtUtc" <= guard_at + OR NEW."AuthenticationExpiresAtUtc" <= guard_at + OR NEW."LastReceivedAtUtc" > guard_at + THEN + RAISE EXCEPTION USING ERRCODE = 'NR001', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-admission-expired-at-commit-guard'; + END IF; + + -- This stamp is transactional evidence, not independently visible + -- acceptance. If later commit processing aborts, it rolls back together + -- with replacement, predecessor retention and admission status=3. + UPDATE public."ClientConnectionOwners" SET "AcceptanceGuardAtUtc" = guard_at + WHERE "TenantId" = NEW."TenantId" AND "AgentId" = NEW."AgentId" + AND "ConnectionEpoch" = NEW."ConnectionEpoch" + AND "ConnectionId" = NEW."ConnectionId" + AND "OwnerRevision" = NEW."OwnerRevision" + AND "StartOperationId" = NEW."StartOperationId" + AND "AdmissionPayloadHash" = NEW."AdmissionPayloadHash" + AND "AcceptanceGuardAtUtc" IS NULL; + GET DIAGNOSTICS stamped = ROW_COUNT; + IF stamped <> 1 THEN + RAISE EXCEPTION USING ERRCODE = 'NR002', + CONSTRAINT = 'CK_ClientConnectionOwners_AcceptanceGuard', + MESSAGE = 'ownership-guard-invalid-authority'; + END IF; + RETURN NULL; +END; +$guard$; + +-- WHEN only selects a replacement event; ALL time and status checks occur in +-- the deferred function. Constraint-trigger WHEN runs at the original DML. +CREATE CONSTRAINT TRIGGER nr_owner_acceptance_guard_insert +AFTER INSERT ON public."ClientConnectionOwners" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +WHEN (NEW."ConnectionEpoch" > 0) +EXECUTE FUNCTION public.nr_check_owner_acceptance(); + +CREATE CONSTRAINT TRIGGER nr_owner_acceptance_guard_update +AFTER UPDATE ON public."ClientConnectionOwners" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +WHEN (OLD."ConnectionEpoch" IS DISTINCT FROM NEW."ConnectionEpoch" + OR OLD."ConnectionId" IS DISTINCT FROM NEW."ConnectionId" + OR OLD."StartOperationId" IS DISTINCT FROM NEW."StartOperationId" + OR OLD."AdmissionPayloadHash" IS DISTINCT FROM NEW."AdmissionPayloadHash" + OR OLD."MetadataJson" IS DISTINCT FROM NEW."MetadataJson") +EXECUTE FUNCTION public.nr_check_owner_acceptance(); + +-- Application/database roles are trusted SQL writers; do not grant them the +-- ability to disable these triggers, alter the function, or replace the schema. +-- Marker-only UPDATE does not queue either trigger, avoiding recursion. +-- Same-owner heartbeat/renew/retire keeps the original marker unchanged. +-- Never backfill this marker from LastIssuedEpoch or cached Services state. + + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261006174907_AddCommittedConnectionOwnership', '10.0.12'); + +COMMIT; + +START TRANSACTION; +ALTER TABLE "MonitoringEvidenceStreams" ADD "CommittedRegistrationOrdinal" bigint NOT NULL DEFAULT 0; + +CREATE TABLE "MonitoringEvidenceRegistrationAttempts" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "RegistrationId" uuid NOT NULL, + "ConnectionId" uuid NOT NULL, + "ConnectionEpoch" bigint NOT NULL, + "RegistrationOrdinal" bigint NOT NULL, + "Status" smallint NOT NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "ExpiresAtUtc" timestamp with time zone NOT NULL, + "RetainUntilUtc" timestamp with time zone NOT NULL, + CONSTRAINT "PK_MonitoringEvidenceRegistrationAttempts" PRIMARY KEY ("TenantId", "AgentId", "RegistrationId"), + CONSTRAINT "CK_MonitoringEvidenceRegistrationAttempts_Shape" CHECK ("TenantId">0 AND "AgentId"<>'00000000-0000-0000-0000-000000000000'::uuid AND "RegistrationId"<>'00000000-0000-0000-0000-000000000000'::uuid AND "ConnectionId"<>'00000000-0000-0000-0000-000000000000'::uuid AND "ConnectionEpoch">0 AND "RegistrationOrdinal">0 AND "Status" IN (1,2,3) AND "ExpiresAtUtc">"CreatedAtUtc" AND "RetainUntilUtc">="ExpiresAtUtc") +); + +CREATE TABLE "MonitoringEvidenceRegistrationCounters" ( + "TenantId" integer NOT NULL, + "AgentId" uuid NOT NULL, + "LastIssuedOrdinal" bigint NOT NULL DEFAULT 0, + CONSTRAINT "PK_MonitoringEvidenceRegistrationCounters" PRIMARY KEY ("TenantId", "AgentId"), + CONSTRAINT "CK_MonitoringEvidenceRegistrationCounters_Identity" CHECK ("TenantId">0 AND "AgentId"<>'00000000-0000-0000-0000-000000000000'::uuid AND "LastIssuedOrdinal">=0) +); + +ALTER TABLE "MonitoringEvidenceStreams" ADD CONSTRAINT "CK_MonitoringEvidenceStreams_Ordinal" CHECK ("CommittedRegistrationOrdinal">=0); + +CREATE INDEX "IX_MonitoringEvidenceRegistrationAttempts_RetainUntilUtc" ON "MonitoringEvidenceRegistrationAttempts" ("RetainUntilUtc"); + +CREATE UNIQUE INDEX "IX_MonitoringEvidenceRegistrationAttempts_TenantId_AgentId_Reg~" ON "MonitoringEvidenceRegistrationAttempts" ("TenantId", "AgentId", "RegistrationOrdinal"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261006182927_AddCommittedMonitoringRegistration', '10.0.12'); + +COMMIT; + diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyDown.sql b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyDown.sql new file mode 100644 index 00000000..84b97581 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyDown.sql @@ -0,0 +1,19 @@ +START TRANSACTION; +DROP TRIGGER "TR_FlowReceiverEvidence_ImmutableInputs" ON "FlowReceiverEvidence"; +DROP FUNCTION nr_receiver_evidence_immutable_inputs(); + +DROP TABLE "FlowReceiverEvidence"; + +ALTER TABLE "FlowActions" DROP CONSTRAINT "AK_FlowActions_TenantId_RunId_NodeId"; + +ALTER TABLE "RatelDeskConnectors" DROP COLUMN "AuthenticationJson"; + +ALTER TABLE "RatelDeskConnectors" DROP COLUMN "ReadinessJson"; + +CREATE INDEX "IX_FlowActions_TenantId_RunId" ON "FlowActions" ("TenantId", "RunId"); + +DELETE FROM "__EFMigrationsHistory" +WHERE "MigrationId" = '20261006195456_AddFlowReceiverEvidence'; + +COMMIT; + diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyUp.sql b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyUp.sql new file mode 100644 index 00000000..61e450cc --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/MigrationBaselines/CommittedReceiverEvidenceOnlyUp.sql @@ -0,0 +1,65 @@ +START TRANSACTION; +DROP INDEX "IX_FlowActions_TenantId_RunId"; + +ALTER TABLE "RatelDeskConnectors" ADD "AuthenticationJson" jsonb; + +ALTER TABLE "RatelDeskConnectors" ADD "ReadinessJson" jsonb; + +ALTER TABLE "FlowActions" ADD CONSTRAINT "AK_FlowActions_TenantId_RunId_NodeId" UNIQUE ("TenantId", "RunId", "NodeId"); + +CREATE TABLE "FlowReceiverEvidence" ( + "RunId" uuid NOT NULL, + "NodeId" uuid NOT NULL, + "TenantId" integer NOT NULL, + "SchemaVersion" integer NOT NULL, + "PreparationJson" jsonb NOT NULL, + "EvidenceFingerprint" character varying(64) NOT NULL, + "ReceiverIdempotencyKey" character varying(256) NOT NULL, + "ReceiverFingerprint" character varying(64) NOT NULL, + "OriginalCreatedAtUtc" timestamp with time zone NOT NULL, + "AutomaticReplayUntilUtc" timestamp with time zone NOT NULL, + "MayHaveCommitted" boolean NOT NULL, + "FirstPostAttemptAtUtc" timestamp with time zone, + "LastPostLeaseFence" bigint, + "FinalReconciliationAttempted" boolean NOT NULL, + "FullReceiptJson" jsonb, + "RowVersion" bigint NOT NULL, + CONSTRAINT "PK_FlowReceiverEvidence" PRIMARY KEY ("RunId", "NodeId"), + CONSTRAINT "CK_FlowReceiverEvidence_V2" CHECK ("SchemaVersion" = 2 AND "TenantId" > 0 AND "RowVersion" > 0 AND jsonb_typeof("PreparationJson") = 'object' AND "AutomaticReplayUntilUtc" > "OriginalCreatedAtUtc" AND "AutomaticReplayUntilUtc" <= "OriginalCreatedAtUtc" + INTERVAL '24 hours' AND ("LastPostLeaseFence" IS NULL OR "LastPostLeaseFence" > 0) AND (NOT "MayHaveCommitted" OR "FirstPostAttemptAtUtc" IS NOT NULL) AND ("FullReceiptJson" IS NULL OR jsonb_typeof("FullReceiptJson") = 'object')), + CONSTRAINT "FK_FlowReceiverEvidence_FlowActions_TenantId_RunId_NodeId" FOREIGN KEY ("TenantId", "RunId", "NodeId") REFERENCES "FlowActions" ("TenantId", "RunId", "NodeId") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "IX_FlowReceiverEvidence_TenantId_RunId_NodeId" ON "FlowReceiverEvidence" ("TenantId", "RunId", "NodeId"); + +CREATE FUNCTION nr_receiver_evidence_immutable_inputs() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF ROW(NEW."TenantId", NEW."RunId", NEW."NodeId", NEW."SchemaVersion", + NEW."PreparationJson", NEW."EvidenceFingerprint", NEW."ReceiverIdempotencyKey", + NEW."ReceiverFingerprint", NEW."OriginalCreatedAtUtc", NEW."AutomaticReplayUntilUtc") + IS DISTINCT FROM + ROW(OLD."TenantId", OLD."RunId", OLD."NodeId", OLD."SchemaVersion", + OLD."PreparationJson", OLD."EvidenceFingerprint", OLD."ReceiverIdempotencyKey", + OLD."ReceiverFingerprint", OLD."OriginalCreatedAtUtc", OLD."AutomaticReplayUntilUtc") + OR NEW."RowVersion" <= OLD."RowVersion" + OR (OLD."FirstPostAttemptAtUtc" IS NOT NULL AND + NEW."FirstPostAttemptAtUtc" IS DISTINCT FROM OLD."FirstPostAttemptAtUtc") + OR (OLD."LastPostLeaseFence" IS NOT NULL AND + (NEW."LastPostLeaseFence" IS NULL OR NEW."LastPostLeaseFence" < OLD."LastPostLeaseFence")) + OR (OLD."FinalReconciliationAttempted" AND NOT NEW."FinalReconciliationAttempted") + OR (OLD."FullReceiptJson" IS NOT NULL AND + NEW."FullReceiptJson" IS DISTINCT FROM OLD."FullReceiptJson") + OR (OLD."MayHaveCommitted" AND NOT NEW."MayHaveCommitted" AND NEW."FullReceiptJson" IS NULL) + THEN + RAISE EXCEPTION 'receiver-evidence-immutable-input-conflict' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER "TR_FlowReceiverEvidence_ImmutableInputs" BEFORE UPDATE ON "FlowReceiverEvidence" + FOR EACH ROW EXECUTE FUNCTION nr_receiver_evidence_immutable_inputs(); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261006195456_AddFlowReceiverEvidence', '10.0.12'); + +COMMIT; + diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorAuthorizationTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorAuthorizationTests.cs new file mode 100644 index 00000000..a051be70 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorAuthorizationTests.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NetRatel.Infrastructure.Identity; +using NetRatel.Infrastructure.Identity.Authorization; +using NetRatel.Infrastructure.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskConnectorAuthorizationTests +{ + [Fact] + public async Task Persisted_authority_rechecks_tenant_grants_credential_revocation_expiry_and_disabled_owner() + { + await using var db = new NetRatelIdentityDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + db.ApplicationPrincipals.Add(new() { Id = "owner", LocalUserId = "local-owner" }); + db.Users.Add(new() { Id = "local-owner", PrincipalId = "owner", UserName = "owner", IsEnabled = true }); + var role = new AccessRole { Name = "ConnectorOwner" }; + role.Permissions.Add(new() { RoleId = role.Id, Permission = NetRatelPermissions.IntegrationManagement }); + role.Permissions.Add(new() { RoleId = role.Id, Permission = NetRatelPermissions.SecretUse }); + db.AccessRoles.Add(role); db.PrincipalRoleAssignments.Add(new() { PrincipalId = "owner", RoleId = role.Id, TenantId = 4 }); + var credential = new IntegrationCredential { Id = "credential", OwnerPrincipalId = "owner", Purpose = IntegrationCredentialPurpose.Api, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), Grants = [new() { CredentialId = "credential", TenantId = 4, Permission = NetRatelPermissions.IntegrationManagement }, + new() { CredentialId = "credential", TenantId = 4, Permission = NetRatelPermissions.SecretUse }] }; + db.IntegrationCredentials.Add(credential); await db.SaveChangesAsync(); + var auth = new RatelDeskConnectorAuthorization(new EffectiveAccessService(db, new ConfigurationBuilder().Build()), db); + Assert.True(await auth.CanExecuteAsync("owner", "credential", 4, CancellationToken.None)); + Assert.False(await auth.CanExecuteAsync("owner", "credential", 5, CancellationToken.None)); + credential.RevokedAtUtc = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); + Assert.False(await auth.CanExecuteAsync("owner", "credential", 4, CancellationToken.None)); + credential.RevokedAtUtc = null; credential.ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1); await db.SaveChangesAsync(); + Assert.False(await auth.CanExecuteAsync("owner", "credential", 4, CancellationToken.None)); + Assert.True(await auth.CanExecuteAsync("owner", null, 4, CancellationToken.None)); + db.Users.Single().IsEnabled = false; await db.SaveChangesAsync(); + Assert.False(await auth.CanExecuteAsync("owner", null, 4, CancellationToken.None)); + Assert.False(await auth.CanExecuteAsync("nonexistent-owner", null, 4, CancellationToken.None)); + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorModeTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorModeTests.cs new file mode 100644 index 00000000..e6a7894f --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorModeTests.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using AwesomeAssertions; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskConnectorModeTests +{ + [Theory] + [InlineData("oauth_client_secret")] + [InlineData("rdk")] + [InlineData("Api_Bearer")] + [InlineData("")] + public void Authentication_selection_never_infers_modes_from_a_secret_or_ambiguous_label(string mode) + { + Action parse = () => RatelDeskConnectorReceiver.Parse(new(mode)); + parse.Should().ThrowExactly(); + } + + [Fact] + public void Omitted_mode_preserves_existing_managed_reference_while_legacy_rows_remain_API_bearer() + { + var managed = new RatelDeskConnectorAuthentication(RatelDeskAuthenticationMode.ManagedServiceLink, + "33333333-3333-4333-8333-333333333333"); + RatelDeskConnectorReceiver.Parse(null, managed).Should().Be(managed); + RatelDeskConnectorReceiver.Parse(null).Should().Be(new RatelDeskConnectorAuthentication(RatelDeskAuthenticationMode.ManualApiBearer, null)); + Action mixed = () => RatelDeskConnectorReceiver.Parse(new("api_bearer", managed.ManagedLinkId)); + mixed.Should().ThrowExactly(); + } + + [Fact] + public async Task Managed_connector_rejects_manual_rotation_before_protecting_or_saving_any_secret() + { + var store = new Store(); var protection = new Protection(); + var service = new RatelDeskConnectorService(store, new Authority(), protection, new Origin(), new Tester()); + Func rotate = () => service.RotateAsync(17, store.Current.Id, + new(0, "rdk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), Human, default); + await rotate.Should().ThrowExactlyAsync(); + protection.Writes.Should().Be(0); store.Writes.Should().Be(0); + store.Current.Revision.Should().Be(7); store.Current.Authentication!.ManagedLinkId.Should().Be("33333333-3333-4333-8333-333333333333"); + } + + [Fact] + public async Task Disabled_current_connector_owner_cannot_rotate_manual_credentials() + { + var store = new Store(); store.Current = store.Current with { Authentication = new(RatelDeskAuthenticationMode.ManualApiBearer, null) }; + var protection = new Protection(); + var service = new RatelDeskConnectorService(store, new Authority { OwnerAllowed = false }, protection, new Origin(), new Tester()); + Func rotate = () => service.RotateAsync(17, store.Current.Id, + new(0, "rdk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), Human, default); + await rotate.Should().ThrowExactlyAsync(); + protection.Writes.Should().Be(0); store.Writes.Should().Be(0); + } + + [Fact] + public void Browser_DTO_omits_internal_credential_ciphertext_and_receiver_evidence() + { + var state = new Store().Current with { ProtectedCredential = "synthetic-protected-value", CredentialRevision = 19 }; + var json = System.Text.Json.JsonSerializer.Serialize(RatelDeskConnectorService.ToDto(state)); + json.Should().NotContain("synthetic-protected-value").And.NotContain("ProtectedCredential").And.NotContain("ExactCreateBodyJson"); + RatelDeskConnectorService.ToDto(state).Authentication.Should().Be(new RatelDeskConnectorAuthenticationDto("service_link", state.Authentication!.ManagedLinkId)); + } + + private static ClaimsPrincipal Human => new(new ClaimsIdentity([new("netratel_principal_id", "owner")], "Oidc")); + private sealed class Store : IRatelDeskConnectorStore + { + public RatelDeskConnectorState Current = new(Guid.Parse("44444444-4444-4444-8444-444444444444"), 17, 7, 3, "owner", + new("Managed", "https://desk.example", "org", "customer", null, [], new(), true), null, 0, + new(RatelDeskAuthenticationMode.ManagedServiceLink, "33333333-3333-4333-8333-333333333333")); + public int Writes; + public Task GetAsync(int tenant, Guid id, CancellationToken ct) => Task.FromResult(tenant == Current.TenantId && id == Current.Id ? Current : null); + public Task> ListAsync(int tenant, CancellationToken ct) => Task.FromResult>([Current]); + public Task SaveAsync(RatelDeskConnectorState value, long expected, CancellationToken ct) { Writes++; Current = value; return Task.FromResult(true); } + } + private sealed class Protection : IRatelDeskCredentialProtector + { + public int Writes; + public string Protect(int tenant, Guid id, string credential) { Writes++; return "synthetic-protected"; } + public string Unprotect(int tenant, Guid id, string ciphertext) => throw new InvalidOperationException("not-needed"); + } + private sealed class Authority : IRatelDeskConnectorAuthorization + { + public bool OwnerAllowed = true; + public Task CanManageAsync(ClaimsPrincipal actor, int tenant, CancellationToken ct) => Task.FromResult(tenant == 17); + public Task CanExecuteAsync(string principal, string? credential, int tenant, CancellationToken ct) => Task.FromResult(OwnerAllowed && tenant == 17 && principal == "owner"); + } + private sealed class Origin : IRatelDeskOriginPolicy + { public bool TryValidate(string value, out Uri? normalized) { normalized = null; return false; } } + private sealed class Tester : IRatelDeskConnectionTester + { public Task TestAsync(int tenant, Guid id, RatelDeskConnectorConfiguration c, string secret, CancellationToken ct) => throw new InvalidOperationException("not-needed"); } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorReadinessTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorReadinessTests.cs new file mode 100644 index 00000000..9d4c9c9f --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorReadinessTests.cs @@ -0,0 +1,224 @@ +using System.Security.Claims; +using AwesomeAssertions; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskConnectorReadinessTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_canceled_fresh_probe_cannot_reveal_the_previous_success(bool callerCancellation) + { + var fixture = new Fixture(); + var before = fixture.Store.Current; + (await fixture.Readiness.CurrentAsync(before, default)).Available.Should().BeTrue(); + using var caller = new CancellationTokenSource(); + fixture.Bindings.OnCapture = (working, _) => + { + fixture.Store.Current.Readiness.Should().BeNull(); + working.Readiness.Should().BeNull(); + working.RowVersion.Should().Be(before.RowVersion + 1); + if (callerCancellation) caller.Cancel(); + throw new OperationCanceledException(new CancellationToken(canceled: true)); + }; + if (callerCancellation) + { + Func operation = () => fixture.Receiver.TestAsync(before, caller.Token); + await operation.Should().ThrowAsync(); + } + else + { + var result = await fixture.Receiver.TestAsync(before, caller.Token); + result.Status.Should().Be(RatelDeskConnectionTestStatus.Unavailable); + result.Code.Should().Be("receiver-readiness-timeout"); + result.AutomaticDeliveryAvailable.Should().BeFalse(); + } + var after = fixture.Store.Current; + after.Readiness.Should().BeNull(); + after.Revision.Should().Be(before.Revision); + after.CredentialRevision.Should().Be(before.CredentialRevision); + after.ProtectedCredential.Should().Be(before.ProtectedCredential); + after.Authentication.Should().Be(before.Authentication); + after.Configuration.Should().Be(before.Configuration); + (await fixture.Readiness.CurrentAsync(after, default)).Should().Be((false, "receiver-readiness-required")); + fixture.Transport.Calls.Should().Be(0); + } + + [Fact] + public async Task A_concurrent_success_is_preserved_when_old_readiness_invalidation_loses_its_row_CAS() + { + var fixture = new Fixture(); + var before = fixture.Store.Current; + var previous = before.Readiness!; + var newer = previous with { Capability = previous.Capability with { } }; + fixture.Store.BeforeClear = () => fixture.Store.Current = before with + { + RowVersion = before.RowVersion + 1, + Readiness = newer + }; + var result = await fixture.Receiver.TestAsync(before, default); + result.Status.Should().Be(RatelDeskConnectionTestStatus.Unavailable); + result.Code.Should().Be("connector-changed-during-readiness"); + result.AutomaticDeliveryAvailable.Should().BeFalse(); + fixture.Store.Current.Readiness.Should().BeSameAs(newer); + fixture.Store.Current.RowVersion.Should().Be(before.RowVersion + 1); + fixture.Bindings.CaptureCalls.Should().Be(0); + fixture.Transport.Calls.Should().Be(0); + } + + [Theory] + [InlineData("peer")] + [InlineData("capability")] + [InlineData("endpoints")] + [InlineData("categories")] + public async Task Malformed_cached_observation_shape_is_unavailable_before_binding_or_profile_reads(string field) + { + var fixture = new Fixture(); + var observation = fixture.Store.Current.Readiness!; + observation = field switch + { + "peer" => observation with { Peer = null! }, + "capability" => observation with { Capability = null! }, + "endpoints" => observation with { Capability = observation.Capability with { Endpoints = null! } }, + "categories" => observation with { Peer = observation.Peer with { CategoryIds = null! } }, + _ => throw new ArgumentException("unexpected-test-field") + }; + var result = await fixture.Readiness.CurrentAsync(fixture.Store.Current with { Readiness = observation }, default); + result.Should().Be((false, "receiver-readiness-required")); + fixture.Store.AuthenticationReads.Should().Be(0); + fixture.Continuity.Calls.Should().Be(0); + fixture.Transport.Calls.Should().Be(0); + } + + [Fact] + public async Task An_expired_timestamp_remains_historical_evidence_without_authorizing_delivery() + { + var fixture = new Fixture(); + var observed = fixture.Store.Current.Readiness!; + var expired = observed.TargetValidatedAtUtc - RatelDeskConnectorReadiness.MaximumObservationAge - TimeSpan.FromSeconds(1); + var historical = observed with + { + TargetValidatedAtUtc = expired, + Capability = observed.Capability with { ObservedAtUtc = expired } + }; + var result = await fixture.Readiness.CurrentAsync(fixture.Store.Current with { Readiness = historical }, default); + result.Should().Be((false, "receiver-readiness-expired")); + historical.TargetValidatedAtUtc.Should().Be(expired); + fixture.Store.AuthenticationReads.Should().Be(0); + fixture.Transport.Calls.Should().Be(0); + } + + private sealed class Fixture + { + internal Store Store { get; } + internal Binding Bindings { get; } = new(); + internal Continuity Continuity { get; } = new(); + internal Transport Transport { get; } = new(); + internal RatelDeskConnectorReadiness Readiness { get; } + internal RatelDeskConnectorReceiver Receiver { get; } + internal Fixture() + { + var peer = RatelDeskReceiverFixture.Peer(RatelDeskAuthenticationMode.ManualApiBearer); + var capability = ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Capability(peer), peer, RatelDeskReceiverFixture.Now); + Store = new(new(peer.ConnectorId, peer.LocalTenantId, 1, 7, "human-owner", + new("receiver", peer.ApiBaseUrl, peer.OrganizationId, peer.CustomerId, peer.AssignedToId, + Array.Empty(), new(), true), "synthetic-protected-credential", 2, + new(RatelDeskAuthenticationMode.ManualApiBearer, null), new(1, peer, capability, RatelDeskReceiverFixture.Now))); + var network = new RatelDeskReceiverNetworkPolicy(new Options(new()), + new Options(new()), new Options(new())); + var clock = new Clock(); + // Manual readiness does not use the managed profile service. Null makes accidental access fail this test. + Readiness = new(new Authority(), Store, Continuity, null!, network, clock); + Receiver = new(Store, Store, Store, Readiness, Bindings, Transport, new Source(), network, clock); + } + } + + private sealed class Store(RatelDeskConnectorState initial) : IRatelDeskConnectorStore, + IRatelDeskConnectorBindingStore, IRatelDeskConnectorReadinessStore + { + internal RatelDeskConnectorState Current { get; set; } = initial; + internal Action? BeforeClear { get; set; } + internal int AuthenticationReads { get; private set; } + public Task GetAsync(int tenantId, Guid id, CancellationToken ct) => + Task.FromResult(Current.TenantId == tenantId && Current.Id == id ? Current : null); + public Task> ListAsync(int tenantId, CancellationToken ct) => throw new NotSupportedException(); + public Task SaveAsync(RatelDeskConnectorState state, long expectedRowVersion, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (Current.RowVersion != expectedRowVersion || Current.TenantId != state.TenantId || Current.Id != state.Id) + return Task.FromResult(false); + Current = state; + return Task.FromResult(true); + } + public Task GetAuthenticationAsync(int tenantId, Guid connectorId, CancellationToken ct) + { + AuthenticationReads++; + return Task.FromResult(Current.Authentication!); + } + public Task SaveReadinessAsync(RatelDeskConnectorState current, RatelDeskReadinessObservation observation, CancellationToken ct) => + SaveAsync(current with { RowVersion = current.RowVersion + 1, Readiness = observation }, current.RowVersion, ct); + public Task ClearReadinessAsync(RatelDeskConnectorState current, CancellationToken ct) + { + BeforeClear?.Invoke(); + return SaveAsync(current with { RowVersion = current.RowVersion + 1, Readiness = null }, current.RowVersion, ct); + } + } + + private sealed class Binding : IRatelDeskOutboundBindingResolver + { + internal int CaptureCalls { get; private set; } + internal Func>? OnCapture { get; set; } + public Task CaptureAsync(RatelDeskConnectorState connector, + RatelDeskConnectorAuthentication authentication, Guid source, CancellationToken ct) + { + CaptureCalls++; + return OnCapture?.Invoke(connector, ct) ?? throw new InvalidOperationException("unexpected-capture"); + } + public Task GetBearerAsync(RatelDeskConnectorState current, RatelDeskSemanticPeer captured, + string scope, CancellationToken ct) => throw new InvalidOperationException("unexpected-token-read"); + } + private sealed class Transport : IRatelDeskReceiverTransport + { + internal int Calls { get; private set; } + public Task CapabilitiesAsync(RatelDeskSemanticPeer peer, string bearer, CancellationToken ct) + { Calls++; throw new InvalidOperationException("unexpected-capability-HTTP"); } + public Task ValidateTargetsAsync(RatelDeskSemanticPeer peer, RatelDeskVerifiedCapability capability, string bearer, CancellationToken ct) + { Calls++; throw new InvalidOperationException("unexpected-target-HTTP"); } + public Task LookupAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { Calls++; throw new InvalidOperationException("unexpected-receipt-HTTP"); } + public Task CreateAsync(RatelDeskReceiverPreparationV2 prepared, string bearer, CancellationToken ct) + { Calls++; throw new InvalidOperationException("unexpected-create-HTTP"); } + } + private sealed class Source : IFlowSourceIdentityResolver + { + public Task EnsureAsync(CancellationToken ct) => Task.FromResult(RatelDeskReceiverFixture.Peer().SourceInstanceId); + } + private sealed class Continuity : IRatelDeskProducerContinuity + { + internal int Calls { get; private set; } + public Task RequireCurrentAsync(Guid expectedFlowSource, CancellationToken ct) { Calls++; return Task.CompletedTask; } + } + private sealed class Authority : IRatelDeskConnectorAuthorization + { + public Task CanManageAsync(ClaimsPrincipal principal, int tenantId, CancellationToken ct) => Task.FromResult(true); + public Task CanExecuteAsync(string principalId, string? integrationCredentialId, int tenantId, CancellationToken ct) => Task.FromResult(true); + } + private sealed class Clock : TimeProvider + { + public override DateTimeOffset GetUtcNow() => RatelDeskReceiverFixture.Now; + } + private sealed class Options(T value) : IOptionsMonitor + { + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorStorePostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorStorePostgresTests.cs new file mode 100644 index 00000000..2d82dab7 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorStorePostgresTests.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Shared.Contracts.RatelDesk; +using Npgsql; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +[Collection(PostgreSqlPersistenceCollection.Name)] +public sealed class RatelDeskConnectorStorePostgresTests(PostgreSqlPersistenceFixture fixture) +{ + [Fact] + public async Task Actual_migration_preserves_tenant_ciphertext_and_separate_revisions_across_scope_restart() + { + var connection = await fixture.CreateDatabaseAsync(); + var options = new DbContextOptionsBuilder().UseNpgsql(connection).Options; + var id = Guid.NewGuid(); + var configuration = new RatelDeskConnectorConfiguration("Helpdesk", "https://helpdesk.example", "organization", "requester", null, [], new(), true); + var initial = new RatelDeskConnectorState(id, 4, 1, 1, "owner", configuration, "protected-credential", 1); + await using (var db = new OrchestratorDbContext(options)) + { + Assert.False(db.Database.HasPendingModelChanges()); await db.Database.MigrateAsync(); + Assert.True(await new RatelDeskConnectorStore(db).SaveAsync(initial, 0, CancellationToken.None)); + Assert.DoesNotContain("ProtectedCredential", (await db.RatelDeskConnectors.SingleAsync()).ConfigurationJson); + } + await using (var db = new OrchestratorDbContext(options)) + { + var store = new RatelDeskConnectorStore(db); var current = await store.GetAsync(4, id, CancellationToken.None); + Assert.Equal(JsonSerializer.Serialize(initial), JsonSerializer.Serialize(current)); Assert.Null(await store.GetAsync(5, id, CancellationToken.None)); + Assert.True(await store.SaveAsync(current! with { RowVersion = 2, CredentialRevision = 2, ProtectedCredential = "rotated" }, 1, CancellationToken.None)); + } + await using (var db = new OrchestratorDbContext(options)) + { + var current = (await new RatelDeskConnectorStore(db).GetAsync(4, id, CancellationToken.None))!; + Assert.Equal(1, current.Revision); Assert.Equal(2, current.CredentialRevision); Assert.Equal("rotated", current.ProtectedCredential); + Assert.Equal(configuration.CustomerId, current.Configuration.CustomerId); + } + } + + [Fact] + public async Task Stale_competing_writes_and_same_revision_mapping_changes_fail_without_overwriting() + { + var connection = await fixture.CreateDatabaseAsync(); + var options = new DbContextOptionsBuilder().UseNpgsql(connection).Options; + var id = Guid.NewGuid(); var configuration = new RatelDeskConnectorConfiguration("Helpdesk", "https://helpdesk.example", "organization", "requester", null, [], new(), true); + var initial = new RatelDeskConnectorState(id, 4, 1, 1, "owner", configuration, null, 0); + await using var firstDb = new OrchestratorDbContext(options); await firstDb.Database.MigrateAsync(); + var first = new RatelDeskConnectorStore(firstDb); Assert.True(await first.SaveAsync(initial, 0, CancellationToken.None)); + await using var secondDb = new OrchestratorDbContext(options); var second = new RatelDeskConnectorStore(secondDb); + var competing = await second.GetAsync(4, id, CancellationToken.None); + Assert.True(await first.SaveAsync(initial with { Revision = 2, RowVersion = 2, Configuration = configuration with { CustomerId = "changed" } }, 1, CancellationToken.None)); + Assert.False(await second.SaveAsync(competing! with { Revision = 2, RowVersion = 2, Configuration = configuration with { OrganizationId = "changed" } }, 1, CancellationToken.None)); + var current = (await second.GetAsync(4, id, CancellationToken.None))!; + Assert.False(await second.SaveAsync(current with { RowVersion = 3, Configuration = configuration }, 2, CancellationToken.None)); + Assert.Equal("changed", (await second.GetAsync(4, id, CancellationToken.None))!.Configuration.CustomerId); + } + + [Fact] + public async Task Parallel_different_ID_admissions_serialize_the_last_slot_and_reject_the_loser_visibly() + { + var connection = await fixture.CreateDatabaseAsync(); + var options = new DbContextOptionsBuilder().UseNpgsql(connection).Options; + var configuration = new RatelDeskConnectorConfiguration("Desk", "https://desk.example", "org", "customer", null, [], new(), true); + await using (var seed = new OrchestratorDbContext(options)) + { + await seed.Database.MigrateAsync(); + seed.RatelDeskConnectors.AddRange(Enumerable.Range(0, RatelDeskConnectorLimits.MaximumConnectorsPerTenant - 1) + .Select(_ => Record(Guid.NewGuid(), configuration))); + await seed.SaveChangesAsync(); + } + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + await using var gate = new NpgsqlConnection(connection); await gate.OpenAsync(deadline.Token); + await using var transaction = await gate.BeginTransactionAsync(deadline.Token); + await using (var acquire = new NpgsqlCommand("SELECT pg_advisory_xact_lock(733460001, 4)", gate, transaction)) + await acquire.ExecuteNonQueryAsync(deadline.Token); + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var tasks = ids.Select(AdmitAsync).ToArray(); + var released = false; + try + { + // Observe both real PostgreSQL lock waits before releasing either request, rather than rely on scheduling a count race. + await using var observer = new NpgsqlConnection(connection); await observer.OpenAsync(deadline.Token); + await using var waits = new NpgsqlCommand("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = 733460001::oid AND objid = 4::oid AND objsubid = 2 AND NOT granted", observer); + while ((long)(await waits.ExecuteScalarAsync(deadline.Token))! != 2) + { + Assert.All(tasks, task => Assert.False(task.IsCompleted)); + await Task.Delay(TimeSpan.FromMilliseconds(20), deadline.Token); + } + Assert.All(tasks, task => Assert.False(task.IsCompleted)); + await transaction.CommitAsync(deadline.Token); released = true; + var outcomes = await Task.WhenAll(tasks).WaitAsync(deadline.Token); + Assert.Single(outcomes, value => value == "saved"); + Assert.Single(outcomes, value => value == "connector-capacity-exhausted"); + await using var check = new OrchestratorDbContext(options); + var store = new RatelDeskConnectorStore(check); + var visible = await store.ListAsync(4, deadline.Token); + Assert.Equal(RatelDeskConnectorLimits.MaximumConnectorsPerTenant, visible.Count); + Assert.Equal(RatelDeskConnectorLimits.MaximumConnectorsPerTenant, await check.RatelDeskConnectors.CountAsync(deadline.Token)); + for (var index = 0; index < ids.Length; index++) + Assert.Equal(outcomes[index] == "saved", visible.Any(row => row.Id == ids[index])); + } + finally + { + if (!released) await transaction.RollbackAsync(CancellationToken.None); + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(20)); + } + + async Task AdmitAsync(Guid id) + { + await using var db = new OrchestratorDbContext(options); + var state = new RatelDeskConnectorState(id, 4, 1, 1, "owner", configuration, null, 0); + try { return await new RatelDeskConnectorStore(db).SaveAsync(state, 0, deadline.Token) ? "saved" : "conflict"; } + catch (InvalidOperationException e) when (e.Message == "connector-capacity-exhausted") { return e.Message; } + } + } + + [Fact] + public async Task Existing_overflow_fails_listing_instead_of_silently_hiding_a_connector() + { + var connection = await fixture.CreateDatabaseAsync(); + var options = new DbContextOptionsBuilder().UseNpgsql(connection).Options; + await using var db = new OrchestratorDbContext(options); await db.Database.MigrateAsync(); + var configuration = new RatelDeskConnectorConfiguration("Desk", "https://desk.example", "org", "customer", null, [], new(), true); + db.RatelDeskConnectors.AddRange(Enumerable.Range(0, RatelDeskConnectorLimits.MaximumConnectorsPerTenant + 1) + .Select(_ => Record(Guid.NewGuid(), configuration))); + await db.SaveChangesAsync(); + var error = await Assert.ThrowsAsync(() => new RatelDeskConnectorStore(db).ListAsync(4, CancellationToken.None)); + Assert.Equal("connector-capacity-exhausted", error.Message); + Assert.Equal(RatelDeskConnectorLimits.MaximumConnectorsPerTenant + 1, await db.RatelDeskConnectors.CountAsync()); + } + + private static RatelDeskConnectorRecord Record(Guid id, RatelDeskConnectorConfiguration configuration) => new() + { + Id = id, TenantId = 4, Revision = 1, RowVersion = 1, OwnerPrincipalId = "owner", + ConfigurationJson = JsonSerializer.Serialize(configuration), CredentialRevision = 0 + }; +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorTests.cs new file mode 100644 index 00000000..c5e3ea71 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskConnectorTests.cs @@ -0,0 +1,267 @@ +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using NetRatel.Application.Flows; +using NetRatel.API.Services.RatelDesk; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +/// Deterministic normal-create/lookup fixtures reflect reviewed RatelDesk DTOs, not the missing #116 receiver. +public sealed class RatelDeskConnectorTests +{ + private const string Credential = "rdk_01234567890123456789012345678901_synthetic-token"; + private static readonly Guid ConnectorId = Guid.Parse("55aad20b-12ce-4ba6-9379-a2c93c9e8b99"); + private static readonly RatelDeskConnectorConfiguration Config = new("Helpdesk", "https://rateldesk.example", "org-1", "customer-1", null, [], new(), true); + private static IRatelDeskOriginPolicy Policy(params string[] origins) => new RatelDeskOriginPolicy(new ConfigurationBuilder() + .AddInMemoryCollection(origins.Select((origin, index) => new KeyValuePair($"RatelDesk:AllowedOrigins:{index}", origin))).Build()); + private static ClaimsPrincipal Principal => new(new ClaimsIdentity([new Claim("netratel_principal_id", "owner")], "Test")); + + [Theory] + [InlineData("http://rateldesk.example")] + [InlineData("https://foreign.example")] + [InlineData("https://user:secret@rateldesk.example")] + [InlineData("https://rateldesk.example/path")] + [InlineData("https://rateldesk.example?token=secret")] + [InlineData("https://rateldesk.example/#fragment")] + public void Origin_policy_requires_operator_approved_exact_https_origin(string origin) => Assert.False(Policy(Config.Origin).TryValidate(origin, out _)); + + [Fact] + public void Explicit_self_hosted_origin_is_accepted_and_default_is_closed() + { + Assert.False(Policy().TryValidate(Config.Origin, out _)); + Assert.True(Policy("https://helpdesk.internal:9443").TryValidate("https://HELPDESK.internal:9443/", out var uri)); + Assert.Equal("https://helpdesk.internal:9443/", uri!.AbsoluteUri); + } + + [Fact] + public void Protected_credential_is_bound_to_tenant_and_connector_and_never_returned_in_dto() + { + var protector = new RatelDeskCredentialProtector(new EphemeralDataProtectionProvider()); + var cipher = protector.Protect(4, ConnectorId, Credential); + Assert.DoesNotContain(Credential, cipher); + Assert.Equal(Credential, protector.Unprotect(4, ConnectorId, cipher)); + Assert.Throws(() => protector.Unprotect(5, ConnectorId, cipher)); + Assert.Throws(() => protector.Unprotect(4, Guid.NewGuid(), cipher)); + var json = JsonSerializer.Serialize(RatelDeskConnectorService.ToDto(State(cipher))); + Assert.DoesNotContain(Credential, json); Assert.DoesNotContain(cipher, json); + } + + [Fact] + public async Task Configuration_and_credential_rotation_preserve_separate_revisions_and_dry_run_has_no_http() + { + var store = new Store(); var auth = new Authorization(); var tester = new Tester(); + var service = new RatelDeskConnectorService(store, auth, new RatelDeskCredentialProtector(new EphemeralDataProtectionProvider()), Policy(Config.Origin), tester); + var configured = await service.SaveAsync(4, ConnectorId, new(0, Config), Principal, CancellationToken.None); + Assert.Equal(1, configured.Revision); Assert.False(configured.HasCredential); + var rotated = await service.RotateAsync(4, ConnectorId, new(0, Credential), Principal, CancellationToken.None); + Assert.Equal(configured.Revision, rotated.Revision); Assert.Equal(1, rotated.CredentialRevision); + Assert.False(rotated.AutomaticDeliveryAvailable); + var preview = await service.DryRunAsync(4, ConnectorId, new("Disk warning", "

Low space

", 1), Principal, CancellationToken.None); + Assert.Equal("Disk warning", preview.Payload.Title); Assert.Equal("Low space", preview.Payload.Description); + Assert.Equal("dry-run-only", preview.FakeIncidentId); Assert.False(preview.SendsIncident); Assert.Equal(0, tester.Calls); + await Assert.ThrowsAsync(() => service.RotateAsync(4, ConnectorId, new(0, Credential), Principal, CancellationToken.None)); + await Assert.ThrowsAsync(() => service.RotateAsync(4, ConnectorId, new(1, "nrt_ic_agent-enrollment-is-not-a-helpdesk-token"), Principal, CancellationToken.None)); + } + + [Fact] + public async Task Current_tenant_authorization_precedes_read_and_disabled_or_rotated_configuration_cannot_dispatch() + { + var store = new Store { Value = State("protected") }; var auth = new Authorization(); + var connector = new RatelDeskFlowConnector(store, auth, Policy(Config.Origin)); + auth.Allowed = false; + Assert.Null(await connector.GetAsync(4, ConnectorId, new("owner"))); Assert.Equal(0, store.Reads); + auth.Allowed = true; + var reference = await connector.GetAsync(4, ConnectorId, new("owner")); + Assert.True(reference!.Enabled); Assert.False(reference.CanExecute); + Assert.Equal(RatelDeskConnectorLimits.ReceiverUnavailableCode, reference.UnavailableReason); + var draft = Draft(); var prepared = await connector.PrepareAsync(draft); + Assert.Equal(FlowIncidentPreparationStatus.Ready, prepared.Status); + var action = prepared.Action!; + Assert.False(action.SupportsSafeReplay); Assert.Equal(draft.IdempotencyKey, action.IdempotencyKey); + Assert.Equal(FlowContractValidation.Fingerprint(action), action.SemanticFingerprint); + Assert.Contains("Resource: C:", action.Fields.Description); Assert.Contains(draft.Event.OccurrenceId.ToString("D"), action.Fields.Description); + Assert.Equal(FlowIncidentActionResultKind.Unavailable, (await connector.DispatchAsync(action)).Kind); + Assert.Equal(RatelDeskConnectorLimits.ReceiverUnavailableCode, (await connector.DispatchAsync(action)).Code); + store.Value = store.Value! with { CredentialRevision = 2, ProtectedCredential = "rotated", RowVersion = 2 }; + Assert.Equal(action.SemanticFingerprint, FlowContractValidation.Fingerprint(action)); + Assert.Equal(RatelDeskConnectorLimits.ReceiverUnavailableCode, (await connector.DispatchAsync(action)).Code); + store.Value = store.Value with { Revision = 2, Configuration = Config with { CustomerId = "new-customer" } }; + Assert.Equal("connector-revision-unavailable", (await connector.DispatchAsync(action)).Code); + store.Value = State("protected") with { Configuration = Config with { Enabled = false } }; + Assert.Equal("connector-disabled-or-owner-denied", (await connector.DispatchAsync(action)).Code); + auth.Allowed = false; + Assert.Equal("connector-current-authority-denied", (await connector.DispatchAsync(action)).Code); + } + + [Fact] + public async Task Tampered_persisted_payload_is_rejected_and_same_semantic_key_is_not_replaced() + { + var connector = new RatelDeskFlowConnector(new Store { Value = State("protected") }, new Authorization(), Policy(Config.Origin)); + var action = (await connector.PrepareAsync(Draft())).Action!; + Assert.Equal("persisted-incident-payload-invalid", (await connector.DispatchAsync(action with { Fields = action.Fields with { Description = "changed" } })).Code); + Assert.Equal("persisted-incident-payload-invalid", (await connector.DispatchAsync(action with { Target = action.Target with { CustomerId = "foreign" } })).Code); + } + + [Fact] + public async Task Connection_test_uses_actual_read_only_organization_customer_routes_and_reports_missing_receiver_capability() + { + var requests = new List(); + using var server = Server(async context => + { + requests.Add(context.Request.Method + " " + context.Request.Path + context.Request.QueryString); + Assert.Equal("Bearer " + Credential, context.Request.Headers.Authorization); + await context.Response.WriteAsJsonAsync(context.Request.Path.Value!.EndsWith("organizations", StringComparison.Ordinal) + ? new[] { new { id = "org-1", organizationId = (string?)null } } + : new[] { new { id = "customer-1", organizationId = "org-1" } }); + }); + var result = await Transport(server).TestAsync(4, ConnectorId, Config, Credential, CancellationToken.None); + Assert.Equal(RatelDeskConnectionTestStatus.MappingValidated, result.Status); Assert.False(result.AutomaticDeliveryAvailable); + Assert.Equal(RatelDeskConnectorLimits.ReceiverUnavailableCode, result.Code); + Assert.Equal(2, requests.Count); Assert.All(requests, request => Assert.StartsWith("GET ", request)); + Assert.Contains("organizationId=org-1", requests[1]); + } + + [Theory] + [InlineData(401, RatelDeskConnectionTestStatus.AuthenticationRejected)] + [InlineData(403, RatelDeskConnectionTestStatus.AuthenticationRejected)] + [InlineData(429, RatelDeskConnectionTestStatus.Unavailable)] + public async Task Read_only_test_bounds_auth_failures_and_retry_after(int status, RatelDeskConnectionTestStatus expected) + { + using var server = Server(context => { context.Response.StatusCode = status; context.Response.Headers.RetryAfter = "999999"; return Task.CompletedTask; }); + var result = await Transport(server).TestAsync(4, ConnectorId, Config, Credential, CancellationToken.None); + Assert.Equal(expected, result.Status); if (status == 429) Assert.Equal(300, result.RetryAfterSeconds); + } + + [Fact] + public async Task Foreign_customer_mapping_is_rejected_without_incident_creation() + { + using var server = Server(context => context.Response.WriteAsJsonAsync(new[] { new { id = "customer-1", organizationId = "foreign" } })); + Assert.Equal(RatelDeskConnectionTestStatus.MappingRejected, (await Transport(server).TestAsync(4, ConnectorId, Config, Credential, CancellationToken.None)).Status); + } + + [Fact] + public async Task Normal_create_uses_reviewed_numeric_priority_and_fields_without_invented_queue_or_idempotency_header() + { + var incidentCount = 0; + using var server = Server(async context => + { + Assert.Equal("POST", context.Request.Method); Assert.Equal("/api/v1/incidents/", context.Request.Path); + Assert.False(context.Request.Headers.ContainsKey("Idempotency-Key")); + using var body = await JsonDocument.ParseAsync(context.Request.Body); + var root = body.RootElement; + Assert.Equal(JsonValueKind.Number, root.GetProperty("priority").ValueKind); Assert.Equal(2, root.GetProperty("priority").GetInt32()); + Assert.Equal("customer-1", root.GetProperty("customerId").GetString()); Assert.Equal("org-1", root.GetProperty("organizationId").GetString()); + Assert.Equal(7, root.EnumerateObject().Count()); Assert.False(root.TryGetProperty("queueId", out _)); + incidentCount++; context.Response.StatusCode = 201; + await context.Response.WriteAsJsonAsync(new { id = "incident-1", trackingId = "INC-1", organizationId = "org-1", customerId = "customer-1" }); + }); + var result = await Transport(server).CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None); + Assert.Equal(RatelDeskDeliveryStatus.Succeeded, result.Status); Assert.Equal("incident-1", result.Receipt!.IncidentId); + Assert.Null(result.Receipt.SafeLink); Assert.Equal(1, incidentCount); + } + + [Theory] + [InlineData(400, RatelDeskDeliveryStatus.PayloadRejected)] + [InlineData(401, RatelDeskDeliveryStatus.AuthenticationRejected)] + [InlineData(403, RatelDeskDeliveryStatus.AuthenticationRejected)] + [InlineData(429, RatelDeskDeliveryStatus.RateLimited)] + [InlineData(302, RatelDeskDeliveryStatus.DeliveryUnknown)] + [InlineData(500, RatelDeskDeliveryStatus.DeliveryUnknown)] + public async Task Bounded_normal_transport_does_not_retry_terminal_or_uncertain_responses(int status, RatelDeskDeliveryStatus expected) + { + var sends = 0; + using var server = Server(context => { sends++; context.Response.StatusCode = status; context.Response.Headers.Location = "https://foreign.example/incident"; context.Response.Headers.RetryAfter = "30"; return Task.CompletedTask; }); + var result = await Transport(server).CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None); + Assert.Equal(expected, result.Status); Assert.Equal(1, sends); if (status == 429) Assert.Equal(30, result.RetryAfterSeconds); + } + + [Fact] + public async Task Lost_response_after_remote_commit_is_delivery_unknown_and_never_blindly_retried() + { + var incidents = 0; var confirmations = 0; + using var server = Server(async context => + { + incidents++; confirmations++; context.Response.StatusCode = 201; + await context.Response.WriteAsync("{\"id\":\"incident-1\""); + }); + var result = await Transport(server).CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None); + Assert.Equal(RatelDeskDeliveryStatus.DeliveryUnknown, result.Status); Assert.Null(result.Receipt); + Assert.Equal(1, incidents); Assert.Equal(1, confirmations); + } + + [Fact] + public async Task Oversized_response_after_create_is_unknown_and_operation_slots_are_released() + { + using var server = Server(async context => { context.Response.StatusCode = 201; await context.Response.WriteAsync(new string('x', RatelDeskConnectorLimits.MaximumResponseBytes + 1)); }); + var transport = Transport(server); + Assert.Equal(RatelDeskDeliveryStatus.DeliveryUnknown, (await transport.CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None)).Status); + Assert.Equal(RatelDeskDeliveryStatus.DeliveryUnknown, (await transport.CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None)).Status); + } + + [Fact] + public async Task Far_future_rate_limit_date_is_capped_without_integer_overflow() + { + using var server = Server(context => { context.Response.StatusCode = 429; context.Response.Headers.RetryAfter = new DateTimeOffset(2300, 1, 1, 0, 0, 0, TimeSpan.Zero).ToString("r", System.Globalization.CultureInfo.InvariantCulture); return Task.CompletedTask; }); + var result = await Transport(server).CreateAsync(4, ConnectorId, Config.Origin, Credential, Payload(), CancellationToken.None); + Assert.Equal(RatelDeskDeliveryStatus.RateLimited, result.Status); Assert.Equal(300, result.RetryAfterSeconds); + } + + [Fact] + public async Task Shared_limiter_bounds_same_connector_and_tenant_across_transport_instances_and_restores_slots() + { + var limiter = new RatelDeskTransportLimiter(); + using var first = await limiter.TryAcquireAsync(4, ConnectorId, CancellationToken.None); + Assert.NotNull(first); Assert.Null(await limiter.TryAcquireAsync(4, ConnectorId, CancellationToken.None)); + IDisposable? second = null; + for (var i = 0; i < 32 && second is null; i++) second = await limiter.TryAcquireAsync(4, Guid.NewGuid(), CancellationToken.None); + Assert.NotNull(second); + using (second) + { + Assert.Null(await limiter.TryAcquireAsync(4, Guid.NewGuid(), CancellationToken.None)); + first.Dispose(); + using var recovered = await limiter.TryAcquireAsync(4, ConnectorId, CancellationToken.None); + Assert.NotNull(recovered); + } + } + + private static TestServer Server(RequestDelegate request) => new(new WebHostBuilder().Configure(app => app.Run(request))); + private static RatelDeskHttpTransport Transport(TestServer server) => new(server.CreateClient(), Policy(Config.Origin), TimeProvider.System, new()); + private static RatelDeskCreateIncidentDto Payload() => RatelDeskPayload.Create(Config, "Disk error", "Disk is full", 2); + private static RatelDeskConnectorState State(string? cipher) => new(ConnectorId, 4, 1, 1, "owner", Config, cipher, 1); + private static FlowIncidentActionDraft Draft() + { + var input = new FlowEventEnvelope(4, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow, + new(Guid.NewGuid(), Guid.NewGuid(), "Disk free", "test-client", "C:", "disk.free.percent", "warning", 4, null, DateTimeOffset.UtcNow), new("owner")); + return new(4, Guid.NewGuid(), Guid.NewGuid(), ConnectorId, 1, Guid.NewGuid(), "stable-action-key", input, new("Disk warning", "Space is low")); + } + private sealed class Authorization : IRatelDeskConnectorAuthorization + { + public bool Allowed { get; set; } = true; + public Task CanManageAsync(ClaimsPrincipal p, int tenant, CancellationToken ct) => Task.FromResult(Allowed && tenant == 4); + public Task CanExecuteAsync(string p, string? credential, int tenant, CancellationToken ct) => Task.FromResult(Allowed && p == "owner" && tenant == 4); + } + private sealed class Store : IRatelDeskConnectorStore + { + public RatelDeskConnectorState? Value { get; set; } public int Reads { get; private set; } + public Task GetAsync(int tenant, Guid id, CancellationToken ct) { Reads++; return Task.FromResult(Value?.TenantId == tenant && Value.Id == id ? Value : null); } + public Task> ListAsync(int tenant, CancellationToken ct) => Task.FromResult>(Value?.TenantId == tenant ? [Value] : []); + public Task SaveAsync(RatelDeskConnectorState state, long expected, CancellationToken ct) { if ((Value?.RowVersion ?? 0) != expected) return Task.FromResult(false); Value = state; return Task.FromResult(true); } + } + private sealed class Tester : IRatelDeskConnectionTester + { + public int Calls { get; private set; } + public Task TestAsync(int tenantId, Guid connectorId, RatelDeskConnectorConfiguration config, string credential, CancellationToken ct) { Calls++; return Task.FromResult(new RatelDeskConnectionTestResult(RatelDeskConnectionTestStatus.MappingValidated, RatelDeskConnectorLimits.ReceiverUnavailableCode)); } + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskFlowSourceAdoptionPostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskFlowSourceAdoptionPostgresTests.cs new file mode 100644 index 00000000..1a31e18f --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskFlowSourceAdoptionPostgresTests.cs @@ -0,0 +1,195 @@ +using System.Security.Claims; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Identity.Authorization; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +[Collection(PostgreSqlPersistenceCollection.Name)] +public sealed class RatelDeskFlowSourceAdoptionPostgresTests(PostgreSqlPersistenceFixture postgres) +{ + private static readonly Guid Installation = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + private static readonly Guid Producer = Guid.Parse("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + private static ClaimsPrincipal Human => new(new ClaimsIdentity([new("netratel_principal_id", "owner")], "Oidc")); + + [Fact] + public async Task Installation_A_adopts_the_existing_Flow_B_without_changing_either_identity() + { + await using var rig = await Rig.CreateAsync(postgres); + var before = await rig.Identity.GetAsync(default); + var result = await rig.Setup.AdoptAsync(17, before.Revision, Human, default); + result.InstallationInstanceId.Should().Be(Installation.ToString("D")); + result.FlowSourceInstanceId.Should().Be(Producer); + result.AdoptedSourceInstanceId.Should().Be(Producer.ToString("D")); + result.IdentityRevision.Should().Be(before.Revision + 1); + (await rig.Db.FlowRuntimeIdentity.SingleAsync()).SourceInstanceId.Should().Be(Producer); + (await rig.Db.Set().SingleAsync()).SourceAdoptedBy.Should().Be("owner"); + (await rig.Db.Set().CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData("AccountApi")] + [InlineData("service")] + [InlineData("machine_token")] + public async Task Account_API_and_service_actors_cannot_adopt_even_with_a_matching_owner_claim(string mode) + { + await using var rig = await Rig.CreateAsync(postgres); + var claims = new List { new("netratel_principal_id", "owner") }; + if (mode == "AccountApi") claims.Add(new("netratel_integration_credential_id", "synthetic-account-credential")); + else { claims.Add(new("auth_mode", mode)); if (mode == "service") claims.Add(new(ServiceIdentityClaims.PrincipalId, "synthetic-service")); } + var actor = new ClaimsPrincipal(new ClaimsIdentity(claims, mode)); + var before = await rig.Identity.GetAsync(default); + Func adopt = () => rig.Setup.AdoptAsync(17, before.Revision, actor, default); + await adopt.Should().ThrowExactlyAsync(); + var after = await rig.Identity.GetAsync(default); + after.Should().Be(before); + (await rig.Db.FlowRuntimeIdentity.SingleAsync()).SourceInstanceId.Should().Be(Producer); + } + + [Fact] + public async Task Tenant_foreign_manager_is_denied_before_instance_identity_mutation() + { + await using var rig = await Rig.CreateAsync(postgres); + var before = await rig.Identity.GetAsync(default); + Func adopt = () => rig.Setup.AdoptAsync(23, before.Revision, Human, default); + await adopt.Should().ThrowExactlyAsync(); + (await rig.Identity.GetAsync(default)).Should().Be(before); + } + + [Fact] + public async Task Tenant_manager_without_instance_permission_cannot_adopt() + { + await using var rig = await Rig.CreateAsync(postgres); + rig.Access.AllowInstance = false; + var before = await rig.Identity.GetAsync(default); + Func adopt = () => rig.Setup.AdoptAsync(17, before.Revision, Human, default); + var failure = await adopt.Should().ThrowExactlyAsync(); + failure.Which.StatusCode.Should().Be(403); + (await rig.Identity.GetAsync(default)).Should().Be(before); + } + + [Fact] + public async Task Existing_adopted_C_conflicts_with_Flow_B_and_preserves_durable_identities() + { + await using var rig = await Rig.CreateAsync(postgres); + var existing = Guid.Parse("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + var row = await rig.Db.Set().SingleAsync(); + row.SourceInstanceId = existing; row.Revision = 9; row.SourceAdoptedBy = "previous-owner"; + await rig.Db.SaveChangesAsync(); + var protectedWork = await SeedExistingWorkAsync(rig.Db); + var before = await rig.Identity.GetAsync(default); + Func adopt = () => rig.Setup.AdoptAsync(17, before.Revision, Human, default); + var failure = await adopt.Should().ThrowExactlyAsync(); + failure.Which.StatusCode.Should().Be(409); failure.Which.Code.Should().Be("source-identity-conflict"); + rig.Db.ChangeTracker.Clear(); + (await rig.Identity.GetAsync(default)).Should().Be(before); + (await rig.Db.FlowRuntimeIdentity.SingleAsync()).SourceInstanceId.Should().Be(Producer); + await AssertProtectedWorkAsync(rig.Db, protectedWork); + } + + [Fact] + public async Task Existing_attempt_blocks_initial_adoption_without_rewriting_pending_action_identity() + { + await using var rig = await Rig.CreateAsync(postgres); + var protectedWork = await SeedExistingWorkAsync(rig.Db); + var before = await rig.Identity.GetAsync(default); + Func adopt = () => rig.Setup.AdoptAsync(17, before.Revision, Human, default); + var failure = await adopt.Should().ThrowExactlyAsync(); + failure.Which.Code.Should().Be("source-identity-conflict"); + rig.Db.ChangeTracker.Clear(); + (await rig.Identity.GetAsync(default)).Should().Be(before); + await AssertProtectedWorkAsync(rig.Db, protectedWork); + } + + private static async Task<(string Attempt, Guid Run, Guid Node, string Key)> SeedExistingWorkAsync(OrchestratorDbContext db) + { + var attempt = Guid.NewGuid().ToString("D"); var flow = Guid.NewGuid(); var version = Guid.NewGuid(); + var run = Guid.NewGuid(); var node = Guid.NewGuid(); var occurrence = Guid.NewGuid(); var eventId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var key = $"{Producer:N}:{occurrence:N}:{eventId:N}:{version:N}:{node:N}"; + db.Set().Add(new() { AttemptId = attempt, Role = "initiator", LocalTenantId = "17", + LocalActorId = "owner", PeerInstanceId = Installation.ToString("D"), DescriptorJson = "{}", + DescriptorHash = new string('d', 64), Revision = 7, CreatedAtUnixSeconds = now.ToUnixTimeSeconds() }); + db.FlowDefinitions.Add(new() { Id = flow, TenantId = 17, Name = "Existing flow", Revision = 1, + DraftJson = "{}", CreatedAtUtc = now, UpdatedAtUtc = now }); + db.FlowVersions.Add(new() { Id = version, TenantId = 17, FlowId = flow, VersionNumber = 1, + GraphJson = "{}", ConfigurationHash = new string('a', 64), PublishedBy = "owner", PublishedAtUtc = now }); + db.FlowRuns.Add(new() { Id = run, TenantId = 17, FlowId = flow, FlowVersionId = version, + EventId = eventId, OccurrenceId = occurrence, EventJson = "{}", EventFingerprint = new string('b', 64), + Status = FlowRunStatus.DeliveryUnknown, CreatedAtUtc = now }); + db.FlowActions.Add(new() { RunId = run, NodeId = node, TenantId = 17, IdempotencyKey = key, + DraftJson = "{}", PreparedJson = "{}", SemanticFingerprint = new string('c', 64), + Status = FlowActionStatus.DeliveryUnknown, Attempts = 1, LeaseFence = 1 }); + await db.SaveChangesAsync(); return (attempt, run, node, key); + } + private static async Task AssertProtectedWorkAsync(OrchestratorDbContext db, (string Attempt, Guid Run, Guid Node, string Key) before) + { + var attempt = await db.Set().SingleAsync(x => x.AttemptId == before.Attempt); + attempt.Revision.Should().Be(7); attempt.DescriptorHash.Should().Be(new string('d', 64)); + var action = await db.FlowActions.SingleAsync(x => x.RunId == before.Run && x.NodeId == before.Node); + action.IdempotencyKey.Should().Be(before.Key); action.PreparedJson.Should().Be("{}"); + action.SemanticFingerprint.Should().Be(new string('c', 64)); action.Attempts.Should().Be(1); + action.Status.Should().Be(FlowActionStatus.DeliveryUnknown); + } + + private sealed class Rig(ServiceProvider provider, IServiceScope scope, OrchestratorDbContext db, + ServiceLinkIdentityStore identity, RatelDeskConnectorSetupService setup, Access access) : IAsyncDisposable + { + public OrchestratorDbContext Db => db; + public ServiceLinkIdentityStore Identity => identity; + public RatelDeskConnectorSetupService Setup => setup; + public Access Access => access; + public static async Task CreateAsync(PostgreSqlPersistenceFixture postgres) + { + var connection = await postgres.CreateDatabaseAsync(); + var services = new ServiceCollection(); + services.AddDbContext(options => options.UseNpgsql(connection)); + services.AddSingleton(TimeProvider.System); + var access = new Access(); services.AddSingleton(access); + services.AddSingleton>(new Options(new())); + services.AddSingleton>(new Options(new())); + services.AddScoped(); services.AddSingleton(); + var provider = services.BuildServiceProvider(); var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Must use the actual appended migration after ordered integration; never bypass pending models with EnsureCreated. + await db.Database.MigrateAsync(); + db.Set().Add(new() { InstanceId = Installation }); + db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = Producer }); await db.SaveChangesAsync(); + var identity = scope.ServiceProvider.GetRequiredService(); + var flow = (IFlowSourceIdentityResolver)provider.GetRequiredService(); + var setup = new RatelDeskConnectorSetupService(new TenantAuthority(), flow, identity, null!, db); + // No outbound links are seeded; the unused profile dependency cannot fabricate a managed grant. + return new(provider, scope, db, identity, setup, access); + } + public async ValueTask DisposeAsync() { scope.Dispose(); await provider.DisposeAsync(); } + } + private sealed class TenantAuthority : IRatelDeskConnectorAuthorization + { + public Task CanManageAsync(ClaimsPrincipal actor, int tenant, CancellationToken ct) => Task.FromResult(tenant == 17 && actor.Identity?.IsAuthenticated == true); + public Task CanExecuteAsync(string principal, string? credential, int tenant, CancellationToken ct) => Task.FromResult(false); + } + private sealed class Access : IEffectiveAccessService + { + public bool AllowInstance = true; + public Task AuthorizeAsync(ClaimsPrincipal actor, string permission, int? tenant, CancellationToken ct = default) => + Task.FromResult(AllowInstance && tenant is null && permission == NetRatelPermissions.IntegrationManagement); + public Task GetSnapshotAsync(ClaimsPrincipal actor, int? tenant, CancellationToken ct = default) => + Task.FromResult(new EffectiveAccessSnapshot(null, false, false, new HashSet())); + public Task ReconcileBuiltInRolesAsync(CancellationToken ct = default) => Task.CompletedTask; + } + private sealed class Options(T value) : IOptionsMonitor + { + public T CurrentValue => value; public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskIdentityObservationPostgresTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskIdentityObservationPostgresTests.cs new file mode 100644 index 00000000..06a1b7f5 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskIdentityObservationPostgresTests.cs @@ -0,0 +1,182 @@ +using System.Security.Claims; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.Flows; +using NetRatel.Infrastructure.Identity.Authorization; +using NetRatel.Infrastructure.Persistence; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +[Collection(PostgreSqlPersistenceCollection.Name)] +public sealed class RatelDeskIdentityObservationPostgresTests(PostgreSqlPersistenceFixture postgres) +{ + private static readonly Guid Installation = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + private static readonly Guid Producer = Guid.Parse("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + private static readonly Guid ForeignProducer = Guid.Parse("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + private static ClaimsPrincipal Human => new(new ClaimsIdentity([new("netratel_principal_id", "owner")], "Oidc")); + + [Fact] + public async Task Repeated_identity_observation_sees_other_context_adoption_despite_an_old_tracked_null() + { + await using var rig = await Rig.CreateAsync(postgres); + var before = await rig.Identity.GetAsync(default); + before.SourceInstanceId.Should().BeNull(); + var tracked = rig.Db.ChangeTracker.Entries().Single().Entity; + tracked.SourceInstanceId.Should().BeNull(); + + await rig.AdoptFromOtherContextAsync(ForeignProducer, before.Revision); + + var current = await rig.Identity.GetAsync(default); + current.InstanceId.Should().Be(Installation.ToString("D")); + current.SourceInstanceId.Should().Be(ForeignProducer.ToString("D")); + current.Revision.Should().Be(before.Revision + 1); + // Observation does not repurpose a preexisting tracked entity as an authority cache. + tracked.SourceInstanceId.Should().BeNull(); + (await rig.Flow.EnsureAsync(default)).Should().Be(Producer); + } + + [Fact] + public async Task Stale_tracked_adoption_cannot_overwrite_a_producer_committed_by_another_human_scope() + { + await using var rig = await Rig.CreateAsync(postgres); + var original = await rig.Identity.GetAsync(default); + await rig.AdoptFromOtherContextAsync(Producer, original.Revision); + + Func replace = () => rig.Identity.AdoptSourceAsync(Human, ForeignProducer, original.Revision, default); + var failure = await replace.Should().ThrowExactlyAsync(); + failure.Which.Code.Should().Be("identity-revision-conflict"); + var durable = await rig.Db.Set().AsNoTracking().SingleAsync(); + durable.InstanceId.Should().Be(Installation); + durable.SourceInstanceId.Should().Be(Producer); + durable.SourceAdoptedBy.Should().Be("owner"); + (await rig.Flow.EnsureAsync(default)).Should().Be(Producer); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Manual_continuity_rechecks_real_adoption_after_a_delayed_actual_Flow_read(bool adoptForeign) + { + await using var rig = await Rig.CreateAsync(postgres); + var before = await rig.Identity.GetAsync(default); + var flow = new GatedFlow(rig.Flow); + var continuity = new RatelDeskProducerContinuity(flow, new RatelDeskInstallationIdentityReader(rig.Identity)); + var pending = continuity.RequireCurrentAsync(Producer, TestContext.Current.CancellationToken); + await flow.Reached.Task.WaitAsync(TestContext.Current.CancellationToken); + try { await rig.AdoptFromOtherContextAsync(adoptForeign ? ForeignProducer : Producer, before.Revision); } + finally { flow.Release.TrySetResult(); } + + if (adoptForeign) + { + Func finish = () => pending; + await finish.Should().ThrowExactlyAsync() + .WithMessage("explicit-source-mapping-and-reapproval-required"); + } + else await pending; + var current = await rig.Identity.GetAsync(default); + current.InstanceId.Should().Be(Installation.ToString("D")); + current.SourceInstanceId.Should().Be((adoptForeign ? ForeignProducer : Producer).ToString("D")); + (await rig.Flow.EnsureAsync(default)).Should().Be(Producer); + } + + [Fact] + public async Task Managed_capture_sees_foreign_adoption_after_its_actual_Flow_read_and_never_resolves_a_grant() + { + await using var rig = await Rig.CreateAsync(postgres); + var before = await rig.Identity.GetAsync(default); + var flow = new GatedFlow(rig.Flow); + var connector = new RatelDeskConnectorState(Guid.NewGuid(), 17, 1, 1, "owner", + new("Desk", "https://receiver.example", "org", "customer", null, [], new(), true), null, 0); + var resolver = new ManagedRatelDeskBindingResolver(null!, rig.Identity, flow, new ConnectorAuthority(), null!); + // There is no approved link in this database. The source denial must happen before + // the unused profile dependency; this is not a fabricated managed capability. + var pending = resolver.CaptureAsync(connector, + new(RatelDeskAuthenticationMode.ManagedServiceLink, Guid.NewGuid().ToString("D")), Producer, + TestContext.Current.CancellationToken); + await flow.Reached.Task.WaitAsync(TestContext.Current.CancellationToken); + try { await rig.AdoptFromOtherContextAsync(ForeignProducer, before.Revision); } + finally { flow.Release.TrySetResult(); } + + Func finish = () => pending; + await finish.Should().ThrowExactlyAsync().WithMessage("flow-source-adoption-required"); + (await rig.Identity.GetAsync(default)).SourceInstanceId.Should().Be(ForeignProducer.ToString("D")); + (await rig.Flow.EnsureAsync(default)).Should().Be(Producer); + } + + private sealed class GatedFlow(IFlowSourceIdentityResolver actual) : IFlowSourceIdentityResolver + { + public TaskCompletionSource Reached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task EnsureAsync(CancellationToken ct) + { + var persisted = await actual.EnsureAsync(ct); + Reached.TrySetResult(); + await Release.Task.WaitAsync(ct); + return persisted; + } + } + + private sealed class Rig(ServiceProvider provider, IServiceScope scope, OrchestratorDbContext db, + ServiceLinkIdentityStore identity, IFlowSourceIdentityResolver flow) : IAsyncDisposable + { + public OrchestratorDbContext Db => db; + public ServiceLinkIdentityStore Identity => identity; + public IFlowSourceIdentityResolver Flow => flow; + public async Task AdoptFromOtherContextAsync(Guid source, long revision) + { + await using var other = provider.CreateAsyncScope(); + var result = await other.ServiceProvider.GetRequiredService() + .AdoptSourceAsync(Human, source, revision, TestContext.Current.CancellationToken); + result.SourceInstanceId.Should().Be(source.ToString("D")); + } + public static async Task CreateAsync(PostgreSqlPersistenceFixture postgres) + { + var connection = await postgres.CreateDatabaseAsync(); + var services = new ServiceCollection(); + services.AddDbContext(options => options.UseNpgsql(connection)); + services.AddSingleton(TimeProvider.System); + // Explicit policy seam: these cases exercise real durable stores and contexts, + // not HTTP/JWT or production principal authorization acceptance. + services.AddSingleton(new InstanceAuthority()); + services.AddSingleton>(new Options(new())); + services.AddSingleton>(new Options(new())); + services.AddScoped(); services.AddSingleton(); + var provider = services.BuildServiceProvider(); var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // The real new appended migration is mandatory; do not bypass pending history. + await db.Database.MigrateAsync(); + db.Set().Add(new() { InstanceId = Installation }); + db.FlowRuntimeIdentity.Add(new() { SourceInstanceId = Producer }); + await db.SaveChangesAsync(); + return new(provider, scope, db, scope.ServiceProvider.GetRequiredService(), + provider.GetRequiredService()); + } + public async ValueTask DisposeAsync() { scope.Dispose(); await provider.DisposeAsync(); } + } + private sealed class ConnectorAuthority : IRatelDeskConnectorAuthorization + { + public Task CanManageAsync(ClaimsPrincipal actor, int tenant, CancellationToken ct) => Task.FromResult(false); + public Task CanExecuteAsync(string principal, string? credential, int tenant, CancellationToken ct) => + Task.FromResult(principal == "owner" && tenant == 17 && credential is null); + } + private sealed class InstanceAuthority : IEffectiveAccessService + { + public Task AuthorizeAsync(ClaimsPrincipal actor, string permission, int? tenant, CancellationToken ct = default) => + Task.FromResult(actor.Identity?.IsAuthenticated == true && tenant is null && permission == NetRatelPermissions.IntegrationManagement); + public Task GetSnapshotAsync(ClaimsPrincipal actor, int? tenant, CancellationToken ct = default) => + Task.FromResult(new EffectiveAccessSnapshot(null, false, false, new HashSet())); + public Task ReconcileBuiltInRolesAsync(CancellationToken ct = default) => Task.CompletedTask; + } + private sealed class Options(T value) : IOptionsMonitor + { + public T CurrentValue => value; public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskManualProfileProbeTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskManualProfileProbeTests.cs new file mode 100644 index 00000000..800d90ad --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskManualProfileProbeTests.cs @@ -0,0 +1,204 @@ +using AwesomeAssertions; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Shared.ServiceLinks; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskManualProfileProbeTests +{ + private static readonly Guid Source = Guid.Parse("11111111-1111-4111-8111-111111111111"); + private static readonly Guid Receiver = Guid.Parse("33333333-3333-4333-8333-333333333333"); + private static readonly Guid SourceNamespace = Guid.Parse("22222222-2222-4222-8222-222222222222"); + private const string Api = "https://receiver.example/helpdesk"; + private const string Bearer = "rdk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static RatelDeskConnectorState Connector => new(Guid.Parse("44444444-4444-4444-8444-444444444444"), 1, 1, 1, "owner", + new("manual", Api, "org", "customer", null, Array.Empty(), new(), true), "ciphertext", 1); + + [Fact] + public async Task Concrete_probe_uses_only_authenticated_capability_and_exact_read_only_target_route() + { + var f = new HttpFixture(); f.Handler.Responses.Enqueue(JsonReply(Capability())); f.Handler.Responses.Enqueue(JsonReply(Target())); + var result = await f.Probe.CaptureAsync(Connector, Source, Bearer, default); + (result.Peer.SourceInstanceId).Should().Be(Source); (result.Peer.SourceNamespaceId).Should().Be(SourceNamespace); + (f.Handler.Requests.Count).Should().Be(2); + ((f.Handler.Requests[0].Method, f.Handler.Requests[0].Uri)).Should().Be(("GET", Api + ReceiverWireValidation.CapabilitiesPath)); + ((f.Handler.Requests[1].Method, f.Handler.Requests[1].Uri)).Should().Be(("POST", Api + ReceiverWireValidation.TargetsPath)); + (f.Handler.Requests).Should().AllSatisfy(request => + { + (request.Scheme).Should().Be("Bearer"); (request.Credential).Should().Be(Bearer); + (request.Source).Should().Be(Source.ToString("D")); (request.IdempotencyKey).Should().BeNull(); + }); + (f.Handler.Requests[0].Body).Should().Be(""); + using var target = JsonDocument.Parse(f.Handler.Requests[1].Body); + (target.RootElement.GetProperty("organizationId").GetString()).Should().Be("org"); + (target.RootElement.GetProperty("customerId").GetString()).Should().Be("customer"); + (f.Handler.Requests).Should().NotContain(request => request.Uri == Api + ReceiverWireValidation.CreatePath); + (f.Factory.Names).Should().AllSatisfy(name => (name).Should().Be(RatelDeskReceiverHttpPipeline.ManualClient)); + } + + [Theory] + [InlineData(401)] + [InlineData(403)] + [InlineData(429)] + [InlineData(503)] + public async Task Denied_or_unavailable_capability_does_not_send_target_or_guess_identity(int status) + { + var f = new HttpFixture(); f.Handler.Responses.Enqueue(JsonReply(new { code = "source-not-authorized" }, status)); + var error = (await ((Func)(() => f.Probe.CaptureAsync(Connector, Source, Bearer, default))).Should().ThrowAsync()).Which; + (error.HttpStatus).Should().Be(status); (f.Handler.Requests).Should().ContainSingle(); + } + + [Theory] + [InlineData("receiverInstanceId")] + [InlineData("sourceInstanceId")] + [InlineData("sourceNamespaceId")] + [InlineData("organizationId")] + [InlineData("customerId")] + [InlineData("categoryIds")] + public async Task Target_validation_cannot_capture_a_different_authorized_namespace_or_mapping(string field) + { + var f = new HttpFixture(); var target = Target(); + if (field is "organizationId" or "customerId" or "categoryIds") + ((Dictionary)target["mapping"]!)[field] = field == "categoryIds" ? new[] { Guid.NewGuid().ToString("D") } : "another-target"; + else target[field] = Guid.NewGuid().ToString("D"); + f.Handler.Responses.Enqueue(JsonReply(Capability())); f.Handler.Responses.Enqueue(JsonReply(target)); + await ((Func)(() => f.Probe.CaptureAsync(Connector, Source, Bearer, default))).Should().ThrowAsync(); + (f.Handler.Requests.Count).Should().Be(2); + } + + [Fact] + public async Task Missing_no_store_and_duplicate_identity_fields_fail_before_target_probe() + { + var f = new HttpFixture(); var reply = JsonReply(Capability()); reply.Headers.CacheControl = null; + f.Handler.Responses.Enqueue(reply); + await ((Func)(() => f.Probe.CaptureAsync(Connector, Source, Bearer, default))).Should().ThrowAsync(); + (f.Handler.Requests).Should().ContainSingle(); + var duplicate = JsonSerializer.Serialize(Capability()).Insert(1, "\"receiverInstanceId\":\"" + Receiver.ToString("D") + "\","); + // Insert only at the root, preserving braces inside JSON string values. + ((Action)(() => ReceiverWireValidation.CaptureManual(Encoding.UTF8.GetBytes(duplicate), Connector, Api, Source, DateTimeOffset.UnixEpoch))).Should().Throw(); + } + + [Fact] + public async Task Bounded_HTTP_pipeline_clamps_rate_diagnosis_and_retains_manual_client_selection() + { + var f = new HttpFixture(); var response = JsonReply(new { code = "rate-limited" }, 429); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(999)); f.Handler.Responses.Enqueue(response); + var reply = await f.Pipeline.ReadAsync(RatelDeskAuthenticationMode.ManualApiBearer, 1, Connector.Id, Api, Source, + Bearer, HttpMethod.Get, Api + ReceiverWireValidation.CapabilitiesPath, null, default); + (reply.Status).Should().Be(429); (reply.RetryAfter).Should().Be(TimeSpan.FromSeconds(300)); + (f.Handler.Requests).Should().ContainSingle(); (f.Factory.Names).Should().ContainSingle(); + } + + [Fact] + public async Task Deployment_source_is_applied_before_concrete_continuity_can_seed_an_absent_Flow_identity() + { + var order = new List(); Guid? appliedSource = null; + var identity = new IdentityReader(() => + { + order.Add("installation"); appliedSource = Source; + return new(Receiver.ToString("D"), Source.ToString("D"), 1); + }); + var flow = new FlowIdentity(() => + { + order.Add("flow"); (appliedSource).Should().Be(Source); return appliedSource!.Value; + }); + await new RatelDeskProducerContinuity(flow, identity).RequireCurrentAsync(Source, default); + (order).Should().Equal(new[] { "installation", "flow", "installation" }); + } + + [Fact] + public async Task Existing_distinct_producer_ids_fail_without_adopting_replacing_or_deleting_either() + { + var other = Guid.Parse("55555555-5555-4555-8555-555555555555"); + var identity = new IdentityReader(() => new(Receiver.ToString("D"), other.ToString("D"), 7)); + var flow = new FlowIdentity(() => Source); + var error = (await ((Func)(() => new RatelDeskProducerContinuity(flow, identity).RequireCurrentAsync(Source, default))).Should().ThrowAsync()).Which; + (error.Message).Should().Be("explicit-source-mapping-and-reapproval-required"); + (identity.Reads).Should().Be(2); (flow.Reads).Should().Be(1); + ((await identity.GetAsync(default)).SourceInstanceId).Should().Be(other.ToString("D")); + } + + [Fact] + public void Managed_private_base_restriction_does_not_require_manual_private_permission() + { + var options = new Monitor(new() { AllowedApiBases = ["http://127.0.0.1:9000/helpdesk"] }); + var network = new RatelDeskReceiverNetworkPolicy(options, new Monitor(new() { AllowPrivateHttp = true }), + new Monitor(new())); + (new RatelDeskReceiverOptionsValidator().Validate(null, options.CurrentValue).Succeeded).Should().BeTrue(); + (network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManagedServiceLink, "http://127.0.0.1:9000/helpdesk")).Should().Be("http://127.0.0.1:9000/helpdesk"); + ((Action)(() => network.ValidateApprovedApiBase(RatelDeskAuthenticationMode.ManualApiBearer, "http://127.0.0.1:9000/helpdesk"))).Should().Throw(); + } + + private static Dictionary Capability() => ManualRatelDeskBindingResolverTests.Capability(); + private static Dictionary Target() => new() + { + ["contractVersion"] = ReceiverWireValidation.Contract, ["receiverInstanceId"] = Receiver.ToString("D"), + ["sourceInstanceId"] = Source.ToString("D"), ["sourceNamespaceId"] = SourceNamespace.ToString("D"), ["valid"] = true, + ["mapping"] = new Dictionary { ["organizationId"] = "org", ["customerId"] = "customer", ["assignedToId"] = null, ["categoryIds"] = Array.Empty() } + }; + private static HttpResponseMessage JsonReply(object value, int status = 200) + { + var result = new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json") }; + result.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true }; return result; + } + private sealed class HttpFixture + { + public Handler Handler { get; } = new(); + public Factory Factory { get; } + public RatelDeskReceiverHttpPipeline Pipeline { get; } + public RatelDeskManualProfileProbe Probe { get; } + public HttpFixture() + { + Factory = new(Handler); + var network = new RatelDeskReceiverNetworkPolicy(new Monitor(new()), + new Monitor(new()), new Monitor(new())); + Pipeline = new(Factory, network, TimeProvider.System, new RatelDeskTransportLimiter()); + Probe = new(Pipeline, network, TimeProvider.System); + } + } + private sealed record Request(string Method, string Uri, string? Scheme, string? Credential, string Source, string? IdempotencyKey, string Body); + private sealed class Handler : HttpMessageHandler + { + public Queue Responses { get; } = new(); + public List Requests { get; } = new(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(new(request.Method.Method, request.RequestUri!.OriginalString, request.Headers.Authorization?.Scheme, + request.Headers.Authorization?.Parameter, request.Headers.GetValues("X-NetRatel-Source-Instance").Single(), + request.Headers.TryGetValues("Idempotency-Key", out var keys) ? keys.Single() : null, + request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct))); + return Responses.Dequeue(); + } + } + private sealed class Factory(Handler handler) : IHttpClientFactory + { + public List Names { get; } = new(); + public HttpClient CreateClient(string name) { Names.Add(name); return new(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan }; } + } + private sealed class IdentityReader(Func get) : IRatelDeskInstallationIdentityReader + { + public int Reads { get; private set; } + public Task GetAsync(CancellationToken ct) { Reads++; return Task.FromResult(get()); } + } + private sealed class FlowIdentity(Func get) : IFlowSourceIdentityResolver + { + public int Reads { get; private set; } + public Task EnsureAsync(CancellationToken ct) { Reads++; return Task.FromResult(get()); } + } + private sealed class Monitor(T value) : IOptionsMonitor + { + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverHttpPipelineTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverHttpPipelineTests.cs new file mode 100644 index 00000000..a904e62b --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverHttpPipelineTests.cs @@ -0,0 +1,526 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Options; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Infrastructure.ServiceIdentity; +using NetRatel.Infrastructure.ServiceLinks; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskReceiverHttpPipelineTests +{ + private const string Bearer = "rdk_synthetic_receiver_test_credential"; + + [Theory] + [InlineData(RatelDeskAuthenticationMode.ManualApiBearer, RatelDeskReceiverHttpPipeline.ManualClient)] + [InlineData(RatelDeskAuthenticationMode.ManagedServiceLink, RatelDeskReceiverHttpPipeline.ManagedClient)] + public async Task Create_sends_exact_durable_body_and_identity_headers_on_the_selected_client(RatelDeskAuthenticationMode mode, string client) + { + using var fixture = new HttpFixture(); + var prepared = RatelDeskReceiverFixture.Prepared(mode); + fixture.Handler.Respond = (_, _) => Task.FromResult(ReceiptReply(prepared, 201)); + var result = await fixture.Transport.CreateAsync(prepared, Bearer, CancellationToken.None); + Assert.Equal(RatelDeskReceiverObservationKind.Committed, result.Kind); + var request = Assert.Single(fixture.Handler.Requests); + Assert.Equal("POST", request.Method); + Assert.Equal(prepared.Capability.Endpoints.Create, request.Uri); + Assert.Equal("Bearer", request.AuthenticationScheme); + Assert.Equal(Bearer, request.AuthenticationParameter); + Assert.Equal(prepared.Peer.SourceInstanceId.ToString("D"), request.Source); + Assert.Equal(prepared.ReceiverIdempotencyKey, request.Key); + Assert.Equal("application/json", request.Accept); + Assert.Equal("application/json", request.ContentType); + Assert.Equal(prepared.Peer.ApiBaseUrl, request.ApprovedApiBase); + Assert.Equal(Encoding.UTF8.GetBytes(prepared.ExactCreateBodyJson), request.Body); + Assert.Equal(client, Assert.Single(fixture.Factory.Names)); + } + + [Theory] + [InlineData(".", false)] + [InlineData(".", true)] + [InlineData("..", false)] + [InlineData("..", true)] + public async Task Receipt_lookup_preserves_dot_segments_at_the_HTTP_boundary_without_a_caller_opt_in(string key, bool optIn) + { + using var fixture = new HttpFixture(); + var peer = RatelDeskReceiverFixture.Peer(); + var endpoint = RatelDeskReceiverFixture.Api + "/api/v1/integrations/netratel/incident-receipts/" + key; + _ = await fixture.Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, peer.ConnectorId, peer.ApiBaseUrl, + peer.SourceInstanceId, Bearer, HttpMethod.Get, endpoint, null, CancellationToken.None, preserveDotKey: optIn); + var request = Assert.Single(fixture.Handler.Requests); + Assert.Equal(endpoint, request.Uri); + Assert.Equal("/help/api/v1/integrations/netratel/incident-receipts/" + key, request.PathAndQuery); + Assert.Null(request.Key); + Assert.Empty(request.Body); + Assert.Equal("GET", request.Method); + } + + [Theory] + [InlineData("/api/v1/incidents/../customers")] + [InlineData("/api/v1/integrations/netratel/incident-receipts/a/b")] + [InlineData("/api/v1/integrations/netratel/incident-receipts/%2e")] + [InlineData("/api/v1/integrations/netratel/capabilities?token=unexpected")] + [InlineData("/api/v1/integrations/netratel/capabilities#fragment")] + [InlineData("/api/v1/integrations/netratel/unknown")] + public async Task Endpoints_outside_the_fixed_protocol_are_rejected_before_HTTP(string suffix) + { + using var fixture = new HttpFixture(); + var peer = RatelDeskReceiverFixture.Peer(); + await Assert.ThrowsAsync(() => fixture.Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, + peer.ConnectorId, peer.ApiBaseUrl, peer.SourceInstanceId, Bearer, HttpMethod.Get, + peer.ApiBaseUrl + suffix, null, CancellationToken.None)); + Assert.Empty(fixture.Handler.Requests); + Assert.Empty(fixture.Factory.Names); + } + + [Fact] + public async Task Receipt_raw_path_mode_cannot_be_applied_to_create_or_another_method() + { + using var fixture = new HttpFixture(); + var peer = RatelDeskReceiverFixture.Peer(); + await Assert.ThrowsAsync(() => fixture.Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, + peer.ConnectorId, peer.ApiBaseUrl, peer.SourceInstanceId, Bearer, HttpMethod.Post, + peer.ApiBaseUrl + ReceiverWireValidation.CreatePath, "{}"u8.ToArray(), CancellationToken.None, "valid-key", preserveDotKey: true)); + await Assert.ThrowsAsync(() => fixture.Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, + peer.ConnectorId, peer.ApiBaseUrl, peer.SourceInstanceId, Bearer, HttpMethod.Post, + peer.ApiBaseUrl + ReceiverWireValidation.CapabilitiesPath, null, CancellationToken.None)); + Assert.Empty(fixture.Handler.Requests); + } + + [Theory] + [InlineData(200)] + [InlineData(201)] + public async Task Create_success_requires_a_complete_matching_receipt(int status) + { + using var fixture = new HttpFixture(); + var prepared = RatelDeskReceiverFixture.Prepared(); + fixture.Handler.Respond = (_, _) => Task.FromResult(ReceiptReply(prepared, status)); + var result = await fixture.Transport.CreateAsync(prepared, Bearer, CancellationToken.None); + Assert.Equal(RatelDeskReceiverObservationKind.Committed, result.Kind); + Assert.Equal(Encoding.UTF8.GetString(RatelDeskReceiverFixture.Receipt(prepared)), result.Receipt!.ExactAcceptedBodyJson); + Assert.Single(fixture.Handler.Requests); + } + + [Theory] + [InlineData("http-error")] + [InlineData("io-error")] + [InlineData("invalid-json")] + [InlineData("missing-no-store")] + [InlineData("wrong-media-type")] + [InlineData("wrong-location")] + [InlineData("oversize-response")] + public async Task Create_uncertainty_is_preserved_while_equivalent_lookup_failures_remain_read_only(string fault) + { + using var fixture = new HttpFixture(); + var prepared = RatelDeskReceiverFixture.Prepared(); + fixture.Handler.Respond = (_, _) => FaultReply(prepared, fault); + var create = await fixture.Transport.CreateAsync(prepared, Bearer, CancellationToken.None); + var lookup = await fixture.Transport.LookupAsync(prepared, Bearer, CancellationToken.None); + Assert.Equal(RatelDeskReceiverObservationKind.PossibleCommit, create.Kind); + Assert.Equal(RatelDeskReceiverObservationKind.TransientReadFailure, lookup.Kind); + Assert.Null(create.Receipt); + Assert.Null(lookup.Receipt); + Assert.Equal(2, fixture.Handler.Requests.Count); + } + + [Theory] + [InlineData(503, null, RatelDeskReceiverObservationKind.PossibleCommit)] + [InlineData(302, null, RatelDeskReceiverObservationKind.PossibleCommit)] + [InlineData(409, "unrelated-conflict", RatelDeskReceiverObservationKind.PossibleCommit)] + [InlineData(400, "unrelated-validation", RatelDeskReceiverObservationKind.PossibleCommit)] + [InlineData(409, "idempotency-payload-conflict", RatelDeskReceiverObservationKind.FingerprintConflict)] + [InlineData(422, "invalid-incident-target", RatelDeskReceiverObservationKind.PayloadRejected)] + [InlineData(401, null, RatelDeskReceiverObservationKind.AuthenticationRejected)] + [InlineData(403, null, RatelDeskReceiverObservationKind.AuthenticationRejected)] + [InlineData(429, null, RatelDeskReceiverObservationKind.RateLimited)] + public async Task Only_explicit_receiver_rejections_erase_a_new_create_attempts_uncertainty(int status, string? code, RatelDeskReceiverObservationKind expected) + { + using var fixture = new HttpFixture(); + fixture.Handler.Respond = (_, _) => Task.FromResult(JsonReply(code is null ? "{}" : "{\"code\":\"" + code + "\"}", status)); + var result = await fixture.Transport.CreateAsync(RatelDeskReceiverFixture.Prepared(), Bearer, CancellationToken.None); + Assert.Equal(expected, result.Kind); + Assert.Single(fixture.Handler.Requests); + } + + [Theory] + [InlineData(404, RatelDeskReceiverObservationKind.Missing)] + [InlineData(410, RatelDeskReceiverObservationKind.Gone)] + [InlineData(503, RatelDeskReceiverObservationKind.TransientReadFailure)] + public async Task Lookup_status_never_creates_or_retries_an_incident(int status, RatelDeskReceiverObservationKind expected) + { + using var fixture = new HttpFixture(); + fixture.Handler.Respond = (_, _) => Task.FromResult(JsonReply("{}", status)); + var result = await fixture.Transport.LookupAsync(RatelDeskReceiverFixture.Prepared(), Bearer, CancellationToken.None); + Assert.Equal(expected, result.Kind); + Assert.Equal("GET", Assert.Single(fixture.Handler.Requests).Method); + } + + [Theory] + [InlineData(404)] + [InlineData(410)] + public async Task Cached_missing_or_gone_lookup_is_not_accepted_as_current_receiver_evidence(int status) + { + using var fixture = new HttpFixture(); + fixture.Handler.Respond = (_, _) => + { + var response = JsonReply("{}", status); + response.Headers.CacheControl = null; + return Task.FromResult(response); + }; + var result = await fixture.Transport.LookupAsync(RatelDeskReceiverFixture.Prepared(), Bearer, CancellationToken.None); + Assert.Equal(RatelDeskReceiverObservationKind.TransientReadFailure, result.Kind); + Assert.Equal("GET", Assert.Single(fixture.Handler.Requests).Method); + } + + [Fact] + public async Task Lookup_success_keeps_the_original_receipt_and_sends_no_create_key_header() + { + using var fixture = new HttpFixture(); + var prepared = RatelDeskReceiverFixture.Prepared(); + fixture.Handler.Respond = (_, _) => Task.FromResult(ReceiptReply(prepared, 200)); + var result = await fixture.Transport.LookupAsync(prepared, Bearer, CancellationToken.None); + Assert.Equal(RatelDeskReceiverObservationKind.Committed, result.Kind); + Assert.Equal(Encoding.UTF8.GetString(RatelDeskReceiverFixture.Receipt(prepared)), result.Receipt!.ExactAcceptedBodyJson); + var request = Assert.Single(fixture.Handler.Requests); + Assert.Equal("GET", request.Method); + Assert.Null(request.Key); + Assert.Empty(request.Body); + } + + [Theory] + [InlineData(RatelDeskAuthenticationMode.ManualApiBearer)] + [InlineData(RatelDeskAuthenticationMode.ManagedServiceLink)] + public void Safe_handler_disables_redirects_proxies_cookies_and_connection_reuse(RatelDeskAuthenticationMode mode) + { + using var fixture = new HttpFixture(); + using var handler = RatelDeskReceiverSafeHttpMessageHandler.Create(mode, fixture.Network); + Assert.False(handler.AllowAutoRedirect); + Assert.False(handler.UseProxy); + Assert.False(handler.UseCookies); + Assert.Equal(TimeSpan.Zero, handler.PooledConnectionLifetime); + Assert.Equal(TimeSpan.Zero, handler.PooledConnectionIdleTimeout); + Assert.Equal(TimeSpan.FromSeconds(5), handler.ConnectTimeout); + Assert.NotNull(handler.ConnectCallback); + } + + [Fact] + public async Task Cancellation_before_create_entry_sends_no_request_and_does_not_invent_a_possible_commit() + { + using var fixture = new HttpFixture(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var result = await fixture.Transport.CreateAsync(RatelDeskReceiverFixture.Prepared(), Bearer, cancellation.Token); + Assert.Equal(RatelDeskReceiverObservationKind.Unavailable, result.Kind); + Assert.Empty(fixture.Handler.Requests); + } + + [Fact] + public async Task Busy_admission_preserves_prior_post_uncertainty_without_entering_HTTP() + { + using var fixture = new HttpFixture(); + var prepared = RatelDeskReceiverFixture.Prepared(); + using var held = await fixture.Limiter.TryAcquireAsync(prepared.Peer.LocalTenantId, prepared.ConnectorId, CancellationToken.None); + Assert.NotNull(held); + Assert.Equal(RatelDeskReceiverObservationKind.PossibleCommit, + (await fixture.Transport.CreateAsync(prepared, Bearer, CancellationToken.None)).Kind); + Assert.Equal(RatelDeskReceiverObservationKind.TransientReadFailure, + (await fixture.Transport.LookupAsync(prepared, Bearer, CancellationToken.None)).Kind); + Assert.Empty(fixture.Handler.Requests); + } + + [Fact] + public async Task Operation_deadline_is_ten_seconds_and_covers_response_body_reads() + { + using var fixture = new HttpFixture(); + var stream = new WaitingStream(); + fixture.Handler.Respond = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamReplyContent(stream) + }); + var task = fixture.ReadCapabilityAsync(); + await stream.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(TimeSpan.FromSeconds(10), RatelDeskReceiverHttpPipeline.OperationTimeout); + Assert.Equal(TimeSpan.FromSeconds(10), Assert.Single(fixture.Clock.Timers).DueTime); + fixture.Clock.Advance(TimeSpan.FromSeconds(9)); + Assert.False(task.IsCompleted); + fixture.Clock.Advance(TimeSpan.FromSeconds(1)); + await Assert.ThrowsAnyAsync(() => task); + Assert.True(stream.WasDisposed); + fixture.Handler.Respond = (_, _) => Task.FromResult(JsonReply("{}")); + _ = await fixture.ReadCapabilityAsync(); + Assert.Equal(2, fixture.Handler.Requests.Count); + } + + [Fact] + public async Task Earlier_caller_cancellation_remains_effective_and_releases_admission() + { + using var fixture = new HttpFixture(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Handler.Respond = async (_, token) => + { + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + throw new InvalidOperationException("The cancellation token must stop the request."); + }; + using var caller = new CancellationTokenSource(); + var task = fixture.ReadCapabilityAsync(caller.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + caller.Cancel(); + await Assert.ThrowsAnyAsync(() => task); + Assert.Equal(RatelDeskReceiverFixture.Now, fixture.Clock.GetUtcNow()); + fixture.Handler.Respond = (_, _) => Task.FromResult(JsonReply("{}")); + _ = await fixture.ReadCapabilityAsync(); + Assert.Equal(2, fixture.Handler.Requests.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Response_byte_limit_covers_declared_and_chunked_content(bool declaredLength) + { + using var fixture = new HttpFixture(); + HttpResponseMessage Response(int length) => new(HttpStatusCode.OK) + { + Content = declaredLength ? new ByteArrayContent(new byte[length]) : new StreamReplyContent(new MemoryStream(new byte[length])) + }; + fixture.Handler.Respond = (_, _) => Task.FromResult(Response(RatelDeskConnectorLimits.MaximumResponseBytes)); + Assert.Equal(RatelDeskConnectorLimits.MaximumResponseBytes, (await fixture.ReadCapabilityAsync()).Body.Length); + fixture.Handler.Respond = (_, _) => Task.FromResult(Response(RatelDeskConnectorLimits.MaximumResponseBytes + 1)); + await Assert.ThrowsAsync(() => fixture.ReadCapabilityAsync()); + Assert.Equal(2, fixture.Handler.Requests.Count); + } + + [Fact] + public async Task Oversize_request_is_rejected_before_creating_a_client_or_sending() + { + using var fixture = new HttpFixture(); + var peer = RatelDeskReceiverFixture.Peer(); + await Assert.ThrowsAsync(() => fixture.Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, + peer.ConnectorId, peer.ApiBaseUrl, peer.SourceInstanceId, Bearer, HttpMethod.Post, + peer.ApiBaseUrl + ReceiverWireValidation.CreatePath, + new byte[RatelDeskConnectorLimits.MaximumRequestBytes + 1], CancellationToken.None, "same-key")); + Assert.Empty(fixture.Handler.Requests); + Assert.Empty(fixture.Factory.Names); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(0.2, 1)] + [InlineData(1.2, 2)] + [InlineData(999, 300)] + public async Task Retry_after_is_bounded_and_rounded_up_without_an_automatic_retry(double delaySeconds, int expectedSeconds) + { + using var fixture = new HttpFixture(); + fixture.Handler.Respond = (_, _) => + { + var reply = JsonReply("{}", 429); + reply.Headers.RetryAfter = new RetryConditionHeaderValue(fixture.Clock.GetUtcNow().AddSeconds(delaySeconds)); + return Task.FromResult(reply); + }; + var reply = await fixture.ReadCapabilityAsync(); + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), reply.RetryAfter); + Assert.Single(fixture.Handler.Requests); + } + + [Fact] + public async Task Limiter_bounds_connector_tenant_and_global_operations_and_disposes_leases_once() + { + var limiter = new RatelDeskTransportLimiter(); + var held = new List(); + try + { + var first = await AcquireDistinctAsync(limiter, 1); + held.Add(first.Lease); + Assert.Null(await limiter.TryAcquireAsync(1, first.Connector, CancellationToken.None)); + held.Add((await AcquireDistinctAsync(limiter, 1)).Lease); + Assert.Null(await limiter.TryAcquireAsync(1, RatelDeskReceiverFixture.Id(999), CancellationToken.None)); + for (var tenant = 2; tenant <= 7; tenant++) held.Add((await AcquireDistinctAsync(limiter, tenant)).Lease); + Assert.Equal(8, held.Count); + Assert.Null(await limiter.TryAcquireAsync(8, RatelDeskReceiverFixture.Id(998), CancellationToken.None)); + first.Lease.Dispose(); + first.Lease.Dispose(); + using var replacement = await limiter.TryAcquireAsync(1, first.Connector, CancellationToken.None); + Assert.NotNull(replacement); + Assert.Null(await limiter.TryAcquireAsync(8, RatelDeskReceiverFixture.Id(997), CancellationToken.None)); + } + finally { foreach (var lease in held) lease.Dispose(); } + using var afterCleanup = await limiter.TryAcquireAsync(1, RatelDeskReceiverFixture.Id(999), CancellationToken.None); + Assert.NotNull(afterCleanup); + } + + private static async Task<(Guid Connector, IDisposable Lease)> AcquireDistinctAsync(RatelDeskTransportLimiter limiter, int tenant) + { + // Stripes may conservatively share slots. Find a free connector without depending on the hash implementation. + for (var value = 100; value < 612; value++) + { + var id = RatelDeskReceiverFixture.Id(value); + var lease = await limiter.TryAcquireAsync(tenant, id, CancellationToken.None); + if (lease is not null) return (id, lease); + } + throw new InvalidOperationException("No connector slot was available below the documented capacity."); + } + + private static Task FaultReply(RatelDeskReceiverPreparationV2 prepared, string fault) + { + if (fault == "http-error") return Task.FromException(new HttpRequestException("synthetic-network-failure")); + if (fault == "io-error") return Task.FromException(new IOException("synthetic-read-failure")); + var reply = ReceiptReply(prepared, 200); + if (fault == "invalid-json") reply.Content = new StringContent("{", Encoding.UTF8, "application/json"); + if (fault == "missing-no-store") reply.Headers.CacheControl = null; + if (fault == "wrong-media-type") reply.Content.Headers.ContentType = new("text/html"); + if (fault == "wrong-location") reply.Headers.Location = new Uri("/help/api/v1/incidents/different", UriKind.Relative); + if (fault == "oversize-response") reply.Content = new ByteArrayContent(new byte[RatelDeskConnectorLimits.MaximumResponseBytes + 1]); + return Task.FromResult(reply); + } + + private static HttpResponseMessage ReceiptReply(RatelDeskReceiverPreparationV2 prepared, int status) + { + var reply = JsonReply(Encoding.UTF8.GetString(RatelDeskReceiverFixture.Receipt(prepared)), status); + reply.Headers.Location = new Uri(RatelDeskReceiverFixture.Location, UriKind.Relative); + return reply; + } + + private static HttpResponseMessage JsonReply(string json, int status = 200) + { + var reply = new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + reply.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true }; + return reply; + } + + private sealed class HttpFixture : IDisposable + { + internal RecordingHandler Handler { get; } = new(); + internal RecordingFactory Factory { get; } + internal ManualClock Clock { get; } = new(); + internal RatelDeskTransportLimiter Limiter { get; } = new(); + internal RatelDeskReceiverNetworkPolicy Network { get; } + internal RatelDeskReceiverHttpPipeline Pipeline { get; } + internal RatelDeskReceiverTransport Transport { get; } + internal HttpFixture() + { + Factory = new(Handler); + Network = new RatelDeskReceiverNetworkPolicy(new Monitor(new()), + new Monitor(new()), new Monitor(new())); + Pipeline = new(Factory, Network, Clock, Limiter); + Transport = new(Pipeline, Clock); + } + internal Task ReadCapabilityAsync(CancellationToken cancellationToken = default) + { + var peer = RatelDeskReceiverFixture.Peer(); + return Pipeline.ReadAsync(peer.Mode, peer.LocalTenantId, peer.ConnectorId, peer.ApiBaseUrl, + peer.SourceInstanceId, Bearer, HttpMethod.Get, peer.ApiBaseUrl + ReceiverWireValidation.CapabilitiesPath, null, cancellationToken); + } + public void Dispose() => Handler.Dispose(); + } + + private sealed record RequestSnapshot(string Method, string Uri, string PathAndQuery, string? AuthenticationScheme, + string? AuthenticationParameter, string Source, string? Key, string Accept, string? ContentType, string? ApprovedApiBase, byte[] Body); + + private sealed class RecordingHandler : HttpMessageHandler + { + internal List Requests { get; } = []; + internal Func> Respond { get; set; } = (_, _) => Task.FromResult(JsonReply("{}")); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(new(request.Method.Method, request.RequestUri!.AbsoluteUri, request.RequestUri.PathAndQuery, + request.Headers.Authorization?.Scheme, request.Headers.Authorization?.Parameter, + request.Headers.GetValues("X-NetRatel-Source-Instance").Single(), + request.Headers.TryGetValues("Idempotency-Key", out var values) ? values.Single() : null, + string.Join(",", request.Headers.Accept.Select(x => x.MediaType)), request.Content?.Headers.ContentType?.MediaType, + request.Options.TryGetValue(RatelDeskReceiverSafeHttpMessageHandler.ApprovedApiBaseOption, out var approvedApiBase) ? approvedApiBase : null, + request.Content is null ? [] : await request.Content.ReadAsByteArrayAsync(cancellationToken))); + return await Respond(request, cancellationToken); + } + } + + private sealed class RecordingFactory(RecordingHandler handler) : IHttpClientFactory + { + internal List Names { get; } = []; + public HttpClient CreateClient(string name) + { + Names.Add(name); + return new(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan }; + } + } + + private sealed class StreamReplyContent(Stream stream) : HttpContent + { + protected override Task CreateContentReadStreamAsync() => Task.FromResult(stream); + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) => Task.FromResult(stream); + protected override Task SerializeToStreamAsync(Stream target, TransportContext? context) => stream.CopyToAsync(target); + protected override bool TryComputeLength(out long length) { length = 0; return false; } + protected override void Dispose(bool disposing) { if (disposing) stream.Dispose(); base.Dispose(disposing); } + } + + private sealed class WaitingStream : Stream + { + internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool WasDisposed { get; private set; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + Entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) { WasDisposed = true; base.Dispose(disposing); } + } + + private sealed class ManualClock : TimeProvider + { + private DateTimeOffset _now = RatelDeskReceiverFixture.Now; + internal List Timers { get; } = []; + public override DateTimeOffset GetUtcNow() => _now; + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state, dueTime); + Timers.Add(timer); + return timer; + } + internal void Advance(TimeSpan elapsed) + { + _now += elapsed; + foreach (var timer in Timers.ToArray()) timer.FireIfDue(_now); + } + internal sealed class ManualTimer(ManualClock owner, TimerCallback callback, object? state, TimeSpan dueTime) : ITimer + { + private DateTimeOffset _due = owner.GetUtcNow() + dueTime; + private bool _disposed; + internal TimeSpan DueTime { get; } = dueTime; + public bool Change(TimeSpan nextDue, TimeSpan period) + { + if (_disposed) return false; + _due = nextDue == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : owner.GetUtcNow() + nextDue; + return true; + } + internal void FireIfDue(DateTimeOffset now) + { + if (_disposed || now < _due) return; + _disposed = true; + callback(state); + } + public void Dispose() => _disposed = true; + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + } + } + + private sealed class Monitor(T value) : IOptionsMonitor + { + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverProtocolTests.cs b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverProtocolTests.cs new file mode 100644 index 00000000..680f8ad6 --- /dev/null +++ b/src/NetRatel/NetRatel.Tests/Infrastructure/RatelDeskReceiverProtocolTests.cs @@ -0,0 +1,534 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using NetRatel.Application.Flows; +using NetRatel.Application.RatelDesk; +using NetRatel.Infrastructure.RatelDesk; +using NetRatel.Shared.Contracts.Flows; +using NetRatel.Shared.Contracts.RatelDesk; +using Xunit; + +namespace NetRatel.Tests.Infrastructure; + +public sealed class RatelDeskReceiverProtocolTests +{ + private const string GoldenV1Json = """ + {"TenantId":71,"RunId":"00000000-0000-0000-0000-000000000013","ActionNodeId":"00000000-0000-0000-0000-000000000005","ConnectorId":"00000000-0000-0000-0000-000000000006","ConnectorRevision":1,"SourceInstanceId":"00000000-0000-0000-0000-000000000001","IdempotencyKey":"00000000000000000000000000000001:00000000000000000000000000000002:00000000000000000000000000000003:00000000000000000000000000000004:00000000000000000000000000000005","Event":{"TenantId":71,"EventId":"00000000-0000-0000-0000-000000000003","OccurrenceId":"00000000-0000-0000-0000-000000000002","FlowVersionId":"00000000-0000-0000-0000-000000000004","OccurredAtUtc":"2026-10-05T00:00:00+00:00","Data":{"AgentId":"00000000-0000-0000-0000-000000000010","RuleId":"00000000-0000-0000-0000-000000000011","RuleName":"rule","ClientName":"client","Resource":"resource","Metric":"cpu","Severity":"warning","NumericValue":95,"ServiceState":null,"ObservedAtUtc":"2026-10-05T00:00:00+00:00"},"Authority":{"PrincipalId":"human-owner","IntegrationCredentialId":null}},"Fields":{"Title":"Alert","Description":"Description","Priority":1},"Target":{"OrganizationId":"org-1","CustomerId":"customer-1","AssignedToId":null,"CategoryIds":[]},"SupportsSafeReplay":true,"SemanticFingerprint":""} + """; + + [Theory] + [MemberData(nameof(ReceiverVectors))] + public void Fingerprint_matches_the_frozen_receiver_vectors(string name, string requestJson, string expected) + { + Assert.False(string.IsNullOrWhiteSpace(name)); + var body = JsonSerializer.Deserialize(requestJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.Strict + }); + Assert.NotNull(body); + Assert.Equal(expected, new RatelDeskReceiverFingerprint().ComputeFull(body)); + } + + public static IEnumerable ReceiverVectors() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + for (var depth = 0; directory is not null && depth < 12; depth++, directory = directory.Parent) + { + var path = Path.Combine(directory.FullName, "docs", "contracts", "rateldesk-incident-create.v1.fixtures.json"); + if (!File.Exists(path)) continue; + using var document = JsonDocument.Parse(File.ReadAllBytes(path)); + foreach (var vector in document.RootElement.GetProperty("vectors").EnumerateArray()) + yield return [vector.GetProperty("name").GetString()!, vector.GetProperty("request").GetRawText(), + vector.GetProperty("fingerprint").GetString()!]; + yield break; + } + throw new FileNotFoundException("The frozen receiver conformance fixture is required."); + } + + [Fact] + public void Receiver_key_has_a_golden_projection_and_preserves_the_local_action_identity() + { + var draft = RatelDeskReceiverFixture.Draft(); + var first = RatelDeskReceiverKey.Prepare(draft, null); + Assert.Equal("8ba5abac281f0a20cf5034acd7e1e1a6e4ac703ff567ab90d105aadf4488d683", first); + Assert.Equal(RatelDeskReceiverFixture.LocalKey, draft.IdempotencyKey); + Assert.Equal(first, RatelDeskReceiverKey.Prepare(draft with { RunId = RatelDeskReceiverFixture.Id(14) }, first)); + Assert.NotEqual(first, RatelDeskReceiverKey.Prepare(draft with + { + ActionNodeId = RatelDeskReceiverFixture.Id(9), + IdempotencyKey = RatelDeskReceiverFixture.LocalKey[..(RatelDeskReceiverFixture.LocalKey.LastIndexOf(':') + 1)] + + RatelDeskReceiverFixture.Id(9).ToString("N") + }, null)); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("persisted_AbC~123")] + public void Durable_conforming_keys_are_preserved_without_encoding(string key) + { + Assert.Equal(key, RatelDeskReceiverKey.Prepare(RatelDeskReceiverFixture.Draft(), key)); + Assert.Equal(RatelDeskReceiverFixture.Api + "/api/v1/integrations/netratel/incident-receipts/" + key, + RatelDeskReceiverKey.ReceiptEndpoint(RatelDeskReceiverFixture.Api + ReceiverWireValidation.ReceiptPath, key)); + Assert.Equal(key, RatelDeskReceiverKey.Prepare(RatelDeskReceiverFixture.Draft() with { IdempotencyKey = key }, null)); + } + + [Theory] + [InlineData("")] + [InlineData("a:b")] + [InlineData("a/b")] + [InlineData("%2e")] + [InlineData("a b")] + [InlineData("α")] + public void Invalid_durable_keys_require_read_only_recovery(string key) => + Assert.Throws(() => RatelDeskReceiverKey.Prepare(RatelDeskReceiverFixture.Draft(), key)); + + [Fact] + public void Key_length_boundary_is_256_ASCII_characters() + { + var accepted = new string('a', 256); + Assert.Equal(accepted, RatelDeskReceiverKey.Prepare(RatelDeskReceiverFixture.Draft(), accepted)); + Assert.Throws(() => RatelDeskReceiverKey.Prepare(RatelDeskReceiverFixture.Draft(), accepted + "a")); + } + + [Fact] + public void Additive_receiver_preparation_keeps_the_golden_V1_JSON_and_semantic_hash() + { + const string expected = GoldenV1Json; + const string expectedHash = "779c42f79681e3418169233ec2eb5432e66b059dde3901ec02d0693867ac3cfd"; + var draft = RatelDeskReceiverFixture.Draft(); + var action = RatelDeskReceiverFixture.Action() with { SemanticFingerprint = "" }; + Assert.Equal(Encoding.UTF8.GetBytes(expected), JsonSerializer.SerializeToUtf8Bytes(action)); + Assert.Equal(expectedHash, FlowContractValidation.Fingerprint(action)); + action = action with { SemanticFingerprint = expectedHash }; + var before = JsonSerializer.SerializeToUtf8Bytes(action); + var peer = RatelDeskReceiverFixture.Peer(); + var capability = ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Capability(peer), peer, RatelDeskReceiverFixture.Now); + var prepared = new ReceiverPreparationBuilder(new RatelDeskReceiverFingerprint()) + .Build(draft, action, peer, capability, RatelDeskReceiverFixture.Now, null); + Assert.Equal(before, JsonSerializer.SerializeToUtf8Bytes(action)); + Assert.Equal(expectedHash, FlowContractValidation.Fingerprint(action)); + Assert.True(ReceiverPreparedBinding.Valid(prepared, action, draft, RatelDeskReceiverFixture.Now, new RatelDeskReceiverFingerprint())); + Assert.DoesNotContain("Receiver", Encoding.UTF8.GetString(before)); + } + + [Fact] + public void Discovering_a_receiver_cannot_retrofit_safe_replay_into_a_historical_V1_request() + { + const string expectedHash = "566d9b5c39e978b5cf1387a7ee4bce0dbd28c7dfe025a40c6c5ea5aa372eb357"; + var expected = GoldenV1Json.Replace("\"SupportsSafeReplay\":true", "\"SupportsSafeReplay\":false", StringComparison.Ordinal); + var historical = RatelDeskReceiverFixture.Action() with { SupportsSafeReplay = false, SemanticFingerprint = "" }; + Assert.Equal(Encoding.UTF8.GetBytes(expected), JsonSerializer.SerializeToUtf8Bytes(historical)); + Assert.Equal(expectedHash, FlowContractValidation.Fingerprint(historical)); + historical = historical with { SemanticFingerprint = expectedHash }; + var original = JsonSerializer.SerializeToUtf8Bytes(historical); + var peer = RatelDeskReceiverFixture.Peer(); + var capability = ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Capability(peer), peer, RatelDeskReceiverFixture.Now); + Assert.Throws(() => new ReceiverPreparationBuilder(new RatelDeskReceiverFingerprint()) + .Build(RatelDeskReceiverFixture.Draft(), historical, peer, capability, RatelDeskReceiverFixture.Now, null)); + Assert.False(historical.SupportsSafeReplay); + Assert.Equal(original, JsonSerializer.SerializeToUtf8Bytes(historical)); + Assert.Equal(expectedHash, FlowContractValidation.Fingerprint(historical)); + } + + [Fact] + public void A_self_consistent_sidecar_cannot_bind_another_body_key_or_target() + { + var draft = RatelDeskReceiverFixture.Draft(); + var action = RatelDeskReceiverFixture.Action(); + var peer = RatelDeskReceiverFixture.Peer(); + var fingerprint = new RatelDeskReceiverFingerprint(); + var prepared = new ReceiverPreparationBuilder(fingerprint).Build(draft, action, peer, + ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Capability(peer), peer, RatelDeskReceiverFixture.Now), + RatelDeskReceiverFixture.Now, null); + foreach (var changed in new[] + { + prepared with { ExactCreateBodyJson = "{}" }, + prepared with { ReceiverIdempotencyKey = "another-logical-action" }, + prepared with { Peer = peer with { ConnectorId = RatelDeskReceiverFixture.Id(9) } }, + prepared with { Peer = peer with { AssignedToId = "other-assignee" } }, + prepared with { OriginalActionCreatedAtUtc = prepared.OriginalActionCreatedAtUtc.AddSeconds(1) }, + prepared with { AutomaticReplayUntilUtc = prepared.AutomaticReplayUntilUtc.AddSeconds(1) } + }) + Assert.False(ReceiverPreparedBinding.Valid(changed with + { + EvidenceFingerprint = ReceiverPreparationBuilder.EvidenceHash(changed) + }, action, draft, RatelDeskReceiverFixture.Now, fingerprint)); + } + + [Theory] + [InlineData("receiverInstanceId")] + [InlineData("sourceInstanceId")] + [InlineData("sourceNamespaceId")] + [InlineData("createEndpoint")] + [InlineData("receiptEndpointTemplate")] + [InlineData("targetValidationEndpoint")] + [InlineData("keyHeader")] + [InlineData("sourceHeader")] + [InlineData("keyPattern")] + public void Capability_binds_every_identity_endpoint_and_header(string field) + { + var peer = RatelDeskReceiverFixture.Peer(); + var body = JsonNode.Parse(RatelDeskReceiverFixture.Capability(peer))!; + body[field] = "different"; + Assert.Throws(() => ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer, RatelDeskReceiverFixture.Now)); + } + + [Theory] + [InlineData("maxKeyLength", "\"256\"")] + [InlineData("minimumReceiptRetentionSeconds", "\"7776000\"")] + [InlineData("maximumAutomaticReplaySeconds", "2592000.5")] + [InlineData("receiptEvictionEnabled", "\"false\"")] + [InlineData("atomicIncidentReceiptAndEffects", "1")] + [InlineData("supportsReceiptLookup", "null")] + [InlineData("supportsSafeSameKeyReplay", "[]")] + [InlineData("authenticationModes", "[1]")] + public void Capability_rejects_JSON_type_coercion(string field, string invalidJson) + { + var peer = RatelDeskReceiverFixture.Peer(); + var body = JsonNode.Parse(RatelDeskReceiverFixture.Capability(peer))!; + body[field] = JsonNode.Parse(invalidJson); + Assert.Throws(() => ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer, RatelDeskReceiverFixture.Now)); + } + + [Fact] + public void Capability_accepts_additions_but_requires_each_replay_guarantee_and_selected_authentication_mode() + { + var peer = RatelDeskReceiverFixture.Peer(); + var body = JsonNode.Parse(RatelDeskReceiverFixture.Capability(peer))!; + body["futureObservation"] = new JsonObject { ["version"] = 2 }; + _ = ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer, RatelDeskReceiverFixture.Now); + foreach (var flag in new[] { "supportsReceiptLookup", "supportsSafeSameKeyReplay", "atomicIncidentReceiptAndEffects" }) + { + body[flag] = false; + Assert.Throws(() => ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer, RatelDeskReceiverFixture.Now)); + body[flag] = true; + } + body["authenticationModes"] = new JsonArray("api_bearer"); + Assert.Throws(() => ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer, RatelDeskReceiverFixture.Now)); + _ = ReceiverWireValidation.Capability(RatelDeskReceiverFixture.Bytes(body), peer with { Mode = RatelDeskAuthenticationMode.ManualApiBearer }, RatelDeskReceiverFixture.Now); + } + + [Theory] + [MemberData(nameof(TargetFields))] + public void Target_validation_requires_every_typed_identity_and_exact_mapping_field(string path) + { + var peer = RatelDeskReceiverFixture.Peer() with + { + AssignedToId = "assignee-1", CategoryIds = [RatelDeskReceiverFixture.Id(20).ToString("D"), RatelDeskReceiverFixture.Id(21).ToString("D")] + }; + var valid = JsonNode.Parse(RatelDeskReceiverFixture.Target(peer))!; + ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(valid), peer); + foreach (var replacement in new[] { "\"different\"", "null", "17" }) + { + var changed = JsonNode.Parse(RatelDeskReceiverFixture.Target(peer))!; + var owner = path.StartsWith("mapping.", StringComparison.Ordinal) ? changed["mapping"]! : changed; + var field = path[(path.LastIndexOf('.') + 1)..]; + owner[field] = JsonNode.Parse(replacement); + Assert.Throws(() => ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(changed), peer)); + } + var missing = JsonNode.Parse(RatelDeskReceiverFixture.Target(peer))!; + var missingOwner = path.StartsWith("mapping.", StringComparison.Ordinal) ? missing["mapping"]! : missing; + missingOwner.AsObject().Remove(path[(path.LastIndexOf('.') + 1)..]); + Assert.Throws(() => ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(missing), peer)); + } + + public static IEnumerable TargetFields() => new[] + { + "contractVersion", "receiverInstanceId", "sourceInstanceId", "sourceNamespaceId", "valid", "mapping", + "mapping.organizationId", "mapping.customerId", "mapping.assignedToId", "mapping.categoryIds" + }.Select(x => new object[] { x }); + + [Fact] + public void Target_categories_must_be_exact_canonical_ordered_and_not_duplicated() + { + var a = RatelDeskReceiverFixture.Id(20).ToString("D"); + var b = RatelDeskReceiverFixture.Id(21).ToString("D"); + var peer = RatelDeskReceiverFixture.Peer() with { CategoryIds = [a, b] }; + var valid = JsonNode.Parse(RatelDeskReceiverFixture.Target(peer))!; + ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(valid), peer); + foreach (var values in new[] + { + JsonSerializer.Serialize(new[] { b, a }), JsonSerializer.Serialize(new[] { a, a }), + JsonSerializer.Serialize(new[] { a }), JsonSerializer.Serialize(new[] { a, b, RatelDeskReceiverFixture.Id(22).ToString("D") }), + JsonSerializer.Serialize(new[] { RatelDeskReceiverFixture.Id(20).ToString("N"), b }), "[17,18]" + }) + { + var changed = JsonNode.Parse(RatelDeskReceiverFixture.Target(peer))!; + changed["mapping"]!["categoryIds"] = JsonNode.Parse(values); + Assert.Throws(() => ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(changed), peer)); + } + valid["mapping"]!["assignedToId"] = ""; + Assert.Throws(() => ReceiverWireValidation.Target(RatelDeskReceiverFixture.Bytes(valid), peer)); + var duplicate = Encoding.UTF8.GetString(RatelDeskReceiverFixture.Target(peer)).Replace( + "\"mapping\":{", "\"mapping\":{\"customerId\":\"customer-1\",", StringComparison.Ordinal); + Assert.Throws(() => ReceiverWireValidation.Target(Encoding.UTF8.GetBytes(duplicate), peer)); + } + + [Fact] + public void Manual_bootstrap_captures_registered_identities_and_keeps_all_managed_authentication_fields_absent() + { + var peer = RatelDeskReceiverFixture.Peer(RatelDeskAuthenticationMode.ManualApiBearer); + var result = ReceiverWireValidation.CaptureManual(RatelDeskReceiverFixture.Capability(peer), + RatelDeskReceiverFixture.Connector(), RatelDeskReceiverFixture.Api, peer.SourceInstanceId, RatelDeskReceiverFixture.Now); + Assert.Equal(peer.SourceInstanceId, result.Peer.SourceInstanceId); + Assert.Equal(peer.SourceNamespaceId, result.Peer.SourceNamespaceId); + Assert.Equal(peer.ReceiverInstanceId, result.Peer.ReceiverInstanceId); + Assert.Equal(RatelDeskAuthenticationMode.ManualApiBearer, result.Peer.Mode); + Assert.Null(result.Peer.LinkId); + Assert.Null(result.Peer.LinkRevision); + Assert.Null(result.Peer.GrantHash); + Assert.Null(result.Peer.Issuer); + Assert.Null(result.Peer.Audience); + Assert.Null(result.Peer.TokenEndpoint); + Assert.Null(result.Peer.ClientId); + Assert.Null(result.Peer.DirectionId); + } + + [Theory] + [InlineData("receiverInstanceId", "00000000000000000000000000000008")] + [InlineData("receiverInstanceId", "ABCDEFAB-1234-4234-8234-123456789ABC")] + [InlineData("sourceNamespaceId", "00000000-0000-0000-0000-000000000000")] + [InlineData("sourceNamespaceId", "00000000000000000000000000000007")] + [InlineData("sourceInstanceId", "00000000-0000-0000-0000-000000000002")] + public void Manual_bootstrap_rejects_noncanonical_or_mismatched_registered_source_identity(string field, string value) + { + var peer = RatelDeskReceiverFixture.Peer(RatelDeskAuthenticationMode.ManualApiBearer); + var body = JsonNode.Parse(RatelDeskReceiverFixture.Capability(peer))!; + body[field] = value; + Assert.Throws(() => ReceiverWireValidation.CaptureManual(RatelDeskReceiverFixture.Bytes(body), + RatelDeskReceiverFixture.Connector(), peer.ApiBaseUrl, peer.SourceInstanceId, RatelDeskReceiverFixture.Now)); + } + + [Theory] + [InlineData("{\"id\":\"one\",\"id\":\"two\"}")] + [InlineData("{\"integrationReceipt\":{\"key\":\"one\",\"key\":\"two\"}}")] + [InlineData("{\"additive\":[{\"value\":1,\"value\":2}]}")] + public void Duplicate_JSON_fields_are_rejected_at_every_depth(string json) => + Assert.Throws(() => ReceiverWireValidation.Parse(Encoding.UTF8.GetBytes(json))); + + [Fact] + public void JSON_response_size_boundary_is_enforced_before_interpretation() + { + var accepted = Encoding.UTF8.GetBytes("{\"padding\":\"" + new string('a', 131058) + "\"}"); + Assert.Equal(131072, accepted.Length); + using var parsed = ReceiverWireValidation.Parse(accepted); + Assert.Equal(JsonValueKind.Object, parsed.RootElement.ValueKind); + Assert.Throws(() => ReceiverWireValidation.Parse([.. accepted, (byte)' '])); + } + + [Theory] + [InlineData("2026-10-05T12:34:56Z")] + [InlineData("2026-10-05T12:34:56.1234567+00:00")] + public void Full_receipt_accepts_receiver_UTC_precision_and_keeps_all_original_bytes(string timestamp) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var node = JsonNode.Parse(RatelDeskReceiverFixture.Receipt(prepared, timestamp))!; + node["futureIncidentField"] = new JsonObject { ["future"] = "retained" }; + var body = RatelDeskReceiverFixture.Bytes(node); + var receipt = ReceiverWireValidation.Receipt(body, RatelDeskReceiverFixture.Location, prepared); + Assert.Equal(Encoding.UTF8.GetString(body), receipt.ExactAcceptedBodyJson); + Assert.Equal("incident-1", receipt.IncidentId); + Assert.Equal("INC-0001", receipt.TrackingId); + Assert.Equal(RatelDeskReceiverFixture.Location, receipt.Location); + } + + [Theory] + [InlineData("contractVersion")] + [InlineData("receiverInstanceId")] + [InlineData("sourceInstanceId")] + [InlineData("sourceNamespaceId")] + [InlineData("key")] + [InlineData("fingerprint")] + [InlineData("outcome")] + [InlineData("incidentId")] + [InlineData("trackingId")] + [InlineData("organizationId")] + [InlineData("customerId")] + [InlineData("committedAtUtc")] + [InlineData("location")] + public void Every_nested_receipt_field_is_required_typed_and_bound(string field) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + foreach (var replacement in new[] { "\"changed\"", "null", "17", "[]" }) + { + var node = JsonNode.Parse(RatelDeskReceiverFixture.Receipt(prepared))!; + node["integrationReceipt"]![field] = JsonNode.Parse(replacement); + Assert.Throws(() => ReceiverWireValidation.Receipt(RatelDeskReceiverFixture.Bytes(node), RatelDeskReceiverFixture.Location, prepared)); + } + var missing = JsonNode.Parse(RatelDeskReceiverFixture.Receipt(prepared))!; + missing["integrationReceipt"]!.AsObject().Remove(field); + Assert.Throws(() => ReceiverWireValidation.Receipt(RatelDeskReceiverFixture.Bytes(missing), RatelDeskReceiverFixture.Location, prepared)); + } + + [Theory] + [InlineData("id")] + [InlineData("trackingId")] + [InlineData("organizationId")] + [InlineData("customerId")] + public void Top_level_incident_identity_and_mapping_must_equal_the_receipt(string field) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var node = JsonNode.Parse(RatelDeskReceiverFixture.Receipt(prepared))!; + node[field] = "different"; + Assert.Throws(() => ReceiverWireValidation.Receipt(RatelDeskReceiverFixture.Bytes(node), RatelDeskReceiverFixture.Location, prepared)); + } + + [Theory] + [InlineData(null)] + [InlineData("/api/v1/incidents/incident-1")] + [InlineData("https://foreign.example.test/incident-1")] + [InlineData("https://api.example.test/help/api/v1/incidents/incident-1")] + public void Location_header_must_equal_the_receivers_captured_resource_path(string? location) => + Assert.Throws(() => ReceiverWireValidation.Receipt( + RatelDeskReceiverFixture.Receipt(RatelDeskReceiverFixture.Prepared()), location, RatelDeskReceiverFixture.Prepared())); + + [Theory] + [InlineData("2026-10-05T12:34:56+01:00")] + [InlineData("2026-10-05T12:34:56")] + [InlineData("2026-02-30T12:34:56Z")] + public void Receipt_commit_time_requires_a_real_UTC_timestamp(string timestamp) => + Assert.Throws(() => ReceiverWireValidation.Receipt( + RatelDeskReceiverFixture.Receipt(RatelDeskReceiverFixture.Prepared(), timestamp), RatelDeskReceiverFixture.Location, RatelDeskReceiverFixture.Prepared())); + + [Theory] + [InlineData(false, FlowIncidentActionResultKind.Failed)] + [InlineData(true, FlowIncidentActionResultKind.DeliveryUnknown)] + public void Expired_or_exhausted_uncertainty_cannot_become_an_ordinary_failure(bool uncertain, FlowIncidentActionResultKind expected) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var rateLimited = new RatelDeskReceiverObservation(RatelDeskReceiverObservationKind.RateLimited, "receiver-rate-limited"); + Assert.Equal(expected, ReceiverDispatchPolicy.Complete(new(prepared, uncertain, 1, null), rateLimited, prepared.AutomaticReplayUntilUtc).Kind); + Assert.Equal(expected, ReceiverDispatchPolicy.Complete(new(prepared, uncertain, FlowLimits.MaximumActionAttempts, null), rateLimited, RatelDeskReceiverFixture.Now).Kind); + Assert.Equal(uncertain ? FlowActionStatus.DeliveryUnknown : FlowActionStatus.Failed, ReceiverDispatchPolicy.BudgetExhausted(uncertain)); + } + + [Theory] + [InlineData(true, FlowIncidentActionResultKind.RetryableSafe)] + [InlineData(false, FlowIncidentActionResultKind.DeliveryUnknown)] + public void Fifth_possible_commit_has_only_its_persisted_final_read_reconciliation(bool pending, FlowIncidentActionResultKind expected) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var evidence = new RatelDeskDispatchEvidence(prepared, true, FlowLimits.MaximumActionAttempts, + RatelDeskReceiverFixture.Now, FinalReconciliationPending: pending); + var result = ReceiverDispatchPolicy.Complete(evidence, + new(RatelDeskReceiverObservationKind.PossibleCommit, "receiver-commit-unconfirmed"), RatelDeskReceiverFixture.Now); + Assert.Equal(expected, result.Kind); + if (pending) Assert.Equal(TimeSpan.FromSeconds(5), result.RetryAfter); + Assert.Equal(FlowIncidentActionResultKind.DeliveryUnknown, ReceiverDispatchPolicy.Complete(evidence, + new(RatelDeskReceiverObservationKind.Missing, "receiver-receipt-missing"), prepared.AutomaticReplayUntilUtc).Kind); + Assert.Equal(FlowIncidentActionResultKind.DeliveryUnknown, ReceiverDispatchPolicy.Complete(evidence, + new(RatelDeskReceiverObservationKind.Gone, "receiver-receipt-expired"), RatelDeskReceiverFixture.Now).Kind); + Assert.Equal(FlowIncidentActionResultKind.DeliveryUnknown, ReceiverDispatchPolicy.Complete(evidence with + { Attempts = FlowLimits.MaximumActionAttempts + 1 }, new(RatelDeskReceiverObservationKind.Missing, + "receiver-receipt-missing"), RatelDeskReceiverFixture.Now).Kind); + } + + [Fact] + public void Verified_receipt_reconciles_the_fifth_post_even_after_the_send_budget_is_exhausted() + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var receipt = ReceiverWireValidation.Receipt(RatelDeskReceiverFixture.Receipt(prepared), + RatelDeskReceiverFixture.Location, prepared); + var result = ReceiverDispatchPolicy.Complete(new(prepared, true, FlowLimits.MaximumActionAttempts, + RatelDeskReceiverFixture.Now), new(RatelDeskReceiverObservationKind.Committed, "receiver-receipt-verified", receipt), + prepared.AutomaticReplayUntilUtc); + Assert.Equal(FlowIncidentActionResultKind.Succeeded, result.Kind); + Assert.Equal(receipt.IncidentId, result.Receipt!.IncidentId); + } + + [Theory] + [InlineData(RatelDeskReceiverObservationKind.AuthenticationRejected)] + [InlineData(RatelDeskReceiverObservationKind.PayloadRejected)] + [InlineData(RatelDeskReceiverObservationKind.FingerprintConflict)] + [InlineData(RatelDeskReceiverObservationKind.Gone)] + public void A_current_rejection_cannot_erase_a_durably_recorded_prior_possible_commit(RatelDeskReceiverObservationKind kind) + { + var prepared = RatelDeskReceiverFixture.Prepared(); + var observation = new RatelDeskReceiverObservation(kind, "current-receiver-rejection"); + Assert.Equal(FlowIncidentActionResultKind.DeliveryUnknown, + ReceiverDispatchPolicy.Complete(new(prepared, true, 1, RatelDeskReceiverFixture.Now), observation, RatelDeskReceiverFixture.Now).Kind); + Assert.Equal(FlowIncidentActionResultKind.Failed, + ReceiverDispatchPolicy.Complete(new(prepared, false, 1, null), observation, RatelDeskReceiverFixture.Now).Kind); + } +} + +internal static class RatelDeskReceiverFixture +{ + internal const string Api = "https://api.example.test/help"; + internal const string Location = "/help/api/v1/incidents/incident-1"; + internal const string LocalKey = "00000000000000000000000000000001:00000000000000000000000000000002:00000000000000000000000000000003:00000000000000000000000000000004:00000000000000000000000000000005"; + internal static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-10-05T00:00:00Z"); + internal static Guid Id(int value) => Guid.Parse($"00000000-0000-0000-0000-{value:000000000000}"); + + internal static FlowIncidentActionDraft Draft() => new(71, Id(13), Id(5), Id(6), 1, Id(1), LocalKey, + new(71, Id(3), Id(2), Id(4), Now, + new(Id(10), Id(11), "rule", "client", "resource", "cpu", "warning", 95, null, Now), new("human-owner")), + new("Alert", "Description", 1)); + + internal static FlowIncidentActionRequest Action() + { + var draft = Draft(); + var action = new FlowIncidentActionRequest(draft.TenantId, draft.RunId, draft.ActionNodeId, draft.ConnectorId, + draft.ConnectorRevision, draft.SourceInstanceId, draft.IdempotencyKey, draft.Event, draft.Fields, + new("org-1", "customer-1", null, []), true, ""); + return action with { SemanticFingerprint = FlowContractValidation.Fingerprint(action) }; + } + + internal static RatelDeskSemanticPeer Peer(RatelDeskAuthenticationMode mode = RatelDeskAuthenticationMode.ManagedServiceLink) => new(mode, 71, + Id(6), mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "approved-link" : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? 1 : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? new string('a', 64) : null, Id(8).ToString("D"), "org-1", Api, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "https://issuer.example.test/services" : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "rateldesk-api" : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? Api + "/connect/token" : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "stable-client" : null, + mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "initiator_to_responder" : null, + Id(1), Id(7), "org-1", "customer-1", null, []); + + internal static RatelDeskConnectorState Connector() => new(Id(6), 71, 1, 1, "human-owner", + new("Helpdesk", Api, "org-1", "customer-1", null, [], new(), true), "protected-test-value", 1, + new(RatelDeskAuthenticationMode.ManualApiBearer, null)); + + internal static byte[] Target(RatelDeskSemanticPeer peer) => JsonSerializer.SerializeToUtf8Bytes(new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = peer.ReceiverInstanceId, + sourceInstanceId = peer.SourceInstanceId.ToString("D"), sourceNamespaceId = peer.SourceNamespaceId.ToString("D"), valid = true, + mapping = new { organizationId = peer.OrganizationId, customerId = peer.CustomerId, assignedToId = peer.AssignedToId, categoryIds = peer.CategoryIds } + }); + + internal static byte[] Capability(RatelDeskSemanticPeer peer) => JsonSerializer.SerializeToUtf8Bytes(new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = peer.ReceiverInstanceId, + sourceInstanceId = peer.SourceInstanceId.ToString("D"), sourceNamespaceId = peer.SourceNamespaceId.ToString("D"), + createEndpoint = Api + ReceiverWireValidation.CreatePath, receiptEndpointTemplate = Api + ReceiverWireValidation.ReceiptPath, + targetValidationEndpoint = Api + ReceiverWireValidation.TargetsPath, keyHeader = "Idempotency-Key", sourceHeader = "X-NetRatel-Source-Instance", + maxKeyLength = 256, keyPattern = ReceiverWireValidation.KeyPattern, minimumReceiptRetentionSeconds = 7776000, + maximumAutomaticReplaySeconds = 2592000, receiptEvictionEnabled = false, atomicIncidentReceiptAndEffects = true, + supportsReceiptLookup = true, supportsSafeSameKeyReplay = true, + authenticationModes = new[] { peer.Mode == RatelDeskAuthenticationMode.ManagedServiceLink ? "oauth_client_credentials" : "api_bearer" } + }); + + internal static RatelDeskReceiverPreparationV2 Prepared(RatelDeskAuthenticationMode mode = RatelDeskAuthenticationMode.ManagedServiceLink) + { + var peer = Peer(mode); + var action = Action(); + return new ReceiverPreparationBuilder(new RatelDeskReceiverFingerprint()).Build(Draft(), action, peer, + ReceiverWireValidation.Capability(Capability(peer), peer, Now), Now, null); + } + + internal static byte[] Receipt(RatelDeskReceiverPreparationV2 prepared, string timestamp = "2026-10-05T12:34:56Z") => JsonSerializer.SerializeToUtf8Bytes(new + { + id = "incident-1", trackingId = "INC-0001", organizationId = "org-1", customerId = "customer-1", + integrationReceipt = new + { + contractVersion = ReceiverWireValidation.Contract, receiverInstanceId = prepared.Peer.ReceiverInstanceId, + sourceNamespaceId = prepared.Peer.SourceNamespaceId.ToString("D"), sourceInstanceId = prepared.Peer.SourceInstanceId.ToString("D"), + key = prepared.ReceiverIdempotencyKey, fingerprint = prepared.ReceiverFingerprint, outcome = "committed", incidentId = "incident-1", + trackingId = "INC-0001", organizationId = "org-1", customerId = "customer-1", committedAtUtc = timestamp, location = Location + } + }); + + internal static byte[] Bytes(JsonNode node) => Encoding.UTF8.GetBytes(node.ToJsonString()); +} diff --git a/src/NetRatel/NetRatel.Tests/NetRatel.Tests.csproj b/src/NetRatel/NetRatel.Tests/NetRatel.Tests.csproj index c259aa8d..8e9855ba 100644 --- a/src/NetRatel/NetRatel.Tests/NetRatel.Tests.csproj +++ b/src/NetRatel/NetRatel.Tests/NetRatel.Tests.csproj @@ -63,5 +63,8 @@ LogicalName="NetRatel.Tests.MigrationBaselines.CommittedConnectionOwnership.HistoricalFlowOnlyDown.sql" /> + + +
diff --git a/src/NetRatel/NetRatel.Web.ComponentTests/OperatorApiCredentialIsolationTests.cs b/src/NetRatel/NetRatel.Web.ComponentTests/OperatorApiCredentialIsolationTests.cs index db303cef..01ce1be8 100644 --- a/src/NetRatel/NetRatel.Web.ComponentTests/OperatorApiCredentialIsolationTests.cs +++ b/src/NetRatel/NetRatel.Web.ComponentTests/OperatorApiCredentialIsolationTests.cs @@ -68,8 +68,10 @@ await PublishAndAssertCredentialAsync( GC.KeepAlive(circuitHandler); } - [Fact] - public async Task Pooled_factory_keeps_two_circuit_tokens_isolated_when_http_context_is_absent_and_auth_updates_arrive_out_of_order() + [Theory] + [InlineData("OrchestratorApi")] + [InlineData("RatelDeskConnectorApi")] + public async Task Pooled_factory_keeps_two_circuit_tokens_isolated_when_http_context_is_absent_and_auth_updates_arrive_out_of_order(string clientName) { var requests = new ConcurrentQueue(); using var services = CreateServices( @@ -108,8 +110,8 @@ public async Task Pooled_factory_keeps_two_circuit_tokens_isolated_when_http_con // Once the initial request contexts disappear, each circuit must still // forward its own protected server-side credential. accessor.HttpContext = null; - using var aliceClient = aliceScope.ServiceProvider.GetRequiredService().CreateClient("OrchestratorApi"); - using var bobClient = bobScope.ServiceProvider.GetRequiredService().CreateClient("OrchestratorApi"); + using var aliceClient = aliceScope.ServiceProvider.GetRequiredService().CreateClient(clientName); + using var bobClient = bobScope.ServiceProvider.GetRequiredService().CreateClient(clientName); using (var response = await aliceClient.GetAsync("api/v2/client-presence")) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -305,6 +307,7 @@ private static ServiceProvider CreateServices( services.AddTransient(); services.AddTransient(); AddOperatorApiClient(services, "OrchestratorApi", baseAddress, primaryHandler); + AddOperatorApiClient(services, "RatelDeskConnectorApi", baseAddress, primaryHandler); var downloadClientName = typeof(IWebClientDownloadService).Name; AddOperatorApiClient(services, downloadClientName, baseAddress, primaryHandler); services.AddScoped(provider => new WebClientDownloadService( diff --git a/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorApiServiceTests.cs b/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorApiServiceTests.cs new file mode 100644 index 00000000..cb0990c2 --- /dev/null +++ b/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorApiServiceTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using AwesomeAssertions; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Web.Services.RatelDesk; +using Xunit; + +namespace NetRatel.Web.ComponentTests; + +public sealed class RatelDeskConnectorApiServiceTests +{ + [Theory] + [InlineData("[]", "connector-conflict")] + [InlineData("null", "connector-conflict")] + [InlineData("42", "connector-conflict")] + [InlineData("\"rdk_never_display\"", "connector-conflict")] + [InlineData("{\"code\":\"source-identity-conflict\",\"details\":\"rdk_never_display\"}", "source-identity-conflict")] + [InlineData("{\"code\":\"identity-revision-conflict\"}", "identity-revision-conflict")] + [InlineData("{\"code\":\"rdk_untrusted_code\"}", "connector-conflict")] + public async Task Source_adoption_conflicts_only_expose_allowlisted_codes_from_JSON_objects(string response, string expectedCode) + { + using var transport = new Transport(HttpStatusCode.Conflict, new { }) + { OverrideContent = new StringContent(response, System.Text.Encoding.UTF8, "application/json") }; + Func action = () => new RatelDeskConnectorApiService(transport) + .AdoptFlowSourceAsync(4, new(1), CancellationToken.None); + + var error = (await action.Should().ThrowAsync()).Which; + error.Code.Should().Be(expectedCode); + error.ToString().Should().NotContain("rdk_"); + transport.Calls.Should().Be(1); + } + + [Fact] + public async Task Rotation_uses_exact_tenant_connector_revision_and_dedicated_client_once() + { + var id = Guid.NewGuid(); using var transport = new Transport(HttpStatusCode.OK, new RatelDeskConnectorDto(id, 4, 2, + new("Desk", "https://desk.example", "org", "customer", null, [], new(), true), true, 5, false, RatelDeskConnectorLimits.ReceiverUnavailableCode)); + var service = new RatelDeskConnectorApiService(transport); + var result = await service.RotateAsync(4, id, new(4, "rdk_synthetic_password_only_in_explicit_post"), CancellationToken.None); + Assert.Equal(RatelDeskConnectorApiService.ClientName, transport.ClientName); + Assert.Equal($"/api/v2/tenants/4/connectors/rateldesk/{id:D}/credential", transport.Path); + Assert.Equal(HttpMethod.Post, transport.Method); Assert.True(transport.AccountHeader); Assert.Equal(1, transport.Calls); + using var body = JsonDocument.Parse(transport.Body!); + Assert.Equal(4, body.RootElement.GetProperty("expectedCredentialRevision").GetInt64()); + Assert.Equal("rdk_synthetic_password_only_in_explicit_post", body.RootElement.GetProperty("credential").GetString()); + var json = JsonSerializer.Serialize(result); + Assert.DoesNotContain("rdk_", json); Assert.DoesNotContain("ProtectedCredential", json); Assert.True(result.HasCredential); + } + + [Theory] + [InlineData(HttpStatusCode.Found, "connector-unavailable")] + [InlineData(HttpStatusCode.ServiceUnavailable, "connector-unavailable")] + [InlineData(HttpStatusCode.Conflict, "connector-conflict")] + [InlineData(HttpStatusCode.Forbidden, "access-denied")] + [InlineData(HttpStatusCode.TooManyRequests, "connector-busy")] + public async Task Remote_error_body_cannot_disclose_a_credential_or_trigger_a_replay(HttpStatusCode status, string code) + { + using var transport = new Transport(status, new { error = "rdk_do_not_render_server_error_details" }); + var service = new RatelDeskConnectorApiService(transport); + var error = await Assert.ThrowsAsync(() => service.RotateAsync(4, Guid.NewGuid(), new(2, "rdk_password"), CancellationToken.None)); + Assert.Equal(code, error.Code); Assert.DoesNotContain("rdk_", error.ToString()); Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task Only_the_fixed_capacity_code_is_exposed_from_a_bounded_error_body() + { + using var transport = new Transport(HttpStatusCode.TooManyRequests, + new { code = "connector-capacity-exhausted", details = "rdk_never_render_remote_details" }); + var error = await Assert.ThrowsAsync(() => + new RatelDeskConnectorApiService(transport).ListAsync(4, CancellationToken.None)); + Assert.Equal("connector-capacity-exhausted", error.Code); + Assert.DoesNotContain("rdk_", error.ToString()); Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task Invalid_tenant_is_rejected_before_an_HTTP_request() + { + using var transport = new Transport(HttpStatusCode.OK, Array.Empty()); + await Assert.ThrowsAsync(() => new RatelDeskConnectorApiService(transport).ListAsync(0, CancellationToken.None)); + Assert.Equal(0, transport.Calls); + } + + [Theory] + [InlineData(true, HttpStatusCode.OK)] + [InlineData(false, HttpStatusCode.OK)] + [InlineData(true, HttpStatusCode.TooManyRequests)] + [InlineData(false, HttpStatusCode.TooManyRequests)] + public async Task Oversized_responses_are_rejected_with_a_stream_budget_before_JSON_parsing(bool advertisedLength, HttpStatusCode status) + { + var size = RatelDeskConnectorApiService.MaximumResponseBytes + 65536; + using var stream = new CountingStream(size); + using var transport = new Transport(status, Array.Empty()) + { OverrideContent = advertisedLength ? new ByteArrayContent(new byte[size]) : new StreamContent(stream) }; + var error = await Assert.ThrowsAsync(() => new RatelDeskConnectorApiService(transport).ListAsync(4, CancellationToken.None)); + Assert.Equal("response-too-large", error.Code); + Assert.InRange(stream.BytesRead, 0, RatelDeskConnectorApiService.MaximumResponseBytes + 8192); + if (!advertisedLength) Assert.True(stream.BytesRead > 0); + } + + private sealed class CountingStream(int size) : Stream + { + public int BytesRead; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => BytesRead; set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) + { var length = Math.Min(count, size - BytesRead); Array.Clear(buffer, offset, length); BytesRead += length; return length; } + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { ct.ThrowIfCancellationRequested(); var length = Math.Min(buffer.Length, size - BytesRead); buffer.Span[..length].Clear(); BytesRead += length; return ValueTask.FromResult(length); } + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private sealed class Transport(HttpStatusCode status, object response) : HttpMessageHandler, IHttpClientFactory + { + public string? ClientName, Path, Body; public HttpMethod? Method; public bool AccountHeader; public int Calls; + public HttpContent? OverrideContent; + public HttpClient CreateClient(string name) { ClientName = name; return new(this, false) { BaseAddress = new("https://netratel.example") }; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Calls++; Method = request.Method; Path = request.RequestUri!.AbsolutePath; AccountHeader = request.Headers.Contains("X-NetRatel-Account-Request"); + Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(ct); + return new(status) { Content = OverrideContent ?? JsonContent.Create(response, response.GetType()) }; + } + } +} diff --git a/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorStateTests.cs b/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorStateTests.cs new file mode 100644 index 00000000..ecb2d818 --- /dev/null +++ b/src/NetRatel/NetRatel.Web.ComponentTests/RatelDeskConnectorStateTests.cs @@ -0,0 +1,221 @@ +using System.Reflection; +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Web.Components.Pages.Flows; +using NetRatel.Web.Services.RatelDesk; +using Xunit; + +namespace NetRatel.Web.ComponentTests; + +public sealed class RatelDeskConnectorStateTests : AsyncBunitContext +{ + [Fact] + public void Mapping_check_and_sanitized_preview_never_claim_delivery_or_render_a_credential() + { + var api = new FakeApi(); Services.AddSingleton(api); + var page = Render(); + page.WaitForAssertion(() => Assert.Single(page.FindAll("[data-testid='connector-revisions']"))); + page.Find("[data-testid='connector-credential']").Input("rdk_synthetic_password_that_must_never_be_rendered"); + Assert.DoesNotContain("rdk_synthetic_password", page.Markup); + page.Find("[data-testid='connector-test']").Click(); + page.WaitForAssertion(() => Assert.Contains("did not establish receiver delivery readiness", page.Markup)); + Assert.Contains("Delivery needs a current approved connection", page.Markup); + page.Find("[data-testid='connector-dry-run']").Click(); + page.WaitForAssertion(() => Assert.Single(page.FindAll("[data-testid='connector-preview']"))); + Assert.Contains("no incident sent", page.Markup, StringComparison.OrdinalIgnoreCase); + Assert.Contains("<script>", page.Markup); + Assert.DoesNotContain("", "Sanitized preview", 1, "customer", "org", null, []), "fingerprint")); } + } +} diff --git a/src/NetRatel/NetRatel.Web.PlaywrightTests/ClientsManagementResponsiveTests.cs b/src/NetRatel/NetRatel.Web.PlaywrightTests/ClientsManagementResponsiveTests.cs index f66639f6..9abc5931 100644 --- a/src/NetRatel/NetRatel.Web.PlaywrightTests/ClientsManagementResponsiveTests.cs +++ b/src/NetRatel/NetRatel.Web.PlaywrightTests/ClientsManagementResponsiveTests.cs @@ -41,6 +41,7 @@ using NetRatel.Web.Services.Telemetry; using NetRatel.Web.Services.Services; using NetRatel.Web.Components; +using NetRatel.Web.Services.RatelDesk; namespace NetRatel.Web.PlaywrightTests; @@ -723,7 +724,7 @@ private static string GetPlaywrightArtifactRoot() return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "TestResults", "playwright")); } - private async Task CaptureFailureDiagnosticsBestEffortAsync( + internal async Task CaptureFailureDiagnosticsBestEffortAsync( IBrowser browser, IPage? page, BrowserStartupDiagnostics? startupDiagnostics, @@ -856,7 +857,7 @@ private static string SanitizePathSegment(string value) return sanitized.Length <= 100 ? sanitized : sanitized[..100]; } - private sealed class BrowserStartupDiagnostics + internal sealed class BrowserStartupDiagnostics { private const int MaximumTimelineEvents = 250; private readonly Stopwatch _clock = Stopwatch.StartNew(); @@ -935,7 +936,7 @@ path is "/_framework/blazor.web.js" or "/_content/MudBlazor/MudBlazor.min.js" or path.StartsWith("/_blazor/", StringComparison.Ordinal); } - private sealed record BrowserTimelineEvent(long ElapsedMilliseconds, string Kind, string? Path, int? Status, string? Failure, string? Severity) + internal sealed record BrowserTimelineEvent(long ElapsedMilliseconds, string Kind, string? Path, int? Status, string? Failure, string? Severity) { public override string ToString() => $"{ElapsedMilliseconds,5} ms {Kind}{(Severity is null ? "" : $" severity={Severity}")}{(Status is null ? "" : $" status={Status}")}{(Failure is null ? "" : $" failure={Failure}")}{(Path is null ? "" : $" path={Path}")}"; @@ -1008,7 +1009,7 @@ private static string GetSafeStartupRequestPath(string requestUrl) var path = GetSafeRequestPath(requestUrl); return path switch { - "/" or "/clients/mgmt" or "/_framework/blazor.web.js" or "/_content/MudBlazor/MudBlazor.min.js" or "/js/theme-preference.js" or "/app-site.css" or "/NetRatel.Web.styles.css" => path, + "/" or "/clients/mgmt" or "/monitoring" or "/_framework/blazor.web.js" or "/_content/MudBlazor/MudBlazor.min.js" or "/js/theme-preference.js" or "/app-site.css" or "/NetRatel.Web.styles.css" => path, _ when path.StartsWith("/_framework/", StringComparison.Ordinal) => "/_framework/(asset)", _ when path.StartsWith("/_content/", StringComparison.Ordinal) => "/_content/(asset)", _ when path.StartsWith("/_blazor", StringComparison.Ordinal) => "/_blazor/(endpoint)", @@ -1492,6 +1493,7 @@ public async ValueTask DisposeAsync() [Route("/clients")] [Route("/flows")] [Route("/monitoring")] +[Route("/flows/connectors")] public sealed class ClientsManagementFixtureApp : ComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder) @@ -1565,6 +1567,7 @@ private ClientsManagementFixtureHost( public FixtureClientServicesService ServicesData => _application.Services.GetRequiredService(); public FixtureFlowApiService FlowsData => _application.Services.GetRequiredService(); public FixtureMonitoringApi MonitoringData => _application.Services.GetRequiredService(); + public FixtureRatelDeskConnectorApiService ConnectorsData => _application.Services.GetRequiredService(); public IReadOnlyList StartupServerDiagnostics => _serverDiagnostics.Snapshot(); public static async Task StartAsync(Action? configureServices = null) @@ -1587,6 +1590,8 @@ public static async Task StartAsync(Action(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddSingleton(); diff --git a/src/NetRatel/NetRatel.Web.PlaywrightTests/LocalFirstComposeBrowserSmokeTests.cs b/src/NetRatel/NetRatel.Web.PlaywrightTests/LocalFirstComposeBrowserSmokeTests.cs index 34573554..d68585e3 100644 --- a/src/NetRatel/NetRatel.Web.PlaywrightTests/LocalFirstComposeBrowserSmokeTests.cs +++ b/src/NetRatel/NetRatel.Web.PlaywrightTests/LocalFirstComposeBrowserSmokeTests.cs @@ -135,11 +135,83 @@ async Task DeliverResponseAsync() await page.GetByTestId("setup-initializing").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); var observedRestartTask = await restartTaskStarted.Task.WaitAsync(TimeSpan.FromMilliseconds(20_000)); await observedRestartTask; - await page.GetByTestId("local-login-email").WaitForAsync(new LocatorWaitForOptions + try { - State = WaitForSelectorState.Visible, - Timeout = 120_000 - }); + await page.GetByTestId("local-login-email").WaitForAsync(new LocatorWaitForOptions + { + State = WaitForSelectorState.Visible, + Timeout = 120_000 + }); + } + catch + { + try + { + // Emit only fixed scalar facts after failure; never capture page content or credentials. + var failureState = await page.EvaluateAsync(""" + async () => { + const observe = selector => { + const element = document.querySelector(selector); + const rect = element?.getBoundingClientRect(); + const visibility = element ? getComputedStyle(element).visibility : null; + return { present: element !== null, visible: Boolean(rect && rect.width > 0 && rect.height > 0 && visibility !== "hidden" && visibility !== "collapse") }; + }; + const setup = window.netratelSetup; + const flag = name => typeof setup?.[name] === "boolean" ? setup[name] : null; + const age = Number.isFinite(setup?.progressStartedAt) && setup.progressStartedAt > 0 + ? Math.min(600, Math.max(0, Math.floor((Date.now() - setup.progressStartedAt) / 1000))) : null; + const result = { + path: location.pathname === "/setup" ? "setup" : location.pathname === "/login" ? "login" : "other", + dom: { + setupWizard: observe('[data-testid="setup-wizard"]'), + setupInitializing: observe('[data-testid="setup-initializing"]'), + setupClientReady: observe('[data-testid="setup-client-ready"]'), + setupProof: observe('[data-testid="setup-proof"]'), + setupOwnerForm: observe('[data-testid="setup-owner-form"]'), + setupClientError: observe("#setup-client-error"), + setupCheckAgain: observe("#setup-check-again"), + setupReviewDetails: observe("#setup-review-details"), + loginEmail: observe('[data-testid="local-login-email"]'), + loginClientReady: observe('[data-testid="local-login-client-ready"]'), + loginTwoFactor: observe('[data-testid="local-login-two-factor"]'), + loginPanel: observe(".netratel-login-panel"), + loginPrimaryAction: observe(".netratel-login-primary-action") + }, + setup: { + present: typeof setup === "object" && setup !== null, + initializeAvailable: typeof setup?.initialize === "function", + pollAvailable: typeof setup?.pollSetup === "function", + initializeInFlight: flag("initializeInFlight"), pollInFlight: flag("pollInFlight"), + stopPolling: flag("stopPolling"), accepted: flag("accepted"), + canReviewDetails: flag("canReviewDetails"), progressAgeSeconds: age + }, + api: { httpCode: -1, stateCode: -1, readyCode: -1, recoveryCode: -1, setupRequiredCode: -1 } + }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + try { + const response = await fetch("/api/v2/setup/status", { credentials: "omit", cache: "no-store", redirect: "manual", signal: controller.signal }); + result.api.httpCode = response.status; + if (response.ok && (response.headers.get("content-type") || "").includes("application/json")) { + const status = await response.json(); + result.api.stateCode = Number.isInteger(status?.state) && status.state >= 0 && status.state <= 3 ? status.state : -1; + result.api.readyCode = typeof status?.isReady === "boolean" ? Number(status.isReady) : -1; + result.api.recoveryCode = typeof status?.isRecoveryRequired === "boolean" ? Number(status.isRecoveryRequired) : -1; + result.api.setupRequiredCode = typeof status?.setupRequired === "boolean" ? Number(status.setupRequired) : -1; + } + } catch { /* Keep the fixed unknown codes without exception text. */ } + finally { clearTimeout(timer); } + return result; + } + """).WaitAsync(TimeSpan.FromSeconds(3)); + TestContext.Current.TestOutputHelper?.WriteLine("LOCAL_FIRST_SETUP_LOGIN_FAILURE_STATE " + JsonSerializer.Serialize(failureState)); + } + catch + { + // A failed observation or output cannot replace the original wait failure. + } + throw; + } Assert.Equal("/login", new Uri(page.Url).AbsolutePath); await page.UnrouteAsync(initializeRoute); Assert.Equal(1, setupSubmissionCount); diff --git a/src/NetRatel/NetRatel.Web.PlaywrightTests/MonitoringResponsiveTests.cs b/src/NetRatel/NetRatel.Web.PlaywrightTests/MonitoringResponsiveTests.cs index 0023d806..420a4aa3 100644 --- a/src/NetRatel/NetRatel.Web.PlaywrightTests/MonitoringResponsiveTests.cs +++ b/src/NetRatel/NetRatel.Web.PlaywrightTests/MonitoringResponsiveTests.cs @@ -8,7 +8,7 @@ namespace NetRatel.Web.PlaywrightTests; [Collection(PlaywrightCollection.Name)] -public sealed class MonitoringResponsiveTests(ClientsManagementBrowserFixture browserFixture) : IClassFixture, IAsyncLifetime +public sealed class MonitoringResponsiveTests(ClientsManagementBrowserFixture browserFixture, ITestOutputHelper testOutputHelper) : IClassFixture, IAsyncLifetime { private ClientsManagementFixtureHost? _host; private readonly FixtureMonitoringApi _api = new(); @@ -131,10 +131,18 @@ await page.WaitForFunctionAsync(""" [Fact] public async Task ClearAndBypassHaveDistinctSemanticsAndHistory() { - var page = await browserFixture.Browser.NewPageAsync(); + var browser = browserFixture.Browser; + var fixture = _host ?? throw new InvalidOperationException("Monitoring fixture was not initialized."); + var page = await browser.NewPageAsync(); + var startupDiagnostics = new ClientsManagementResponsiveTests.BrowserStartupDiagnostics(page); + Exception? testFailureInFlight = null; try { - await page.GotoAsync(_host!.BaseAddress + "/monitoring"); await page.GetByTestId("monitoring-clear").First.WaitForAsync(); + var response = await page.GotoAsync(fixture.BaseAddress + "/monitoring"); + Assert.NotNull(response); + Assert.True(response.Ok, $"Monitoring fixture returned HTTP {response.Status}."); + await page.GetByTestId("monitoring-interactive").WaitForAsync(new() { State = WaitForSelectorState.Attached }); + await page.GetByTestId("monitoring-clear").First.WaitForAsync(); var oldOccurrence = _api.Active.Occurrence!.OccurrenceId; await page.GetByTestId("monitoring-clear").First.ClickAsync(); await Assertions.Expect(page.GetByTestId("monitoring-editor")).ToContainTextAsync("new full breach window"); @@ -155,7 +163,36 @@ public async Task ClearAndBypassHaveDistinctSemanticsAndHistory() await page.GetByTestId("monitoring-tab-history").ClickAsync(); await Assertions.Expect(page.GetByTestId("monitoring-history-row").First).ToContainTextAsync("ManuallyCleared"); } - finally { await page.CloseAsync(); } + catch (Exception exception) + { + testFailureInFlight = exception; + try + { + await new ClientsManagementResponsiveTests(browserFixture, testOutputHelper).CaptureFailureDiagnosticsBestEffortAsync( + browser, + page, + startupDiagnostics, + fixture, + "monitoring-clear-and-bypass-semantics", + page.ViewportSize?.Width ?? 0, + page.ViewportSize?.Height ?? 0, + 100, + exception); + } + catch (Exception) + { + // Best-effort diagnostics must preserve the original test failure. + } + throw; + } + finally + { + try { await page.CloseAsync(); } + catch (Exception) when (testFailureInFlight is not null) + { + // Cleanup must preserve the original test failure. + } + } } [Fact] public async Task DelayedOldTenantReadIsCancelledAndCannotReplaceNewTenant() diff --git a/src/NetRatel/NetRatel.Web.PlaywrightTests/README.md b/src/NetRatel/NetRatel.Web.PlaywrightTests/README.md index e0e71892..0d225997 100644 --- a/src/NetRatel/NetRatel.Web.PlaywrightTests/README.md +++ b/src/NetRatel/NetRatel.Web.PlaywrightTests/README.md @@ -12,6 +12,7 @@ The checks cover: - Flows with the desktop navigation drawer open, narrow layouts, light/dark/system themes and the same paired viewport/device-scale 200% zoom equivalents; every editor control is reachable and Fit graph places all native nodes inside the canvas with their headers and ports unobscured by the collapsible native overview; - preserved dirty edits on close, revision conflicts and tenant changes, inert validation/sample preview, independent clone, immutable published history and keyboard focus restoration; - tenant-scoped Monitoring links to immutable versions and actual run receipts without history fanout, plus wrong-tenant lookup rejection; +- tenant-authorized RatelDesk connector setup through real Routes/MainLayout, explicit mappings, read-only validation, inert preview, password rotation, dirty/conflict retention, pending-write guards, tenant changes and the same 200% zoom equivalents; - dialog/viewport geometry, usable mobile charts and controls, document horizontal overflow, and visible Blazor error UI; - Monitoring tenant-scoped counts, exact rule/group/bypass editors, manual clear/refire history, honest Unknown evidence, cached Services launch, failed draft retention, guarded duplicate saves and late-tenant response cancellation; - Monitoring at desktop with drawer open, light/dark/system themes, narrow viewports and the same true 200% zoom-equivalent geometry; diff --git a/src/NetRatel/NetRatel.Web.PlaywrightTests/RatelDeskConnectorResponsiveTests.cs b/src/NetRatel/NetRatel.Web.PlaywrightTests/RatelDeskConnectorResponsiveTests.cs new file mode 100644 index 00000000..367b490c --- /dev/null +++ b/src/NetRatel/NetRatel.Web.PlaywrightTests/RatelDeskConnectorResponsiveTests.cs @@ -0,0 +1,190 @@ +using System.Text.Json; +using Microsoft.Playwright; +using NetRatel.Shared.Contracts.RatelDesk; +using NetRatel.Web.Services.RatelDesk; + +namespace NetRatel.Web.PlaywrightTests; + +[Collection(PlaywrightCollection.Name)] +public sealed class RatelDeskConnectorResponsiveTests(ClientsManagementBrowserFixture browserFixture) : IClassFixture, IAsyncLifetime +{ + private ClientsManagementFixtureHost? _fixture; + + [Theory] + [InlineData(1280, 800, "light", 1d)] + [InlineData(1280, 800, "dark", 1d)] + [InlineData(390, 844, "system", 1d)] + [InlineData(195, 422, "light", 2d)] + [InlineData(640, 400, "dark", 2d)] + public async Task Actual_routes_and_shell_keep_explicit_mapping_controls_reachable(int width, int height, string theme, double dpr) + { + await using var context = await browserFixture.Browser.NewContextAsync(new() + { ViewportSize = new() { Width = width, Height = height }, DeviceScaleFactor = (float)dpr, ColorScheme = ColorScheme.Light }); + var page = await context.NewPageAsync(); page.SetDefaultTimeout(30_000); + var errors = new List(); page.PageError += (_, error) => errors.Add(error); + await OpenAsync(page); + await SetThemeAsync(page, theme); + var drawer = page.GetByTestId("app-navigation-drawer"); + var closed = await drawer.EvaluateAsync("e => e.classList.contains('mud-drawer--closed')"); + if (width >= 1280 && closed || width < 1280 && !closed) await page.GetByTestId("navigation-toggle").ClickAsync(); + await page.WaitForFunctionAsync("() => document.documentElement.scrollWidth <= innerWidth"); + foreach (var label in new[] { "Name", "Approved API base", "Organization ID", "Customer ID", "Assignment ID (optional)", "Category IDs (optional, comma separated GUIDs)", "Information priority", "Warning priority", "Error priority", "Critical priority" }) + await ReachableAsync(page.GetByLabel(label, new() { Exact = true }), width, height); + foreach (var id in new[] { "connector-tenant", "connector-save", "connector-credential", "connector-test", "connector-dry-run" }) + await ReachableAsync(page.GetByTestId(id), width, height); + await page.GetByTestId("connector-test").ClickAsync(); + await Assertions.Expect(page.GetByTestId("connector-notice")).ToContainTextAsync("did not establish receiver delivery readiness"); + await page.GetByTestId("connector-dry-run").ClickAsync(); + await Assertions.Expect(page.GetByTestId("connector-preview")).ToContainTextAsync("no incident sent"); + Assert.Equal(1, _fixture!.ConnectorsData.TestCalls); Assert.Equal(1, _fixture.ConnectorsData.PreviewCalls); + await AssertNoOverflowAsync(page); Assert.Empty(errors); + Assert.False(await page.Locator("#blazor-error-ui").IsVisibleAsync()); + var root = EvidenceRoot(); Directory.CreateDirectory(root); + var name = $"connectors-{width}x{height}-{theme}-dpr{dpr}"; + await page.EvaluateAsync("() => scrollTo(0,0)"); + await page.ScreenshotAsync(new() { Path = Path.Combine(root, name + ".png"), FullPage = true, Animations = ScreenshotAnimations.Disabled }); + await page.ScreenshotAsync(new() { Path = Path.Combine(root, name + "-viewport.png"), Animations = ScreenshotAnimations.Disabled }); + var geometry = await page.EvaluateAsync(""" + () => { + const bounds=e=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height}}; + return {cssWidth:innerWidth,cssHeight:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize, + documentWidth:document.documentElement.scrollWidth,tenant:document.querySelector('[data-testid=connector-tenant]').value, + bodyFont:getComputedStyle(document.body).fontSize,headingFont:getComputedStyle(document.querySelector('.rd-connectors h1')).fontSize, + appMain:bounds(document.querySelector('[data-testid=app-main-content]')),main:bounds(document.querySelector('.rd-connectors')),form:bounds(document.querySelector('.rd-grid')), + fields:[...document.querySelectorAll('.rd-connectors fieldset input:not([type=checkbox]),.rd-connectors fieldset select,.rd-connectors textarea')] + .map(e=>({tag:e.tagName,label:e.getAttribute('aria-label')||e.closest('label')?.firstChild?.textContent?.trim()||'Credential',...bounds(e)}))}; + } + """); + await File.WriteAllTextAsync(Path.Combine(root, name + ".json"), geometry.GetRawText()); + Assert.Equal(width, geometry.GetProperty("cssWidth").GetInt32()); Assert.Equal(dpr, geometry.GetProperty("dpr").GetDouble()); Assert.Equal("16px", geometry.GetProperty("rootFont").GetString()); + Assert.Equal("16px", geometry.GetProperty("bodyFont").GetString()); + if (width <= 260) + { + foreach (var field in geometry.GetProperty("fields").EnumerateArray()) + Assert.True(field.GetProperty("width").GetDouble() >= 140, $"Narrow field {field.GetProperty("label").GetString()} has insufficient editable width."); + Assert.True(double.Parse(geometry.GetProperty("headingFont").GetString()!.Replace("px", ""), System.Globalization.CultureInfo.InvariantCulture) >= 20); + } + await page.GetByLabel("Organization ID", new() { Exact = true }).ScrollIntoViewIfNeededAsync(); + await page.ScreenshotAsync(new() { Path = Path.Combine(root, name + "-mapping-viewport.png"), Animations = ScreenshotAnimations.Disabled }); + if (theme == "system") + { + await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Light }); + await page.Locator("html[data-netratel-theme=light]").WaitForAsync(); await AssertNoOverflowAsync(page); + } + } + + [Fact] + public async Task Password_rotation_dirty_conflict_pending_write_and_tenant_change_preserve_the_boundary() + { + var fixture = _fixture!; + await using var context = await browserFixture.Browser.NewContextAsync(new() { ViewportSize = new() { Width = 1280, Height = 800 } }); + var page = await context.NewPageAsync(); page.SetDefaultTimeout(30_000); await OpenAsync(page); + var password = page.GetByTestId("connector-credential"); + await password.FillAsync(FixtureRatelDeskConnectorApiService.SyntheticCredential); + await Assertions.Expect(page.GetByTestId("connector-rotate")).ToBeEnabledAsync(); + Assert.Equal("password", await password.GetAttributeAsync("type")); + Assert.False((await page.Locator("body").InnerTextAsync()).Contains(FixtureRatelDeskConnectorApiService.SyntheticCredential, StringComparison.Ordinal)); + Assert.DoesNotContain(FixtureRatelDeskConnectorApiService.SyntheticCredential, await page.ContentAsync()); + await page.GetByTestId("connector-rotate").ClickAsync(); + await Assertions.Expect(page.GetByTestId("connector-notice")).ToContainTextAsync("stored protected"); + await Assertions.Expect(password).ToHaveValueAsync(""); + Assert.True(fixture.ConnectorsData.ReceivedExpectedCredential); + Assert.DoesNotContain(FixtureRatelDeskConnectorApiService.SyntheticCredential, fixture.ConnectorsData.GetResponseJson); + Assert.DoesNotContain(FixtureRatelDeskConnectorApiService.SyntheticCredential, page.Url); + Assert.False(await page.EvaluateAsync("secret => JSON.stringify(history.state).includes(secret)", FixtureRatelDeskConnectorApiService.SyntheticCredential)); + + await page.GetByTestId("connector-name").FillAsync("Retain this explicit mapping"); + await page.GetByTestId("connector-name").PressAsync("Tab"); fixture.ConnectorsData.FailNextSave = true; + await page.GetByTestId("connector-save").ClickAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Alert).Filter(new() { HasText = "revision changed" })).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByTestId("connector-name")).ToHaveValueAsync("Retain this explicit mapping"); + await page.GetByTestId("connector-tenant").SelectOptionAsync("23"); + await page.GetByRole(AriaRole.Button, new() { Name = "Keep editing", Exact = true }).ClickAsync(); + await Assertions.Expect(page.GetByTestId("connector-name")).ToHaveValueAsync("Retain this explicit mapping"); + fixture.ConnectorsData.PreparePendingSave(); + await page.GetByTestId("connector-save").ClickAsync(); await fixture.ConnectorsData.SaveStarted.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await Assertions.Expect(page.GetByTestId("connector-save")).ToBeDisabledAsync(); + await Assertions.Expect(page.GetByTestId("connector-tenant")).ToBeDisabledAsync(); + Assert.Equal(2, fixture.ConnectorsData.SaveCalls); Assert.Equal(17, fixture.ConnectorsData.LastSaveTenant); + fixture.ConnectorsData.CompletePendingSave(); + await Assertions.Expect(page.GetByTestId("connector-save")).ToBeEnabledAsync(); + await page.GetByTestId("connector-tenant").SelectOptionAsync("23"); + await Assertions.Expect(page.GetByTestId("connector-name")).ToHaveValueAsync("Tenant 23 connector"); + await Assertions.Expect(password).ToHaveValueAsync(""); + Assert.DoesNotContain("Retain this explicit mapping", await page.ContentAsync()); + Assert.DoesNotContain(FixtureRatelDeskConnectorApiService.SyntheticCredential, await page.ContentAsync()); + Assert.Equal("/flows?tenantId=23", await page.GetByRole(AriaRole.Link, new() { Name = "Return to Flows" }).GetAttributeAsync("href")); + var root = EvidenceRoot(); Directory.CreateDirectory(root); + await page.EvaluateAsync("() => scrollTo(0,0)"); + await page.ScreenshotAsync(new() { Path = Path.Combine(root, "connectors-rotation-tenant.png"), FullPage = true }); + await File.WriteAllTextAsync(Path.Combine(root, "connectors-boundaries.json"), JsonSerializer.Serialize(new { fixture.ConnectorsData.SaveCalls, fixture.ConnectorsData.LastSaveTenant, fixture.ConnectorsData.RotateCalls, receiverIncidentPosts = 0, credentialInGet = false, credentialInHistory = false })); + Assert.DoesNotContain(fixture.StartupServerDiagnostics, m => m.Contains(FixtureRatelDeskConnectorApiService.SyntheticCredential, StringComparison.Ordinal)); + } + + private async Task OpenAsync(IPage page) + { + await page.GotoAsync(_fixture!.BaseAddress + "/flows/connectors?tenantId=17", new() { WaitUntil = WaitUntilState.DOMContentLoaded }); + await page.GetByTestId("connector-revisions").WaitForAsync(new() { Timeout = 90_000 }); + } + private static async Task ReachableAsync(ILocator control, int width, int height) + { + await control.ScrollIntoViewIfNeededAsync(); await control.FocusAsync(); + var r = (await control.BoundingBoxAsync())!; + Assert.True(r.Width > 0 && r.X >= -1 && r.X + r.Width <= width + 1); + Assert.True(r.Y < height && r.Y + r.Height > 0); + } + private static async Task AssertNoOverflowAsync(IPage page) => Assert.False(await page.EvaluateAsync("() => document.documentElement.scrollWidth > innerWidth")); + private static async Task SetThemeAsync(IPage page, string theme) + { + await page.EvaluateAsync("() => scrollTo(0,0)"); + var mobile = await page.GetByTestId("mobile-overflow").IsVisibleAsync(); + await page.GetByTestId(mobile ? "mobile-overflow" : "theme-preference-menu").ClickAsync(); + await page.GetByTestId((mobile ? "mobile-theme-option-" : "theme-option-") + theme).ClickAsync(); + if (theme == "system") await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Dark }); + await page.Locator("html[data-netratel-theme=" + (theme == "system" ? "dark" : theme) + "]").WaitForAsync(); + } + private static string EvidenceRoot() => Environment.GetEnvironmentVariable("NETRATEL_PLAYWRIGHT_ARTIFACT_ROOT") is { Length: > 0 } configured ? configured : Path.Combine(AppContext.BaseDirectory, "TestResults", "playwright"); + public async ValueTask InitializeAsync() => _fixture = await ClientsManagementFixtureHost.StartAsync(); + public async ValueTask DisposeAsync() { if (_fixture is not null) await _fixture.DisposeAsync(); } +} + +internal sealed class FixtureRatelDeskConnectorApiService : IRatelDeskConnectorApiService +{ + public const string SyntheticCredential = "rdk_synthetic_browser_password_never_in_receipts"; + private readonly Dictionary _connectors = new() { [17] = Create(17), [23] = Create(23) }; + private TaskCompletionSource? _pendingSave; + public TaskCompletionSource SaveStarted { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool FailNextSave, ReceivedExpectedCredential; + public int SaveCalls, LastSaveTenant, RotateCalls, TestCalls, PreviewCalls; + public string GetResponseJson => JsonSerializer.Serialize(_connectors.Values); + public void PreparePendingSave() { SaveStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); _pendingSave = new(TaskCreationOptions.RunContinuationsAsynchronously); } + public void CompletePendingSave() => _pendingSave?.TrySetResult(true); + public Task GetSetupAsync(int tenantId, CancellationToken ct) => Task.FromResult(new RatelDeskConnectorSetupDto( + Guid.Parse("11111111-1111-1111-1111-111111111111"), "22222222-2222-2222-2222-222222222222", null, 1, [])); + public Task AdoptFlowSourceAsync(int tenantId, AdoptRatelDeskFlowSourceRequest request, CancellationToken ct) => + Task.FromResult(new RatelDeskConnectorSetupDto(Guid.Parse("11111111-1111-1111-1111-111111111111"), + "22222222-2222-2222-2222-222222222222", "11111111-1111-1111-1111-111111111111", request.ExpectedIdentityRevision + 1, [])); + public Task> GetTenantsAsync(CancellationToken ct) => Task.FromResult>([new(17, "Explicit NetRatel tenant 17"), new(23, "Explicit NetRatel tenant 23")]); + public Task> ListAsync(int tenantId, CancellationToken ct) => Task.FromResult>([_connectors[tenantId]]); + public async Task SaveAsync(int tenantId, Guid id, SaveRatelDeskConnectorRequest request, CancellationToken ct) + { + SaveCalls++; LastSaveTenant = tenantId; + if (FailNextSave) { FailNextSave = false; throw new RatelDeskConnectorApiException("connector-conflict"); } + if (_pendingSave is not null) { SaveStarted.TrySetResult(true); await _pendingSave.Task.WaitAsync(ct); _pendingSave = null; } + return _connectors[tenantId] = _connectors[tenantId] with { Id = id, Configuration = request.Configuration, Revision = request.ExpectedRevision + 1 }; + } + public Task RotateAsync(int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, CancellationToken ct) + { + RotateCalls++; ReceivedExpectedCredential = request.Credential == SyntheticCredential; + return Task.FromResult(_connectors[tenantId] = _connectors[tenantId] with { HasCredential = true, CredentialRevision = request.ExpectedCredentialRevision + 1 }); + } + public Task TestAsync(int tenantId, Guid id, CancellationToken ct) + { TestCalls++; return Task.FromResult(new RatelDeskConnectionTestResult(RatelDeskConnectionTestStatus.MappingValidated, "mapping-validated")); } + public Task DryRunAsync(int tenantId, Guid id, RatelDeskDryRunRequest request, CancellationToken ct) + { + PreviewCalls++; var c = _connectors[tenantId].Configuration; + return Task.FromResult(new RatelDeskDryRunResult(new(request.Title, "Plain sanitized description", request.Priority, c.CustomerId, c.OrganizationId, c.AssignedToId, c.CategoryIds), "fixture-fingerprint")); + } + private static RatelDeskConnectorDto Create(int tenant) => new(Guid.Parse($"00000000-0000-0000-0000-{tenant:D12}"), tenant, 1, + new($"Tenant {tenant} connector", "https://approved-helpdesk.example.test", "explicit-organization-id", "explicit-requester-customer-id", "optional-assignee-id", [Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")], new(), true), true, 1, false, RatelDeskConnectorLimits.ReceiverUnavailableCode); +} diff --git a/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor b/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor new file mode 100644 index 00000000..3ef15be6 --- /dev/null +++ b/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor @@ -0,0 +1,358 @@ +@page "/flows/connectors" +@attribute [Authorize] +@rendermode InteractiveServer +@implements IDisposable +@using NetRatel.Shared.Contracts.RatelDesk +@using NetRatel.Web.Services.RatelDesk +@using NetRatel.Web.Components.Shared.ServiceLinks +@inject IRatelDeskConnectorApiService Api + +RatelDesk connectors +
+

RatelDesk connectors

+

Configure an incident destination for a tenant. Return to Flows

+
+ @(_saved?.AutomaticDeliveryAvailable == true ? "The saved receiver and target passed the authenticated read-only check." : + "Delivery needs a current approved connection or registered API credential and a verified receiver check.") + @if (_saved?.ReadinessObservedAtUtc is { } observed) { Last verified @observed.ToString("u"). } + Each delivery checks current access and uses its saved destination. +
+ @if (_error is not null) { } + @if (_notice is not null) {

@_notice

} + @if (_tenants.Count == 0 && !_loading) {

No tenants with connector management access are available.

} + + + @if (_pendingTenant is not null) + { + + } + @if (_loading) {

Loading connector settings…

} + @if (_tenantId > 0 && !_loading) + { +
+ + + +
+
+ + + @if (_showConnections) { } + @if (_setup is not null) + { +

Flow producer @_setup.FlowSourceInstanceId · installation @_setup.InstallationInstanceId

+ @if (_setup.AdoptedSourceInstanceId is null) + { + +

This needs server-wide integration management access. The existing Flow identity is preserved.

+ } + else if (_setup.AdoptedSourceInstanceId != _setup.FlowSourceInstanceId.ToString("D")) + {

A different producer is already approved. Restore the matching approved identity before pairing.

} +

For API credential mode, a RatelDesk administrator must register this Flow producer against the dedicated API credential.

+ } +
+
+ Destination and mapping + + @if (_authenticationMode == "service_link") + { + +

The connection supplies its protected credentials. Rotate it in reciprocal connection management.

+ } + + +

Use the approved API base, including its configured path base. Organization and customer must match the selected connection or registered source.

+ + + + + + + + + +
+ + @if (_saved is not null) + { +

Settings revision @_saved.Revision · @(_authenticationMode == "service_link" ? "Credentials supplied by the approved connection" : + $"API credential {(_saved.HasCredential ? "stored" : "required")} · credential revision {_saved.CredentialRevision}")

+ @if (_authenticationMode == "api_bearer") + { +
+ RatelDesk API credential + + + +

The credential is stored protected. Saved settings return only its presence and revision.

+
+ } + +

This authenticates receiver capabilities and validates the exact source and target. It creates no incident.

+
+ Incident preview + + + +
+ + @if (_preview is not null) + { +
+

Preview only — no incident sent

+
+
Title
@_preview.Payload.Title
+
Description
@_preview.Payload.Description
+
Priority
@_preview.Payload.Priority
+
Organization / customer
@_preview.Payload.OrganizationId / @_preview.Payload.CustomerId
+
Assignment
@(_preview.Payload.AssignedToId ?? "None")
+
Categories
@string.Join(", ", _preview.Payload.CategoryIds)
+
Preview receipt
dry-run-only
+
+
+ } + } + } +
+ +@code { + [SupplyParameterFromQuery(Name = "tenantId")] public int? RequestedTenantId { get; set; } + private IReadOnlyList _tenants = []; + private IReadOnlyList _connectors = []; + private CancellationTokenSource _scope = new(); + private int _generation, _tenantId, _credentialInputRevision; + private int? _pendingTenant, _requestedTenantProcessed; + private bool _initialized, _loading = true, _busy, _disposed, _dirty, _enabled = true, _showConnections; + private RatelDeskConnectorSetupDto? _setup; + private string _authenticationMode = "api_bearer", _managedLinkId = ""; + private Guid _id; + private RatelDeskConnectorDto? _saved; + private RatelDeskDryRunResult? _preview; + private string? _error, _notice, _credential; + private string _name = "", _origin = "", _organization = "", _customer = "", _assignment = "", _categories = ""; + private int _information, _warning = 1, _errorPriority = 2, _critical = 3, _previewPriority = 1; + private string _previewTitle = "NetRatel alert preview", _previewDescription = "Preview only. No incident will be created."; + + protected override async Task OnInitializedAsync() + { + _requestedTenantProcessed = RequestedTenantId; + var generation = _generation; + try + { + var tenants = await Api.GetTenantsAsync(_scope.Token); + if (!Current(generation)) return; + _tenants = tenants.Where(t => t.TenantId > 0).ToArray(); + var id = RequestedTenantId is { } requested ? (_tenants.Any(t => t.TenantId == requested) ? requested : 0) : _tenants.FirstOrDefault()?.TenantId ?? 0; + if (RequestedTenantId is not null && id == 0) _error = "The requested tenant is not available with your current connector management access."; + if (id > 0) await LoadTenantAsync(id); + } + catch (Exception e) when (IsExpected(e)) { if (Current(generation)) _error = ErrorText(e); } + finally { if (Current(generation)) _loading = false; _initialized = true; } + } + protected override Task OnParametersSetAsync() => ApplyRequestedTenantAsync(); + private async Task ApplyRequestedTenantAsync() + { + if (!_initialized || _disposed || _busy || RequestedTenantId == _requestedTenantProcessed) return; + _requestedTenantProcessed = RequestedTenantId; + if (RequestedTenantId is not { } id || id == _tenantId) return; + if (!_tenants.Any(t => t.TenantId == id)) + { _error = "The requested tenant is not available with your current connector management access."; return; } + await TenantChangedAsync(new ChangeEventArgs { Value = id.ToString(System.Globalization.CultureInfo.InvariantCulture) }); + } + private static RenderFragment PriorityOptions() => @ + + ; + private async Task TenantChangedAsync(ChangeEventArgs e) + { + if (_busy || _disposed || !int.TryParse(e.Value?.ToString(), out var id) || id == _tenantId || !_tenants.Any(t => t.TenantId == id)) return; + if (_dirty) { _pendingTenant = id; return; } + await LoadTenantAsync(id); + } + private void KeepEditing() => _pendingTenant = null; + private async Task DiscardAndChangeAsync() + { + if (_busy || _pendingTenant is not { } id) return; + _pendingTenant = null; await LoadTenantAsync(id); + } + private async Task LoadTenantAsync(int id) + { + _scope.Cancel(); _scope.Dispose(); _scope = new(); var generation = ++_generation; + _tenantId = id; _setup = null; _showConnections = false; _loading = true; _error = _notice = null; _connectors = []; ResetDraft(); + try + { + var list = await Api.ListAsync(id, _scope.Token); + if (!Current(generation)) return; + if (list.Any(c => c.TenantId != id)) throw new RatelDeskConnectorApiException("invalid-response"); + _connectors = list; + if (list.Count > 0) Apply(list[0]); + } + catch (Exception e) when (IsExpected(e)) { if (Current(generation)) _error = ErrorText(e); } + finally { if (Current(generation)) _loading = false; } + } + private void ConnectorChanged(ChangeEventArgs e) + { + if (_busy || _dirty || !Guid.TryParse(e.Value?.ToString(), out var id)) return; + if (id == Guid.Empty) NewConnector(); else if (_connectors.FirstOrDefault(c => c.Id == id) is { } connector) Apply(connector); + } + private void NewConnector() { if (!_busy && !_dirty) { ResetDraft(); _error = _notice = null; } } + private void ResetDraft() + { + _id = Guid.Empty; _saved = null; _preview = null; _dirty = false; ForgetCredential(); + _authenticationMode = "api_bearer"; _managedLinkId = ""; + _name = _origin = _organization = _customer = _assignment = _categories = ""; + _information = 0; _warning = 1; _errorPriority = 2; _critical = 3; _enabled = true; + } + private void Apply(RatelDeskConnectorDto dto) + { + _id = dto.Id; _saved = dto; _dirty = false; _preview = null; ForgetCredential(); + _authenticationMode = dto.Authentication?.Mode ?? "api_bearer"; _managedLinkId = dto.Authentication?.ManagedLinkId ?? ""; + var c = dto.Configuration; + _name = c.Name; _origin = c.Origin; _organization = c.OrganizationId; _customer = c.CustomerId; + _assignment = c.AssignedToId ?? ""; _categories = string.Join(", ", c.CategoryIds); + _information = c.Priorities.Information; _warning = c.Priorities.Warning; _errorPriority = c.Priorities.Error; _critical = c.Priorities.Critical; _enabled = c.Enabled; + } + private async Task LoadSetupAsync() + { + if (!CanStart()) return; + await RunAsync(async (tenant, ct) => + { + var setup = await Api.GetSetupAsync(tenant, ct); + if (setup.FlowSourceInstanceId == Guid.Empty || !Guid.TryParseExact(setup.InstallationInstanceId, "D", out _)) + throw new RatelDeskConnectorApiException("invalid-response"); + return () => _setup = setup; + }); + } + private async Task AdoptFlowSourceAsync() + { + if (!CanStart() || _dirty || _setup is null || _setup.AdoptedSourceInstanceId is not null) return; + var expected = _setup.IdentityRevision; + await RunAsync(async (tenant, ct) => + { + var setup = await Api.AdoptFlowSourceAsync(tenant, new(expected), ct); + return () => { _setup = setup; _notice = "The existing Flow producer is approved. Continue reciprocal connection setup."; }; + }); + } + private void ManagedLinkChanged(ChangeEventArgs change) + { + if (_busy) return; + var value = change.Value?.ToString() ?? ""; + var link = _setup?.ManagedLinks.SingleOrDefault(x => x.LinkId == value); + _managedLinkId = link?.LinkId ?? ""; + if (link is not null) { _origin = link.ApiBaseUrl; _organization = link.OrganizationId; _customer = link.CustomerId; } + MarkDirty(); + } + private void MarkDirty() { _dirty = true; _preview = null; _notice = null; } + private void CredentialChanged(ChangeEventArgs e) => _credential = e.Value?.ToString(); + private void ForgetCredential() { _credential = null; _credentialInputRevision++; } + private async Task SaveAsync() + { + if (!CanStart()) return; + Guid[] categories; + try { categories = string.IsNullOrWhiteSpace(_categories) ? [] : _categories.Split(',', StringSplitOptions.TrimEntries).Select(Guid.Parse).ToArray(); } + catch (FormatException) { _error = "Enter category IDs as comma separated GUIDs."; return; } + var id = _id == Guid.Empty ? Guid.NewGuid() : _id; + _id = id; // Preserve a new connector's identity if a response is lost after its revision-fenced write. + var request = new SaveRatelDeskConnectorRequest(_saved?.Revision ?? 0, new(_name.Trim(), _origin.Trim(), _organization.Trim(), _customer.Trim(), + string.IsNullOrWhiteSpace(_assignment) ? null : _assignment.Trim(), categories, new(_information, _warning, _errorPriority, _critical), _enabled), + new(_authenticationMode, _authenticationMode == "service_link" ? _managedLinkId : null)); + await RunAsync(async (tenant, ct) => + { + var dto = await Api.SaveAsync(tenant, id, request, ct); + ValidateDto(dto, tenant, id); + return () => { Upsert(dto); Apply(dto); _notice = "Settings saved. Run the authenticated receiver check before delivery."; }; + }); + } + private async Task RotateAsync() + { + if (!CanStart() || _saved is null || _dirty || string.IsNullOrWhiteSpace(_credential)) return; + var id = _saved.Id; + var request = new RotateRatelDeskConnectorCredentialRequest(_saved.CredentialRevision, _credential); + ForgetCredential(); + await RunAsync(async (tenant, ct) => + { + var dto = await Api.RotateAsync(tenant, id, request, ct); ValidateDto(dto, tenant, id); + return () => { Upsert(dto); Apply(dto); _notice = "Credential stored protected. Run the authenticated receiver check before delivery."; }; + }); + } + private async Task TestAsync() + { + if (!CanStart() || _saved is null || _dirty || (_authenticationMode == "api_bearer" && !_saved.HasCredential)) return; + var id = _saved.Id; + await RunAsync(async (tenant, ct) => + { + var result = await Api.TestAsync(tenant, id, ct); + return () => + { + _saved = _saved! with { AutomaticDeliveryAvailable = result.AutomaticDeliveryAvailable, AvailabilityCode = result.Code }; + Upsert(_saved); + _notice = result.Status switch + { + RatelDeskConnectionTestStatus.MappingValidated => result.AutomaticDeliveryAvailable + ? "Receiver and exact target verified. Delivery rechecks current access for each saved action." + : "The check did not establish receiver delivery readiness.", + RatelDeskConnectionTestStatus.AuthenticationRejected => "RatelDesk rejected the API credential. Check its purpose and current access.", + RatelDeskConnectionTestStatus.MappingRejected => "The saved mapping could not be validated. Check organization, customer, assignment and categories.", + _ => "The read-only check is unavailable. Check the saved credential, operator-approved origin and current access." + }; + }; + }); + } + private async Task DryRunAsync() + { + if (!CanStart() || _saved is null || _dirty) return; + var id = _saved.Id; var request = new RatelDeskDryRunRequest(_previewTitle, _previewDescription, _previewPriority); + await RunAsync(async (tenant, ct) => + { + var result = await Api.DryRunAsync(tenant, id, request, ct); + if (result.SendsIncident || result.AutomaticDeliveryAvailable || result.FakeIncidentId != "dry-run-only") throw new RatelDeskConnectorApiException("invalid-response"); + return () => { _preview = result; _notice = "Preview prepared. No incident sent."; }; + }); + } + private async Task RunAsync(Func> action) + { + var generation = _generation; var tenant = _tenantId; _busy = true; _error = _notice = null; + try { var apply = await action(tenant, _scope.Token); if (Current(generation)) apply(); } + catch (Exception e) when (IsExpected(e)) { if (Current(generation)) _error = ErrorText(e); } + finally { if (Current(generation)) { _busy = false; await ApplyRequestedTenantAsync(); } } + } + private bool CanStart() => !_disposed && !_busy && !_loading && _tenantId > 0; + private bool Current(int generation) => !_disposed && generation == _generation; + private void Upsert(RatelDeskConnectorDto dto) => _connectors = _connectors.Where(c => c.Id != dto.Id).Append(dto).OrderBy(c => c.Configuration.Name).ToArray(); + private static void ValidateDto(RatelDeskConnectorDto dto, int tenant, Guid id) + { if (dto.TenantId != tenant || dto.Id != id) throw new RatelDeskConnectorApiException("invalid-response"); } + private static bool IsExpected(Exception e) => e is RatelDeskConnectorApiException or HttpRequestException or IOException or OperationCanceledException; + private static string ErrorText(Exception e) => e is RatelDeskConnectorApiException api ? api.Code switch + { + "source-identity-conflict" => "A different incident producer is already approved. Existing identities are preserved; review the approved mapping before new pairing.", + "identity-revision-conflict" => "The producer revision changed. Reload approved connections and producer before trying again.", + "connector-conflict" => "The saved revision changed. Your draft is retained; reload this page before applying it again.", + "invalid-connector-request" => "Review the approved HTTPS origin, required IDs, category IDs and priorities. Your settings draft is retained.", + "access-denied" => "Your current access does not allow this connector operation.", + "connector-not-found" => "This connector is no longer available.", + "connector-capacity-exhausted" => "The tenant connector limit has been reached or exceeded. Your draft is retained; contact an administrator before adding another connector.", + "connector-busy" => "The connector is busy. Try again later.", + _ => "The connector response could not be verified. Your settings draft is retained." + } : "The connector request did not complete. Your settings draft is retained; check the current saved revision before retrying a write."; + public void Dispose() { if (_disposed) return; _disposed = true; _generation++; _scope.Cancel(); _scope.Dispose(); ForgetCredential(); _preview = null; } +} diff --git a/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor.css b/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor.css new file mode 100644 index 00000000..200f753b --- /dev/null +++ b/src/NetRatel/NetRatel.Web/Components/Pages/Flows/RatelDeskConnectors.razor.css @@ -0,0 +1,19 @@ +.rd-connectors { max-width: 68rem; min-width: 0; margin: auto; padding: 1.5rem; overflow-wrap: anywhere; } +.rd-connectors h1 { font-size: 1.75rem; margin-bottom: .75rem; } +.rd-connectors h2 { font-size: 1.2rem; } +.rd-connectors p { margin: .7rem 0; } +.rd-notice, .rd-error { padding: .9rem; margin: 1rem 0; border: 1px solid #d89025; border-radius: .4rem; background: rgba(216, 144, 37, .12); } +.rd-error { border-color: #dc5656; background: rgba(220, 86, 86, .1); } +.rd-connectors label { display: block; } +.rd-connectors input:not([type="checkbox"]), .rd-connectors select, .rd-connectors textarea { display: block; box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; padding: .6rem; border: 1px solid #888; border-radius: .3rem; color: inherit; background: var(--mud-palette-surface, #fff); } +.rd-connectors select { max-width: 32rem; } +.rd-connectors button { padding: .6rem .9rem; margin: .5rem .5rem .5rem 0; border: 1px solid #888; border-radius: .3rem; color: inherit; background: var(--mud-palette-surface, #fff); cursor: pointer; } +.rd-connectors button:disabled { cursor: default; opacity: .55; } +.rd-connectors fieldset { min-width: 0; border: 1px solid #888; border-radius: .4rem; padding: 1rem; margin: 1rem 0; } +.rd-connectors legend { max-width: 100%; } +.rd-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; } +.rd-wide { grid-column: 1 / -1; } +.rd-description { white-space: pre-wrap; overflow-wrap: anywhere; } +.rd-preview dd { margin: 0 0 .6rem; overflow-wrap: anywhere; } +@media (max-width: 40rem) { .rd-grid { grid-template-columns: minmax(0, 1fr); } .rd-connectors { padding: 1rem; } } +@media (max-width: 16rem) { .rd-connectors { padding: .5rem 0; } .rd-connectors fieldset { padding: .5rem; } .rd-connectors h1 { font-size: 1.4rem; } } diff --git a/src/NetRatel/NetRatel.Web/Program.cs b/src/NetRatel/NetRatel.Web/Program.cs index b213384c..f0c5ee92 100644 --- a/src/NetRatel/NetRatel.Web/Program.cs +++ b/src/NetRatel/NetRatel.Web/Program.cs @@ -18,6 +18,7 @@ using NetRatel.Web.Services; using NetRatel.Web.Services.Clients; using NetRatel.Web.Services.Tenants; +using NetRatel.Web.Services.RatelDesk; using NetRatel.Web.Services.Requests; using NetRatel.Web.Services.ClientTasks; using NetRatel.Web.Services.Terminal; @@ -110,6 +111,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -360,6 +362,15 @@ builder.Services.AddFlowsApiClient(builder.Configuration); builder.Services.AddMonitoringApiClient(builder.Configuration); +// Connector credentials and revision-fenced writes must never be replayed by redirects or transport retries. +builder.Services.AddHttpClient(RatelDeskConnectorApiService.ClientName, c => +{ + c.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://localhost:5001/"); + c.Timeout = TimeSpan.FromSeconds(30); +}) +.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }) +.AddHttpMessageHandler() +.RemoveAllResilienceHandlers(); builder.Services.AddHttpClient("Bff", (sp, c) => { diff --git a/src/NetRatel/NetRatel.Web/Services/Authentication/OperatorApiHttpClientFactory.cs b/src/NetRatel/NetRatel.Web/Services/Authentication/OperatorApiHttpClientFactory.cs index cbda2bdd..882a886d 100644 --- a/src/NetRatel/NetRatel.Web/Services/Authentication/OperatorApiHttpClientFactory.cs +++ b/src/NetRatel/NetRatel.Web/Services/Authentication/OperatorApiHttpClientFactory.cs @@ -42,7 +42,8 @@ public HttpClient CreateClient(string name) private static bool RequiresOperatorCredential(string name) => name is "OrchestratorApi" or "OrchestratorApiStreaming" or "ServiceLinkApi" or NetRatel.Web.Services.Flows.FlowApiService.ClientName or - NetRatel.Web.Services.Monitoring.MonitoringApiService.ClientName || + NetRatel.Web.Services.Monitoring.MonitoringApiService.ClientName or + NetRatel.Web.Services.RatelDesk.RatelDeskConnectorApiService.ClientName || string.Equals(name, DownloadClientName, StringComparison.Ordinal); private sealed class PooledMessageHandlerLease(HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler) diff --git a/src/NetRatel/NetRatel.Web/Services/RatelDesk/IRatelDeskConnectorApiService.cs b/src/NetRatel/NetRatel.Web/Services/RatelDesk/IRatelDeskConnectorApiService.cs new file mode 100644 index 00000000..9f763c59 --- /dev/null +++ b/src/NetRatel/NetRatel.Web/Services/RatelDesk/IRatelDeskConnectorApiService.cs @@ -0,0 +1,20 @@ +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Web.Services.RatelDesk; + +public interface IRatelDeskConnectorApiService +{ + Task> GetTenantsAsync(CancellationToken ct); + Task GetSetupAsync(int tenantId, CancellationToken ct); + Task AdoptFlowSourceAsync(int tenantId, AdoptRatelDeskFlowSourceRequest request, CancellationToken ct); + Task> ListAsync(int tenantId, CancellationToken ct); + Task SaveAsync(int tenantId, Guid id, SaveRatelDeskConnectorRequest request, CancellationToken ct); + Task RotateAsync(int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, CancellationToken ct); + Task TestAsync(int tenantId, Guid id, CancellationToken ct); + Task DryRunAsync(int tenantId, Guid id, RatelDeskDryRunRequest request, CancellationToken ct); +} + +public sealed class RatelDeskConnectorApiException(string code) : Exception(code) +{ + public string Code { get; } = code; +} diff --git a/src/NetRatel/NetRatel.Web/Services/RatelDesk/RatelDeskConnectorApiService.cs b/src/NetRatel/NetRatel.Web/Services/RatelDesk/RatelDeskConnectorApiService.cs new file mode 100644 index 00000000..f4c2e182 --- /dev/null +++ b/src/NetRatel/NetRatel.Web/Services/RatelDesk/RatelDeskConnectorApiService.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using NetRatel.Shared.Contracts.RatelDesk; + +namespace NetRatel.Web.Services.RatelDesk; + +public sealed class RatelDeskConnectorApiService(IHttpClientFactory clients) : IRatelDeskConnectorApiService +{ + public const string ClientName = "RatelDeskConnectorApi"; + public const int MaximumResponseBytes = 1_048_576; // Includes a bounded tenant list of at most 256 connectors. + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + public Task> GetTenantsAsync(CancellationToken ct) => + ReadListAsync("/api/v2/connectors/rateldesk/tenants", ct); + public Task GetSetupAsync(int tenantId, CancellationToken ct) => + SendAsync(HttpMethod.Get, Path(tenantId) + "/setup", null, ct); + public Task AdoptFlowSourceAsync(int tenantId, AdoptRatelDeskFlowSourceRequest request, CancellationToken ct) => + SendAsync(HttpMethod.Post, Path(tenantId) + "/setup/source", request, ct); + public Task> ListAsync(int tenantId, CancellationToken ct) => + ReadListAsync(Path(tenantId), ct); + public Task SaveAsync(int tenantId, Guid id, SaveRatelDeskConnectorRequest request, CancellationToken ct) => + SendAsync(HttpMethod.Put, Path(tenantId, id), request, ct); + public Task RotateAsync(int tenantId, Guid id, RotateRatelDeskConnectorCredentialRequest request, CancellationToken ct) => + SendAsync(HttpMethod.Post, Path(tenantId, id) + "/credential", request, ct); + public Task TestAsync(int tenantId, Guid id, CancellationToken ct) => + SendAsync(HttpMethod.Post, Path(tenantId, id) + "/connection-test", null, ct); + public Task DryRunAsync(int tenantId, Guid id, RatelDeskDryRunRequest request, CancellationToken ct) => + SendAsync(HttpMethod.Post, Path(tenantId, id) + "/dry-run", request, ct); + + private async Task> ReadListAsync(string path, CancellationToken ct) => + await SendAsync(HttpMethod.Get, path, null, ct).ConfigureAwait(false); + private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken ct) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(TimeSpan.FromSeconds(30)); + var token = deadline.Token; + using var request = new HttpRequestMessage(method, path); + if (method != HttpMethod.Get) request.Headers.Add("X-NetRatel-Account-Request", "1"); + if (body is not null) request.Content = JsonContent.Create(body, body.GetType()); + using var client = clients.CreateClient(ClientName); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + var code = "connector-busy"; + try + { + using var error = JsonDocument.Parse(await ReadBoundedAsync(response, token).ConfigureAwait(false)); + if (error.RootElement.ValueKind == JsonValueKind.Object && error.RootElement.TryGetProperty("code", out var value) && + value.ValueKind == JsonValueKind.String && value.GetString() == "connector-capacity-exhausted") + code = "connector-capacity-exhausted"; + } + catch (JsonException) { } + throw new RatelDeskConnectorApiException(code); + } + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.Conflict) + { + try + { + using var error = JsonDocument.Parse(await ReadBoundedAsync(response, token).ConfigureAwait(false)); + if (error.RootElement.ValueKind == JsonValueKind.Object && + error.RootElement.TryGetProperty("code", out var value) && value.ValueKind == JsonValueKind.String && + value.GetString() is "source-identity-conflict" or "identity-revision-conflict") + throw new RatelDeskConnectorApiException(value.GetString()!); + } + catch (JsonException) { } + } + throw new RatelDeskConnectorApiException(response.StatusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => "access-denied", + HttpStatusCode.Conflict => "connector-conflict", + HttpStatusCode.BadRequest => "invalid-connector-request", + HttpStatusCode.NotFound => "connector-not-found", + HttpStatusCode.TooManyRequests => "connector-busy", + _ => "connector-unavailable" + }); + } + try { return JsonSerializer.Deserialize(await ReadBoundedAsync(response, token).ConfigureAwait(false), JsonOptions) ?? throw new RatelDeskConnectorApiException("invalid-response"); } + catch (JsonException) { throw new RatelDeskConnectorApiException("invalid-response"); } + } + private static async Task ReadBoundedAsync(HttpResponseMessage response, CancellationToken token) + { + if (response.Content.Headers.ContentLength > MaximumResponseBytes) throw new RatelDeskConnectorApiException("response-too-large"); + await using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var bytes = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var count = await stream.ReadAsync(buffer, token).ConfigureAwait(false); + if (count == 0) break; + if (bytes.Length + count > MaximumResponseBytes) throw new RatelDeskConnectorApiException("response-too-large"); + bytes.Write(buffer, 0, count); + } + return bytes.ToArray(); + } + private static string Path(int tenantId, Guid? id = null) + { + if (tenantId <= 0 || id == Guid.Empty) throw new ArgumentException("invalid-connector-scope"); + return $"/api/v2/tenants/{tenantId}/connectors/rateldesk" + (id is { } value ? $"/{value:D}" : ""); + } +} diff --git a/tests/compose/owner-pair-publication.json b/tests/compose/owner-pair-publication.json index d60afa20..1808eb21 100644 --- a/tests/compose/owner-pair-publication.json +++ b/tests/compose/owner-pair-publication.json @@ -1,9 +1,9 @@ { "ratelDesk": { - "version": "0.1.1-beta.15", - "source": "e543fb13eb0e23db09fdfd6b4232067cecf61675", - "api": "ghcr.io/bostontechnologies/rateldesk-api@sha256:4f266560a2cd925210d492d47ae5ba0ffcbc9c3af44631aff39ee403f4be30b8", - "web": "ghcr.io/bostontechnologies/rateldesk-web@sha256:2c623963456ee61ff9040e4462fbd85bb2d8d0f077b0fdad74840f9447f3bec6", + "version": "0.1.1-beta.16", + "source": "a428d84b228213a3b1036b896610a19e51e19fa6", + "api": "ghcr.io/bostontechnologies/rateldesk-api@sha256:7d8c84b756981f4c94c04d2fdeffefe08f472739c06eb60b05e57b74f4110410", + "web": "ghcr.io/bostontechnologies/rateldesk-web@sha256:6a79b193ed20a7d14383821aab64dfb8b72fb4a68ec42ef5a16a061710c4d64c", "postgres": "postgres@sha256:a3b7f434b2dc57ce85a67e171163eb8ab1a1ebcb39d27484661f26b1dfbe30d6" }, "fixture": { diff --git a/tools/ci/check-postgresql-only.sh b/tools/ci/check-postgresql-only.sh index 702c8d3c..58678ced 100755 --- a/tools/ci/check-postgresql-only.sh +++ b/tools/ci/check-postgresql-only.sh @@ -4,6 +4,11 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$root" +if ! command -v rg >/dev/null 2>&1; then + echo "ripgrep (rg) is required for the PostgreSQL-only provider and deployment scans." >&2 + exit 1 +fi + if [[ -e compose.sqlite.yaml || -e release/compose.local-sqlite.yaml || -e src/NetRatel/NetRatel.SqliteMigrations ]]; then echo "An active first-party SQLite deployment or migration artifact remains." >&2 exit 1 diff --git a/tools/ci/physical-disk-incident-proof.schema.json b/tools/ci/physical-disk-incident-proof.schema.json new file mode 100644 index 00000000..ae5f589f --- /dev/null +++ b/tools/ci/physical-disk-incident-proof.schema.json @@ -0,0 +1,902 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:netratel:physical-disk-incident-proof:v2", + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "const": "netratel.physical-disk-incident-proof.v2" + }, + "status": { + "const": "PASS_ACTUAL_PHYSICAL_INCIDENT" + }, + "candidateSource": { + "type": "string", + "maxLength": 1024 + }, + "candidateTree": { + "type": "string", + "maxLength": 1024 + }, + "reviewedSource": { + "type": "string", + "maxLength": 1024 + }, + "actualTestCheckout": { + "type": "string", + "maxLength": 1024 + }, + "companion": { + "$ref": "#/$defs/PhysicalProduct" + }, + "producer": { + "$ref": "#/$defs/PhysicalProducer" + }, + "admission": { + "$ref": "#/$defs/PhysicalAdmission" + }, + "volume": { + "$ref": "#/$defs/PhysicalVolume" + }, + "rule": { + "$ref": "#/$defs/PhysicalRule" + }, + "recordedTask": { + "$ref": "#/$defs/PhysicalRecordedTask" + }, + "baselineCollections": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalDiskSample" + }, + "maxItems": 128 + }, + "breachCollections": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalDiskSample" + }, + "maxItems": 128 + }, + "continuedBreachCollections": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalDiskSample" + }, + "maxItems": 128 + }, + "recoveryCollections": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalDiskSample" + }, + "maxItems": 128 + }, + "secondBreachCollections": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalDiskSample" + }, + "maxItems": 128 + }, + "committedLoss": { + "$ref": "#/$defs/PhysicalCommittedLoss" + }, + "rotation": { + "$ref": "#/$defs/PhysicalRotation" + }, + "durableIdentities": { + "$ref": "#/$defs/PhysicalDurableProof" + }, + "unlink": { + "$ref": "#/$defs/PhysicalUnlinkProof" + }, + "apiProcesses": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalApiProcess" + }, + "maxItems": 128 + }, + "cleanup": { + "$ref": "#/$defs/PhysicalCleanup" + } + }, + "required": [ + "schema", + "status", + "candidateSource", + "candidateTree", + "reviewedSource", + "actualTestCheckout", + "companion", + "producer", + "admission", + "volume", + "rule", + "recordedTask", + "baselineCollections", + "breachCollections", + "continuedBreachCollections", + "recoveryCollections", + "secondBreachCollections", + "committedLoss", + "rotation", + "durableIdentities", + "unlink", + "apiProcesses", + "cleanup" + ], + "$defs": { + "PhysicalAction": { + "type": "object", + "additionalProperties": false, + "properties": { + "occurrenceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "flowRunId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "flowVersionId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "actionKey": { + "type": "string", + "maxLength": 1024 + }, + "receiverKey": { + "type": "string", + "maxLength": 1024 + }, + "payloadSha256": { + "type": "string", + "maxLength": 1024 + }, + "receiverFingerprint": { + "type": "string", + "maxLength": 1024 + }, + "sourceNamespaceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "sourceInstanceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "semanticRevision": { + "type": "integer" + } + }, + "required": [ + "occurrenceId", + "flowRunId", + "flowVersionId", + "actionKey", + "receiverKey", + "payloadSha256", + "receiverFingerprint", + "sourceNamespaceId", + "sourceInstanceId", + "semanticRevision" + ] + }, + "PhysicalAdmission": { + "type": "object", + "additionalProperties": false, + "properties": { + "agentId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "connectionId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "connectionEpoch": { + "type": "integer", + "minimum": 0 + }, + "evidenceStreamId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "committedOwner": { + "type": "boolean" + }, + "productionSystemClock": { + "type": "boolean" + }, + "producer": { + "type": "string", + "maxLength": 1024 + } + }, + "required": [ + "agentId", + "connectionId", + "connectionEpoch", + "evidenceStreamId", + "committedOwner", + "productionSystemClock", + "producer" + ] + }, + "PhysicalAllocation": { + "type": "object", + "additionalProperties": false, + "properties": { + "completedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "bytes": { + "type": "integer" + }, + "actualBlocksBytes": { + "type": "integer" + }, + "selectedFreeBefore": { + "type": "integer" + }, + "selectedFreeAfter": { + "type": "integer" + }, + "hostFreeAfter": { + "type": "integer" + } + }, + "required": [ + "completedAtUtc", + "bytes", + "actualBlocksBytes", + "selectedFreeBefore", + "selectedFreeAfter", + "hostFreeAfter" + ] + }, + "PhysicalApiProcess": { + "type": "object", + "additionalProperties": false, + "properties": { + "processId": { + "type": "integer" + }, + "startedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "exitedAtUtc": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string", + "maxLength": 1024 + }, + "generation": { + "type": "integer" + } + }, + "required": [ + "processId", + "startedAtUtc", + "exitedAtUtc", + "exitCode", + "role", + "generation" + ] + }, + "PhysicalCleanup": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientStoppedAndRemoved": { + "type": "boolean" + }, + "mountRemoved": { + "type": "boolean" + }, + "loopDetached": { + "type": "boolean" + }, + "backingRemoved": { + "type": "boolean" + }, + "privateCredentialsRemoved": { + "type": "boolean" + }, + "allApiProcessesExited": { + "type": "boolean" + }, + "imagesRemoved": { + "type": "integer" + }, + "fleetChanges": { + "type": "integer" + }, + "bothProductFixturesDisposed": { + "type": "boolean", + "const": true + }, + "apiPrivateStateRemoved": { + "type": "boolean", + "const": true + }, + "ownedMetadataRemoved": { + "type": "boolean", + "const": true + } + }, + "required": [ + "clientStoppedAndRemoved", + "mountRemoved", + "loopDetached", + "backingRemoved", + "privateCredentialsRemoved", + "allApiProcessesExited", + "imagesRemoved", + "fleetChanges", + "bothProductFixturesDisposed", + "apiPrivateStateRemoved", + "ownedMetadataRemoved" + ] + }, + "PhysicalCommittedLoss": { + "type": "object", + "additionalProperties": false, + "properties": { + "actualUpstreamStatus": { + "type": "integer" + }, + "transactionCommitted": { + "type": "boolean" + }, + "responseAbortedAfterIndependentRead": { + "type": "boolean" + }, + "namespaceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "key": { + "type": "string", + "maxLength": 1024 + }, + "fingerprint": { + "type": "string", + "maxLength": 1024 + }, + "incidentRows": { + "type": "integer" + }, + "receiptRows": { + "type": "integer" + }, + "confirmationEnqueues": { + "type": "integer" + } + }, + "required": [ + "actualUpstreamStatus", + "transactionCommitted", + "responseAbortedAfterIndependentRead", + "namespaceId", + "key", + "fingerprint", + "incidentRows", + "receiptRows", + "confirmationEnqueues" + ] + }, + "PhysicalDiskSample": { + "type": "object", + "additionalProperties": false, + "properties": { + "scope": { + "type": "string", + "maxLength": 1024 + }, + "collectionId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "collectedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "frameObservedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "receivedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "transportSequence": { + "type": "integer", + "minimum": 0 + }, + "totalBytes": { + "type": "integer" + }, + "freeBytes": { + "type": "integer" + }, + "collectionComplete": { + "type": "boolean" + }, + "transportAccepted": { + "type": "boolean" + }, + "durableCommittedOwner": { + "type": "boolean" + } + }, + "required": [ + "scope", + "collectionId", + "collectedAtUtc", + "frameObservedAtUtc", + "receivedAtUtc", + "transportSequence", + "totalBytes", + "freeBytes", + "collectionComplete", + "transportAccepted", + "durableCommittedOwner" + ] + }, + "PhysicalDurableProof": { + "type": "object", + "additionalProperties": false, + "properties": { + "occurrences": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "maxItems": 128 + }, + "raisedEvents": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "maxItems": 128 + }, + "dispatchIntentKeys": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1024 + }, + "maxItems": 128 + }, + "flowRunIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "maxItems": 128 + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/PhysicalAction" + }, + "maxItems": 128 + }, + "receipts": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "maxItems": 128 + }, + "incidents": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1024 + }, + "maxItems": 128 + }, + "confirmationEffects": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "maxItems": 128 + }, + "mayHaveCommitted": { + "type": "boolean" + }, + "actionSucceeded": { + "type": "boolean" + } + }, + "required": [ + "occurrences", + "raisedEvents", + "dispatchIntentKeys", + "flowRunIds", + "actions", + "receipts", + "incidents", + "confirmationEffects", + "mayHaveCommitted", + "actionSucceeded" + ] + }, + "PhysicalProducer": { + "type": "object", + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string", + "maxLength": 1024 + }, + "executableSha256": { + "type": "string", + "maxLength": 1024 + }, + "version": { + "type": "string", + "maxLength": 1024 + }, + "source": { + "type": "string", + "maxLength": 1024 + }, + "producer": { + "type": "string", + "maxLength": 1024 + }, + "slowCollectionSeconds": { + "type": "integer" + }, + "maximumLifetimeSeconds": { + "type": "integer" + }, + "caSha256": { + "type": "string", + "maxLength": 1024 + }, + "trustedTlsAlpnVerified": { + "type": "boolean" + }, + "unrelatedCaRejected": { + "type": "boolean" + }, + "ownedExt4Visible": { + "type": "boolean" + } + }, + "required": [ + "imageId", + "executableSha256", + "version", + "source", + "producer", + "slowCollectionSeconds", + "maximumLifetimeSeconds", + "caSha256", + "trustedTlsAlpnVerified", + "unrelatedCaRejected", + "ownedExt4Visible" + ] + }, + "PhysicalProduct": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "maxLength": 1024 + }, + "source": { + "type": "string", + "maxLength": 1024 + }, + "apiImage": { + "type": "string", + "maxLength": 1024 + }, + "webImage": { + "type": "string", + "maxLength": 1024 + } + }, + "required": [ + "version", + "source", + "apiImage", + "webImage" + ] + }, + "PhysicalRecordedTask": { + "type": "object", + "additionalProperties": false, + "properties": { + "parentRequestId": { + "type": "string", + "maxLength": 1024 + }, + "taskId": { + "type": "string", + "maxLength": 1024 + }, + "automationBindingId": { + "type": "string", + "maxLength": 1024 + }, + "netRatelRequestId": { + "type": "integer" + }, + "jobRunId": { + "type": "string", + "maxLength": 1024 + }, + "stepId": { + "type": "string", + "maxLength": 1024 + }, + "activityId": { + "type": "string", + "maxLength": 1024 + }, + "successWorklogId": { + "type": "string", + "maxLength": 1024 + }, + "callbackStatus": { + "type": "integer" + }, + "taskTemplateId": { + "type": "string", + "maxLength": 1024 + }, + "correlationId": { + "type": "string", + "maxLength": 1024 + }, + "commandMarker": { + "type": "string", + "maxLength": 1024 + } + }, + "required": [ + "parentRequestId", + "taskId", + "automationBindingId", + "netRatelRequestId", + "jobRunId", + "stepId", + "activityId", + "successWorklogId", + "callbackStatus", + "taskTemplateId", + "correlationId", + "commandMarker" + ] + }, + "PhysicalRotation": { + "type": "object", + "additionalProperties": false, + "properties": { + "beforeCredentialRevision": { + "type": "integer" + }, + "afterCredentialRevision": { + "type": "integer" + }, + "beforeSemanticRevision": { + "type": "integer" + }, + "afterSemanticRevision": { + "type": "integer" + }, + "beforeSourceNamespaceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "afterSourceNamespaceId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + } + }, + "required": [ + "beforeCredentialRevision", + "afterCredentialRevision", + "beforeSemanticRevision", + "afterSemanticRevision", + "beforeSourceNamespaceId", + "afterSourceNamespaceId" + ] + }, + "PhysicalRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "ruleId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "flowVersionId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "configurationHash": { + "type": "string", + "maxLength": 1024 + }, + "resourceKey": { + "type": "string", + "maxLength": 1024 + }, + "configurationRevision": { + "type": "integer" + }, + "connectorId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "semanticRevision": { + "type": "integer" + } + }, + "required": [ + "ruleId", + "flowVersionId", + "configurationHash", + "resourceKey", + "configurationRevision", + "connectorId", + "semanticRevision" + ] + }, + "PhysicalUnlinkProof": { + "type": "object", + "additionalProperties": false, + "properties": { + "positiveControlStatusBeforeUnlink": { + "type": "integer" + }, + "protectedDenialStatus": { + "type": "integer" + }, + "samePrivateAuthorizationHandleUsed": { + "type": "boolean" + }, + "cachedAuthorizationCommonExpiresAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64, + "description": "Minimum of the actually parsed receiver and NetRatel inbound cached JWT expiries; no token value." + }, + "latestDenialObservedAtUtc": { + "type": "string", + "format": "date-time", + "maxLength": 64, + "description": "Final observation after receiver denial, immediate and settled denial on both actual NetRatel API replicas and revoked-current credential issuance denial." + }, + "positivelyControlledInboundReplicas": { + "type": "integer", + "const": 2 + }, + "immediateDeniedInboundReplicas": { + "type": "integer", + "const": 2 + }, + "settledDeniedInboundReplicas": { + "type": "integer", + "const": 2 + } + }, + "required": [ + "positiveControlStatusBeforeUnlink", + "protectedDenialStatus", + "cachedAuthorizationCommonExpiresAtUtc", + "latestDenialObservedAtUtc", + "samePrivateAuthorizationHandleUsed", + "positivelyControlledInboundReplicas", + "immediateDeniedInboundReplicas", + "settledDeniedInboundReplicas" + ] + }, + "PhysicalVolume": { + "type": "object", + "additionalProperties": false, + "properties": { + "backingBytes": { + "type": "integer" + }, + "selectedAvailableBefore": { + "type": "integer" + }, + "hostAvailableBefore": { + "type": "integer" + }, + "filesystem": { + "type": "string", + "maxLength": 1024 + }, + "sparseBacking": { + "type": "boolean" + }, + "allocation": { + "$ref": "#/$defs/PhysicalAllocation" + }, + "recovery": { + "$ref": "#/$defs/PhysicalAllocation" + }, + "secondAllocation": { + "$ref": "#/$defs/PhysicalAllocation" + } + }, + "required": [ + "backingBytes", + "selectedAvailableBefore", + "hostAvailableBefore", + "filesystem", + "sparseBacking", + "allocation", + "recovery", + "secondAllocation" + ] + } + } +} diff --git a/tools/ci/promote-release.py b/tools/ci/promote-release.py index 404fe7cd..f0281151 100644 --- a/tools/ci/promote-release.py +++ b/tools/ci/promote-release.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Explicit owner-operated promotion. Never invoked by PR or rehearsal workflows.""" import argparse +from datetime import datetime, timezone import hashlib import json import os @@ -339,6 +340,85 @@ def create_input_receipt(inputs, output, run_id, version, revision): atomic_json(output, receipt) +def write_publication_image_journal(args): + """Preserve authenticated image phase evidence; never authorize a resume.""" + version = product_version() + revision = run("git", "rev-parse", "HEAD") + tag = f"v{version}" + if os.environ.get("GITHUB_ACTIONS") != "true" or \ + os.environ.get("GITHUB_REPOSITORY") != REPOSITORY: + raise ValueError("Publication image journals require this repository's workflow context") + if os.environ.get("RELEASE_TAG") != tag or \ + run("git", "rev-list", "-n", "1", tag) != revision or \ + run("git", "status", "--porcelain"): + raise ValueError("Publication image journal source/tag must identify the clean release checkout") + run_id = os.environ.get("GITHUB_RUN_ID", "") + attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "") + workflow_sha = os.environ.get("GITHUB_SHA", "") + if not re.fullmatch(r"[1-9][0-9]*", run_id) or \ + not re.fullmatch(r"[1-9][0-9]*", attempt) or \ + not re.fullmatch(r"[a-f0-9]{40}", workflow_sha): + raise ValueError("Publication image journal requires an exact workflow run/attempt/source") + workflow_ref = os.environ.get("GITHUB_WORKFLOW_REF", "") + if not workflow_ref.startswith(REPOSITORY + "/.github/workflows/release-publish.yml@refs/"): + raise ValueError("Publication image journal requires the generic publication workflow") + current_run = json.loads(run( + "gh", "api", f"repos/{REPOSITORY}/actions/runs/{run_id}/attempts/{attempt}")) + if (current_run.get("id"), current_run.get("run_attempt"), current_run.get("path"), + current_run.get("head_sha"), current_run.get("status"), current_run.get("event")) != ( + int(run_id), int(attempt), ".github/workflows/release-publish.yml", workflow_sha, + "in_progress", os.environ.get("GITHUB_EVENT_NAME")) or \ + current_run.get("event") not in {"release", "workflow_dispatch"}: + raise ValueError("Publication image journal context does not match the authenticated workflow attempt") + if not re.fullmatch(r"sha256:[a-f0-9]{64}", args.local_image_config): + raise ValueError("Publication image journal requires the scanned local image config ID") + repository = IMAGE_REPOSITORIES[args.component] + if args.phase == "prepared": + if args.image_digest is not None: + raise ValueError("A prepared image journal cannot claim a published digest") + elif not re.fullmatch(re.escape(repository) + r"@sha256:[a-f0-9]{64}", args.image_digest or ""): + raise ValueError("Published image journal digest differs from the fixed repository contract") + input_receipt = verified_input_receipt(args.inputs, args.receipt, version, revision) + tooling_revision = subprocess.check_output( + ("git", "-C", str(Path(__file__).resolve().parents[2]), "rev-parse", "HEAD"), + text=True).strip() + if not re.fullmatch(r"[a-f0-9]{40}", tooling_revision): + raise ValueError("Publication image journal requires the exact public tooling checkout") + journal = { + "schema": "netratel.publication-image-journal.v1", + "phase": args.phase, + "recordedAtUtc": datetime.now(timezone.utc).isoformat(), + "repository": REPOSITORY, + "tag": tag, "version": version, "revision": revision, + "component": args.component, "imageRepository": repository, + "imageVersionTag": release_image_tag(args.component, version), + "scannedLocalImageConfig": args.local_image_config, + "imageDigest": args.image_digest, + "publication": {"runId": int(run_id), "attempt": int(attempt), + "workflow": ".github/workflows/release-publish.yml", + "workflowRef": workflow_ref, "workflowSourceSha": workflow_sha, + "toolingSourceSha": tooling_revision, "event": current_run["event"]}, + "inputReceipt": input_receipt, + "resumeAuthority": False, + } + if args.phase == "digest-recorded": + prepared_path = args.output.parent / "prepared.json" + if prepared_path.is_symlink() or not prepared_path.is_file(): + raise ValueError("Published digest journal requires its original regular prepared journal") + prepared = json.loads(prepared_path.read_text()) + if not isinstance(prepared, dict): + raise ValueError("Prepared publication image journal is not an identity document") + expected_prepared = {**journal, "phase": "prepared", "imageDigest": None} + expected_prepared["recordedAtUtc"] = prepared.get("recordedAtUtc") + if not isinstance(expected_prepared["recordedAtUtc"], str) or prepared != expected_prepared: + raise ValueError("Published digest journal differs from the authenticated prepared image identity") + args.output.parent.mkdir(parents=True, exist_ok=True) + # Keep each phase/attempt file immutable; no environment or registry token + # is copied into this allowlisted public identity and receipt document. + with args.output.open("x") as output: + output.write(json.dumps(journal, indent=2) + "\n") + + def resume_state(path, version, revision, input_receipt): state = json.loads(path.read_text()) if path.exists() else { "version": version, "revision": revision, "packageNames": dict(PACKAGE_NAMES), @@ -667,6 +747,14 @@ def publish_assets(directory, tag): registry_check = commands.add_parser("registry-check", help="Verify public GHCR packages and unused release tags") registry_check.add_argument("--version", required=True) registry_check.add_argument("--component", choices=COMPONENTS) + image_journal = commands.add_parser("image-journal", help="Preserve authenticated image phase evidence without authorizing resume") + image_journal.add_argument("--inputs", required=True, type=Path) + image_journal.add_argument("--receipt", required=True, type=Path) + image_journal.add_argument("--output", required=True, type=Path) + image_journal.add_argument("--component", required=True, choices=COMPONENTS) + image_journal.add_argument("--phase", required=True, choices=("prepared", "digest-recorded")) + image_journal.add_argument("--local-image-config", required=True) + image_journal.add_argument("--image-digest") publication = commands.add_parser("publish", help="Upload missing verified assets to a published release") publication.add_argument("--directory", required=True, type=Path) publication.add_argument("--tag", required=True) @@ -689,6 +777,8 @@ def publish_assets(directory, tag): raise ValueError("Registry check version differs from Directory.Build.props") for component in ((args.component,) if args.component else COMPONENTS): require_unused_release_tag(component, args.version) + elif args.command == "image-journal": + write_publication_image_journal(args) else: promote(args) except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: diff --git a/tools/ci/run-physical-disk-incident-proof.sh b/tools/ci/run-physical-disk-incident-proof.sh new file mode 100755 index 00000000..ee55ae66 --- /dev/null +++ b/tools/ci/run-physical-disk-incident-proof.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Opt-in hosted physical case selected by the manual integration workflow. +# Build one full Client from this exact checkout and require independent receipts. +set -euo pipefail + +: "${GITHUB_WORKSPACE:?The hosted physical lane requires its actual workspace.}" +: "${RUNNER_TEMP:?The hosted physical lane requires its owned work directory parent.}" +: "${GITHUB_SHA:?The hosted physical lane requires its actual test checkout.}" +: "${NETRATEL_REVIEW_SOURCE_SHA:?The reviewed source must be explicit.}" +: "${NETRATEL_REVIEW_TEST_MERGE_SHA:?The actual test checkout must be explicit.}" +source_sha="$(git rev-parse HEAD)" +[[ "$source_sha" == "$GITHUB_SHA" && "$source_sha" == "$NETRATEL_REVIEW_TEST_MERGE_SHA" ]] || { + echo "The hosted physical lane must use the actual reviewed test checkout." >&2 + exit 1 +} + +# Reuse the existing current-constants/accepted-publication comparison, leaving +# both the ordinary native receipt verifier and its exactly-two gate unchanged. +companion_pin_lines="$(python3 - <<'PY_PINS' +import importlib.util +from pathlib import Path + +path = Path("tools/ci/verify-service-link-native-evidence.py") +spec = importlib.util.spec_from_file_location("verify_service_link_native_evidence", path) +if spec is None or spec.loader is None: + raise SystemExit("The existing accepted companion identity validator is unavailable.") +verifier = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verifier) +pins = verifier.peer_pins(Path.cwd()) +for name in ("PublishedSource", "PublishedVersion", "ApiImage", "WebImage"): + print(pins[name]) +PY_PINS +)" +mapfile -t companion_pins <<< "$companion_pin_lines" +[[ "${#companion_pins[@]}" == 4 ]] || { + echo "The accepted companion identity must contain the exact source/version/image tuple." >&2 + exit 1 +} +PHYSICAL_VERIFIED_COMPANION_SOURCE="${companion_pins[0]}" +PHYSICAL_VERIFIED_COMPANION_VERSION="${companion_pins[1]}" +PHYSICAL_VERIFIED_COMPANION_API_IMAGE="${companion_pins[2]}" +PHYSICAL_VERIFIED_COMPANION_WEB_IMAGE="${companion_pins[3]}" +export NETRATEL_DOTNET_SDK_VERSION="$(dotnet --version)" +export NETRATEL_PHYSICAL_DISK_EVIDENCE_DIRECTORY="${GITHUB_WORKSPACE}/TestResults/physical-disk-incident" +physical_trx="$NETRATEL_PHYSICAL_DISK_EVIDENCE_DIRECTORY/physical-disk-incident.trx" +# Fail on retained dedicated receipts rather than accepting or deleting old +# evidence. Ordinary/native proof files remain outside this directory. +if [[ -e "$physical_trx" ]] || compgen -G "$NETRATEL_PHYSICAL_DISK_EVIDENCE_DIRECTORY/disk-incident-*.json" >/dev/null; then + echo "The hosted physical invocation requires fresh dedicated receipt paths." >&2 + exit 1 +fi + +candidate_version="$(dotnet msbuild src/NetRatel/NetRatel.Client/NetRatel.Client.csproj -nologo -getProperty:Version)" +physical_image="netratel-physical-client:${source_sha}" +tools/ci/build-public-image.sh --dockerfile docker/client/Dockerfile.public \ + --image "$physical_image" --version "$candidate_version" --revision "$source_sha" +tools/ci/scan-public-image.sh --image "$physical_image" --version "$candidate_version" --revision "$source_sha" + +export NETRATEL_PHYSICAL_CLIENT_IMAGE_ID +NETRATEL_PHYSICAL_CLIENT_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$physical_image")" +export NETRATEL_PHYSICAL_CLIENT_EXECUTABLE_SHA256 +NETRATEL_PHYSICAL_CLIENT_EXECUTABLE_SHA256="$(docker run --rm --user 10001:10001 --cap-drop ALL \ + --security-opt no-new-privileges --network none --entrypoint python3 "$NETRATEL_PHYSICAL_CLIENT_IMAGE_ID" \ + -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("/app/NetRatel.Client").read_bytes()).hexdigest())')" +export NETRATEL_PHYSICAL_WORK_ROOT +NETRATEL_PHYSICAL_WORK_ROOT="$(mktemp -d "${RUNNER_TEMP}/netratel-physical-work.XXXXXXXX")" +cleanup_physical_work_root() { + local status=$? + trap - EXIT + # The reviewed helper must already have removed its exact owned resources and + # allowlisted metadata. Never erase retained uncertain ownership recursively. + if ! rmdir "$NETRATEL_PHYSICAL_WORK_ROOT"; then + echo "The hosted physical fixture did not completely remove its owned work root." >&2 + status=1 + fi + exit "$status" +} +trap cleanup_physical_work_root EXIT + +# This hosted Category case is explicitly selected by the manual physical suite, +# outside the default fast regressions. It runs once when requested and fails +# that manual job on any prerequisite/producer/protocol/evidence/cleanup error. +dotnet test --project src/NetRatel/NetRatel.API.IntegrationTests/NetRatel.API.IntegrationTests.csproj \ + --configuration Release --no-build --max-parallel-test-modules 1 \ + --filter-class NetRatel.API.IntegrationTests.ServiceLinks.PhysicalDiskIncidentTests \ + --results-directory TestResults/physical-disk-incident \ + --report-trx --report-trx-filename physical-disk-incident.trx +python3 tools/ci/verify-mtp-trx.py TestResults/physical-disk-incident/physical-disk-incident.trx \ + --expected-executed 1 + +# The following values must come from the actual current checked-out generic +# published-peer constants/accepted published companion receipt. No default or +# guessed future release is permitted. The source/tree IDs come from the real +# checkout; NETRATEL_REVIEW_SOURCE_SHA is the existing reviewed PR/Main context. +python3 tools/ci/verify-physical-disk-incident-evidence.py \ + --directory "$NETRATEL_PHYSICAL_DISK_EVIDENCE_DIRECTORY" \ + --trx TestResults/physical-disk-incident/physical-disk-incident.trx \ + --schema tools/ci/physical-disk-incident-proof.schema.json \ + --source-sha "$(git rev-parse HEAD)" --tree-sha "$(git rev-parse 'HEAD^{tree}')" \ + --reviewed-source-sha "$NETRATEL_REVIEW_SOURCE_SHA" \ + --client-image-id "$NETRATEL_PHYSICAL_CLIENT_IMAGE_ID" \ + --client-executable-sha256 "$NETRATEL_PHYSICAL_CLIENT_EXECUTABLE_SHA256" \ + --candidate-version "$candidate_version" \ + --companion-source "$PHYSICAL_VERIFIED_COMPANION_SOURCE" \ + --companion-version "$PHYSICAL_VERIFIED_COMPANION_VERSION" \ + --companion-api-image "$PHYSICAL_VERIFIED_COMPANION_API_IMAGE" \ + --companion-web-image "$PHYSICAL_VERIFIED_COMPANION_WEB_IMAGE" diff --git a/tools/ci/test-release-validation.py b/tools/ci/test-release-validation.py index bffffe3c..209f95c7 100644 --- a/tools/ci/test-release-validation.py +++ b/tools/ci/test-release-validation.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """Negative release-gate tests using isolated source fixtures.""" import base64 +import contextlib +import copy +import runpy +import stat +from types import SimpleNamespace import importlib.util import io import json @@ -227,6 +232,23 @@ def test_ci_uses_sdk10_mtp_selectors_and_requires_each_generic_test_assembly(sel self.assertIn("src/NetRatel/NetRatel.Web.PlaywrightTests/bin/Release/net10.0/TestResults/playwright", manual) self.assertRegex(manual, r"(?s)name: Retain selected TRX, screenshots and sanitized receipts\s+if: always\(\).*?path: \|\s+TestResults") + # Retain cheap selector/source checks for opt-in Compose helpers. + for relative in ( + "tools/ci/smoke-oidc-compose.sh", + "tools/ci/smoke-postgresql-oidc-upgrade.sh", + "tools/ci/smoke-local-first-compose.sh", + ): + optional_source = (ROOT / relative).read_text(encoding="utf-8") + with self.subTest(optional_helper=relative): + self.assertNotRegex(optional_source, r"--filter(?:\s|=)", "VSTest filter syntax is invalid under the selected MTP runner.") + self.assertNotIn("-- --report-trx", optional_source, "SDK 10 MTP options are passed directly without a legacy separator.") + self.assertIn("--report-trx-filename", optional_source) + + browser_test = (ROOT / "src/NetRatel/NetRatel.Web.PlaywrightTests/ClientsManagementResponsiveTests.cs").read_text(encoding="utf-8") + self.assertIn("AbortedCriticalStartupScript_FailsAndWritesDiagnosticsBeforeContextDisposal", browser_test) + self.assertIn('"startup-diagnostics.json"', browser_test) + self.assertIn('"startup-failure.png"', browser_test) + api_root = ROOT / "src/NetRatel/NetRatel.API.IntegrationTests" for name in ( "ServiceLinkLifecycleTests", "ServiceLinkOrchestrationRoundTripTests", @@ -1962,5 +1984,430 @@ def test_directory_only_inventory_and_wrong_file_digests_are_rejected(self): self.verifier.verify(document, self.root) +class PublicationImageJournalTests(unittest.TestCase): + """Real local validation with synthetic authority and no external transports.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="netratel-publication-journal-tests-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.promotion = module("promote-release") + self.version = "9.8.7-regression.4" + self.revision = "a" * 40 + self.tooling_revision = "b" * 40 + self.workflow_revision = "c" * 40 + self.tag_revision = self.revision + self.dirty = "" + self.inputs = self.root / "inputs" + self.inputs.mkdir() + self.receipt_path = self.root / "input-receipt.json" + self.commands = [] + self.downloads = [] + self.archives = {} + self.metadata = [] + self.receipt = { + "repository": self.promotion.REPOSITORY, "workflow": ".github/workflows/release-build.yml", + "runId": 1010, "attempt": 2, "headSha": self.revision, + "productVersion": self.version, "files": {}, + } + groups = {101: [], 102: [], 103: [], 104: []} + for name in self.promotion.required_artifacts(self.version): + artifact_id = 101 + if name.startswith(f"netratel-client-{self.version}-"): + artifact_id = next(value for runtime, value in + (("linux-x64", 102), ("win-x64", 103), ("osx-arm64", 104)) + if runtime in name) + content = ("synthetic downloadable artifact: " + name).encode() + (self.inputs / name).write_bytes(content) + groups[artifact_id].append((name, content)) + self.promotion.checksums(self.inputs) + for artifact_id, payloads in groups.items(): + archive = self.root / f"{artifact_id}.zip" + with zipfile.ZipFile(archive, "w") as output: + for name, content in payloads: + output.writestr(name, content) + self.archives[artifact_id] = archive + digest = "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest() + artifact_name = f"synthetic-original-{artifact_id}" + self.metadata.append({"id": artifact_id, "name": artifact_name, + "digest": digest, "expired": False}) + for name, content in payloads: + self.receipt["files"][name] = { + "artifact": artifact_name, "artifactId": artifact_id, "artifactDigest": digest, + "path": name, "sha256": hashlib.sha256(content).hexdigest(), + } + self.save_receipt() + self.current_run = {"id": 2020, "run_attempt": 3, "path": ".github/workflows/release-publish.yml", + "head_sha": self.workflow_revision, "status": "in_progress", "event": "release"} + self.original_run = {"conclusion": "success", "head_sha": self.revision, + "path": ".github/workflows/release-build.yml"} + self.environment = { + "GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": self.promotion.REPOSITORY, + "RELEASE_TAG": f"v{self.version}", "GITHUB_RUN_ID": "2020", "GITHUB_RUN_ATTEMPT": "3", + "GITHUB_SHA": self.workflow_revision, + "GITHUB_WORKFLOW_REF": self.promotion.REPOSITORY + "/.github/workflows/release-publish.yml@refs/heads/main", + "GITHUB_EVENT_NAME": "release", + } + self.real_run = self.promotion.run + for guard in ( + patch.dict(os.environ, self.environment, clear=True), + patch.object(self.promotion, "ROOT", self.root), + patch.object(self.promotion, "run", side_effect=self.command), + patch.object(self.promotion, "download_artifact", side_effect=self.download), + patch.object(self.promotion.subprocess, "check_output", side_effect=self.tooling_command), + patch.object(self.promotion.subprocess, "run", side_effect=AssertionError("Unexpected external subprocess")), + patch.object(self.promotion.urllib.request, "urlopen", side_effect=AssertionError("Unexpected network request"))): + guard.start() + self.addCleanup(guard.stop) + + def save_receipt(self): + self.receipt_path.write_text(json.dumps(self.receipt)) + + def command(self, *command, env=None): + self.commands.append(command) + self.assertIsNone(env, "Unexpected external command environment") + if command == ("python3", str(Path(self.promotion.__file__).with_name("product-version.py"))): + return self.version + if command == ("git", "rev-parse", "HEAD"): + return self.revision + if command == ("git", "rev-list", "-n", "1", f"v{self.version}"): + return self.tag_revision + if command == ("git", "status", "--porcelain"): + return self.dirty + if command[:2] == ("gh", "api"): + self.assertEqual(len(command), 3, "Unexpected metadata command arguments") + endpoint = command[2] + prefix = f"repos/{self.promotion.REPOSITORY}/actions/runs/" + if endpoint == prefix + "2020/attempts/3": + return json.dumps(self.current_run) + if endpoint == prefix + "1010/attempts/2": + return json.dumps(self.original_run) + if endpoint == prefix + "1010": + return json.dumps(self.original_run) + if endpoint == prefix + "1010/artifacts?per_page=100": + return json.dumps({"artifacts": self.metadata}) + # The fake authority refuses any unowned original run or attempt. + if endpoint in (prefix + "1011/attempts/2", prefix + "1010/attempts/1"): + return json.dumps({"conclusion": "failure"}) + raise AssertionError(f"Unexpected external command: {command[:2]}") + + def tooling_command(self, command, **kwargs): + self.assertEqual(tuple(command), ("git", "-C", str(Path(self.promotion.__file__).resolve().parents[2]), "rev-parse", "HEAD")) + self.assertEqual(kwargs, {"text": True}) + return self.tooling_revision + + def download(self, artifact_id, destination): + self.assertIn(artifact_id, self.archives, "Unexpected unauthenticated artifact download") + self.downloads.append(artifact_id) + shutil.copyfile(self.archives[artifact_id], destination) + + def args(self, phase="prepared", component="api"): + return SimpleNamespace(inputs=self.inputs, receipt=self.receipt_path, + output=self.root / "journal" / f"{phase}.json", component=component, + phase=phase, local_image_config="sha256:" + "d" * 64, + image_digest=None if phase == "prepared" else + self.promotion.IMAGE_REPOSITORIES[component] + "@sha256:" + "e" * 64) + + def write(self, args=None): + args = args or self.args() + self.promotion.write_publication_image_journal(args) + return json.loads(args.output.read_text()) + + def refuse(self, args=None): + args = args or self.args() + with self.assertRaises((ValueError, OSError)): + self.promotion.write_publication_image_journal(args) + self.assertFalse(args.output.exists()) + self.assertFalse(any(command[:2] in (("docker", "push"), ("gh", "release")) + for command in self.commands)) + + def bind_changed_archive(self, artifact_id=101): + digest = "sha256:" + hashlib.sha256(self.archives[artifact_id].read_bytes()).hexdigest() + for item in self.receipt["files"].values(): + if item["artifactId"] == artifact_id: + item["artifactDigest"] = digest + next(item for item in self.metadata if item["id"] == artifact_id)["digest"] = digest + self.save_receipt() + + def test_prepared_and_recorded_phases_bind_real_local_payloads_without_resume_authority(self): + for component in self.promotion.COMPONENTS: + with self.subTest(component=component): + args = self.args(component=component) + args.output = self.root / component / "prepared.json" + prepared = self.write(args) + args.phase = "digest-recorded" + args.output = args.output.with_name("digest-recorded.json") + args.image_digest = self.promotion.IMAGE_REPOSITORIES[component] + "@sha256:" + "e" * 64 + recorded = self.write(args) + self.assertEqual((prepared["revision"], prepared["version"], prepared["tag"], prepared["component"]), + (self.revision, self.version, f"v{self.version}", component)) + self.assertEqual(prepared["imageRepository"], self.promotion.IMAGE_REPOSITORIES[component]) + self.assertEqual(prepared["imageVersionTag"], self.promotion.release_image_tag(component, self.version)) + self.assertEqual(prepared["scannedLocalImageConfig"], args.local_image_config) + self.assertEqual((prepared["publication"]["runId"], prepared["publication"]["attempt"], + prepared["publication"]["workflowSourceSha"], prepared["publication"]["toolingSourceSha"]), + (2020, 3, self.workflow_revision, self.tooling_revision)) + self.assertFalse(prepared["resumeAuthority"]) + self.assertFalse(recorded["resumeAuthority"]) + self.assertIsNone(prepared["imageDigest"]) + self.assertEqual(recorded["imageDigest"], args.image_digest) + self.assertEqual(prepared["inputReceipt"]["files"], self.receipt["files"]) + for name, item in recorded["inputReceipt"]["files"].items(): + self.assertEqual(item["sha256"], hashlib.sha256((self.inputs / name).read_bytes()).hexdigest()) + stable = lambda document: {key: value for key, value in document.items() + if key not in {"phase", "recordedAtUtc", "imageDigest"}} + self.assertEqual(stable(prepared), stable(recorded)) + self.assertEqual(set(self.downloads), {101, 102, 103, 104}) + + def test_source_tag_dirty_checkout_and_workflow_environment_fail_before_evidence_creation(self): + for key, value in (("GITHUB_ACTIONS", "false"), ("GITHUB_REPOSITORY", "example/other"), + ("RELEASE_TAG", "v0.0.0-wrong"), ("GITHUB_RUN_ID", "0"), + ("GITHUB_RUN_ATTEMPT", "-1"), ("GITHUB_SHA", "not-a-source"), + ("GITHUB_WORKFLOW_REF", "example/other/.github/workflows/release-publish.yml@refs/heads/main")): + with self.subTest(field=key), patch.dict(os.environ, {key: value}): + self.refuse() + for field, value in (("tag_revision", "f" * 40), ("dirty", " M unreviewed.py")): + with self.subTest(field=field), patch.object(self, field, value): + self.refuse() + with patch.object(self.promotion.subprocess, "check_output", return_value="malformed-tooling-source"): + self.refuse() + + def test_current_publication_authority_requires_the_exact_running_attempt(self): + for key, value in (("id", 2021), ("run_attempt", 2), ("path", ".github/workflows/other.yml"), + ("head_sha", "f" * 40), ("status", "completed"), ("event", "push")): + with self.subTest(field=key), patch.dict(self.current_run, {key: value}): + self.refuse() + with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "workflow_dispatch"}): + self.refuse() + + def test_component_config_and_published_reference_cannot_claim_another_image(self): + for config in ("sha512:" + "d" * 64, "sha256:short", "sha256:" + "D" * 64): + with self.subTest(config=config): + args = self.args(); args.local_image_config = config; self.refuse(args) + args = self.args(); args.image_digest = "sha256:" + "e" * 64; self.refuse(args) + self.write() + for reference in (None, "sha256:" + "e" * 64, + self.promotion.IMAGE_REPOSITORIES["web"] + "@sha256:" + "e" * 64, + self.promotion.IMAGE_REPOSITORIES["api"] + "@sha256:malformed"): + with self.subTest(reference=reference): + args = self.args("digest-recorded"); args.image_digest = reference; self.refuse(args) + + def test_cli_rejects_unknown_phase_and_component_before_running_any_transport(self): + for option, value in (("--phase", "restore"), ("--component", "unknown")): + argv = ["promote-release.py", "image-journal", "--inputs", str(self.inputs), + "--receipt", str(self.receipt_path), "--output", str(self.root / "never.json"), + "--component", "api", "--phase", "prepared", "--local-image-config", "sha256:" + "d" * 64] + argv[argv.index(option) + 1] = value + with self.subTest(option=option), patch.object(sys, "argv", argv), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as rejected: + runpy.run_path(str(ROOT / "tools/ci/promote-release.py"), run_name="__main__") + self.assertEqual(rejected.exception.code, 2) + self.assertEqual(self.commands, []) + self.assertFalse((self.root / "never.json").exists()) + + def test_original_receipt_and_workflow_authority_are_revalidated_before_each_phase(self): + for key, value in (("repository", "example/other"), ("workflow", ".github/workflows/other.yml"), + ("headSha", "f" * 40), ("productVersion", "0.0.0-wrong"), ("runId", 1011), ("attempt", 1)): + with self.subTest(field=key), patch.dict(self.receipt, {key: value}): + self.save_receipt(); self.refuse() + self.save_receipt() + for key, value in (("conclusion", "failure"), ("head_sha", "f" * 40), ("path", ".github/workflows/other.yml")): + with self.subTest(field=key), patch.dict(self.original_run, {key: value}): + self.refuse() + self.write() + prepared = self.args().output.read_bytes() + raw_digest = self.root / "raw-published-digest.txt" + raw_digest.write_text(self.args("digest-recorded").image_digest) + with patch.dict(self.original_run, {"conclusion": "failure"}): + self.refuse(self.args("digest-recorded")) + self.assertEqual(self.args().output.read_bytes(), prepared) + self.assertEqual(raw_digest.read_text(), self.args("digest-recorded").image_digest) + + def test_missing_expired_unowned_or_changed_artifact_metadata_is_rejected(self): + before = list(self.downloads) + for key, value in (("id", 999), ("expired", True), ("name", "unowned-artifact"), ("digest", "sha256:" + "f" * 64)): + with self.subTest(field=key), patch.dict(self.metadata[0], {key: value}): + self.refuse() + self.assertEqual(self.downloads, before) + with patch.object(self, "metadata", self.metadata[1:]): + self.refuse() + self.assertEqual(self.downloads, before) + name = next(iter(self.receipt["files"])) + with patch.dict(self.receipt["files"][name], {"artifactId": 999}): + self.save_receipt(); self.refuse() + self.assertEqual(self.downloads, before) + + def test_original_receipt_creation_requires_the_successful_push_and_exact_source_tag(self): + grouped = self.root / "original-downloads"; grouped.mkdir() + for metadata in self.metadata: + members = [name for name, item in self.receipt["files"].items() if item["artifactId"] == metadata["id"]] + runtime = {101: None, 102: "linux-x64", 103: "win-x64", 104: "osx-arm64"}[metadata["id"]] + metadata["name"] = (f"netratel-client-{self.version}-{runtime}" if runtime else + f"netratel-{self.version}-linux-x64") + directory = grouped / metadata["name"]; directory.mkdir() + for name in members: shutil.copyfile(self.inputs / name, directory / name) + self.promotion.checksums(directory) + self.original_run.update(event="push", head_branch=f"v{self.version}", run_attempt=2) + output = self.root / "created-original-receipt.json" + for key, value in (("event", "workflow_dispatch"), ("head_branch", "v0.0.0-wrong"), ("run_attempt", 0)): + with self.subTest(field=key), patch.dict(self.original_run, {key: value}), self.assertRaises(ValueError): + self.promotion.create_input_receipt(grouped, output, 1010, self.version, self.revision) + self.assertFalse(output.exists()) + self.promotion.create_input_receipt(grouped, output, 1010, self.version, self.revision) + receipt = json.loads(output.read_text()) + self.assertEqual(receipt["attempt"], 2) + self.assertEqual(set(receipt["files"]), set(self.promotion.required_artifacts(self.version))) + args = self.args(); args.receipt = output + journal = self.write(args) + self.assertEqual(journal["inputReceipt"], receipt) + + def test_authentication_checks_outer_zip_digest_and_selected_inner_payload_bytes(self): + self.archives[101].write_bytes(self.archives[101].read_bytes() + b"unapproved outer bytes") + self.refuse() + # The synthetic authority now authenticates the altered ZIP; its selected inner bytes still must match. + name = next(name for name, item in self.receipt["files"].items() if item["artifactId"] == 101) + with zipfile.ZipFile(self.archives[101]) as archive: + payloads = {member: archive.read(member) for member in archive.namelist()} + payloads[name] = b"different authenticated inner payload" + with zipfile.ZipFile(self.archives[101], "w") as archive: + for member, content in payloads.items(): archive.writestr(member, content) + self.bind_changed_archive() + self.refuse() + + def test_authenticated_zip_rejects_traversal_absolute_symlink_and_duplicate_members(self): + original = self.archives[101].read_bytes() + for kind in ("traversal", "absolute", "symlink", "duplicate"): + with self.subTest(kind=kind): + self.archives[101].write_bytes(original) + with zipfile.ZipFile(self.archives[101], "a") as archive: + if kind == "traversal": archive.writestr("../outside.txt", b"unsafe") + elif kind == "absolute": archive.writestr("/outside.txt", b"unsafe") + elif kind == "symlink": + member = zipfile.ZipInfo("link"); member.create_system = 3 + member.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(member, "../outside.txt") + else: + member = archive.namelist()[0] + with self.assertWarns(UserWarning): archive.writestr(member, b"duplicate") + self.bind_changed_archive() + self.refuse() + self.assertFalse((self.root / "outside.txt").exists()) + + def test_prepared_identity_changes_preserve_prior_evidence_and_refuse_new_result(self): + prepared = self.write() + mutations = [ + (key, value) for key, value in (("revision", "f" * 40), ("version", "0.0.0-wrong"), + ("component", "web"), ("scannedLocalImageConfig", "sha256:" + "f" * 64), + ("resumeAuthority", True), ("schema", "untrusted-schema")) + ] + for key, value in mutations: + with self.subTest(field=key): + changed = copy.deepcopy(prepared); changed[key] = value + self.args().output.write_text(json.dumps(changed)); before = self.args().output.read_bytes() + self.refuse(self.args("digest-recorded")); self.assertEqual(self.args().output.read_bytes(), before) + for field in ("publication-attempt", "publication-tooling", "publication-source", "inputReceipt"): + with self.subTest(field=field): + changed = copy.deepcopy(prepared) + if field == "publication-attempt": changed["publication"]["attempt"] = 4 + elif field == "publication-tooling": changed["publication"]["toolingSourceSha"] = "f" * 40 + elif field == "publication-source": changed["publication"]["workflowSourceSha"] = "f" * 40 + else: changed[field]["files"][next(iter(changed[field]["files"]))]["sha256"] = "f" * 64 + self.args().output.write_text(json.dumps(changed)); self.refuse(self.args("digest-recorded")) + changed = copy.deepcopy(prepared); changed["unapprovedKey"] = "untrusted" + self.args().output.write_text(json.dumps(changed)); self.refuse(self.args("digest-recorded")) + del changed["unapprovedKey"]; del changed["tag"] + self.args().output.write_text(json.dumps(changed)); self.refuse(self.args("digest-recorded")) + + def test_missing_malformed_nonobject_and_symlink_prepared_documents_are_refused(self): + self.refuse(self.args("digest-recorded")) + self.args().output.parent.mkdir() + for contents in ("{bad-json", "[]", "null", '"not-an-object"'): + with self.subTest(contents=contents): + self.args().output.write_text(contents); self.refuse(self.args("digest-recorded")) + target = self.root / "retained-prepared.json"; target.write_text("retained sentinel") + self.args().output.unlink(); self.args().output.symlink_to(target) + self.refuse(self.args("digest-recorded")); self.assertEqual(target.read_text(), "retained sentinel") + + def test_phase_files_are_exclusive_and_never_replace_an_existing_document(self): + for phase in ("prepared", "digest-recorded"): + with self.subTest(phase=phase): + if phase == "digest-recorded": self.write() + args = self.args(phase); args.output.parent.mkdir(exist_ok=True) + args.output.write_bytes(b"existing immutable phase sentinel") + with self.assertRaises(FileExistsError): self.promotion.write_publication_image_journal(args) + self.assertEqual(args.output.read_bytes(), b"existing immutable phase sentinel") + args.output.unlink() + + def test_allowlisted_journals_do_not_serialize_environment_or_receipt_secret_canaries(self): + canary = "unit-secret-canary-do-not-serialize" + self.receipt["token"] = canary + for item in self.receipt["files"].values(): item["password"] = canary + self.save_receipt() + with patch.dict(os.environ, {"GH_TOKEN": canary, "DOCKER_PASSWORD": canary, "UNRELATED_PRIVATE_VALUE": canary}): + self.write(); self.write(self.args("digest-recorded")) + for path in self.args().output.parent.iterdir(): + self.assertNotIn(canary, path.read_text()) + + def test_command_error_redacts_secret_canaries_and_creates_no_journal(self): + canary = "unit-command-secret-canary" + error = subprocess.CalledProcessError(1, ("python3", "product-version.py"), + stderr=f"token={canary} password={canary} authorization={canary}") + with patch.object(self.promotion, "run", self.real_run), \ + patch.object(self.promotion.subprocess, "run", side_effect=error): + with self.assertRaises(ValueError) as rejected: + self.promotion.write_publication_image_journal(self.args()) + self.assertNotIn(canary, str(rejected.exception)) + self.assertFalse(self.args().output.exists()) + + def test_registry_absence_is_only_typed_404_and_never_auth_timeout_or_existing_tag(self): + from urllib.error import URLError + with patch.object(self.promotion, "public_registry_token", return_value=("bostontechnologies/netratel-api", "synthetic-token")): + for status in (401, 403, 429, 500): + with self.subTest(status=status): + error = HTTPError("https://registry.example.test", status, "synthetic failure", {}, None) + with patch.object(self.promotion.urllib.request, "urlopen", side_effect=error), self.assertRaises(ValueError): + self.promotion.require_unused_release_tag("api", self.version) + error.close() + for error in (URLError("synthetic timeout"), TimeoutError("synthetic timeout")): + with patch.object(self.promotion.urllib.request, "urlopen", side_effect=error), self.assertRaises((ValueError, OSError)): + self.promotion.require_unused_release_tag("api", self.version) + absent = HTTPError("https://registry.example.test", 404, "Not Found", {}, None) + with patch.object(self.promotion.urllib.request, "urlopen", side_effect=absent): + self.promotion.require_unused_release_tag("api", self.version) + absent.close() + with patch.object(self.promotion.urllib.request, "urlopen", return_value=io.BytesIO(b"malformed manifest")), self.assertRaisesRegex(ValueError, "already exists"): + self.promotion.require_unused_release_tag("api", self.version) + + def test_existing_public_assets_are_reused_and_only_missing_exact_bytes_are_uploaded(self): + directory = self.root / "complete"; directory.mkdir() + for name in self.promotion.required_artifacts(self.version): (directory / name).write_bytes(name.encode()) + record = {"productVersion": self.version, "verification": {"state": "complete"}, + "artifacts": {name: hashlib.sha256((directory / name).read_bytes()).hexdigest() + for name in self.promotion.required_artifacts(self.version)}} + (directory / "publication.json").write_text(json.dumps(record)); self.promotion.checksums(directory) + files = {path.name: "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() for path in directory.iterdir()} + first = min(files) + existing = {first: files[first]} + uploads = [] + def transport(*command, env=None): + if command[:2] == ("gh", "api"): + self.assertEqual(command[2], f"repos/{self.promotion.REPOSITORY}/releases/tags/v{self.version}") + return json.dumps({"draft": False, "tag_name": f"v{self.version}", + "assets": [{"name": name, "digest": digest} for name, digest in existing.items()]}) + self.assertEqual(command[:3], ("gh", "release", "upload")) + self.assertNotIn("--clobber", command) + path = Path(command[4]); self.assertEqual(path.parent, directory) + self.assertNotIn(path.name, existing) + uploads.append(path.name); existing[path.name] = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + return "" + with patch.object(self.promotion, "run", side_effect=transport), contextlib.redirect_stdout(io.StringIO()): + self.promotion.publish_assets(directory, f"v{self.version}") + self.assertEqual(set(uploads), set(files) - {first}) + self.assertEqual(existing, files) + uploads.clear(); existing[first] = "sha256:" + "f" * 64 + with patch.object(self.promotion, "run", side_effect=transport), self.assertRaisesRegex(ValueError, "differs"): + self.promotion.publish_assets(directory, f"v{self.version}") + self.assertEqual(uploads, []) + if __name__ == "__main__": unittest.main() diff --git a/tools/ci/verify-physical-disk-incident-evidence.py b/tools/ci/verify-physical-disk-incident-evidence.py new file mode 100755 index 00000000..3806c2a3 --- /dev/null +++ b/tools/ci/verify-physical-disk-incident-evidence.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Verify separate cleanup-first actual physical proof and its actual MTP TRX. + +This gate does not build, run, grant, seed, fill, dispatch or repair either product. +It leaves the existing exactly-two-native-receipts verifier unchanged. +""" +from __future__ import annotations +import argparse +import datetime as dt +import json +import pathlib +import re +import sys +import xml.etree.ElementTree as ET + +CASE = "Real_full_Client_disk_breach_survives_actual_worker_process_crashes_rotation_and_unlink_without_duplicates" + +def require(value: bool, code: str) -> None: + if not value: + raise ValueError(code) + +def no_duplicates(pairs): + value = {} + for key, item in pairs: + require(key not in value, "duplicate-json-property") + value[key] = item + return value + +def timestamp(value: str) -> dt.datetime: + result = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + require(result.utcoffset() == dt.timedelta(0), "non-utc-proof-time") + return result + +def shape(value, contract, definitions): + if "$ref" in contract: + return shape(value, definitions[contract["$ref"].rsplit("/", 1)[1]], definitions) + if "const" in contract: + require(type(value) is type(contract["const"]) and value == contract["const"], "invalid-proof-constant"); return + if "anyOf" in contract: + if value is None: return + return shape(value, contract["anyOf"][0], definitions) + kind = contract["type"] + if kind == "object": + require(type(value) is dict and set(value) == set(contract["properties"]), "unexpected-proof-object-shape") + for key, sub in contract["properties"].items(): shape(value[key], sub, definitions) + elif kind == "array": + require(type(value) is list and len(value) <= contract["maxItems"], "invalid-proof-array") + for item in value: shape(item, contract["items"], definitions) + elif kind == "boolean": require(type(value) is bool, "invalid-proof-boolean") + elif kind == "integer": require(type(value) is int and value >= contract.get("minimum", -(2**63)), "invalid-proof-integer") + elif kind == "null": require(value is None, "invalid-proof-null") + else: + require(type(value) is str and len(value) <= contract.get("maxLength", 1024), "invalid-proof-string") + if "pattern" in contract: require(re.fullmatch(contract["pattern"], value) is not None, "invalid-canonical-proof-identity") + if contract.get("format") == "date-time": timestamp(value) + +def collection_window(samples, after=None): + require(3 <= len(samples) <= 128, "physical-window-needs-three-unique-collections") + require(len({x["collectionId"] for x in samples}) == len(samples), "cached-copy-counted-as-fresh") + times = [timestamp(x["collectedAtUtc"]) for x in samples] + require(all(b > a for a, b in zip(times, times[1:])) and times[-1]-times[0] >= dt.timedelta(seconds=10), "physical-hold-not-proven") + for row, collected in zip(samples, times): + frame, received = timestamp(row["frameObservedAtUtc"]), timestamp(row["receivedAtUtc"]) + require(row["scope"] == "/netratel-physical-disk" and collected <= frame <= received and + received-collected <= dt.timedelta(seconds=15) and 0 <= row["freeBytes"] <= row["totalBytes"] and + row["collectionComplete"] and row["transportAccepted"] and row["durableCommittedOwner"], "invalid-real-accepted-collection") + if after: require(collected > after, "pre-mutation-collection-used-as-physical-evidence") + +def main() -> None: + parser = argparse.ArgumentParser() + for argument in ("directory", "trx", "schema", "source-sha", "reviewed-source-sha", "tree-sha", "client-image-id", + "client-executable-sha256", "candidate-version", "companion-source", "companion-version", "companion-api-image", "companion-web-image"): + parser.add_argument("--"+argument, required=True) + args = parser.parse_args() + folder = pathlib.Path(args.directory) + files = sorted(folder.glob("disk-incident-*.json")) + require(len(files) == 1 and files[0].stat().st_size <= 262144, "exactly-one-bounded-actual-disk-proof-required") + proof = json.loads(files[0].read_text(), object_pairs_hook=no_duplicates) + contract = json.loads(pathlib.Path(args.schema).read_text(), object_pairs_hook=no_duplicates) + shape(proof, contract, contract["$defs"]) + require(proof["candidateSource"] == proof["actualTestCheckout"] == args.source_sha and proof["reviewedSource"] == args.reviewed_source_sha and + proof["candidateTree"] == args.tree_sha, "physical-source-checkout-mismatch") + producer = proof["producer"] + require(producer["source"] == args.source_sha and producer["imageId"] == args.client_image_id and + producer["executableSha256"] == args.client_executable_sha256 and producer["version"] == args.candidate_version and + producer["producer"] == "full-production-NetRatel.Client" and producer["slowCollectionSeconds"] == 5 and + producer["maximumLifetimeSeconds"] == 300 and producer["trustedTlsAlpnVerified"] and producer["unrelatedCaRejected"] and producer["ownedExt4Visible"], + "actual-full-client-provenance-trust-cadence-not-proven") + companion = proof["companion"] + require(companion == {"version":args.companion_version,"source":args.companion_source,"apiImage":args.companion_api_image,"webImage":args.companion_web_image}, + "actual-published-compatible-companion-mismatch") + admission = proof["admission"] + require(admission["committedOwner"] and admission["productionSystemClock"] and admission["connectionEpoch"] > 0 and + admission["producer"] == "full-production-NetRatel.Client", "actual-committed-owner-admission-not-proven") + volume = proof["volume"]; allocation = volume["allocation"]; recovery = volume["recovery"]; second_allocation = volume["secondAllocation"] + require(volume["backingBytes"] == 3*1024**3 and volume["filesystem"] == "ext4" and not volume["sparseBacking"] and + allocation["bytes"] == 256*1024**2 and allocation["actualBlocksBytes"] >= allocation["bytes"] and + allocation["bytes"] <= allocation["selectedFreeBefore"]//10 and allocation["selectedFreeAfter"] >= 2*1024**3 and + allocation["hostFreeAfter"] >= 2*1024**3 and recovery["hostFreeAfter"] >= 2*1024**3 and recovery["selectedFreeAfter"] > recovery["selectedFreeBefore"], + "bounded-owned-real-block-allocation-recovery-not-proven") + collection_window(proof["baselineCollections"]) + collection_window(proof["breachCollections"], timestamp(allocation["completedAtUtc"])) + collection_window(proof["continuedBreachCollections"], timestamp(allocation["completedAtUtc"])) + collection_window(proof["recoveryCollections"], timestamp(recovery["completedAtUtc"])) + require(second_allocation["bytes"] == allocation["bytes"] and second_allocation["actualBlocksBytes"] >= second_allocation["bytes"] and + second_allocation["bytes"] <= second_allocation["selectedFreeBefore"]//10 and second_allocation["selectedFreeAfter"] >= 2*1024**3 and + second_allocation["hostFreeAfter"] >= 2*1024**3 and timestamp(second_allocation["completedAtUtc"]) > timestamp(recovery["completedAtUtc"]), + "second-bounded-real-block-allocation-not-proven") + collection_window(proof["secondBreachCollections"], timestamp(second_allocation["completedAtUtc"])) + baseline_min = min(x["freeBytes"] for x in proof["baselineCollections"]) + breach_threshold, recovery_threshold = baseline_min-allocation["bytes"]//2, baseline_min-allocation["bytes"]//4 + require(all(x["freeBytes"] < breach_threshold for name in ("breachCollections","continuedBreachCollections","secondBreachCollections") for x in proof[name]) and + all(x["freeBytes"] > recovery_threshold for x in proof["recoveryCollections"]), "physical-breach-and-recovery-values-not-proven") + ids = proof["durableIdentities"] + for name in ("occurrences", "raisedEvents", "dispatchIntentKeys", "flowRunIds", "actions", "receipts", "incidents", "confirmationEffects"): + require(len(ids[name]) == 2, "expected-two-distinct-physical-effects") + require(not ids["mayHaveCommitted"] and ids["actionSucceeded"] and len({x["receiverKey"] for x in ids["actions"]}) == 2 and + len({x["occurrenceId"] for x in ids["actions"]}) == 2 and len(set(ids["receipts"])) == 2 and len(set(ids["incidents"])) == 2 and + len(set(ids["confirmationEffects"])) == 2, "durable-rearm-or-same-key-reconciliation-not-proven") + loss = proof["committedLoss"]; first = ids["actions"][0] + require(loss["actualUpstreamStatus"] == 201 and loss["transactionCommitted"] and loss["responseAbortedAfterIndependentRead"] and + (loss["incidentRows"], loss["receiptRows"], loss["confirmationEnqueues"]) == (1,1,1) and + loss["key"] == first["receiverKey"] and loss["fingerprint"] == first["receiverFingerprint"] and loss["namespaceId"] == first["sourceNamespaceId"], + "independent-actual-committed-response-loss-not-proven") + rotation = proof["rotation"] + require(rotation["afterCredentialRevision"] > rotation["beforeCredentialRevision"] and rotation["beforeSemanticRevision"] == rotation["afterSemanticRevision"] and + rotation["beforeSourceNamespaceId"] == rotation["afterSourceNamespaceId"] == first["sourceNamespaceId"], "rotation-changed-semantic-action-identity") + processes = proof["apiProcesses"] + require(len(processes) == 4 and len({x["processId"] for x in processes}) == 4 and all(x["exitedAtUtc"] and x["exitCode"] is not None for x in processes) and + sorted(x["role"] for x in processes) == ["primary","primary","replica","replica"], "actual-independent-api-worker-process-lifecycle-not-proven") + unlink = proof["unlink"] + require(unlink["positiveControlStatusBeforeUnlink"] == 200 and unlink["protectedDenialStatus"] in (401,403) and unlink["samePrivateAuthorizationHandleUsed"] and + unlink["positivelyControlledInboundReplicas"] == unlink["immediateDeniedInboundReplicas"] == unlink["settledDeniedInboundReplicas"] == 2 and + timestamp(unlink["cachedAuthorizationCommonExpiresAtUtc"]) > timestamp(unlink["latestDenialObservedAtUtc"]), "unexpired-current-cached-token-both-channel-both-replica-unlink-denial-not-proven") + cleanup = proof["cleanup"] + require(all(cleanup[x] for x in ("clientStoppedAndRemoved","mountRemoved","loopDetached","backingRemoved","privateCredentialsRemoved","allApiProcessesExited", + "bothProductFixturesDisposed","apiPrivateStateRemoved","ownedMetadataRemoved")) and + cleanup["imagesRemoved"] == cleanup["fleetChanges"] == 0, "actual-owned-cleanup-not-proven") + task = proof["recordedTask"] + require(task["callbackStatus"] == 204 and all(task[x] for x in ("parentRequestId","taskId","automationBindingId","jobRunId","stepId","activityId","successWorklogId")), + "actual-recorded-task-native-result-callback-not-proven") + tree = ET.parse(args.trx).getroot() + results = [x for x in tree.iter() if x.tag.rsplit("}",1)[-1] == "UnitTestResult"] + require(results and all(x.attrib.get("outcome") == "Passed" for x in results), "physical-trx-contains-failure-or-skipped-case") + selected = [x for x in results if CASE in x.attrib.get("testName", "")] + require(len(selected) == 1 and selected[0].attrib.get("outcome") == "Passed", "registered-actual-physical-case-not-passed") + print("PASS_ACTUAL_PHYSICAL_INCIDENT_EVIDENCE") + +if __name__ == "__main__": + try: main() + except Exception as error: + print("Physical disk proof rejected: " + type(error).__name__, file=sys.stderr) + raise SystemExit(1) diff --git a/tools/testing/physical-incident/owned_loop_volume.py b/tools/testing/physical-incident/owned_loop_volume.py new file mode 100644 index 00000000..e937e8e4 --- /dev/null +++ b/tools/testing/physical-incident/owned_loop_volume.py @@ -0,0 +1,296 @@ +"""UNAPPLIED fixture helper. Import only from the future root-capable CI driver. + +This provisions a real, fixed ext4 filesystem. It never fills the host drive to +cause a breach. Three GiB of non-sparse backing storage is a separate, explicit +provisioning requirement; the breach stimulus is bounded to 256 MiB. No helper +from this preparation has been executed. +""" +from __future__ import annotations + +import json +import os +import pathlib +import re +import shutil +import stat +import subprocess +import time +import uuid + +MIB = 1024 * 1024 +GIB = 1024 * MIB +HOST_FLOOR = 2 * GIB +SELECTED_FLOOR = 2 * GIB +BACKING_BYTES = 3 * GIB +STIMULUS_CAP = 256 * MIB +BLOCK = bytes([0x6D]) * MIB + + +class FixturePreconditionError(RuntimeError): + pass + + +def command(arguments: list[str], timeout: int = 60) -> str: + # Commands are arrays, never shell text. Keep arbitrary diagnostics private. + result = subprocess.run(arguments, capture_output=True, timeout=timeout, check=False) + if result.returncode or len(result.stdout) > 65536: + raise FixturePreconditionError("owned-volume-command-failed:" + pathlib.Path(arguments[0]).name) + return result.stdout.decode("utf-8", "strict").strip() + + +def available(path: pathlib.Path) -> int: + value = os.statvfs(path) + return value.f_bavail * value.f_frsize + + +def private_json(path: pathlib.Path, value: dict) -> None: + temporary = path.with_name(path.name + "." + uuid.uuid4().hex) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +class OwnedLoopVolume: + """Owns only a new UUID directory, its exact loop device and one mount. + + The caller must own this object until all Client containers have stopped. + Cleanup refuses a busy mount, a changed backing/device identity, or a leaked + container rather than deleting files beneath another live filesystem. + """ + + def __init__(self, authorized_work_root: pathlib.Path): + self.parent = authorized_work_root.absolute() + self.root = self.parent / ("netratel-owned-disk-" + uuid.uuid4().hex) + self.image = self.root / "filesystem.ext4" + self.mount = self.root / "mounted" + self.stimulus = self.mount / "physical-allocation.bin" + self.marker = self.mount / ".fixture-owner" + self.owner = uuid.uuid4().hex + self.loop: str | None = None + self.mounted = False + self.closed = False + self.metadata_acknowledged = False + self.metadata_validated = False + self.container_users: set[str] = set() + self.proof: list[dict] = [] + + def __enter__(self): + try: + self.acquire() + return self + except BaseException: + self.close() + raise + + def __exit__(self, *unused): + self.close() + + def preflight(self) -> dict: + if os.geteuid() != 0: + raise FixturePreconditionError("owned-ext4-volume-requires-root-capable-fixture") + for name in ("losetup", "mke2fs", "mount", "umount", "findmnt"): + if not shutil.which(name): + raise FixturePreconditionError("missing-owned-volume-tool:" + name) + if not self.parent.is_dir() or self.parent.resolve() != self.parent or str(self.parent) in ("/", "/home", "/tmp"): + raise FixturePreconditionError("explicit-private-work-root-required") + # A backing file on memory/network storage would change the physical claim. + filesystem = command(["findmnt", "--noheadings", "--output", "FSTYPE", "--target", str(self.parent)]) + if filesystem not in ("ext4", "xfs", "btrfs"): + raise FixturePreconditionError("physical-disk-backed-work-root-required") + baseline = available(self.parent) + if baseline - BACKING_BYTES < HOST_FLOOR: + raise FixturePreconditionError("insufficient-host-space-for-explicit-three-gib-reservation") + caps = pathlib.Path("/proc/self/status").read_text().splitlines() + effective = next(int(line.split()[1], 16) for line in caps if line.startswith("CapEff:")) + if not effective & (1 << 21): + raise FixturePreconditionError("owned-ext4-volume-requires-CAP_SYS_ADMIN") + return {"backingBytes": BACKING_BYTES, "hostAvailableBefore": baseline, + "hostFloorBytes": HOST_FLOOR, "backingFilesystem": filesystem, + "stimulusCapBytes": STIMULUS_CAP, "selectedFloorBytes": SELECTED_FLOOR} + + def acquire(self) -> None: + capacity = self.preflight() + self.root.mkdir(mode=0o700) + self.mount.mkdir(mode=0o700) + private_json(self.root / "ownership.json", {"owner": self.owner, "phase": "allocating", **capacity}) + fd = os.open(self.image, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + for _ in range(BACKING_BYTES // len(BLOCK)): + if available(self.parent) < HOST_FLOOR + len(BLOCK): + raise FixturePreconditionError("host-capacity-changed-during-bounded-provisioning") + view = memoryview(BLOCK) + while view: + written = os.write(fd, view) + if written <= 0: + raise FixturePreconditionError("physical-backing-write-did-not-progress") + view = view[written:] + os.fsync(fd) + finally: + os.close(fd) + observed = self.image.stat() + if observed.st_size != BACKING_BYTES or observed.st_blocks * 512 < BACKING_BYTES: + raise FixturePreconditionError("sparse-or-incomplete-backing-is-not-physical-proof") + command(["mke2fs", "-q", "-t", "ext4", "-F", "-m", "0", "-E", + "nodiscard,lazy_itable_init=0,lazy_journal_init=0", str(self.image)]) + formatted = self.image.stat() + if formatted.st_size != BACKING_BYTES or formatted.st_blocks * 512 < BACKING_BYTES: + raise FixturePreconditionError("formatting-changed-non-sparse-backing-allocation") + self.loop = command(["losetup", "--find", "--show", "--nooverlap", str(self.image)]) + if not self.loop.startswith("/dev/loop") or not self.loop[9:].isdigit(): + raise FixturePreconditionError("unexpected-owned-loop-device") + private_json(self.root / "ownership.json", {"owner": self.owner, "phase": "loop-attached", "loop": self.loop, **capacity}) + command(["mount", "-t", "ext4", "-o", "nodev,nosuid,noexec", self.loop, str(self.mount)]) + self.mounted = True + self.assert_owned() + # The unprivileged, real production Client can write only inside its fixture volumes. + os.chown(self.mount, 10001, 10001) + os.chmod(self.mount, 0o700) + self.marker.write_text(self.owner, encoding="ascii") + os.chown(self.marker, 10001, 10001) + os.chmod(self.marker, 0o400) + self.proof.append({"phase": "provisioned", "backingBytes": BACKING_BYTES, + "selectedAvailable": available(self.mount), "hostAvailable": available(self.parent), + "realFilesystem": "ext4", "sparseBacking": False, "physicalStimulusBytes": 0}) + private_json(self.root / "ownership.json", {"owner": self.owner, "phase": "mounted", "loop": self.loop, **capacity}) + + def assert_owned(self) -> None: + if not self.loop: + raise FixturePreconditionError("no-owned-loop-device") + identity = json.loads(command(["losetup", "--json", "--output", "NAME,BACK-FILE", self.loop]))["loopdevices"] + if len(identity) != 1 or identity[0]["name"] != self.loop or pathlib.Path(identity[0]["back-file"]).resolve() != self.image: + raise FixturePreconditionError("loop-backing-identity-changed") + if self.mounted: + source = command(["findmnt", "--noheadings", "--output", "SOURCE", "--mountpoint", str(self.mount)]) + if source != self.loop: + raise FixturePreconditionError("owned-mount-identity-changed") + if self.marker.exists() and self.marker.read_text(encoding="ascii") != self.owner: + raise FixturePreconditionError("owned-volume-marker-changed") + + def allocate(self, count: int = 256 * MIB) -> dict: + self.assert_owned() + baseline = available(self.mount) + if self.stimulus.exists() or count <= 0 or count > STIMULUS_CAP or count > baseline // 10 or baseline - count < SELECTED_FLOOR: + raise FixturePreconditionError("physical-stimulus-bounds-not-satisfied") + fd = os.open(self.stimulus, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + remaining = count + while remaining: + chunk = BLOCK[:min(remaining, len(BLOCK))] + if available(self.mount) - len(chunk) < SELECTED_FLOOR or available(self.parent) < HOST_FLOOR: + raise FixturePreconditionError("space-floor-changed-during-owned-stimulus") + view = memoryview(chunk) + while view: + written = os.write(fd, view) + if written <= 0: + raise FixturePreconditionError("physical-stimulus-write-did-not-progress") + view = view[written:] + remaining -= written + os.fsync(fd) + finally: + os.close(fd) + after = available(self.mount) + value = self.stimulus.stat() + if value.st_size != count or value.st_blocks * 512 < count or baseline - after < count: + raise FixturePreconditionError("actual-block-allocation-not-proven") + receipt = {"phase": "allocated", "stimulusBytes": count, "actualAllocatedBytes": value.st_blocks * 512, + "selectedAvailableBefore": baseline, "selectedAvailableAfter": after, + "hostAvailableAfter": available(self.parent), "observedAtUtcUnixNanoseconds": time.time_ns()} + self.proof.append(receipt) + return receipt + + def recover(self) -> dict: + self.assert_owned() + if self.stimulus.is_symlink() or not self.stimulus.is_file(): + raise FixturePreconditionError("owned-stimulus-identity-missing") + before = available(self.mount) + self.stimulus.unlink() + directory = os.open(self.mount, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(directory) + finally: + os.close(directory) + value = {"phase": "recovered", "selectedAvailableBefore": before, "selectedAvailableAfter": available(self.mount), + "hostAvailableAfter": available(self.parent), "observedAtUtcUnixNanoseconds": time.time_ns()} + self.proof.append(value) + return value + + def close(self) -> None: + if self.closed: + return + if self.container_users: + raise FixturePreconditionError("stop-all-owned-client-containers-before-volume-cleanup") + # A command timeout may follow an actual kernel mutation. Reconcile + # only this exact freshly created image/mount before deleting anything. + if self.image.exists() and not self.loop and shutil.which("losetup"): + rows = json.loads(command(["losetup", "--json", "--associated", str(self.image), + "--output", "NAME,BACK-FILE"]))["loopdevices"] + if len(rows) > 1: + raise FixturePreconditionError("ambiguous-owned-loop-reconciliation") + if rows: + self.loop = rows[0]["name"] + if self.mount.exists() and shutil.which("findmnt"): + check = subprocess.run(["findmnt", "--noheadings", "--output", "SOURCE", "--mountpoint", str(self.mount)], + capture_output=True, timeout=10, check=False) + if check.returncode == 0: + if not self.loop or check.stdout.decode("utf-8", "strict").strip() != self.loop: + raise FixturePreconditionError("unexpected-mount-during-owned-cleanup") + self.mounted = True + elif check.returncode != 1: + raise FixturePreconditionError("owned-cleanup-mount-state-could-not-be-read") + if self.loop: + self.assert_owned() + if self.mounted: + command(["umount", str(self.mount)], timeout=15) # No force/lazy unmount. + self.mounted = False + if self.loop: + command(["losetup", "--detach", self.loop], timeout=15) + self.loop = None + if self.image.exists(): + if self.image.is_symlink() or not stat.S_ISREG(self.image.lstat().st_mode): + raise FixturePreconditionError("owned-backing-file-type-changed") + self.image.unlink() + # Keep allowlisted identity/capacity/cleanup evidence; remove no external path. + if self.mount.exists(): + self.mount.rmdir() + if self.root.exists(): + private_json(self.root / "cleanup.json", {"owner": self.owner, "phase": "cleaned", "mountRemoved": True, + "loopDetached": True, "backingRemoved": True, "proof": self.proof}) + self.closed = True + + def acknowledge_cleanup(self, ownership_id: str) -> None: + """Remove only exact safe metadata after the caller consumed close facts.""" + if self.metadata_acknowledged: + return + if ownership_id != self.owner or not self.closed or self.container_users or self.mounted or self.loop: + raise FixturePreconditionError("owned-cleanup-acknowledgement-not-authorized") + if self.image.exists() or self.mount.exists() or self.root.parent != self.parent or \ + not re.fullmatch(r"netratel-owned-disk-[0-9a-f]{32}", self.root.name) or \ + self.root.is_symlink() or self.root.resolve() != self.root or not self.root.is_dir(): + raise FixturePreconditionError("owned-cleanup-metadata-root-identity-changed") + allowed = {"ownership.json", "cleanup.json"} + children = list(self.root.iterdir()) + names = {entry.name for entry in children} + if names != allowed and not (self.metadata_validated and names <= allowed): + raise FixturePreconditionError("unexpected-owned-cleanup-metadata-entry") + for entry in children: + if entry.is_symlink() or not stat.S_ISREG(entry.lstat().st_mode): + raise FixturePreconditionError("owned-cleanup-metadata-file-type-changed") + value = json.loads(entry.read_text(encoding="utf-8")) + if value.get("owner") != self.owner: + raise FixturePreconditionError("owned-cleanup-metadata-owner-changed") + if entry.name == "cleanup.json" and not (value.get("phase") == "cleaned" and + value.get("mountRemoved") is True and value.get("loopDetached") is True and value.get("backingRemoved") is True): + raise FixturePreconditionError("owned-cleanup-metadata-not-complete") + self.metadata_validated = True + # No recursive deletion and no external/parent path is removed. + for name in sorted(allowed): + (self.root / name).unlink(missing_ok=True) + self.root.rmdir() + self.metadata_acknowledged = True diff --git a/tools/testing/physical-incident/physical_disk_fixture_process.py b/tools/testing/physical-incident/physical_disk_fixture_process.py new file mode 100644 index 00000000..653d81c0 --- /dev/null +++ b/tools/testing/physical-incident/physical_disk_fixture_process.py @@ -0,0 +1,129 @@ +"""Own the real fixture volume/Client for one bounded integration-test process. + +JSON lines travel over redirected private stdin/stdout. Enrollment and CA input +are never arguments, environment values or exported proof. This entry point does +not build/pull/publish images, configure product authority or manufacture samples. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import signal +import sys + +from owned_loop_volume import FixturePreconditionError, OwnedLoopVolume +from production_client_container import ProductionClientContainer + + +def emit(request_id: str, facts: dict) -> None: + raw = json.dumps({"id": request_id, "facts": facts}, separators=(",", ":")) + if len(raw.encode("utf-8")) > 65536: + raise FixturePreconditionError("typed-fixture-response-limit-exceeded") + sys.stdout.write(raw + "\n") + sys.stdout.flush() + + +def interrupted(signum, frame) -> None: + raise FixturePreconditionError("owned-fixture-process-interrupted") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--work-root", required=True, type=pathlib.Path) + args = parser.parse_args() + volume = OwnedLoopVolume(args.work_root) + client: ProductionClientContainer | None = None + provisioned = False + closed = False + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, interrupted) + + def close() -> dict: + nonlocal closed + if not closed: + if client is not None: + client.close() # Stop only exact owned container IDs before unmount. + volume.close() + closed = True + if volume.container_users or volume.mounted or volume.loop or volume.image.exists(): + raise FixturePreconditionError("owned-fixture-cleanup-not-proven") + return {"ownedClientStoppedAndRemoved": client is None or client.closed, + "ownedMountRemoved": not volume.mount.exists(), + "ownedLoopDetached": volume.loop is None, + "ownedBackingRemoved": not volume.image.exists(), + "privateCredentialsRemoved": client is None or not client.root.exists(), + "ownershipId": volume.owner, + "imagesRemoved": 0, "fleetChanges": 0} + + try: + while True: + line = sys.stdin.buffer.readline(65537) + if not line: + # A lost parent closes its private pipe; cleanup still owns the disk. + close() + return 1 + if len(line) > 65536 or not line.endswith(b"\n"): + raise FixturePreconditionError("private-fixture-request-limit-exceeded") + request = json.loads(line) + if not isinstance(request, dict) or set(request) != {"id", "operation", "input"}: + raise FixturePreconditionError("invalid-private-fixture-request-shape") + request_id, operation, data = request["id"], request["operation"], request["input"] + if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", request_id): + raise FixturePreconditionError("invalid-private-fixture-request-id") + if not isinstance(data, dict): + raise FixturePreconditionError("invalid-private-fixture-input-shape") + if operation == "provision" and not provisioned and not closed and data == {}: + volume.acquire() + provisioned = True + emit(request_id, {"provisioned": True, "volume": volume.proof[-1]}) + elif operation == "start" and provisioned and client is None and not closed: + expected = {"imageId", "sourceSha", "version", "executableSha256", "trustedCaPem", + "apiBaseUrl", "gatewayEndpoint", "enrollmentCode"} + if set(data) != expected or any(not isinstance(value, str) for value in data.values()): + raise FixturePreconditionError("invalid-private-client-start-shape") + client = ProductionClientContainer(volume, data["imageId"], data["sourceSha"], + data["version"], data["executableSha256"], data["trustedCaPem"], data["apiBaseUrl"], + data["gatewayEndpoint"], data["enrollmentCode"]) + client.start() + data.clear() + request.clear() + emit(request_id, client.proof.copy()) + elif operation == "allocate" and client is not None and not closed and data == {}: + client.assert_alive() + emit(request_id, volume.allocate(256 * 1024 * 1024)) + elif operation == "recover" and client is not None and not closed and data == {}: + client.assert_alive() + emit(request_id, volume.recover()) + elif operation == "release-command" and client is not None and not closed and data == {}: + emit(request_id, client.release_command()) + elif operation == "alive" and client is not None and not closed and data == {}: + client.assert_alive() + emit(request_id, {"alive": True, "maximumClientLifetimeSeconds": 300}) + elif operation == "close" and data == {}: + emit(request_id, close()) + # Stay alive until C# has consumed and validated the real close + # receipt. A failed/lost caller leaves exact safe ownership facts. + elif operation == "cleanup-ack" and closed and set(data) == {"ownershipId"}: + volume.acknowledge_cleanup(data["ownershipId"]) + emit(request_id, {"ownedMetadataRemoved": volume.metadata_acknowledged}) + return 0 + else: + raise FixturePreconditionError("invalid-private-fixture-operation-or-phase") + except BaseException as error: + # No exception message/raw body/secret enters CI output. Failure returns + # nonzero even if bounded owned cleanup succeeds. + sys.stderr.write("Physical fixture process failed: " + type(error).__name__ + "\n") + return 1 + finally: + try: + close() + except BaseException as error: + sys.stderr.write("Physical fixture cleanup failed: " + type(error).__name__ + "\n") + # Teardown failure must override an earlier return of zero. + raise SystemExit(1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/testing/physical-incident/production_client_container.py b/tools/testing/physical-incident/production_client_container.py new file mode 100644 index 00000000..db28f930 --- /dev/null +++ b/tools/testing/physical-incident/production_client_container.py @@ -0,0 +1,272 @@ +"""Candidate full production Client process, private installation and actual TLS. + +Requires an already-built exact-current CI image. This module never builds, +pulls, publishes, updates, or removes an image and never starts the reduced +ServiceLink.NativeRunner in place of the full production telemetry producer. +""" +from __future__ import annotations + +import hashlib +import json +import os +import pathlib +import re +import shutil +import ssl +import subprocess +import threading +import time +import uuid +from urllib.parse import urlsplit + +from owned_loop_volume import FixturePreconditionError, OwnedLoopVolume, private_json + + +PROBE = '''import hashlib,json,os,pathlib,socket,ssl,sys +cfg=json.loads(pathlib.Path('/fixture/probe.json').read_text()) +volume=pathlib.Path('/netratel-physical-disk') +volume_stat=volume.stat() +assert [os.major(volume_stat.st_dev),os.minor(volume_stat.st_dev)]==cfg['volumeDevice'] +assert (volume/'.fixture-owner').read_text(encoding='ascii')==cfg['volumeOwner'] +assert volume_stat.st_dev!=pathlib.Path('/').stat().st_dev +mounts=[line.split() for line in pathlib.Path('/proc/self/mountinfo').read_text().splitlines()] +assert any(parts[4]=='/netratel-physical-disk' and parts[parts.index('-')+1]=='ext4' for parts in mounts) +observed=os.statvfs(volume) +assert observed.f_bavail*observed.f_frsize==cfg['volumeAvailableBytes'] +assert observed.f_blocks*observed.f_frsize==cfg['volumeTotalBytes'] +manifest=json.loads(pathlib.Path('/app/netratel-client-manifest.json').read_text(encoding='utf-8-sig')) +assert manifest['schema']=='netratel.client.manifest.v1' +assert manifest['product']=='NetRatel.Client' +assert manifest['commitSha']==cfg['sourceSha'] and manifest['version']==cfg['version'] +assert manifest['runtimeId']=='linux-x64' and manifest['executable']=='NetRatel.Client' +assert hashlib.sha256(pathlib.Path('/app/NetRatel.Client').read_bytes()).hexdigest()==cfg['executableSha256'] +ctx=ssl.create_default_context(cafile='/fixture/trusted-ca.pem' if cfg['trusted'] else None) +ctx.set_alpn_protocols(['h2']) +try: + with socket.create_connection((cfg['host'],cfg['port']),timeout=10) as raw: + with ctx.wrap_socket(raw,server_hostname=cfg['host']) as secure: + assert secure.selected_alpn_protocol()=='h2' +except ssl.SSLCertVerificationError: + if cfg['trusted']: sys.exit(43) + print('production-image-unrelated-ca-rejected'); sys.exit(42) +if not cfg['trusted']: sys.exit(43) +print('production-image-tls-alpn-h2-verified') +''' + + +class ProductionClientContainer: + """Owns one full Client plus private identity, and records only safe facts. + + Run inside the future root-capable CI orchestration process. Host networking + connects to the existing loopback-only real HTTPS/H2 listener; the process + uses UID10001 with no capabilities, no Docker socket and no host identity. + """ + + def __init__(self, volume: OwnedLoopVolume, image_id: str, source_sha: str, + version: str, executable_sha256: str, trusted_ca_pem: str, + api_base_url: str, gateway_endpoint: str, enrollment_code: str): + if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id) or not re.fullmatch(r"[0-9a-f]{40}", source_sha) or not re.fullmatch(r"[0-9a-f]{64}", executable_sha256): + raise FixturePreconditionError("exact-image-source-executable-identities-required") + api = urlsplit(api_base_url) + gateway = urlsplit(gateway_endpoint) + if api.scheme not in ("http", "https") or api.username or api.password or api.query or api.fragment: + raise FixturePreconditionError("invalid-actual-rest-endpoint") + if gateway.scheme != "https" or gateway.hostname != "127.0.0.1" or not gateway.port or gateway.username or gateway.password or gateway.query or gateway.fragment: + raise FixturePreconditionError("actual-loopback-https-h2-listener-required") + if not enrollment_code or len(enrollment_code) > 4096 or len(trusted_ca_pem) > 8192: + raise FixturePreconditionError("bounded-private-enrollment-and-ca-required") + self.volume, self.image_id, self.source_sha = volume, image_id, source_sha + self.version, self.executable_sha256 = version, executable_sha256 + self.name = "netratel-physical-client-" + uuid.uuid4().hex + self.root = volume.root / ("client-" + uuid.uuid4().hex) + self.state = self.root / "state" + self.settings = self.root / "clientsettings.json" + self.ca = self.root / "trusted-ca.pem" + self.probe_file = self.root / "trust_probe.py" + self.probe_config = self.root / "probe.json" + self.created_ids: list[str] = [] + self.pending_names: set[str] = set() + self.main_id: str | None = None + self.started_at: float | None = None + self.closed = False + self.command_marker = "physical-command-" + uuid.uuid4().hex + self.release_file = "/var/lib/netratel/physical-command-release-" + uuid.uuid4().hex + self.lifetime_expired = False + self.watchdog: threading.Timer | None = None + self.proof = {"sourceSha": source_sha, "clientImageId": image_id, "clientVersion": version, + "clientExecutableSha256": executable_sha256, + "caSha256": hashlib.sha256(ssl.PEM_cert_to_DER_cert(trusted_ca_pem)).hexdigest(), + "producer": "full-production-NetRatel.Client", "slowCollectionSeconds": 5, + "maximumClientLifetimeSeconds": 300, "credentialsExported": False, "commandMarker": self.command_marker, + "releaseFile": self.release_file, + "command": "for i in $(seq 1 25); do test -f "+self.release_file+" && { printf '%s\n' "+self.command_marker+"; exit 0; }; sleep 1; done; exit 91"} + self._private_input = (api_base_url, gateway_endpoint, enrollment_code, trusted_ca_pem) + + def docker(self, arguments: list[str], timeout: int = 30) -> str: + docker_env = os.environ.copy() + for name in ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"): + docker_env.pop(name, None) + result = subprocess.run(["docker", "--host=unix:///var/run/docker.sock", *arguments], + capture_output=True, timeout=timeout, check=False, env=docker_env) + if result.returncode or len(result.stdout) > 65536: + raise FixturePreconditionError("owned-client-docker-command-failed:" + arguments[0]) + return result.stdout.decode("utf-8", "strict").strip() + + def private_file(self, path: pathlib.Path, data: str) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.chown(path, 10001, 10001) + + def inspect_image(self) -> None: + rows = json.loads(self.docker(["image", "inspect", self.image_id])) + if len(rows) != 1 or rows[0]["Id"] != self.image_id or rows[0]["Os"] != "linux" or rows[0]["Architecture"] != "amd64": + raise FixturePreconditionError("same-current-ci-linux-amd64-client-image-required") + config = rows[0]["Config"] + labels = config.get("Labels") or {} + if labels.get("org.opencontainers.image.revision") != self.source_sha or labels.get("org.opencontainers.image.version") != self.version: + raise FixturePreconditionError("current-client-image-source-version-labels-not-proven") + if config.get("Entrypoint") != ["/app/NetRatel.Client"] or config.get("User") not in ("netratel", "10001", "10001:10001"): + raise FixturePreconditionError("production-client-entrypoint-user-not-preserved") + + def create(self, extra: list[str], probe: bool) -> str: + mounts = ["--mount", f"type=bind,source={self.root},target=/fixture,readonly", + "--mount", f"type=bind,source={self.volume.mount},target=/netratel-physical-disk,readonly"] + if not probe: + mounts += ["--mount", f"type=bind,source={self.state},target=/var/lib/netratel", + "--mount", f"type=bind,source={self.settings},target=/app/clientsettings.json,readonly"] + name = self.name + ("-probe-" + uuid.uuid4().hex if probe else "") + self.pending_names.add(name) + created = self.docker(["create", "--name", name, "--label", "netratel.fixture.owner=" + self.volume.owner, + "--network", "host", "--user", "10001:10001", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--pids-limit", "128", *mounts, *extra, + self.image_id, *( ["/fixture/trust_probe.py"] if probe else ["--service"] )]) + if not re.fullmatch(r"[0-9a-f]{64}", created): + raise FixturePreconditionError("owned-client-create-did-not-return-exact-container-id") + self.created_ids.append(created) + self.pending_names.remove(name) + self.volume.container_users.add(created) + return created + + def start(self) -> None: + try: + self.volume.assert_owned() + self.inspect_image() + self.root.mkdir(mode=0o700) + os.chown(self.root, 10001, 10001) + self.state.mkdir(mode=0o700) + os.chown(self.state, 10001, 10001) + api, endpoint, code, ca = self._private_input + self.private_file(self.settings, json.dumps({"Client": {"ApiBaseUrl": api, "EnrollmentCode": code, "StateDirectory": "/var/lib/netratel"}, + "Gateway": {"Endpoint": endpoint, "TelemetrySlowIntervalSeconds": 5}})) + self.private_file(self.ca, ca) + self.private_file(self.probe_file, PROBE) + address = urlsplit(endpoint) + for trusted, expected in ((True, (0, "production-image-tls-alpn-h2-verified")), + (False, (42, "production-image-unrelated-ca-rejected"))): + private_json(self.probe_config, {"trusted": trusted, "host": address.hostname, "port": address.port, + "sourceSha": self.source_sha, "version": self.version, + "executableSha256": self.executable_sha256, + "volumeDevice": [os.major(self.volume.mount.stat().st_dev), os.minor(self.volume.mount.stat().st_dev)], + "volumeOwner": self.volume.owner, + "volumeAvailableBytes": os.statvfs(self.volume.mount).f_bavail * os.statvfs(self.volume.mount).f_frsize, + "volumeTotalBytes": os.statvfs(self.volume.mount).f_blocks * os.statvfs(self.volume.mount).f_frsize}) + os.chown(self.probe_config, 10001, 10001) + os.chmod(self.probe_config, 0o400) + probe = self.create(["--entrypoint", "python3"], probe=True) + self.docker(["start", probe]) + status = int(self.docker(["wait", probe], timeout=15)) + output = self.docker(["logs", probe]) + if (status, output) != expected: + raise FixturePreconditionError("actual-production-image-tls-trust-probe-failed") + self.proof["trustedTlsAlpnVerified" if trusted else "unrelatedCaRejected"] = True + self.proof["daemonVisibleOwnedExt4Verified"] = True + self.remove(probe) + # This CA is process-local and this enrollment code never appears in argv/env/log exports. + self.main_id = self.create(["--env", "SSL_CERT_FILE=/fixture/trusted-ca.pem", + "--env", "NetRatel_CLIENT_LOG_DIR=/var/lib/netratel/logs"], probe=False) + self.docker(["start", self.main_id]) + self.started_at = time.monotonic() + self.watchdog = threading.Timer(300, self.stop_at_deadline) + self.watchdog.daemon = True + self.watchdog.start() + self._private_input = () + except BaseException: + self.close() + raise + + def assert_alive(self) -> None: + if not self.main_id or self.started_at is None: + raise FixturePreconditionError("full-production-client-not-started") + if self.lifetime_expired or time.monotonic() - self.started_at > 300: + raise FixturePreconditionError("fixed-actual-native-client-lifetime-exceeded") + row = json.loads(self.docker(["inspect", self.main_id]))[0] + if not row["State"]["Running"] or row["Config"]["Image"] != self.image_id: + raise FixturePreconditionError("full-production-client-exited-or-image-identity-changed") + + def release_command(self) -> dict: + self.assert_alive() + # Create only the disposable command gate after the real durable ACK. + # The production executor, not docker exec, runs the recorded command. + self.docker(["exec", "--user", "10001:10001", self.main_id, "python3", "-c", + "import os,sys; f=os.open(sys.argv[1],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600); os.close(f)", + self.release_file]) + return {"ownedCommandGateReleased": True, "commandMarker": self.command_marker} + + def remove(self, container: str) -> None: + rows = json.loads(self.docker(["inspect", container])) + if len(rows) != 1 or rows[0]["Id"] != container or rows[0]["Config"]["Image"] != self.image_id or (rows[0]["Config"].get("Labels") or {}).get("netratel.fixture.owner") != self.volume.owner: + raise FixturePreconditionError("owned-client-container-identity-changed") + if rows[0]["State"]["Running"]: + self.docker(["stop", "--time", "8", container], timeout=15) + self.docker(["rm", container], timeout=15) # No force, image deletion or fleet operation. + self.created_ids.remove(container) + self.volume.container_users.remove(container) + + def stop_at_deadline(self) -> None: + self.lifetime_expired = True + if self.main_id in self.created_ids: + try: + self.docker(["stop", "--time", "8", self.main_id], timeout=15) + except BaseException: + # The caller fails on lifetime_expired; normal bounded cleanup + # must still prove the exact owned container stopped and removed. + pass + + def close(self) -> None: + if self.closed: + return + if self.watchdog: + self.watchdog.cancel() + self.watchdog.join(timeout=16) + if self.watchdog.is_alive(): + raise FixturePreconditionError("owned-client-deadline-stop-still-active") + failures = [] + # A timed-out create may have committed in the daemon. Reconcile only + # the exact new UUID names before reporting cleanup, never all containers. + for name in list(self.pending_names): + try: + ids = self.docker(["ps", "--all", "--no-trunc", "--filter", "name=^/" + name + "$", "--format", "{{.ID}}"]).splitlines() + if len(ids) > 1 or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in ids): + raise FixturePreconditionError("ambiguous-owned-create-reconciliation") + for value in ids: + if value not in self.created_ids: + self.created_ids.append(value) + self.volume.container_users.add(value) + self.pending_names.remove(name) + except BaseException as error: + failures.append(type(error).__name__) + for container in list(reversed(self.created_ids)): + try: + self.remove(container) + except BaseException as error: + failures.append(type(error).__name__) + if failures: + raise FixturePreconditionError("bounded-owned-client-cleanup-incomplete:" + ",".join(failures)) + # Keep no credential/identity/private arbitrary process diagnostics after owned teardown. + if self.root.exists(): + shutil.rmtree(self.root) + self._private_input = () + self.closed = True