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
@@ -17,50 +17,67 @@ public static class AgentEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/agents", ListAsync)
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/agents/{id}", GetAsync)
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPatch("/agents/{id}", PatchAsync)
|
||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
||||
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
|
||||
group.MapGet("/join-tokens", ListTokensAsync)
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
||||
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
||||
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ public static class AuthEndpoints
|
||||
public static void MapLogin(RouteGroupBuilder open)
|
||||
{
|
||||
open.MapPost("/auth/login", LoginAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
}
|
||||
@@ -24,10 +25,12 @@ public static class AuthEndpoints
|
||||
{
|
||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401);
|
||||
.Returns<MeDto>().ProducesProblems(401)
|
||||
.Requires(Capability.Authenticated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
||||
@@ -85,7 +88,7 @@ public static class AuthEndpoints
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
return TypedResults.Json(
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken),
|
||||
ApiJsonContext.Default.MeDto);
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,23 @@ public static class BuildEndpoints
|
||||
{
|
||||
group.MapGet("/build-info", GetAsync)
|
||||
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapGet("/releases", ListReleasesAsync)
|
||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
|
||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403);
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412);
|
||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
|
||||
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403);
|
||||
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||
@@ -35,7 +41,8 @@ public static class BuildEndpoints
|
||||
{
|
||||
group.MapPost("/releases", PublishReleaseAsync)
|
||||
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.ReleasePublish);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||
|
||||
@@ -16,14 +16,18 @@ public static class DataEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.DataRead);
|
||||
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410)
|
||||
.Requires(Capability.DataRead);
|
||||
group.MapGet("/events", ListEventsAsync)
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.EventsRead);
|
||||
group.MapGet("/analysis/recompute", RecomputeAsync)
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.DataWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListChunksAsync(
|
||||
|
||||
@@ -15,9 +15,11 @@ public static class LinkEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/links", ListAsync)
|
||||
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
|
||||
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
|
||||
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
|
||||
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
|
||||
|
||||
@@ -18,33 +18,53 @@ public static class SystemEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/api-tokens", ListTokensAsync)
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysRead);
|
||||
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
.Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.KeysManage);
|
||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysManage);
|
||||
|
||||
group.MapGet("/capabilities", CapabilitiesAsync)
|
||||
.Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag)
|
||||
.Returns<CapabilityCatalogDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.KeysRead);
|
||||
|
||||
group.MapGet("/settings", ListSettingsAsync)
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.SettingsRead);
|
||||
group.MapPatch("/settings", PatchSettingsAsync)
|
||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.SettingsWrite);
|
||||
|
||||
group.MapGet("/users", ListUsersAsync)
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.AccountsManage);
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage);
|
||||
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.AccountsManage);
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<CapabilityCatalogDto> CapabilitiesAsync() =>
|
||||
TypedResults.Json(
|
||||
new CapabilityCatalogDto(
|
||||
[.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))],
|
||||
[.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]),
|
||||
ApiJsonContext.Default.CapabilityCatalogDto);
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
|
||||
@@ -16,26 +16,34 @@ public static class TaskEndpoints
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/tasks", ListAsync)
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPost("/tasks", CreateAsync)
|
||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapGet("/tasks/{id}", GetAsync)
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapDelete("/tasks/{id}", DeleteAsync)
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksWrite);
|
||||
|
||||
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409)
|
||||
.Requires(Capability.TasksWrite);
|
||||
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.TasksRead);
|
||||
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.TasksWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||
|
||||
@@ -103,9 +103,9 @@ public static class ApiModule
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
AuthEndpoints.MapLogin(admin);
|
||||
|
||||
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
|
||||
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
|
||||
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
|
||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
|
||||
AuthEndpoints.MapSession(guarded);
|
||||
AgentEndpoints.Map(guarded);
|
||||
TaskEndpoints.Map(guarded);
|
||||
|
||||
@@ -9,6 +9,19 @@ public static class EndpointConventions
|
||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
||||
|
||||
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
|
||||
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
|
||||
{
|
||||
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
|
||||
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||
builder.WithMetadata(new CapabilityRequirement(null));
|
||||
|
||||
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||
builder.Produces<T>(status);
|
||||
|
||||
|
||||
@@ -53,17 +53,29 @@ public static class PrincipalResolver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class AdminGuard
|
||||
/// <summary>
|
||||
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
|
||||
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
|
||||
/// </summary>
|
||||
public sealed record CapabilityRequirement(string? Capability);
|
||||
|
||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||
public static class CapabilityGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
|
||||
if (requirement is null)
|
||||
{
|
||||
return principal.Scope != ApiScope.None
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
|
||||
}
|
||||
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (requirement.Capability is { } capability && !principal.Has(capability))
|
||||
{
|
||||
return principal.Kind != PrincipalKind.Anonymous
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
@@ -80,20 +92,13 @@ public static class AdminGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
|
||||
public static class ReleaseGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
|
||||
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
|
||||
}
|
||||
|
||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||
public static class PublicGuard
|
||||
{
|
||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
HttpContext http = context.HttpContext;
|
||||
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||
}
|
||||
@@ -102,5 +107,5 @@ public static class PublicGuard
|
||||
}
|
||||
|
||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
|
||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ public static class Mappers
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind));
|
||||
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
|
||||
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
|
||||
|
||||
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
||||
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
||||
|
||||
@@ -7,7 +7,7 @@ public sealed record LoginRequest(string Username, string Password, bool? Return
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
|
||||
@@ -117,9 +117,19 @@ public sealed record RawChunkMetaDto(
|
||||
bool Partial,
|
||||
int Bytes);
|
||||
|
||||
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
|
||||
public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList<string> Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);
|
||||
|
||||
public sealed record CreateApiTokenRequest(string Name, string Scope);
|
||||
/// <param name="Capabilities">能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。</param>
|
||||
/// <param name="ExpiresInDays">有效天数;缺省不过期。</param>
|
||||
public sealed record CreateApiTokenRequest(string Name, IReadOnlyList<string> Capabilities, int? ExpiresInDays = null);
|
||||
|
||||
public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList<string> Implies);
|
||||
|
||||
public sealed record CapabilityGroupDto(string Id, IReadOnlyList<CapabilityDto> Capabilities);
|
||||
|
||||
public sealed record CapabilityPresetDto(string Id, IReadOnlyList<string> Capabilities);
|
||||
|
||||
public sealed record CapabilityCatalogDto(IReadOnlyList<CapabilityGroupDto> Groups, IReadOnlyList<CapabilityPresetDto> Presets);
|
||||
|
||||
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(ApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||
[JsonSerializable(typeof(CapabilityCatalogDto))]
|
||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class OpsEndpoints
|
||||
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
||||
.Returns<HealthDto>().ProducesProblems(503);
|
||||
app.MapGet("/metrics", MetricsAsync)
|
||||
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
|
||||
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||
// 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。
|
||||
// Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。
|
||||
@@ -57,11 +57,11 @@ public static class OpsEndpoints
|
||||
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
if (!principal.IsAdmin)
|
||||
if (!principal.Has(Capability.OpsMetrics))
|
||||
{
|
||||
return principal.CanRead
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
|
||||
return principal.Kind != PrincipalKind.Anonymous
|
||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required")
|
||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||
}
|
||||
|
||||
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
||||
|
||||
@@ -7,24 +7,16 @@ public enum PrincipalKind
|
||||
Token,
|
||||
}
|
||||
|
||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
|
||||
public enum ApiScope
|
||||
/// <summary>
|
||||
/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 <see cref="Has"/>。
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
None = 0,
|
||||
Release = 1,
|
||||
Read = 2,
|
||||
Admin = 3,
|
||||
}
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
|
||||
|
||||
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||
{
|
||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
|
||||
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
|
||||
|
||||
public bool CanRead => Scope >= ApiScope.Read;
|
||||
|
||||
public bool IsAdmin => Scope >= ApiScope.Admin;
|
||||
|
||||
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
|
||||
public static IReadOnlySet<string> SetOf(IEnumerable<string> capabilities) => new HashSet<string>(capabilities, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
||||
|
||||
@@ -60,25 +60,24 @@ public sealed partial class AuthService
|
||||
}
|
||||
|
||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
||||
}
|
||||
|
||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||
{
|
||||
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
|
||||
if (token is null || token.RevokedAtMs is not null)
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
|
||||
{
|
||||
await _store.TouchApiTokenAsync(token.Id, now, ct);
|
||||
}
|
||||
|
||||
ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read };
|
||||
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
|
||||
return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id);
|
||||
}
|
||||
|
||||
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
|
||||
@@ -128,20 +127,48 @@ public sealed partial class AuthService
|
||||
|
||||
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
|
||||
|
||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。
|
||||
/// <paramref name="expiresInDays"/> 为空表示不过期。
|
||||
/// </summary>
|
||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, IReadOnlyList<string> capabilities, int? expiresInDays, Principal creator, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (name.Length is 0 or > 64)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
|
||||
errors["name"] = "must be 1-64 characters";
|
||||
}
|
||||
|
||||
if (scope is not ("read" or "admin" or "release"))
|
||||
string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))];
|
||||
if (capabilities.Count == 0)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
|
||||
errors["capabilities"] = "choose at least one capability";
|
||||
}
|
||||
else if (unknown.Length > 0)
|
||||
{
|
||||
errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown);
|
||||
}
|
||||
|
||||
if (expiresInDays is < 1 or > 3650)
|
||||
{
|
||||
errors["expires_in_days"] = "must be between 1 and 3650";
|
||||
}
|
||||
|
||||
IReadOnlyList<string> effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown));
|
||||
string[] beyond = [.. effective.Where(c => !creator.Has(c))];
|
||||
if (beyond.Length > 0)
|
||||
{
|
||||
errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<CreatedApiToken>(errors);
|
||||
}
|
||||
|
||||
string plaintext = TokenHash.NewToken("mos");
|
||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null);
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null);
|
||||
await _store.CreateApiTokenAsync(record, ct);
|
||||
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口,
|
||||
/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。
|
||||
/// </summary>
|
||||
public static class Capability
|
||||
{
|
||||
/// <summary>任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。</summary>
|
||||
public const string Authenticated = "authenticated";
|
||||
|
||||
public const string PublicRead = "public.read";
|
||||
|
||||
public const string FleetRead = "fleet.read";
|
||||
public const string FleetWrite = "fleet.write";
|
||||
public const string FleetEnroll = "fleet.enroll";
|
||||
public const string FleetRevoke = "fleet.revoke";
|
||||
|
||||
public const string TasksRead = "tasks.read";
|
||||
public const string TasksWrite = "tasks.write";
|
||||
|
||||
public const string DataRead = "data.read";
|
||||
public const string DataWrite = "data.write";
|
||||
|
||||
public const string SettingsRead = "settings.read";
|
||||
public const string SettingsWrite = "settings.write";
|
||||
|
||||
public const string UpdateRead = "update.read";
|
||||
public const string UpdateConfirm = "update.confirm";
|
||||
public const string ReleasePublish = "release.publish";
|
||||
|
||||
public const string KeysRead = "keys.read";
|
||||
public const string KeysManage = "keys.manage";
|
||||
|
||||
public const string AccountsManage = "accounts.manage";
|
||||
|
||||
public const string AuditRead = "audit.read";
|
||||
public const string EventsRead = "events.read";
|
||||
public const string OpsMetrics = "ops.metrics";
|
||||
|
||||
// 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面
|
||||
public const string DataExport = "data.export";
|
||||
public const string WebhooksRead = "webhooks.read";
|
||||
public const string WebhooksManage = "webhooks.manage";
|
||||
}
|
||||
|
||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
||||
|
||||
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||
|
||||
/// <summary>能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。</summary>
|
||||
public static class CapabilityCatalog
|
||||
{
|
||||
public static IReadOnlyList<CapabilityInfo> All { get; } =
|
||||
[
|
||||
new(Capability.PublicRead, "public"),
|
||||
|
||||
new(Capability.FleetRead, "fleet"),
|
||||
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
|
||||
new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead),
|
||||
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
|
||||
|
||||
new(Capability.TasksRead, "tasks"),
|
||||
new(Capability.TasksWrite, "tasks", false, false, Capability.TasksRead),
|
||||
|
||||
new(Capability.DataRead, "data"),
|
||||
new(Capability.DataWrite, "data", false, false, Capability.DataRead),
|
||||
new(Capability.DataExport, "data", false, true, Capability.DataRead),
|
||||
|
||||
new(Capability.SettingsRead, "settings"),
|
||||
new(Capability.SettingsWrite, "settings", false, false, Capability.SettingsRead),
|
||||
|
||||
new(Capability.UpdateRead, "update"),
|
||||
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
|
||||
new(Capability.ReleasePublish, "update"),
|
||||
|
||||
new(Capability.KeysRead, "keys"),
|
||||
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
|
||||
|
||||
new(Capability.AccountsManage, "accounts", true),
|
||||
|
||||
new(Capability.EventsRead, "ops"),
|
||||
new(Capability.AuditRead, "ops"),
|
||||
new(Capability.OpsMetrics, "ops"),
|
||||
|
||||
new(Capability.WebhooksRead, "integrations", false, true),
|
||||
new(Capability.WebhooksManage, "integrations", false, true, Capability.WebhooksRead),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, CapabilityInfo> s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
||||
|
||||
public static IReadOnlyList<string> Groups { get; } = [.. All.Select(c => c.Group).Distinct()];
|
||||
|
||||
public static IReadOnlyList<CapabilityPreset> Presets { get; } =
|
||||
[
|
||||
new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]),
|
||||
new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
|
||||
new("enroll", Expand([Capability.FleetEnroll])),
|
||||
new("ci-release", [Capability.ReleasePublish]),
|
||||
new("all", [.. All.Select(c => c.Id)]),
|
||||
];
|
||||
|
||||
public static bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||
|
||||
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (string id in ids)
|
||||
{
|
||||
Add(result, id);
|
||||
}
|
||||
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
private static void Add(SortedSet<string> set, string id)
|
||||
{
|
||||
if (id is Capability.Authenticated || !set.Add(id) || !s_byId.TryGetValue(id, out CapabilityInfo? info))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string implied in info.Implies)
|
||||
{
|
||||
Add(set, implied);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。</summary>
|
||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||
}
|
||||
@@ -4,7 +4,7 @@ public sealed record UserRecord(string Id, string Username, byte[] PasswordHash,
|
||||
|
||||
public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs);
|
||||
|
||||
public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs);
|
||||
public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList<string> Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs);
|
||||
|
||||
public interface IAuthStore
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 10;
|
||||
public const int CurrentVersion = 11;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -140,6 +140,27 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 10");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 11)
|
||||
{
|
||||
// 权限从三个作用域改成细粒度的能力。破坏性变更,不做兼容:旧的 API 令牌全部作废,需要重新创建
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("DROP TABLE api_tokens");
|
||||
connection.Exec("""
|
||||
CREATE TABLE api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
capabilities TEXT NOT NULL,
|
||||
token_sha256 BLOB NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
)
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 11");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
public sealed class SqliteAuthStore : IAuthStore
|
||||
{
|
||||
private const string UserColumns = "id, username, password_hash, password_salt, iterations, created_at";
|
||||
private const string TokenColumns = "id, name, scope, token_sha256, created_at, last_used_at, revoked_at";
|
||||
private const string TokenColumns = "id, name, capabilities, token_sha256, created_at, last_used_at, expires_at, revoked_at";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
@@ -64,8 +64,8 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
public ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(
|
||||
c => c.Exec(
|
||||
"INSERT INTO api_tokens (id, name, scope, token_sha256, created_at) VALUES (@id, @n, @s, @h, @at)",
|
||||
("@id", token.Id), ("@n", token.Name), ("@s", token.Scope), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs)),
|
||||
"INSERT INTO api_tokens (id, name, capabilities, token_sha256, created_at, expires_at) VALUES (@id, @n, @caps, @h, @at, @exp)",
|
||||
("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)),
|
||||
ct);
|
||||
|
||||
public ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) =>
|
||||
@@ -82,5 +82,5 @@ public sealed class SqliteAuthStore : IAuthStore
|
||||
|
||||
private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5));
|
||||
|
||||
private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6));
|
||||
private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7));
|
||||
}
|
||||
Reference in new issue
Block a user