Add source-scoped automation identity

This commit is contained in:
Ochenstarik 2026-07-17 08:15:35 +07:00
parent c252701623
commit f34f902f3b
9 changed files with 434 additions and 15 deletions

View file

@ -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 50100 Node load test, signed Windows installer, and desktop/mobile clients for additional platforms.

View file

@ -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.

View file

@ -67,7 +67,7 @@
- [x] экспорт диагностики без секретов;
- [x] отдельный прямой SSH-терминал;
- [x] отдельная terminal identity и подтверждение пользователя;
- [ ] отдельная automation identity для AI-агента.
- [x] отдельная automation identity для AI-агента.
## Этап 6 — постоянный control layer

View file

@ -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 или аудит.

View file

@ -54,6 +54,20 @@ public sealed class ControlStore(IOptions<ControlOptions> 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<ControlOptions> options)
return token;
}
public async Task<AutomationTokenResponse> 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<AutomationTokenResponse>(
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<DeviceEnrollmentResponse?> EnrollDeviceAsync(
DeviceEnrollmentRequest request,
Func<IssuedCertificate> issueCertificate,
@ -218,6 +307,96 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
return response;
}
public async Task<AutomationEnrollmentResponse?> EnrollAutomationAsync(
AutomationEnrollmentRequest request,
Func<IssuedCertificate> 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<AutomationEnrollmentResponse>(
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<EnrollmentResponse?> EnrollAsync(
EnrollmentRequest request,
Func<IssuedCertificate> issueCertificate,
@ -306,12 +485,17 @@ public sealed class ControlStore(IOptions<ControlOptions> 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<ControlOptions> 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);

View file

@ -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<Claim>
{
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<string, string[]>
{
["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<string, string[]>
{
["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<string, string[]>
{
["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;

View file

@ -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,

View file

@ -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))]

View file

@ -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()
{