diff --git a/README.md b/README.md index 12eb6d5..a2ec2ec 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ sudo ./ochenstarik-server-monitor-manager.sh install-control-agent The installer selects the `amd64` or `arm64` archive and verifies its SHA-256 checksum. The `SMMDEV1` code enrolls the Windows application: the app creates its operator key locally, confirms the Hub CA fingerprint, obtains a separate certificate, and protects it with Windows DPAPI. +An Operator can issue a ten-minute Automation enrollment token for exactly one source Node through `POST /api/v1/control/automations/token` or the local `automation-token-create AUTOMATION_ID SOURCE_NODE_ID` command. The automation process creates its private key and CSR locally, enrolls through `/api/v1/automation-enroll`, and can then read only `/api/v1/automation/links`. Link mutations remain Operator-only. + ## Windows client Requirements for building from source: @@ -110,6 +112,7 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile - SSH monitoring uses a root-owned forced command without shell, PTY, or forwarding; - Agent certificates can only submit heartbeat data for their own Node; - Operator certificates are required for inventory, Links, and event streaming; +- Automation certificates are bound to one source Node and can only read that source's effective Link grants; they cannot create, disable, or enumerate unrelated Links; - Link traffic is denied by default and allowed only by explicit nftables rules; - disabling a Link persists the desired state before the firewall rule is removed; - idempotency keys prevent a retry from repeating a policy side effect; @@ -119,7 +122,7 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile `v0.1.0-alpha.4` is an early testing release, not a production security appliance. Windows and Linux builds, control-plane tests, Bash syntax checks, self-contained `linux-x64`/`linux-arm64` artifacts, and checksums are automated in GitHub Actions. -The current development branch implements Windows SSH monitoring, the Hub/Node WireGuard installer, directional Links, one-time enrollment, mTLS Agent and Operator identities, certificate revocation/re-enrollment, SQLite control state, audit, authenticated event streaming, Windows Control API integration, and a bounded durable Agent buffer with downsampling. +The current development branch implements Windows SSH monitoring, the Hub/Node WireGuard installer, directional Links, one-time enrollment, separate mTLS Agent, Operator, and source-scoped Automation identities, certificate revocation/re-enrollment, SQLite control state, audit, authenticated event streaming, Windows Control API integration, and a bounded durable Agent buffer with downsampling. Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Linux CI exercises the real Control-to-helper process boundary, including a helper failure and Control process reconstruction over the same SQLite database. Still planned: end-to-end nftables and host-reboot tests with the installer, a 50–100 Node load test, signed Windows installer, and desktop/mobile clients for additional platforms. diff --git a/docs/architecture.md b/docs/architecture.md index 4211451..c2d7302 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,7 +81,7 @@ Active -> Expired -> Disabled 1. На целевом сервере создаётся отдельный Unix-пользователь, например `ai-agent-dev`, без root-доступа. 2. Рабочие каталоги и команды ограничиваются Unix-правами, группами, контейнером или sandbox-профилем. 3. Создаётся Link от узла с AI-агентом к целевому адресу и только необходимому SSH-порту. -4. AI-агент использует отдельную SSH identity, не ключ мониторинга приложения. +4. AI-агент использует отдельную SSH identity, не ключ мониторинга приложения. Отдельный mTLS-сертификат Automation привязан к source Node и позволяет читать только его эффективные Link-grants; он не открывает трафик и не изменяет Links. 5. Пользователь включает Link на ограниченное время и после работы отключает его. ## 6. Следующий control layer @@ -98,7 +98,7 @@ Hub остаётся маршрутизатором Mesh первого поко Первый реализованный срез control layer использует ASP.NET Core 10 и SQLite. Hub выдаёт агенту сертификат по CSR только после атомарного погашения десятиминутного token. После регистрации Agent выполняет только исходящие HTTPS-запросы с mTLS, а Hub связывает thumbprint сертификата с конкретным `node_id`. Heartbeat содержит idempotency key и отклоняется при попытке повторить тот же ключ с другим телом запроса. -Control Hub сохраняет inventory, heartbeat-метрики, направленные Links, idempotency и аудит в SQLite. Желаемое состояние отключения Link фиксируется до вызова ограниченного nftables wrapper; при ошибке фактическое состояние становится `Partial`. Отдельная mTLS identity `Operator` читает inventory, управляет Links и получает NDJSON event stream, а сертификат `Agent` ограничен heartbeat собственного `node_id`. +Control Hub сохраняет inventory, heartbeat-метрики, направленные Links, idempotency и аудит в SQLite. Желаемое состояние отключения Link фиксируется до вызова ограниченного nftables wrapper; при ошибке фактическое состояние становится `Partial`. Отдельная mTLS identity `Operator` читает inventory, управляет Links и получает NDJSON event stream, сертификат `Agent` ограничен heartbeat собственного `node_id`, а `Automation` — чтением эффективных Link-grants одного закреплённого source Node. Agent использует ограниченный долговечный буфер и повторяет доставку с тем же idempotency key. Hub принимает накопленные точки только в настроенном временном окне и сохраняет исходное время измерения. Event stream уже подключён к WinUI. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2283874..4e77def 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ - [x] экспорт диагностики без секретов; - [x] отдельный прямой SSH-терминал; - [x] отдельная terminal identity и подтверждение пользователя; -- [ ] отдельная automation identity для AI-агента. +- [x] отдельная automation identity для AI-агента. ## Этап 6 — постоянный control layer diff --git a/docs/security-model.md b/docs/security-model.md index 1302bfb..2bd8692 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -33,7 +33,7 @@ Windows-клиент использует отдельный Ed25519-ключ т Windows-клиент получает отдельный код `SMMDEV1`, показывает URL и SHA-256 fingerprint Control CA и продолжает регистрацию только после явного подтверждения пользователя. Приватный operator key создаётся локально, хранится в DPAPI current-user scope и не используется как SSH monitoring, terminal или Agent identity. -Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены. +Сертификат `Agent` разрешает только heartbeat собственного `node_id`. Только сертификат с ролью `Operator` может читать общий inventory, изменять Links и подписываться на event stream. Сертификат `Automation` выдаётся по отдельному одноразовому token, привязан к одному source Node и разрешает только чтение его эффективных Link-grants без `reason`, общего inventory и чужих Links. Control service вызывает от root только отдельный wrapper с командами `link-connect` и `link-disconnect`; другие Hub-команды через него запрещены. При перерегистрации Node Control Hub в одной SQLite-транзакции помечает старый сертификат `Revoked`, погашает ранее выданные enrollment tokens и переводит все связанные Links в желаемое состояние `Disabled`. Только после этого ограниченный firewall wrapper удаляет фактические правила. Новый token живёт 10 минут, а повтор запроса с тем же idempotency key не создаёт второй token и не повторяет firewall-операции. Windows-клиент требует отдельного подтверждения и не публикует token в event stream или аудит. diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs index 0f72b51..1de5f45 100644 --- a/src/ServerMonitorManager.Control/ControlStore.cs +++ b/src/ServerMonitorManager.Control/ControlStore.cs @@ -54,6 +54,20 @@ public sealed class ControlStore(IOptions options) status TEXT NOT NULL, last_seen_at TEXT NULL ); + CREATE TABLE IF NOT EXISTS automation_tokens ( + token_hash TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + source_node_id TEXT NOT NULL REFERENCES agents(node_id), + expires_at TEXT NOT NULL, + consumed_at TEXT NULL + ); + CREATE TABLE IF NOT EXISTS automations ( + automation_id TEXT PRIMARY KEY, + source_node_id TEXT NOT NULL REFERENCES agents(node_id), + certificate_thumbprint TEXT NOT NULL UNIQUE, + certificate_expires_at TEXT NOT NULL, + status TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS metric_samples ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE, @@ -144,6 +158,81 @@ public sealed class ControlStore(IOptions options) return token; } + public async Task CreateAutomationTokenAsync( + AutomationTokenCreateRequest request, + string actor, + TimeSpan lifetime, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var operationKey = $"automation-token:{actor}:{request.IdempotencyKey}"; + var requestHash = Fingerprint(request, SmmJsonContext.Default.AutomationTokenCreateRequest); + var cached = await ReadIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + SmmJsonContext.Default.AutomationTokenResponse, + cancellationToken); + if (cached is not null) + { + await transaction.CommitAsync(cancellationToken); + return cached; + } + + var source = connection.CreateCommand(); + source.Transaction = transaction; + source.CommandText = """ + SELECT EXISTS( + SELECT 1 FROM agents + WHERE node_id = $source AND status != 'Revoked'); + """; + source.Parameters.AddWithValue("$source", request.SourceNodeId); + if (Convert.ToInt32(await source.ExecuteScalarAsync(cancellationToken)) != 1) + { + throw new InvalidOperationException("Automation source Node is not registered."); + } + + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var expiresAt = DateTimeOffset.UtcNow.Add(lifetime); + var insert = connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = """ + INSERT INTO automation_tokens( + token_hash, automation_id, source_node_id, expires_at) + VALUES ($hash, $automation, $source, $expires); + """; + insert.Parameters.AddWithValue("$hash", Hash(token)); + insert.Parameters.AddWithValue("$automation", request.AutomationId); + insert.Parameters.AddWithValue("$source", request.SourceNodeId); + insert.Parameters.AddWithValue("$expires", expiresAt.ToString("O")); + await insert.ExecuteNonQueryAsync(cancellationToken); + var response = new AutomationTokenResponse( + request.AutomationId, request.SourceNodeId, token, expiresAt); + await WriteIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + response, + SmmJsonContext.Default.AutomationTokenResponse, + cancellationToken); + await WriteAuditAsync( + connection, + transaction, + actor, + "automation.enrollment.requested", + request.AutomationId, + JsonSerializer.Serialize( + new AutomationScope(request.AutomationId, request.SourceNodeId, expiresAt), + SmmJsonContext.Default.AutomationScope), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return response; + } + public async Task EnrollDeviceAsync( DeviceEnrollmentRequest request, Func issueCertificate, @@ -218,6 +307,96 @@ public sealed class ControlStore(IOptions options) return response; } + public async Task EnrollAutomationAsync( + AutomationEnrollmentRequest request, + Func issueCertificate, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var operationKey = $"automation-enroll:{request.IdempotencyKey}"; + var requestHash = Fingerprint(request, SmmJsonContext.Default.AutomationEnrollmentRequest); + var cached = await ReadIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + SmmJsonContext.Default.AutomationEnrollmentResponse, + cancellationToken); + if (cached is not null) + { + await transaction.CommitAsync(cancellationToken); + return cached; + } + + var now = DateTimeOffset.UtcNow.ToString("O"); + var consume = connection.CreateCommand(); + consume.Transaction = transaction; + consume.CommandText = """ + UPDATE automation_tokens + SET consumed_at = $now + WHERE token_hash = $hash + AND automation_id = $automation + AND consumed_at IS NULL + AND expires_at >= $now + RETURNING source_node_id; + """; + consume.Parameters.AddWithValue("$now", now); + consume.Parameters.AddWithValue("$hash", Hash(request.Token)); + consume.Parameters.AddWithValue("$automation", request.AutomationId); + var sourceNodeId = await consume.ExecuteScalarAsync(cancellationToken) as string; + if (sourceNodeId is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var issued = issueCertificate(); + var response = new AutomationEnrollmentResponse( + request.AutomationId, + sourceNodeId, + issued.CertificatePem, + issued.CertificateAuthorityPem, + issued.ExpiresAt); + var upsert = connection.CreateCommand(); + upsert.Transaction = transaction; + upsert.CommandText = """ + INSERT INTO automations( + automation_id, source_node_id, certificate_thumbprint, certificate_expires_at, status) + VALUES ($automation, $source, $thumbprint, $expires, 'Active') + ON CONFLICT(automation_id) DO UPDATE SET + source_node_id = excluded.source_node_id, + certificate_thumbprint = excluded.certificate_thumbprint, + certificate_expires_at = excluded.certificate_expires_at, + status = 'Active'; + """; + upsert.Parameters.AddWithValue("$automation", request.AutomationId); + upsert.Parameters.AddWithValue("$source", sourceNodeId); + upsert.Parameters.AddWithValue("$thumbprint", issued.Thumbprint); + upsert.Parameters.AddWithValue("$expires", issued.ExpiresAt.ToString("O")); + await upsert.ExecuteNonQueryAsync(cancellationToken); + await WriteIdempotentAsync( + connection, + transaction, + operationKey, + requestHash, + response, + SmmJsonContext.Default.AutomationEnrollmentResponse, + cancellationToken); + await WriteAuditAsync( + connection, + transaction, + request.AutomationId, + "automation.enroll", + request.AutomationId, + JsonSerializer.Serialize( + new AutomationScope(request.AutomationId, sourceNodeId, issued.ExpiresAt), + SmmJsonContext.Default.AutomationScope), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return response; + } + public async Task EnrollAsync( EnrollmentRequest request, Func issueCertificate, @@ -306,12 +485,17 @@ public sealed class ControlStore(IOptions options) await using var connection = await OpenAsync(cancellationToken); var command = connection.CreateCommand(); command.CommandText = """ - SELECT node_id, 'Agent' FROM agents + SELECT node_id, 'Agent', NULL FROM agents WHERE certificate_thumbprint = $thumbprint AND certificate_expires_at > $now AND status != 'Revoked' UNION ALL - SELECT device_id, 'Operator' FROM devices + SELECT device_id, 'Operator', NULL FROM devices + WHERE certificate_thumbprint = $thumbprint + AND certificate_expires_at > $now + AND status != 'Revoked' + UNION ALL + SELECT automation_id, 'Automation', source_node_id FROM automations WHERE certificate_thumbprint = $thumbprint AND certificate_expires_at > $now AND status != 'Revoked' @@ -321,7 +505,10 @@ public sealed class ControlStore(IOptions options) command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O")); await using var reader = await command.ExecuteReaderAsync(cancellationToken); return await reader.ReadAsync(cancellationToken) - ? new ControlIdentity(reader.GetString(0), reader.GetString(1)) + ? new ControlIdentity( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2)) : null; } @@ -1138,7 +1325,7 @@ public sealed class IdempotencyConflictException : Exception } } -public sealed record ControlIdentity(string Id, string Role); +public sealed record ControlIdentity(string Id, string Role, string? SourceNodeId = null); public sealed record LinkMutation(LinkPolicy Link, bool IsReplay); diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index a128fb9..75587b8 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -11,6 +11,8 @@ using Microsoft.Extensions.Options; using ServerMonitorManager.Control; using ServerMonitorManager.Core; +const string AutomationSourceClaim = "smm:source_node_id"; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); @@ -65,12 +67,16 @@ builder.Services.AddAuthentication(CertificateAuthenticationDefaults.Authenticat return; } - context.Principal = new ClaimsPrincipal(new ClaimsIdentity( - [ - new Claim(ClaimTypes.NameIdentifier, identity.Id), - new Claim(ClaimTypes.Role, identity.Role) - ], - context.Scheme.Name)); + var claims = new List + { + new(ClaimTypes.NameIdentifier, identity.Id), + new(ClaimTypes.Role, identity.Role) + }; + if (identity.SourceNodeId is not null) + { + claims.Add(new Claim(AutomationSourceClaim, identity.SourceNodeId)); + } + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); context.Success(); } }; @@ -86,6 +92,7 @@ builder.Services.AddAuthorization(options => { options.AddPolicy("Agent", policy => policy.RequireRole("Agent")); options.AddPolicy("Operator", policy => policy.RequireRole("Operator")); + options.AddPolicy("Automation", policy => policy.RequireRole("Automation")); }); builder.WebHost.ConfigureKestrel(options => { @@ -128,6 +135,23 @@ if (args is ["device-token-create", var deviceId]) return 0; } +if (args is ["automation-token-create", var automationId, var sourceNodeId]) +{ + if (!NodeIdValidator.IsValid(automationId) || !NodeIdValidator.IsValid(sourceNodeId)) + { + Console.Error.WriteLine("Automation and source Node ids must contain 1-63 lowercase letters, digits, or hyphens."); + return 2; + } + + var response = await store.CreateAutomationTokenAsync( + new AutomationTokenCreateRequest( + automationId, sourceNodeId, Guid.NewGuid().ToString()), + "hub-cli", + TimeSpan.FromMinutes(10)); + Console.WriteLine(JsonSerializer.Serialize(response, SmmJsonContext.Default.AutomationTokenResponse)); + return 0; +} + app.MapHealthChecks("/healthz").AllowAnonymous(); app.MapPost("/api/v1/enroll", async ( @@ -218,6 +242,49 @@ app.MapPost("/api/v1/device-enroll", async ( } }).AllowAnonymous().RequireRateLimiting("enrollment"); +app.MapPost("/api/v1/automation-enroll", async ( + AutomationEnrollmentRequest request, + ControlStore controlStore, + CertificateAuthority authority, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(request.AutomationId) + || string.IsNullOrWhiteSpace(request.Token) + || string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem) + || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey)) + { + return Results.ValidationProblem(new Dictionary + { + ["request"] = ["Invalid automation enrollment request."] + }); + } + + try + { + var response = await controlStore.EnrollAutomationAsync( + request, + () => authority.IssueClientCertificate( + request.AutomationId, request.CertificateSigningRequestPem), + cancellationToken); + return response is null ? Results.Unauthorized() : Results.Ok(response); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails + { + Title = "Idempotency key conflict", + Status = StatusCodes.Status409Conflict + }); + } + catch (Exception exception) when (exception is CryptographicException or InvalidOperationException) + { + return Results.ValidationProblem(new Dictionary + { + ["certificateSigningRequestPem"] = ["Invalid certificate signing request."] + }); + } +}).AllowAnonymous().RequireRateLimiting("enrollment"); + var agents = app.MapGroup("/api/v1/agents").RequireAuthorization("Agent"); agents.MapPost("/heartbeat", async ( AgentHeartbeat heartbeat, @@ -288,6 +355,37 @@ agents.MapPost("/heartbeat", async ( var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); +control.MapPost("/automations/token", async ( + AutomationTokenCreateRequest request, + HttpContext context, + ControlStore controlStore, + CancellationToken cancellationToken) => +{ + if (!NodeIdValidator.IsValid(request.AutomationId) + || !NodeIdValidator.IsValid(request.SourceNodeId) + || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey)) + { + return Results.ValidationProblem(new Dictionary + { + ["automation"] = ["Invalid automation id, source Node id, or idempotency key."] + }); + } + + try + { + var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; + return Results.Ok(await controlStore.CreateAutomationTokenAsync( + request, actor, TimeSpan.FromMinutes(10), cancellationToken)); + } + catch (IdempotencyConflictException) + { + return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" }); + } + catch (InvalidOperationException exception) + { + return Results.BadRequest(new ProblemDetails { Title = exception.Message }); + } +}); control.MapPost("/agents/{nodeId}/reenroll", async ( string nodeId, CertificateReenrollmentRequest request, @@ -424,6 +522,32 @@ control.MapGet("/events", async (HttpContext context, ControlEventBroker broker) } }); +var automation = app.MapGroup("/api/v1/automation").RequireAuthorization("Automation"); +automation.MapGet("/links", async ( + HttpContext context, + ControlStore controlStore, + CancellationToken cancellationToken) => +{ + var sourceNodeId = context.User.FindFirstValue(AutomationSourceClaim); + if (string.IsNullOrWhiteSpace(sourceNodeId)) + { + return Results.Forbid(); + } + + var grants = (await controlStore.ListEffectiveLinksForNodeAsync(sourceNodeId, cancellationToken)) + .Where(link => string.Equals(link.SourceNodeId, sourceNodeId, StringComparison.Ordinal)) + .Select(link => new AutomationLinkGrant( + link.TargetNodeId, + link.Protocol, + link.Port, + link.DesiredState, + link.ActualState, + link.Version, + link.ExpiresAt)) + .ToArray(); + return Results.Ok(grants); +}); + await app.RunAsync(); return 0; diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs index 0e65154..42f1101 100644 --- a/src/ServerMonitorManager.Core/Contracts.cs +++ b/src/ServerMonitorManager.Core/Contracts.cs @@ -67,6 +67,44 @@ public sealed record DeviceEnrollmentResponse( string CertificateAuthorityPem, DateTimeOffset ExpiresAt); +public sealed record AutomationTokenCreateRequest( + string AutomationId, + string SourceNodeId, + string IdempotencyKey); + +public sealed record AutomationTokenResponse( + string AutomationId, + string SourceNodeId, + string Token, + DateTimeOffset ExpiresAt); + +public sealed record AutomationEnrollmentRequest( + string AutomationId, + string Token, + string CertificateSigningRequestPem, + string IdempotencyKey); + +public sealed record AutomationEnrollmentResponse( + string AutomationId, + string SourceNodeId, + string CertificatePem, + string CertificateAuthorityPem, + DateTimeOffset ExpiresAt); + +public sealed record AutomationScope( + string AutomationId, + string SourceNodeId, + DateTimeOffset ExpiresAt); + +public sealed record AutomationLinkGrant( + string TargetNodeId, + string Protocol, + int Port, + string DesiredState, + string ActualState, + long Version, + DateTimeOffset? ExpiresAt); + public sealed record LinkPolicyCreateRequest( string SourceNodeId, string TargetNodeId, diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index 88a1560..847bd3b 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -13,6 +13,12 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(CertificateStatusEvent))] [JsonSerializable(typeof(DeviceEnrollmentRequest))] [JsonSerializable(typeof(DeviceEnrollmentResponse))] +[JsonSerializable(typeof(AutomationTokenCreateRequest))] +[JsonSerializable(typeof(AutomationTokenResponse))] +[JsonSerializable(typeof(AutomationEnrollmentRequest))] +[JsonSerializable(typeof(AutomationEnrollmentResponse))] +[JsonSerializable(typeof(AutomationScope))] +[JsonSerializable(typeof(AutomationLinkGrant[]))] [JsonSerializable(typeof(LinkPolicyCreateRequest))] [JsonSerializable(typeof(LinkPolicyDisableRequest))] [JsonSerializable(typeof(LinkPolicy))] diff --git a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs index cbb3fee..beb3c61 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs @@ -240,6 +240,67 @@ public sealed class ControlStoreTests : IAsyncDisposable Assert.False(await store.IsCertificateForNodeAsync("CC33", "home", cancellationToken)); } + [Fact] + public async Task AutomationCertificateIsScopedToOneSourceAndTokenIsNotAudited() + { + var cancellationToken = TestContext.Current.CancellationToken; + var store = CreateStore(); + await store.InitializeAsync(cancellationToken); + await EnrollAgentAsync(store, "ai-agent", "AC11", cancellationToken); + await EnrollAgentAsync(store, "home", "AC22", cancellationToken); + var tokenRequest = new AutomationTokenCreateRequest( + "coding-agent", "ai-agent", Guid.NewGuid().ToString()); + + var token = await store.CreateAutomationTokenAsync( + tokenRequest, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + var tokenReplay = await store.CreateAutomationTokenAsync( + tokenRequest, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken); + Assert.Equal(token, tokenReplay); + Assert.Equal("ai-agent", token.SourceNodeId); + + var enrollRequest = new AutomationEnrollmentRequest( + "coding-agent", token.Token, "csr", Guid.NewGuid().ToString()); + var issued = new IssuedCertificate( + "automation-certificate", "ca", "AC33", DateTimeOffset.UtcNow.AddYears(1)); + var enrolled = await store.EnrollAutomationAsync( + enrollRequest, () => issued, cancellationToken); + var enrollmentReplay = await store.EnrollAutomationAsync( + enrollRequest, + () => throw new InvalidOperationException("must use cache"), + cancellationToken); + var reusedToken = await store.EnrollAutomationAsync( + enrollRequest with { IdempotencyKey = Guid.NewGuid().ToString() }, + () => issued, + cancellationToken); + + Assert.NotNull(enrolled); + Assert.Equal(enrolled, enrollmentReplay); + Assert.Null(reusedToken); + Assert.Equal("ai-agent", enrolled.SourceNodeId); + Assert.Equal( + new ControlIdentity("coding-agent", "Automation", "ai-agent"), + await store.ResolveIdentityAsync("AC33", cancellationToken)); + Assert.False(await store.IsCertificateForNodeAsync("AC33", "ai-agent", cancellationToken)); + + await using var connection = new SqliteConnection( + $"Data Source={Path.Combine(_directory, "control.db")}"); + await connection.OpenAsync(cancellationToken); + var audit = connection.CreateCommand(); + audit.CommandText = """ + SELECT details_json FROM audit + WHERE action LIKE 'automation.%' + ORDER BY sequence; + """; + await using var reader = await audit.ExecuteReaderAsync(cancellationToken); + var automationAuditRecords = 0; + while (await reader.ReadAsync(cancellationToken)) + { + automationAuditRecords++; + Assert.DoesNotContain(token.Token, reader.GetString(0), StringComparison.Ordinal); + } + Assert.Equal(2, automationAuditRecords); + } + [Fact] public async Task LinkDesiredStateIsPersistedBeforeActualStateChanges() {