ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
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/security/access.md 第 3 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。</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>());
|
|
}
|
|
}
|