From a59b11b2f0185f8bc9fa9ab158d438f1f54c8c58 Mon Sep 17 00:00:00 2001 From: thiagonogueira Date: Thu, 17 Sep 2026 07:17:45 -0300 Subject: [PATCH] feat(auth): expose skill lifecycle routes to API tokens (#865) * feat(auth): expose skill lifecycle routes to API tokens With an API token, v0.2.19 can remove a whole skill (DELETE /api/v1/skills/{ns}/{slug} with skill:delete) but cannot archive or unarchive a skill, nor delete a single draft/rejected version. Those three routes are opened by AUTHORIZATION_POLICIES (authenticated fallback) yet have no entry in API_TOKEN_POLICIES, so a bearer request falls through to "unsupported" and is rejected with 403. That contradicts the contract written above SESSION_ONLY_ROUTES in RouteSecurityPolicyRegistry: bearer tokens are rejected on exactly the listed session-only routes and nowhere else, and anything else the authorization list opens must be reachable with a token holding the required scope. Add API-token policies for both the /api/v1 and /api/web prefixes that SkillLifecycleController serves: - POST .../skills/{ns}/{slug}/archive and .../unarchive require skill:publish. They are owner-level operations, gated by the same assertCanManageLifecycle check as publishing, so they sit at the same scope tier. - DELETE .../skills/{ns}/{slug}/versions/{version} requires skill:delete, matching the existing whole-skill delete. Whole-skill DELETE on /api/web stays session-only as documented; the new version-delete pattern does not overlap it. No scope allow-list exists outside the registry (TokenController and ApiTokenScopeService accept any scope string), so no other change is needed for tokens to carry these scopes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU * fix(auth): complete API token lifecycle access Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: Thiago Nascimento Nogueira Co-authored-by: Claude Fable 5.1 Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/03-authentication-design.md | 1 + .../skillhub/controller/TokenController.java | 5 +- .../controller/TokenControllerTest.java | 2 +- .../auth/device/DeviceAuthService.java | 4 +- .../policy/RouteSecurityPolicyRegistry.java | 14 +++++ .../skillhub/auth/token/ApiTokenScopes.java | 13 +++++ .../auth/device/DeviceAuthServiceTest.java | 5 ++ .../RouteSecurityPolicyRegistryTest.java | 52 +++++++++++++++++++ 8 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopes.java diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 269a85bc..acf1a662 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -381,6 +381,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 - 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份;Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401,即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session;若无 Session,公共读接口按匿名访问,`whoami` 返回 401 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` +- 默认权限:Web 自助创建和 CLI Device Flow 均签发 `skill:read`、`skill:publish`、`skill:delete`;`token:manage` 仅在调用方显式请求时授予 - 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常 > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index 19115087..eb2156be 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.auth.token.ApiTokenScopes; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; @@ -41,12 +42,12 @@ public class TokenController extends BaseApiController { @Valid @RequestBody TokenCreateRequest request) { String scopeJson; if (request.scopes() == null || request.scopes().isEmpty()) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = ApiTokenScopes.DEFAULT_USER_SCOPE_JSON; } else { try { scopeJson = objectMapper.writeValueAsString(request.scopes()); } catch (JsonProcessingException e) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = ApiTokenScopes.DEFAULT_USER_SCOPE_JSON; } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index d97b47f3..81d1842c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -131,7 +131,7 @@ class TokenControllerTest { org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-15T12:00:00Z")); token.setExpiresAt(java.time.Instant.parse("2026-04-15T12:00:00Z")); - given(apiTokenService.rotateToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) + given(apiTokenService.rotateToken("user-42", "cli", "[\"skill:read\",\"skill:publish\",\"skill:delete\"]", "2026-04-15T12:00:00")) .willReturn(new ApiTokenService.TokenCreateResult("sk_raw", token)); mockMvc.perform(post("/api/v1/tokens") diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index d0eead62..d926c862 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.device; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.auth.token.ApiTokenScopes; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; @@ -30,7 +31,6 @@ public class DeviceAuthService { private static final long PENDING_CODE_TTL_MINUTES = EXPIRES_IN_SECONDS / 60L; private static final long USED_CODE_TTL_MINUTES = 1L; private static final String CLI_DEVICE_TOKEN_NAME = "CLI Device Flow"; - private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\"]"; private final RedisTemplate redisTemplate; private final ApiTokenService apiTokenService; @@ -133,7 +133,7 @@ public class DeviceAuthService { String token = apiTokenService.rotateToken( data.getUserId(), CLI_DEVICE_TOKEN_NAME, - CLI_DEVICE_SCOPE_JSON + ApiTokenScopes.DEFAULT_USER_SCOPE_JSON ).rawToken(); data.setStatus(DeviceCodeStatus.USED); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 06b01000..ee1c52bf 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -244,6 +244,20 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(null, "/api/v1/tokens/**", "token:manage"), ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/id/*", "skill:delete"), ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/*/*", "skill:delete"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/*/*/versions/*", "skill:delete"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/web/skills/*/*/versions/*", "skill:delete"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/archive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/unarchive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/archive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/unarchive", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/versions/*/withdraw-review", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/versions/*/withdraw-review", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/versions/*/rerelease", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/versions/*/rerelease", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/submit-review", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/submit-review", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/confirm-publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/confirm-publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/versions/*/security-audit/retry", "skill:publish"), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopes.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopes.java new file mode 100644 index 00000000..e80b43f3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopes.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.token; + +/** + * Default self-service API token profile. + */ +public final class ApiTokenScopes { + + public static final String DEFAULT_USER_SCOPE_JSON = + "[\"skill:read\",\"skill:publish\",\"skill:delete\"]"; + + private ApiTokenScopes() { + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java index f9517e43..99563a72 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -83,6 +83,11 @@ class DeviceAuthServiceTest { DeviceTokenResponse response = service.pollToken(DEVICE_CODE); assertThat(response.accessToken()).isEqualTo("sk_test_token"); + verify(apiTokenService).rotateToken( + "usr_1", + "CLI Device Flow", + "[\"skill:read\",\"skill:publish\",\"skill:delete\"]" + ); } @Test diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 1fc54c60..bf6ac622 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -138,6 +138,58 @@ class RouteSecurityPolicyRegistryTest { assertTrue(allowed.allowed()); } + @Test + void authorizeApiToken_requiresPublishScopeForArchiveAndUnarchive() { + for (String prefix : List.of("/api/v1", "/api/web")) { + for (String action : List.of("archive", "unarchive")) { + String path = prefix + "/skills/global/demo-skill/" + action; + var denied = registry.authorizeApiToken("POST", path, Set.of("skill:read", "skill:delete")); + var allowed = registry.authorizeApiToken("POST", path, Set.of("skill:publish")); + + assertFalse(denied.allowed(), path); + assertEquals("skill:publish", denied.requiredScope(), path); + assertTrue(allowed.allowed(), path); + } + } + } + + @Test + void authorizeApiToken_requiresDeleteScopeForVersionDeleteEndpoint() { + for (String prefix : List.of("/api/v1", "/api/web")) { + String path = prefix + "/skills/global/demo-skill/versions/1.2.3"; + var denied = registry.authorizeApiToken("DELETE", path, Set.of("skill:publish")); + var allowed = registry.authorizeApiToken("DELETE", path, Set.of("skill:delete")); + + assertFalse(denied.allowed(), path); + assertEquals("skill:delete", denied.requiredScope(), path); + assertTrue(allowed.allowed(), path); + } + } + + @Test + void authorizeApiToken_requiresPublishScopeForOwnerLifecycleEndpoints() { + for (String prefix : List.of("/api/v1", "/api/web")) { + for (String path : List.of( + prefix + "/skills/global/demo-skill/versions/1.2.3/withdraw-review", + prefix + "/skills/global/demo-skill/versions/1.2.3/rerelease", + prefix + "/skills/global/demo-skill/submit-review", + prefix + "/skills/global/demo-skill/confirm-publish")) { + var denied = registry.authorizeApiToken("POST", path, Set.of("skill:read", "skill:delete")); + var allowed = registry.authorizeApiToken("POST", path, Set.of("skill:publish")); + + assertFalse(denied.allowed(), path); + assertEquals("skill:publish", denied.requiredScope(), path); + assertTrue(allowed.allowed(), path); + } + } + } + + @Test + void authorizeApiToken_keepsWholeSkillWebDeleteSessionOnlyWhileAllowingVersionDelete() { + assertFalse(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill", ALL_SCOPES).allowed()); + assertTrue(registry.authorizeApiToken("DELETE", "/api/web/skills/global/demo-skill/versions/1.2.3", ALL_SCOPES).allowed()); + } + @Test void authorizationPolicies_shouldDeclareSuperAdminDeleteRuleForHardDeleteEndpoint() { boolean matched = registry.authorizationPolicies().stream()