ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The official CI key is built in and always trusted; an own P-256 public key can be added (off by default, the private key is rejected) so manifests signed with it are accepted too. Removing it drops the manifests only it signed. Changes are audited and need update.confirm. Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on generating a key pair and signing a manifest with openssl. Registration now has one path, POST /api/release/offer (no token, signature verified). The admin POST /releases route, the release.publish capability and the ci-release preset are removed.
125 lines
8.3 KiB
C#
125 lines
8.3 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using MoShiro.Backend.Api;
|
|
using MoShiro.Backend.Application;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
/// <summary>能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。</summary>
|
|
[Collection("probe-e2e")]
|
|
public sealed class CapabilityTests
|
|
{
|
|
private readonly BackendFixture _backend;
|
|
|
|
public CapabilityTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryAdminEndpoint_DeclaresACapabilityThatIsInTheCatalog()
|
|
{
|
|
RouteEndpoint[] endpoints = [.. _backend.Services.GetServices<EndpointDataSource>().SelectMany(s => s.Endpoints).OfType<RouteEndpoint>()
|
|
.Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)];
|
|
Assert.NotEmpty(endpoints);
|
|
foreach (RouteEndpoint endpoint in endpoints)
|
|
{
|
|
CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata<CapabilityRequirement>();
|
|
Assert.True(requirement is not null, $"{endpoint.RoutePattern.RawText} does not declare a capability");
|
|
if (requirement.Capability is { } capability)
|
|
{
|
|
Assert.True(capability == Capability.Authenticated || CapabilityCatalog.IsKnown(capability), $"{endpoint.RoutePattern.RawText} declares the unknown capability {capability}");
|
|
}
|
|
}
|
|
|
|
// 只有登录(密码和 passkey)是匿名的
|
|
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void Catalog_ExpandsWritesToReads_AndPresetsAreConsistent()
|
|
{
|
|
Assert.Equal([Capability.FleetRead, Capability.FleetWrite], CapabilityCatalog.Expand([Capability.FleetWrite]));
|
|
Assert.Contains(Capability.FleetRead, CapabilityCatalog.Expand([Capability.FleetEnroll]));
|
|
foreach (CapabilityPreset preset in CapabilityCatalog.Presets)
|
|
{
|
|
Assert.All(preset.Capabilities, id => Assert.True(CapabilityCatalog.IsKnown(id), $"{preset.Id}: {id}"));
|
|
Assert.Equal(CapabilityCatalog.Expand(preset.Capabilities).ToArray(), preset.Capabilities.Order(StringComparer.Ordinal).ToArray()); // 预设已经包含了蕴含的能力
|
|
}
|
|
|
|
Assert.All(CapabilityCatalog.All.Where(c => c.Id.EndsWith(".write", StringComparison.Ordinal)), c => Assert.Contains(c.Id[..^"write".Length] + "read", c.Implies));
|
|
}
|
|
|
|
private async Task<HttpClient> KeyAsync(params string[] capabilities)
|
|
{
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("cap"), ["capabilities"] = BackendFixture.Capabilities(capabilities) }));
|
|
string body = await created.Content.ReadAsStringAsync();
|
|
Assert.True(created.StatusCode == HttpStatusCode.Created, body);
|
|
HttpClient client = _backend.NewClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["plaintext"]!.GetValue<string>());
|
|
return client;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AKey_CanOnlyDoWhatItsCapabilitiesAllow_AndWriteImpliesRead()
|
|
{
|
|
using HttpClient reader = await KeyAsync(Capability.FleetRead);
|
|
using HttpClient writer = await KeyAsync(Capability.FleetWrite); // 写蕴含读
|
|
using HttpClient settings = await KeyAsync(Capability.SettingsRead);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await reader.GetAsync("/api/admin/agents")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, (await writer.GetAsync("/api/admin/agents")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await settings.GetAsync("/api/admin/agents")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/settings")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/raw/chunks")).StatusCode);
|
|
|
|
// 读不能写:fleet.read 的 key 不能生成加入代码,也不能创建 key
|
|
using HttpResponseMessage enroll = await reader.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
|
|
Assert.Equal(HttpStatusCode.Forbidden, enroll.StatusCode);
|
|
using HttpResponseMessage keys = await reader.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) }));
|
|
Assert.Equal(HttpStatusCode.Forbidden, keys.StatusCode);
|
|
|
|
// 没有凭证是 401,凭证不够是 403
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.GetAsync("/api/admin/agents")).StatusCode);
|
|
// /auth/me 列出实际拥有的能力(包含蕴含的)
|
|
JsonNode me = JsonNode.Parse(await (await writer.GetAsync("/api/admin/auth/me")).Content.ReadAsStringAsync())!;
|
|
Assert.Equal([Capability.FleetRead, Capability.FleetWrite], me["capabilities"]!.AsArray().Select(c => c!.GetValue<string>()).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AKey_CannotGrantWhatItDoesNotHave_ButCanGrantASubset()
|
|
{
|
|
using HttpClient manager = await KeyAsync(Capability.KeysManage, Capability.FleetRead); // keys.manage 蕴含 keys.read
|
|
using HttpResponseMessage beyond = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead, Capability.FleetEnroll) }));
|
|
Assert.Equal(HttpStatusCode.BadRequest, beyond.StatusCode);
|
|
Assert.Contains("fleet.enroll", await beyond.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
|
|
|
using HttpResponseMessage subset = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "sub", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) }));
|
|
Assert.Equal(HttpStatusCode.Created, subset.StatusCode);
|
|
|
|
using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities("fleet.nope") }));
|
|
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
|
using HttpResponseMessage empty = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = new JsonArray() }));
|
|
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AKey_HasAnExpiry_AndTheCatalogEndpointDescribesEverything()
|
|
{
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "short", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 1 }));
|
|
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
|
Assert.NotNull(body["token"]!["expires_at"]);
|
|
using HttpResponseMessage bad = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 0 }));
|
|
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
|
|
|
JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/capabilities");
|
|
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue<string>())];
|
|
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
|
|
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
|
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
|
|
}
|
|
}
|