Add source-scoped automation identity
This commit is contained in:
parent
c252701623
commit
f34f902f3b
9 changed files with 434 additions and 15 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
- [x] экспорт диагностики без секретов;
|
||||
- [x] отдельный прямой SSH-терминал;
|
||||
- [x] отдельная terminal identity и подтверждение пользователя;
|
||||
- [ ] отдельная automation identity для AI-агента.
|
||||
- [x] отдельная automation identity для AI-агента.
|
||||
|
||||
## Этап 6 — постоянный control layer
|
||||
|
||||
|
|
|
|||
|
|
@ -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 или аудит.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue