feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI]
This commit is contained in:
1 parent
55b80f0a97
commit
ddb6608e32
38 files changed
+1148
-218
No files matched your search
@@ -27,7 +27,7 @@ public sealed class AotSmokeTests
|
||||
login.EnsureSuccessStatusCode();
|
||||
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
||||
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
|
||||
using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["scope"] = "admin" }) };
|
||||
using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["capabilities"] = BackendFixture.AllCapabilities() }) };
|
||||
tokenRequest.Headers.Add("Cookie", cookie);
|
||||
tokenRequest.Headers.Add("X-CSRF-Token", csrf);
|
||||
using HttpResponseMessage tokenResponse = await http.SendAsync(tokenRequest);
|
||||
|
||||
@@ -124,9 +124,9 @@ public sealed class ApiBasicsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadScopedToken_CanReadButNotManage()
|
||||
public async Task PublicReadToken_CanReadThePublicApiButNothingUnderAdmin()
|
||||
{
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "read" }));
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["capabilities"] = BackendFixture.Capabilities("public.read") }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
|
||||
string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
|
||||
@@ -173,6 +173,11 @@ public class BackendFixture : IAsyncLifetime
|
||||
return JsonNode.Parse(text)!;
|
||||
}
|
||||
|
||||
/// <summary>目录里的全部能力("all" 预设)。</summary>
|
||||
public static JsonArray AllCapabilities() => Capabilities(CapabilityCatalog.Presets.Single(p => p.Id == "all").Capabilities.ToArray());
|
||||
|
||||
public static JsonArray Capabilities(params string[] ids) => [.. ids.Select(id => (JsonNode?)JsonValue.Create(id))];
|
||||
|
||||
public static StringContent Json(JsonNode node, string mediaType = "application/json") =>
|
||||
new(node.ToJsonString(), Encoding.UTF8, mediaType);
|
||||
|
||||
@@ -237,7 +242,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
|
||||
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }) };
|
||||
request.Headers.Add("Cookie", cookie);
|
||||
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue<string>());
|
||||
using HttpResponseMessage created = await session.SendAsync(request);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
// 只有登录是匿名的
|
||||
Assert.Equal(["/api/admin/auth/login"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().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.FleetEnroll && c["trust"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseKey_CanOnlyPublishReleases()
|
||||
{
|
||||
using HttpClient ci = await KeyAsync(Capability.ReleasePublish);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ public sealed class OperationsAndSecurityTests
|
||||
[Fact]
|
||||
public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed()
|
||||
{
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" }));
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["capabilities"] = BackendFixture.Capabilities("public.read") }));
|
||||
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
||||
string plaintext = body["plaintext"]!.GetValue<string>();
|
||||
string id = body["token"]!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -75,9 +75,9 @@ public sealed class UpdateTests
|
||||
[Fact]
|
||||
public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
|
||||
{
|
||||
async Task<string> NewTokenAsync(string scope)
|
||||
async Task<string> NewTokenAsync(params string[] capabilities)
|
||||
{
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope }));
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = BackendFixture.Capabilities(capabilities) }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
}
|
||||
@@ -90,8 +90,8 @@ public sealed class UpdateTests
|
||||
}
|
||||
|
||||
long seq = NextSeq();
|
||||
using HttpClient release = Client(await NewTokenAsync("release"));
|
||||
using HttpClient read = Client(await NewTokenAsync("read"));
|
||||
using HttpClient release = Client(await NewTokenAsync("release.publish"));
|
||||
using HttpClient read = Client(await NewTokenAsync("public.read", "fleet.read"));
|
||||
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
|
||||
|
||||
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
|
||||
|
||||
Reference in new issue
Block a user