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 <noreply@anthropic.com>
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 <thiago.nascimento.nogueira@emeal.nttdata.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
thiagonogueira 2026-09-17 07:17:45 -03:00 committed by GitHub
parent 7cf9f22182
commit a59b11b2f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 91 additions and 5 deletions

View file

@ -381,6 +381,7 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合与身份优先级:共享认证过滤器只识别 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 的绑定。

View file

@ -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;
}
}

View file

@ -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")

View file

@ -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<String, Object> 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);

View file

@ -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"),

View file

@ -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() {
}
}

View file

@ -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

View file

@ -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()