diff --git a/README.md b/README.md index 22cf301..9c5c48c 100644 --- a/README.md +++ b/README.md @@ -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(只存公钥) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index a4801fb..28a6789 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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": [ diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 63ed0c3..669d2ac 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -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>().ProducesProblems(401, 403); + .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/agents/{id}", GetAsync) - .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404); + .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/agents/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) - .Returns().ProducesProblems(400, 401, 403, 404, 409, 412); + .Returns().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().ProducesProblems(401, 403, 404, 409); + .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().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().ProducesProblems(401, 403, 404, 409); + .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().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().ProducesProblems(401, 403, 404, 409); + .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().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().ProducesProblems(401, 403, 404); + .Returns().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(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetRevoke); group.MapGet("/join-tokens", ListTokensAsync) - .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403); + .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().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(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + .Returns(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(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(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>().ProducesProblems(401, 403); + .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/probes/{id}", GetProbeAsync) - .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404); + .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/probes/{id}", PatchProbeAsync) - .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409); + .Api("probes_update", "Rename a probe", ProbeTag).Returns().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().ProducesProblems(401, 403, 404); + .Returns().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(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(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(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, CancellationToken ct) => diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 54a47d4..6782cff 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -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().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(StatusCodes.Status204NoContent).ProducesProblems(401); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) - .Returns().ProducesProblems(401); + .Returns().ProducesProblems(401) + .Requires(Capability.Authenticated); } private static async Task 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); } } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 652f721..501c6b5 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -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().ProducesProblems(401, 403); + .Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapGet("/releases", ListReleasesAsync) - .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403); + .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().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(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(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().ProducesProblems(401, 403); + .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().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(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412); + .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(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); } /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 @@ -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(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.ReleasePublish); } private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs index 3dda979..a7c5181 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs @@ -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>().ProducesProblems(400, 401, 403); + .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().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>().ProducesProblems(400, 401, 403); + .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.EventsRead); group.MapGet("/analysis/recompute", RecomputeAsync) - .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403); + .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.DataWrite); } private static async Task ListChunksAsync( diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index 7ee75d6..f459f46 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -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().ProducesProblems(401, 403); + .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().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().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().ProducesProblems(400, 401, 403, 404) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 8c67d36..f52f95a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -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>().ProducesProblems(401, 403); + .Api("api_tokens_list", "API tokens", TokensTag).Returns>().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(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(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(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(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().ProducesProblems(401, 403) + .Requires(Capability.KeysRead); group.MapGet("/settings", ListSettingsAsync) - .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403); + .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.SettingsRead); group.MapPatch("/settings", PatchSettingsAsync) .Accepts>("application/merge-patch+json", "application/json") - .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403); + .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.SettingsWrite); group.MapGet("/users", ListUsersAsync) - .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403); + .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.AccountsManage); group.MapPost("/users", CreateUserAsync) - .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Api("users_create", "Create a panel user", UsersTag).Returns(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(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403); + .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403) + .Requires(Capability.AccountsManage); } + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult 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 ListTokensAsync(AuthService auth, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto); private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { - Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted); + Result 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); diff --git a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs index f6fc4a1..c5b4cc2 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs @@ -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>().ProducesProblems(401, 403); + .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tasks", CreateAsync) .Api("tasks_create", "Create a task; params are validated against the kind", TasksTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404) + .Requires(Capability.TasksWrite); group.MapGet("/tasks/{id}", GetAsync) - .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404); + .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksRead); group.MapPatch("/tasks/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag) - .Returns().ProducesProblems(400, 401, 403, 404, 412); + .Returns().ProducesProblems(400, 401, 403, 404, 412) + .Requires(Capability.TasksWrite); group.MapDelete("/tasks/{id}", DeleteAsync) - .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("tasks_delete", "Delete a task", TasksTag).Returns(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(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409); + .Returns(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>().ProducesProblems(401, 403); + .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tests/{id}/cancel", CancelAsync) - .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404); + .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksWrite); } private static async Task ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 6cb4d31..74300f3 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index f749fd0..aec509b 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -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); + /// 声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 x-moshiro-capability。 + public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) => + builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) => + { + operation.Extensions ??= new Dictionary(); + operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability)); + return Task.CompletedTask; + }); + + /// 匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。 + public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => + builder.WithMetadata(new CapabilityRequirement(null)); + public static RouteHandlerBuilder Returns(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) => builder.Produces(status); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 0be49b8..431c4d3 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -53,17 +53,29 @@ public static class PrincipalResolver } } -/// 管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 -public static class AdminGuard +/// +/// 端点声明自己需要的能力()。Capability == null 表示匿名可访问(登录)。 +/// 没有声明的 /api/admin 端点一律拒绝(并有对账测试保证不存在)。 +/// +public sealed record CapabilityRequirement(string? Capability); + +/// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 +public static class CapabilityGuard { public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - Principal principal = http.GetPrincipal(); - if (!principal.IsAdmin) + CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata(); + 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 } } -/// 登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。 -public static class ReleaseGuard -{ - public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) => - context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next); -} - /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) + if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService().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 } /// 被隐藏(public_visible = false)的线路只对已认证的访问者可见。 - public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead; + public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead); } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 0a7f60b..b540d8a 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index fa639aa..7985292 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -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 Capabilities, string? UserId, string? Username, string? CsrfToken); public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList 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 Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt); -public sealed record CreateApiTokenRequest(string Name, string Scope); +/// 能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。 +/// 有效天数;缺省不过期。 +public sealed record CreateApiTokenRequest(string Name, IReadOnlyList Capabilities, int? ExpiresInDays = null); + +public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList Implies); + +public sealed record CapabilityGroupDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityPresetDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityCatalogDto(IReadOnlyList Groups, IReadOnlyList Presets); public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 53f9803..30892fe 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -73,6 +73,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(ApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] +[JsonSerializable(typeof(CapabilityCatalogDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 9c8d766..f098c99 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -17,7 +17,7 @@ public static class OpsEndpoints .Api("health_get", "SQLite and the raw data directory are both writable", Tag) .Returns().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 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"); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 9f438c5..6d8cf92 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -7,24 +7,16 @@ public enum PrincipalKind Token, } -/// 访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。 -public enum ApiScope +/// +/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 。 +/// +public sealed record Principal(PrincipalKind Kind, IReadOnlySet 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(), 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 SetOf(IEnumerable capabilities) => new HashSet(capabilities, StringComparer.Ordinal); } public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index cfc1ea2..7dd0bf8 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -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 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> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct); - public async ValueTask> CreateApiTokenAsync(string name, string scope, CancellationToken ct) + /// + /// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。 + /// 为空表示不过期。 + /// + public async ValueTask> CreateApiTokenAsync(string name, IReadOnlyList capabilities, int? expiresInDays, Principal creator, CancellationToken ct) { + var errors = new Dictionary(); if (name.Length is 0 or > 64) { - return ValidationError.Fail("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("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 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(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.Ok(new CreatedApiToken(record, plaintext)); } diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs new file mode 100644 index 0000000..ec8590c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -0,0 +1,135 @@ +namespace MoShiro.Backend.Application; + +/// +/// 能力(capability):一项具体的权限。端点声明自己需要哪一项, 是唯一的判断入口, +/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。 +/// +public static class Capability +{ + /// 任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。 + 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"; +} + +/// 一项能力的元数据。Trust:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;Reserved:还没有对应的功能。 +public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies); + +public sealed record CapabilityPreset(string Id, IReadOnlyList Capabilities); + +/// 能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。 +public static class CapabilityCatalog +{ + public static IReadOnlyList 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 s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal); + + public static IReadOnlyList Groups { get; } = [.. All.Select(c => c.Group).Distinct()]; + + public static IReadOnlyList 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); + + /// 补上蕴含的能力(写蕴含读),去重并排序,结果稳定。 + public static IReadOnlyList Expand(IEnumerable ids) + { + var result = new SortedSet(StringComparer.Ordinal); + foreach (string id in ids) + { + Add(result, id); + } + + return [.. result]; + } + + private static void Add(SortedSet 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); + } + } + + /// 登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。 + public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)]; +} diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 235dcf3..3fc8b97 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -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 Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs); public interface IAuthStore { diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index eb95d9d..79ec4e1 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 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(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index b9a7092..2498413 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -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 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)); } diff --git a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs index 708f428..d07c39c 100644 --- a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs @@ -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(); - 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); diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index d6bbc84..3f351ae 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -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(); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index e0c8675..8d9258c 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -173,6 +173,11 @@ public class BackendFixture : IAsyncLifetime return JsonNode.Parse(text)!; } + /// 目录里的全部能力("all" 预设)。 + 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()); using HttpResponseMessage created = await session.SendAsync(request); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs new file mode 100644 index 0000000..5d0f7f0 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -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; + +/// 能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。 +[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().SelectMany(s => s.Endpoints).OfType() + .Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)]; + Assert.NotEmpty(endpoints); + foreach (RouteEndpoint endpoint in endpoints) + { + CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata(); + 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()!.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 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()); + 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()).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())]; + 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() == "all"); + Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetEnroll && c["trust"]!.GetValue()); + } + + [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); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs index 5fdd240..fb1e8ed 100644 --- a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs @@ -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 id = body["token"]!["id"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 7b41051..113cee2 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -75,9 +75,9 @@ public sealed class UpdateTests [Fact] public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat() { - async Task NewTokenAsync(string scope) + async Task 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(); } @@ -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" })); diff --git a/docs/access.md b/docs/access.md index 250b7b5..8b02422 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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)。 diff --git a/docs/api.md b/docs/api.md index 11e0909..3f0a577 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` | diff --git a/docs/enrollment.md b/docs/enrollment.md index 614e3d3..512c7d3 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -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 接入流程 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 64f14c3..f1d5c4a 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -153,7 +153,7 @@ export function App() {
{me.username ?? 'API 令牌'}
- {me.scope} + {me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
diff --git a/web/admin/src/BackendUpdateNotice.tsx b/web/admin/src/BackendUpdateNotice.tsx index f56006f..4c20f9d 100644 --- a/web/admin/src/BackendUpdateNotice.tsx +++ b/web/admin/src/BackendUpdateNotice.tsx @@ -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 diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index 7c00a0e..9dc5252 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -48,7 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const r = await api.post('/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('/api/admin/auth/me')) }, []) const logout = useCallback(async () => { diff --git a/web/admin/src/pages/CapabilityPicker.tsx b/web/admin/src/pages/CapabilityPicker.tsx new file mode 100644 index 0000000..f159fd7 --- /dev/null +++ b/web/admin/src/pages/CapabilityPicker.tsx @@ -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 = { + '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 = { + public: '展示', + fleet: '节点', + tasks: '任务', + data: '数据', + settings: '设置', + update: '更新与发布', + keys: 'API 令牌', + accounts: '账户', + ops: '运维与审计', + integrations: '集成(预留)', +} + +const PRESETS: Record = { + '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(null) + useEffect(() => { + if (ref.current) ref.current.indeterminate = !!partial && !checked + }, [partial, checked]) + return ( + + ) +} + +/** + * 能力选择器:分组复选框 + 预设 + 全选/组内全选(带半选)/反选 + 搜索。 + * `mine` 是创建者自己拥有的能力:不在里面的灰色不可选(不能授予自己没有的);写蕴含读,勾选写时读会一起被勾上。 + */ +export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog: CapabilityCatalogDto; mine: ReadonlySet; value: ReadonlySet; onChange: (next: Set) => 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): Set => { + const out = new Set() + 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) => 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 ( +
+
+ 预设: + {catalog.presets.map((p) => ( + + ))} + setQuery(e.target.value)} /> +
+
+ 0} onChange={(on) => set(on ? all : [])} label={全选} /> + + 已选 {value.size} 项 +
+ {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 ( +
+ 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={{GROUPS[g.id] ?? g.id}} + /> +
+ {rows.map((c) => ( +
+ toggle(c.id, next)} + label={ + <> + {NAMES[c.id]?.[0] ?? c.id} + + {c.id} + + + } + /> + {c.trust ? ( + + 信任类 + + ) : null} + {c.reserved ? 预留 : null} + {NAMES[c.id]?.[1] ? {NAMES[c.id]![1]} : null} +
+ ))} +
+
+ ) + })} +
+ ) +} + +export function capabilityName(id: string): string { + return NAMES[id]?.[0] ?? id +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 60a088a..2856c34 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -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>('/api/admin/api-tokens'), []) + const catalog = useQuery(() => api.get('/api/admin/capabilities'), []) + const me = useQuery(() => api.get('/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(null) + const [caps, setCaps] = useState>(new Set()) const [created, setCreated] = useState() - const [revoking, setRevoking] = useState() + const [revoking, setRevoking] = useState() + const [picked, setPicked] = useState>(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 (
- 给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。 - + 给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。 + + {picked.size > 0 ? ( + + ) : null} + +
- {(tokens.data?.items ?? []).length === 0 ? ( + {items.length === 0 ? ( 没有 API 令牌 ) : ( + - + + - {tokens.data?.items.map((t) => ( + {items.map((t) => ( - + + +
+ setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} /> + 名称权限能力过期 创建 最后使用
{t.name} - {t.scope} + { + const next = new Set(picked) + if (e.target.checked) next.add(t.id) + else next.delete(t.id) + setPicked(next) + }} + /> {t.name} + trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项{' '} + + {t.capabilities.slice(0, 2).map(capabilityName).join('、')} + {t.capabilities.length > 2 ? '…' : ''} + + {t.expires_at ? fmtTime(t.expires_at) : '不过期'} {fmtTime(t.created_at)} {t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} {!t.revoked_at ? ( - @@ -367,16 +414,15 @@ function Tokens() { }> + setCreated(undefined)} + actions={ + + } + >
令牌明文只显示这一次,请现在就复制。
{created.plaintext}
+

+ {created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'} +

) : null} {revoking ? ( - setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> - 使用「{revoking.name}」的程序会立刻失去访问权限。 + 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('、')} 的程序会立刻失去访问权限。 ) : null} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 5d6add3..6c0246b 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -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; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index c56c1fb..483df39 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -39,6 +39,8 @@ export type JoinTokenDto = Narrow export type CreatedJoinTokenDto = Narrow export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow +export type CapabilityCatalogDto = Narrow +export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow export type EventDto = Narrow @@ -46,7 +48,6 @@ export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow export type LoginResponse = Narrow -export type MeDto = Narrow export interface Page { items: T[]