mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
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:
parent
7cf9f22182
commit
a59b11b2f0
8 changed files with 91 additions and 5 deletions
|
|
@ -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 的绑定。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue