feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI]

This commit is contained in:
justme committed 2026-10-07 20:56:36 +08:00
1 parent 55b80f0a97
commit ddb6608e32
38 files changed
+1148 -218

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
+254 -59
View File
@@ -1255,7 +1255,8 @@
}
}
}
}
},
"x-moshiro-capability": "release.publish"
},
"get": {
"tags": [
@@ -1294,7 +1295,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/auth/logout": {
@@ -1323,7 +1325,8 @@
}
}
}
}
},
"x-moshiro-capability": "authenticated"
}
},
"/api/admin/auth/me": {
@@ -1354,7 +1357,8 @@
}
}
}
}
},
"x-moshiro-capability": "authenticated"
}
},
"/api/admin/agents": {
@@ -1395,7 +1399,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
}
},
"/api/admin/agents/{id}": {
@@ -1456,7 +1461,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
},
"patch": {
"tags": [
@@ -1560,7 +1566,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
},
"delete": {
"tags": [
@@ -1607,7 +1614,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/agents/{id}/approve": {
@@ -1678,7 +1686,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/agents/{id}/disable": {
@@ -1749,7 +1758,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
}
},
"/api/admin/agents/{id}/enable": {
@@ -1820,7 +1830,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
}
},
"/api/admin/agents/{id}/takeover": {
@@ -1881,7 +1892,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/join-tokens": {
@@ -1922,7 +1934,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
},
"post": {
"tags": [
@@ -1981,7 +1994,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/join-tokens/{id}": {
@@ -2037,7 +2051,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.enroll"
}
},
"/api/admin/probes": {
@@ -2078,7 +2093,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
}
},
"/api/admin/probes/{id}": {
@@ -2139,7 +2155,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
},
"patch": {
"tags": [
@@ -2228,7 +2245,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
},
"delete": {
"tags": [
@@ -2275,7 +2293,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/probes/{id}/revoke": {
@@ -2336,7 +2355,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.revoke"
}
},
"/api/admin/agents/{id}/probes/{probe_id}/disconnect": {
@@ -2413,7 +2433,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
}
},
"/api/admin/tasks": {
@@ -2470,7 +2491,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.read"
},
"post": {
"tags": [
@@ -2539,7 +2561,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.write"
}
},
"/api/admin/tasks/{id}": {
@@ -2600,7 +2623,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.read"
},
"patch": {
"tags": [
@@ -2694,7 +2718,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.write"
},
"delete": {
"tags": [
@@ -2741,7 +2766,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.write"
}
},
"/api/admin/lines/{line_id}/tests": {
@@ -2832,7 +2858,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.write"
},
"get": {
"tags": [
@@ -2893,7 +2920,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.read"
}
},
"/api/admin/tests/{id}/cancel": {
@@ -2954,7 +2982,8 @@
}
}
}
}
},
"x-moshiro-capability": "tasks.write"
}
},
"/api/admin/raw/chunks": {
@@ -3061,7 +3090,8 @@
}
}
}
}
},
"x-moshiro-capability": "data.read"
}
},
"/api/admin/raw/chunks/{chunk_id}": {
@@ -3125,7 +3155,8 @@
}
}
}
}
},
"x-moshiro-capability": "data.read"
}
},
"/api/admin/events": {
@@ -3225,7 +3256,8 @@
}
}
}
}
},
"x-moshiro-capability": "events.read"
}
},
"/api/admin/analysis/recompute": {
@@ -3266,7 +3298,8 @@
}
}
}
}
},
"x-moshiro-capability": "data.write"
}
},
"/api/admin/api-tokens": {
@@ -3307,13 +3340,14 @@
}
}
}
}
},
"x-moshiro-capability": "keys.read"
},
"post": {
"tags": [
"admin-api-tokens"
],
"summary": "Create an API token (scope read or admin); the plaintext is returned only once",
"summary": "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once",
"operationId": "api_tokens_create",
"requestBody": {
"content": {
@@ -3366,7 +3400,8 @@
}
}
}
}
},
"x-moshiro-capability": "keys.manage"
}
},
"/api/admin/api-tokens/{id}": {
@@ -3415,7 +3450,50 @@
}
}
}
}
},
"x-moshiro-capability": "keys.manage"
}
},
"/api/admin/capabilities": {
"get": {
"tags": [
"admin-api-tokens"
],
"summary": "The capability catalog: groups, which ones change trust, which are reserved, and the presets",
"operationId": "capabilities_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CapabilityCatalogDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
},
"x-moshiro-capability": "keys.read"
}
},
"/api/admin/settings": {
@@ -3456,7 +3534,8 @@
}
}
}
}
},
"x-moshiro-capability": "settings.read"
},
"patch": {
"tags": [
@@ -3520,7 +3599,8 @@
}
}
}
}
},
"x-moshiro-capability": "settings.write"
}
},
"/api/admin/users": {
@@ -3561,7 +3641,8 @@
}
}
}
}
},
"x-moshiro-capability": "accounts.manage"
},
"post": {
"tags": [
@@ -3630,7 +3711,8 @@
}
}
}
}
},
"x-moshiro-capability": "accounts.manage"
}
},
"/api/admin/users/{id}": {
@@ -3699,7 +3781,8 @@
}
}
}
}
},
"x-moshiro-capability": "accounts.manage"
}
},
"/api/admin/build-info": {
@@ -3740,7 +3823,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/nodes/{id}/update": {
@@ -3799,7 +3883,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
}
},
"/api/admin/backend/update": {
@@ -3840,7 +3925,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.read"
},
"post": {
"tags": [
@@ -3907,7 +3993,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.confirm"
}
},
"/api/admin/backend/update/live": {
@@ -3941,7 +4028,8 @@
}
}
}
}
},
"x-moshiro-capability": "update.read"
}
},
"/api/admin/links": {
@@ -3982,7 +4070,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.read"
}
},
"/api/admin/links/{probe_id}/{agent_id}": {
@@ -4071,7 +4160,8 @@
}
}
}
}
},
"x-moshiro-capability": "fleet.write"
}
},
"/healthz": {
@@ -4110,7 +4200,7 @@
"tags": [
"ops"
],
"summary": "Prometheus metrics (admin token)",
"summary": "Prometheus metrics (requires ops.metrics)",
"operationId": "metrics_get",
"responses": {
"200": {
@@ -4512,9 +4602,10 @@
"required": [
"id",
"name",
"scope",
"capabilities",
"created_at",
"last_used_at",
"expires_at",
"revoked_at"
],
"type": "object",
@@ -4525,8 +4616,11 @@
"name": {
"type": "string"
},
"scope": {
"type": "string"
"capabilities": {
"type": "array",
"items": {
"type": "string"
}
},
"created_at": {
"format": "date-time"
@@ -4534,6 +4628,9 @@
"last_used_at": {
"format": "date-time"
},
"expires_at": {
"format": "date-time"
},
"revoked_at": {
"format": "date-time"
}
@@ -4680,6 +4777,89 @@
}
}
},
"CapabilityCatalogDto": {
"required": [
"groups",
"presets"
],
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CapabilityGroupDto"
}
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CapabilityPresetDto"
}
}
}
},
"CapabilityDto": {
"required": [
"id",
"trust",
"reserved",
"implies"
],
"type": "object",
"properties": {
"id": {
"type": "string"
},
"trust": {
"type": "boolean"
},
"reserved": {
"type": "boolean"
},
"implies": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"CapabilityGroupDto": {
"required": [
"id",
"capabilities"
],
"type": "object",
"properties": {
"id": {
"type": "string"
},
"capabilities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CapabilityDto"
}
}
}
},
"CapabilityPresetDto": {
"required": [
"id",
"capabilities"
],
"type": "object",
"properties": {
"id": {
"type": "string"
},
"capabilities": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"ChangePasswordRequest": {
"required": [
"password"
@@ -4845,15 +5025,27 @@
"CreateApiTokenRequest": {
"required": [
"name",
"scope"
"capabilities"
],
"type": "object",
"properties": {
"name": {
"type": "string"
},
"scope": {
"type": "string"
"capabilities": {
"type": "array",
"items": {
"type": "string"
}
},
"expires_in_days": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "int32"
}
}
},
@@ -5988,7 +6180,7 @@
"MeDto": {
"required": [
"kind",
"scope",
"capabilities",
"user_id",
"username",
"csrf_token"
@@ -5998,8 +6190,11 @@
"kind": {
"type": "string"
},
"scope": {
"type": "string"
"capabilities": {
"type": "array",
"items": {
"type": "string"
}
},
"user_id": {
"type": [
@@ -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)
+2 -2
View File
@@ -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));
}
@@ -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" }));
+1 -1
View File
@@ -272,7 +272,7 @@
## 12. 实施顺序(每步可独立验证和推送)
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、现有令牌迁移、API key 创建界面(预设、全选、能力子集校验)、IP 白名单。不改节点。
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。
2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。
3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
+4 -3
View File
@@ -15,7 +15,7 @@
```text
/api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌;
public_visible = false 的 Agent 对匿名访客隐藏
/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌(`POST /api/admin/releases` 也接受只能登记发布清单的 release 令牌)
/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或带有对应**能力**的 API 令牌;每个端点声明自己需要的一项能力(OpenAPI 里的 `x-moshiro-capability`),完整目录见 `GET /api/admin/capabilities`,设计见 [access.md](access.md)
/api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证)
/healthz /metrics /openapi/v1.json /install /install.ps1
```
@@ -140,7 +140,8 @@
| GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.moshiro.rawchunk`);过期返回 410 `raw_expired` |
| GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 |
| GET | `/api/admin/analysis/recompute` | 指标重算进度 |
| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read`、`admin` 或 `release`(只能登记发布清单,给 CI 用,什么都读不了);明文只显示一次 |
| GET/POST/DELETE | `/api/admin/api-tokens` | 创建时勾选能力(`capabilities`,只能是创建者自己拥有能力的子集,写蕴含读)和可选的有效期(`expires_in_days`);明文只显示一次 |
| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) |
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 |
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 |
@@ -178,7 +179,7 @@
| --- | --- |
| 400 | `validation_failed`、`budget_exceeded` |
| 401 | `unauthorized`、`csrf_failed` |
| 403 | `forbidden`(令牌作用域不足)、`site_private` |
| 403 | `forbidden`(凭证缺少端点需要的能力)、`site_private` |
| 404 | `agent_not_found`、`task_not_found`、`test_not_found`、`chunk_not_found`、`unknown_kind`、`unknown_plan` |
| 409 | `name_conflict`、`probe_offline`、`test_busy`、`agent_not_active` |
| 410 | `raw_expired`、`join_token_expired` |
+1 -1
View File
@@ -9,7 +9,7 @@
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 |
| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 |
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 |
| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 |
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** |
## 2. Agent 接入流程
+1 -1
View File
@@ -153,7 +153,7 @@ export function App() {
<span className="grow">
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
<div className="faint" style={{ fontSize: 12 }}>
{me.scope}
{me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
</div>
</span>
<ThemeToggle />
+2 -2
View File
@@ -16,7 +16,7 @@ export function BackendUpdateNotice() {
const { busy, run } = useAction()
useEffect(() => {
if (me?.scope !== 'admin') return
if (!me?.capabilities.includes('update.read')) return
let alive = true
const receive = (value: BackendUpdateDto) => {
if (!alive) return
@@ -27,7 +27,7 @@ export function BackendUpdateNotice() {
const source = openLive('/api/admin/backend/update/live')
source.addEventListener('backend_update', (event) => receive(JSON.parse(event.data) as BackendUpdateDto))
return () => { alive = false; source.close() }
}, [me?.scope])
}, [me?.capabilities])
useEffect(() => {
if (!requested || !update) return
+2 -1
View File
@@ -48,7 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
setCsrf(r.csrf_token)
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
// 登录响应没有能力列表:登录后取一次当前身份
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const logout = useCallback(async () => {
+174
View File
@@ -0,0 +1,174 @@
import { KeyRound } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CapabilityCatalogDto } from '@shared/api/types'
import { Chip } from '@shared/ui'
/** 能力的中文名和说明。目录(分组、依赖、预设)来自后端,这里只管显示文字。 */
const NAMES: Record<string, [string, string]> = {
'public.read': ['查看展示数据', '看隐藏线路;站点私有时看展示页'],
'fleet.read': ['查看节点', 'Agent、Probe、线路、加入代码'],
'fleet.write': ['修改节点', '改名、标签、可见性、地址、线路开关、断开会话'],
'fleet.enroll': ['添加节点', '生成加入代码、批准新节点'],
'fleet.revoke': ['吊销或删除节点', '破坏性操作'],
'tasks.read': ['查看任务', '任务模板、覆盖、测试记录'],
'tasks.write': ['修改任务', '创建、修改、删除任务;发起或取消测试'],
'data.read': ['读取原始数据', '原始块、导出'],
'data.write': ['维护数据', '重算分析、清理'],
'data.export': ['批量导出数据', '预留:按线路和时间批量拉取,带配额'],
'settings.read': ['查看设置', '站点设置、保留策略'],
'settings.write': ['修改设置', '站点设置、保留策略、重启计划'],
'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'],
'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'],
'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'],
'keys.read': ['查看 API 令牌', ''],
'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'],
'accounts.manage': ['管理账户', '面板用户、密码'],
'events.read': ['查看事件', '节点上下线等事件流'],
'audit.read': ['查看审计日志', ''],
'ops.metrics': ['读取 /metrics', 'Prometheus 指标'],
'webhooks.read': ['查看 webhook', '预留:外部回调订阅'],
'webhooks.manage': ['管理 webhook', '预留:创建、修改、删除回调订阅,触发测试回调'],
}
const GROUPS: Record<string, string> = {
public: '展示',
fleet: '节点',
tasks: '任务',
data: '数据',
settings: '设置',
update: '更新与发布',
keys: 'API 令牌',
accounts: '账户',
ops: '运维与审计',
integrations: '集成(预留)',
}
const PRESETS: Record<string, string> = {
'read-only': '只读',
operations: '运维',
enroll: '接入',
'ci-release': 'CI 发布',
all: '全部',
}
/** 复选框:支持半选(indeterminate)。 */
function Check({ checked, partial, disabled, onChange, label }: { checked: boolean; partial?: boolean; disabled?: boolean; onChange: (on: boolean) => void; label: React.ReactNode }) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = !!partial && !checked
}, [partial, checked])
return (
<label className="switch">
<input ref={ref} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
/**
* 能力选择器:分组复选框 + 预设 + 全选/组内全选(带半选)/反选 + 搜索。
* `mine` 是创建者自己拥有的能力:不在里面的灰色不可选(不能授予自己没有的);写蕴含读,勾选写时读会一起被勾上。
*/
export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog: CapabilityCatalogDto; mine: ReadonlySet<string>; value: ReadonlySet<string>; onChange: (next: Set<string>) => void }) {
const [query, setQuery] = useState('')
const info = useMemo(() => new Map(catalog.groups.flatMap((g) => g.capabilities.map((c) => [c.id, c] as const))), [catalog])
const selectable = (id: string) => mine.has(id)
// 勾选一项:补上它蕴含的;取消一项:同时取消依赖它的(写依赖读)
const closure = (ids: Iterable<string>): Set<string> => {
const out = new Set<string>()
const add = (id: string) => {
if (out.has(id) || !selectable(id)) return
out.add(id)
info.get(id)?.implies.forEach(add)
}
for (const id of ids) add(id)
return out
}
const set = (ids: Iterable<string>) => onChange(closure(ids))
const toggle = (id: string, on: boolean) => {
if (on) return set([...value, id])
const next = new Set(value)
next.delete(id)
// 依赖被取消的项一起取消
let changed = true
while (changed) {
changed = false
for (const x of next) if (info.get(x)?.implies.some((i) => !next.has(i))) { next.delete(x); changed = true }
}
onChange(next)
}
const needle = query.trim().toLowerCase()
const visible = (id: string) => !needle || id.includes(needle) || (NAMES[id]?.[0] ?? '').includes(needle) || (NAMES[id]?.[1] ?? '').includes(needle)
const all = [...info.keys()].filter(selectable)
const allOn = all.length > 0 && all.every((id) => value.has(id))
return (
<div className="stack" style={{ gap: 10 }}>
<div className="row wrap" style={{ gap: 6 }}>
<span className="muted">预设:</span>
{catalog.presets.map((p) => (
<button key={p.id} type="button" className="btn small" onClick={() => set(p.capabilities.filter(selectable))}>
{PRESETS[p.id] ?? p.id}
</button>
))}
<input className="input" style={{ maxWidth: 200, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="row wrap" style={{ gap: 12 }}>
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
反选
</button>
<span className="muted">已选 {value.size} 项</span>
</div>
{catalog.groups.map((g) => {
const rows = g.capabilities.filter((c) => visible(c.id))
if (rows.length === 0) return null
const own = g.capabilities.filter((c) => selectable(c.id))
const on = own.filter((c) => value.has(c.id))
return (
<div key={g.id} className="card" style={{ padding: 10 }}>
<Check
checked={own.length > 0 && on.length === own.length}
partial={on.length > 0}
disabled={own.length === 0}
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
label={<b>{GROUPS[g.id] ?? g.id}</b>}
/>
<div className="stack" style={{ gap: 4, marginTop: 6, paddingLeft: 22 }}>
{rows.map((c) => (
<div key={c.id} className="row wrap" style={{ gap: 8 }}>
<Check
checked={value.has(c.id)}
disabled={!selectable(c.id)}
onChange={(next) => toggle(c.id, next)}
label={
<>
{NAMES[c.id]?.[0] ?? c.id}
<span className="mono faint" style={{ marginLeft: 6 }}>
{c.id}
</span>
</>
}
/>
{c.trust ? (
<Chip tone="warn">
<KeyRound size={12} /> 信任类
</Chip>
) : null}
{c.reserved ? <Chip>预留</Chip> : null}
{NAMES[c.id]?.[1] ? <span className="faint">{NAMES[c.id]![1]}</span> : null}
</div>
))}
</div>
</div>
)
})}
</div>
)
}
export function capabilityName(id: string): string {
return NAMES[id]?.[0] ?? id
}
+105 -26
View File
@@ -1,12 +1,13 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
@@ -304,47 +305,93 @@ function Versions() {
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const catalog = useQuery(() => api.get<CapabilityCatalogDto>('/api/admin/capabilities'), [])
const me = useQuery(() => api.get<MeDto>('/api/admin/auth/me'), [])
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read')
const [days, setDays] = useState<number | null>(null)
const [caps, setCaps] = useState<Set<string>>(new Set())
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto[]>()
const [picked, setPicked] = useState<Set<string>>(new Set())
const items = tokens.data?.items ?? []
const active = items.filter((t) => !t.revoked_at)
const allPicked = active.length > 0 && active.every((t) => picked.has(t.id))
const mine = useMemo(() => new Set(me.data?.capabilities ?? []), [me.data])
const trust = useMemo(() => new Set((catalog.data?.groups ?? []).flatMap((g) => g.capabilities.filter((c) => c.trust).map((c) => c.id))), [catalog.data])
const open = () => {
setName('')
setDays(null)
setCaps(new Set())
setCreating(true)
}
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。</span>
<button type="button" className="btn primary right" onClick={() => setCreating(true)}>
<Plus size={18} />
创建令牌
</button>
<span className="muted">给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。</span>
<span className="right row">
{picked.size > 0 ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(active.filter((t) => picked.has(t.id)))}>
<Trash2 size={14} />
吊销所选({picked.size})
</button>
) : null}
<button type="button" className="btn primary" onClick={open}>
<Plus size={18} />
创建令牌
</button>
</span>
</div>
<ErrorNotice error={tokens.error} />
{(tokens.data?.items ?? []).length === 0 ? (
{items.length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th style={{ width: 28 }}>
<input type="checkbox" aria-label="全选" checked={allPicked} onChange={(e) => setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} />
</th>
<th>名称</th>
<th>权限</th>
<th>能力</th>
<th>过期</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => (
{items.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td>
<Chip tone={t.scope === 'admin' ? 'warn' : ''}>{t.scope}</Chip>
<input
type="checkbox"
aria-label={`选择 ${t.name}`}
disabled={!!t.revoked_at}
checked={picked.has(t.id)}
onChange={(e) => {
const next = new Set(picked)
if (e.target.checked) next.add(t.id)
else next.delete(t.id)
setPicked(next)
}}
/>
</td>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td title={t.capabilities.map(capabilityName).join('、')}>
<Chip tone={t.capabilities.some((c) => trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项</Chip>{' '}
<span className="faint">
{t.capabilities.slice(0, 2).map(capabilityName).join('、')}
{t.capabilities.length > 2 ? '…' : ''}
</span>
</td>
<td className="muted">{t.expires_at ? fmtTime(t.expires_at) : '不过期'}</td>
<td className="muted">{fmtTime(t.created_at)}</td>
<td className="muted">{t.revoked_at ? <Chip tone="bad">已吊销</Chip> : fmtTime(t.last_used_at)}</td>
<td className="r">
{!t.revoked_at ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<button type="button" className="btn small danger" onClick={() => setRevoking([t])}>
<Trash2 size={14} />
吊销
</button>
@@ -367,16 +414,15 @@ function Tokens() {
<button
type="button"
className="btn primary"
disabled={!name.trim()}
disabled={!name.trim() || caps.size === 0}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), scope })
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), capabilities: [...caps], expires_in_days: days })
})
if (ok && result) {
setCreated(result)
setCreating(false)
setName('')
tokens.reload()
}
}}
@@ -387,29 +433,62 @@ function Tokens() {
}
>
<div className="stack">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }, { value: 'release' as const, label: 'release(只登记发布,给 CI)' }]} value={scope} onChange={setScope} />
<div className="form-grid">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="有效期">
<select className="input" value={days ?? ''} onChange={(e) => setDays(e.target.value ? Number(e.target.value) : null)}>
<option value="">不过期</option>
<option value="1">1 天</option>
<option value="7">7 天</option>
<option value="30">30 天</option>
<option value="90">90 天</option>
<option value="365">365 天</option>
</select>
</Field>
</div>
<Field label="能力" hint="写蕴含读:勾选“修改”会自动带上对应的“查看”。灰色的是你自己没有的能力,不能授予。">
{catalog.data ? <CapabilityPicker catalog={catalog.data} mine={mine} value={caps} onChange={setCaps} /> : <Spinner />}
</Field>
</div>
</Dialog>
) : null}
{created ? (
<Dialog title="令牌已创建" onClose={() => setCreated(undefined)} actions={<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>完成</button>}>
<Dialog
title="令牌已创建"
onClose={() => setCreated(undefined)}
actions={
<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} />
</div>
<p className="muted">
{created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'}
</p>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm title="吊销令牌" danger confirmText="吊销" onClose={() => setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}>
使用「{revoking.name}」的程序会立刻失去访问权限。
<Confirm
title="吊销令牌"
danger
confirmText={`吊销 ${revoking.length} 个`}
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
for (const t of revoking) await run(() => api.del(`/api/admin/api-tokens/${t.id}`), `已吊销 ${t.name}`)
setPicked(new Set())
tokens.reload()
}}
>
使用 {revoking.map((t) => `「${t.name}」`).join('、')} 的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>
+82 -5
View File
@@ -738,7 +738,7 @@ export interface paths {
/** API tokens */
get: operations["api_tokens_list"];
put?: never;
/** Create an API token (scope read or admin); the plaintext is returned only once */
/** Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once */
post: operations["api_tokens_create"];
delete?: never;
options?: never;
@@ -763,6 +763,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/capabilities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The capability catalog: groups, which ones change trust, which are reserved, and the presets */
get: operations["capabilities_list"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/settings": {
parameters: {
query?: never;
@@ -943,7 +960,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Prometheus metrics (admin token) */
/** Prometheus metrics (requires ops.metrics) */
get: operations["metrics_get"];
put?: never;
post?: never;
@@ -1069,12 +1086,14 @@ export interface components {
ApiTokenDto: {
id: string;
name: string;
scope: string;
capabilities: string[];
/** Format: date-time */
created_at: unknown;
/** Format: date-time */
last_used_at: unknown;
/** Format: date-time */
expires_at: unknown;
/** Format: date-time */
revoked_at: unknown;
};
BackendUpdateDto: {
@@ -1104,6 +1123,24 @@ export interface components {
nodes: components["schemas"]["NodeBuildDto"][];
backend_update?: null | components["schemas"]["BackendUpdateDto"];
};
CapabilityCatalogDto: {
groups: components["schemas"]["CapabilityGroupDto"][];
presets: components["schemas"]["CapabilityPresetDto"][];
};
CapabilityDto: {
id: string;
trust: boolean;
reserved: boolean;
implies: string[];
};
CapabilityGroupDto: {
id: string;
capabilities: components["schemas"]["CapabilityDto"][];
};
CapabilityPresetDto: {
id: string;
capabilities: string[];
};
ChangePasswordRequest: {
password: string;
};
@@ -1146,7 +1183,9 @@ export interface components {
};
CreateApiTokenRequest: {
name: string;
scope: string;
capabilities: string[];
/** Format: int32 */
expires_in_days?: null | number | string;
};
CreateJoinTokenRequest: {
note: null | string;
@@ -1407,7 +1446,7 @@ export interface components {
};
MeDto: {
kind: string;
scope: string;
capabilities: string[];
user_id: null | string;
username: null | string;
csrf_token: null | string;
@@ -4485,6 +4524,44 @@ export interface operations {
};
};
};
capabilities_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CapabilityCatalogDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
settings_list: {
parameters: {
query?: never;
+2 -1
View File
@@ -39,6 +39,8 @@ export type JoinTokenDto = Narrow<S['JoinTokenDto']>
export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
export type MeDto = Narrow<S['MeDto']>
export type SettingDto = Narrow<S['SettingDto']>
export type UserDto = Narrow<S['UserDto']>
export type EventDto = Narrow<S['EventDto']>
@@ -46,7 +48,6 @@ export type GapDto = Narrow<S['GapDto']>
export type RecomputeDto = Narrow<S['RecomputeDto']>
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>
export type LoginResponse = Narrow<S['LoginResponse']>
export type MeDto = Narrow<S['MeDto']>
export interface Page<T> {
items: T[]