skillhub/docs/03-authentication-design.md
2026-04-24 13:45:20 +08:00

659 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# skillhub 认证与授权设计
## 0. 身份标识约束
- `PlatformPrincipal.userId` 必须是稳定的字符串标识,而不是 `Long`
- 用户身份在系统内的主契约是字符串 `userId`;认证、授权、审计、资源 owner 判定都基于该字符串进行。
- 外部身份源的 `subject`、企业 SSO UID、工号型字符串等都必须可以原样或经确定性映射后进入系统禁止先压缩成自增整数再作为正式用户主键在全链路传播。
- 历史草案里的整型用户主键描述全部废弃,当前认证与授权设计只承认字符串身份主键。
## 1. 认证架构
```
请求进入
┌─────────────────────────────┐
│ Layer 1: OAuth2/OIDC Login │ Spring Security OAuth2 Client
│ (GitHub/GitLab/OIDC 可扩展) │ 授权码模式 (Authorization Code)
│ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭)
└─────────────┬───────────────┘
│ OAuth2User
┌─────────────────────────────┐
│ Layer 2: Access Policy │ 准入策略判定
│ (认证成功 ≠ 有权使用平台) │ 白名单/邮箱域名/开放注册
└─────────────┬───────────────┘
│ 准入通过
┌─────────────────────────────┐
│ Layer 3: Identity Mapping │ OAuth2 用户 → 平台用户
│ (查询/创建 identity_binding) │ 自动注册 + 信息同步
└─────────────┬───────────────┘
│ PlatformPrincipal
┌─────────────────────────────┐
│ Layer 4: Session / Token │ Web: Spring Session (Redis)
│ │ CLI: Device Flow + Bearer Token
└─────────────┬───────────────┘
│ SecurityContext
┌─────────────────────────────┐
│ Layer 5: Authorization │ RBAC + 资源级判定
└─────────────────────────────┘
```
## 2. 准入策略Access Policy
OAuth 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。
```java
// 基于 claims 的准入策略,与 Provider 无关
public interface AccessPolicy {
AccessDecision evaluate(OAuthClaims claims);
}
public record OAuthClaims(
String provider, // github, google, wechat
String subject, // provider 唯一 ID
String email, // nullable微信等可能无邮箱
boolean emailVerified, // 是否已验证
String providerLogin, // 如 GitHub login
Map<String, Object> extra
) {}
public enum AccessDecision {
ALLOW, // 准入,继续创建/绑定平台用户
DENY, // 拒绝,不建立 Session重定向到拒绝页
PENDING_APPROVAL // 等待管理员审批,不建立业务 Session
}
```
### 2.1 一期支持的策略(通过配置切换)
```yaml
astron:
access-policy:
mode: EMAIL_DOMAIN # OPEN / PROVIDER_ALLOWLIST / EMAIL_DOMAIN / SUBJECT_WHITELIST
allowed-providers:
- github
allowed-email-domains:
- company.com
- subsidiary.com
```
| 策略 | 判定依据 | 说明 |
|------|---------|------|
| `OPEN` | 无限制 | 所有 OAuth 登录用户自动准入 |
| `PROVIDER_ALLOWLIST` | `claims.provider` | 仅允许指定 Provider 登录 |
| `EMAIL_DOMAIN` | `claims.email` + `claims.emailVerified` | 仅允许已验证邮箱且域名匹配email 为空或未验证则 DENY |
| `SUBJECT_WHITELIST` | `claims.provider` + `claims.subject` | 按 `provider:subject` 白名单,管理员预添加 |
### 2.2 准入失败处理
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
- `PENDING_APPROVAL`:创建 `user_account`status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
安全边界PENDING / DISABLED 用户绝不会拥有有效的业务 Session从根源上杜绝"待审批账号已认证"的风险。
### 2.3 扩展性
后续新增 OAuth ProviderGoogle、GitLab、微信准入策略与 Provider 无关,统一在 AccessPolicy 层判定,不需要重做入驻逻辑。
## 3. Web 认证流程OAuth2 / OIDC Authorization Code
```
浏览器点击"登录"
前端跳转: /oauth2/authorization/github
Spring Security 重定向到 GitHub 授权页
用户在 GitHub 授权
GitHub 回调: /login/oauth2/code/github?code=xxx&state=xxx
Spring Security 自动完成:
① 用 code 换取 access_token
② 调用 GitHub API 获取用户信息
③ 触发自定义 OAuth2UserService
CustomOAuth2UserService / CustomOidcUserService:
① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims
② AccessPolicy.evaluate(claims) → 准入判定
├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied不建立 Session
├── PENDING_APPROVAL → 创建 PENDING 用户 → 抛出 AccountPendingException → failureHandler 重定向 /pending-approval不建立 Session
└── ALLOW ↓
③ 查询 identity_binding 是否已绑定
├── 已绑定 → 加载平台用户检查用户状态DISABLED → 抛异常),同步最新头像/昵称
└── 未绑定 → 创建 user_account(ACTIVE) + identity_binding
AuthenticationSuccessHandler:
① 创建 Spring Session (Redis)
② 重定向到前端页面 (可配置的 redirect_uri)
```
OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService`
分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为
`OAuthClaims`
- `provider`Spring OAuth2 client registration id例如 `okta``keycloak`
`oidc`
- `subject`OIDC `sub`
- `email` / `emailVerified``email``email_verified`
- `providerLogin`:优先 `preferred_username`,其次 `name``email``sub`
- `picture` 会同步为 `avatar_url`,供现有头像同步逻辑复用
因此 OIDC 不需要新增数据库表;现有 `identity_binding(provider_code,
subject)` 可以保存任意 OIDC issuer 下的稳定用户标识。不同 IdP 应使用不同
registration id避免多个 issuer 的 `sub` 值空间混用。
### 3.1 统一 Session 建立约束
所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括:
- 本地用户名密码登录
- OAuth 登录成功回调
- `POST /api/v1/auth/direct/login`
- `POST /api/v1/auth/session/bootstrap`
- 本地开发态 `MockAuthFilter`
统一约束如下:
- 统一写入 `platformPrincipal`
- 统一写入 `SPRING_SECURITY_CONTEXT`
- 统一通过 `HttpSession` 持久化,确保 Spring Session Redis 能无差别接管
- 交互式登录默认调用 `changeSessionId()`,降低 session fixation 风险
- 已由 Spring Security 完成认证的入口可以复用现有 `Authentication`,避免重复构造认证结果
这意味着未来私有版新增企业 SSO provider 时,只能扩展认证来源本身,不能绕开统一的 session 建立服务直接操作 Session。
## 3.3 Session Bootstrap 扩展点
为了兼容未来私有部署中的企业 SSO 被动登录,开源版预留显式会话引导协议:
- 接口:`POST /api/v1/auth/session/bootstrap`
- 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程
- 默认状态:关闭,开源版不提供任何 `PassiveSessionAuthenticator` 实现
- 安全边界:默认不做全局自动登录 filter避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度
扩展接口如下:
```java
public interface PassiveSessionAuthenticator {
String providerCode();
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
}
```
约束如下:
- `authenticate()` 只负责验证外部被动会话并返回平台登录所需主体
- 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false`
- 未启用时接口返回 `403`
- 启用但 provider 不受支持时返回 `400`
- 启用但请求中不存在有效外部会话时返回 `401`
- 成功时建立标准 Spring Security Session并返回与 `/api/v1/auth/me` 一致的用户结构
## 3.4 Direct Authentication 扩展点
为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象:
```java
public interface DirectAuthProvider {
String providerCode();
PlatformPrincipal authenticate(DirectAuthRequest request);
}
```
对应公共协议:
- `POST /api/v1/auth/direct/login`
约束如下:
- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制
- 关闭时返回 `403`
- provider 不受支持时返回 `400`
- provider 认证失败时沿用 provider 自身的认证异常语义
- 成功时建立标准 Session并返回与 `/api/v1/auth/me` 一致的用户结构
- 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口
### 3.5 Spring Security 配置要点
```java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(info -> info
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/v1/**"))
// ...
;
}
}
```
### 3.6 OAuth2 Provider 扩展设计
一期只实现 GitHub但架构支持后续扩展
```yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${OAUTH2_GITHUB_CLIENT_ID}
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET}
scope: read:user,user:email
# 二期扩展示例:
# gitlab:
# client-id: ...
# authorization-grant-type: authorization_code
# google:
# client-id: ...
```
Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需:
1. `application.yml` 添加 registration 配置
2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射
3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现)
## 4. 核心接口设计
```java
// 自定义 OAuth2 用户服务,处理准入 + 用户映射
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
OAuth2User oAuth2User = super.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
// 提取标准化 claims传入 accessToken 用于调用 Provider API如 GitHub /user/emails
OAuthClaims claims = OAuthClaimsExtractor.extract(registrationId, oAuth2User, request.getAccessToken());
// 准入策略判定(基于 claims与 Provider 无关)
AccessDecision decision = accessPolicy.evaluate(claims);
if (decision == AccessDecision.DENY) {
throw new OAuth2AccessDeniedException("Access denied by policy");
}
if (decision == AccessDecision.PENDING_APPROVAL) {
// 创建 PENDING 用户但不返回有效 principal不建立业务 Session
identityBindingService.createPendingUser(registrationId, claims);
throw new AccountPendingException("Account pending approval");
}
// 绑定或创建平台用户(仅 ALLOW 才走到这里)
UserAccount account = identityBindingService.bindOrCreate(registrationId, claims);
if (account.getStatus() == UserStatus.DISABLED) {
throw new AccountDisabledException("Account is disabled");
}
return new PlatformOAuth2User(account, oAuth2User.getAuthorities());
}
}
// 按 Provider 提取标准化 claims每个 Provider 有自己的可信字段契约)
public class OAuthClaimsExtractor {
public static OAuthClaims extract(String registrationId, OAuth2User user,
OAuth2AccessToken accessToken) {
return switch (registrationId) {
case "github" -> extractGitHub(user, accessToken);
// 后续扩展其他 Provider
default -> throw new OAuth2AuthenticationException("Unsupported provider: " + registrationId);
};
}
// GitHub: 公开 email 可能为空,需调用 /user/emails API 获取已验证邮箱
private static OAuthClaims extractGitHub(OAuth2User user, OAuth2AccessToken accessToken) {
String verifiedEmail = GitHubEmailFetcher.fetchVerifiedEmail(accessToken);
return new OAuthClaims(
"github",
String.valueOf(user.getAttribute("id")),
verifiedEmail, // 从 /user/emails 获取的已验证邮箱,可能为 null
verifiedEmail != null, // 只有确认 verified 才为 true
user.getAttribute("login"),
Map.of("avatar_url", user.getAttribute("avatar_url"))
);
}
// GitHubEmailFetcher: 调用 GitHub /user/emails API
// 返回 primary + verified 的邮箱,无则返回 null
}
```
### 4.1 多 Provider 账号合并策略
同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`
一期策略:默认关闭自动合并,仅支持管理员手动合并。
- 一期 GitHub-only不需要自动合并每个 Provider 登录独立创建用户
- 多 Provider 上线时,再引入显式绑定/合并流程(用户主动发起 + 邮箱验证确认)
- 管理员可在后台手动合并两个 user_account合并 identity_binding、迁移 skill ownership、合并角色取并集
合并操作规则:
- 合并操作写入审计日志
- 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除
- 预留扩展位:未来可配置 `astron.identity.auto-merge-on-verified-email=true` 开启基于已验证邮箱的自动合并
## 5. CLI 认证OAuth Device Flow + 平台凭证)
CLI 主认证基线调整为 OAuth Device Flow。用户在 CLI 中发起授权浏览器侧完成登录与确认CLI 轮询后获取平台签发的凭证并访问 CLI API。
- 发起CLI 请求 device code展示 `user_code` 与验证地址
- 授权:用户在浏览器完成 GitHub OAuth 登录并确认绑定
- 轮询CLI 使用 `device_code` 轮询授权结果
- 完成:服务端签发 CLI 可用凭证CLI 持 `Authorization: Bearer <token>` 调用后续接口
API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台通用凭证能力”:
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
## 6. RBAC 授权判定
```
权限判定 = 平台角色权限role → permission 查询) 命名空间角色namespace_member.role
```
一期即上线完整 RBAC平台角色按最小权限拆分
| 平台角色 | 职责 |
|---------|------|
| `SUPER_ADMIN` | 全部权限,硬判定短路 |
| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/恢复技能、撤回已发布版本 |
| `USER_ADMIN` | 准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN |
| `AUDITOR` | 审计日志只读 |
- 命名空间权限仍由 `namespace_member.role`OWNER / ADMIN / MEMBER决定
- 一个用户可持有多个平台角色
- 普通用户无平台角色,仅通过 namespace 成员关系获得操作权限
判定逻辑:
1. 从 SecurityContext 获取当前用户
2. 检查用户状态(`DISABLED` → 拒绝所有操作)
3. 查询用户的平台角色(`user_role_binding``role``role_permission`
4. `SUPER_ADMIN` 短路:直接通过所有权限检查
5. 如果涉及命名空间资源,查询用户在该命名空间的角色(`namespace_member.role`
6. 检查命名空间状态(`FROZEN` → 拒绝写操作)
7. 合并平台权限 + 命名空间角色,判定是否满足
| 操作 | 所需权限 | 判定逻辑 |
|------|---------|---------|
| 发布技能包 | `skill:publish` | 普通用户要求是目标 namespace 成员;`SUPER_ADMIN` 可绕过成员校验并直发 |
| 提交已有版本进入审核 | `review:submit` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` |
| 管理技能(归档/版本管理) | `skill:manage` | namespace ADMIN 以上,或 owner 本人 |
| 提升到全局 | `skill:promote` | namespace ADMIN 以上,或 owner 本人 |
| 审核技能发布 | `review:approve` | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN`;提交人本人仅 `SUPER_ADMIN` 可审核自己的 review task |
| 审核提升申请 | `promotion:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN |
| 隐藏/恢复技能 | `skill:manage` | 仅 `SUPER_ADMIN` |
| 撤回已发布版本YANK | `skill:manage` | `SKILL_ADMIN` / `SUPER_ADMIN` |
| 管理用户角色 | `user:manage` | 持有 USER_ADMIN / SUPER_ADMIN |
| 审批用户准入 | `user:approve` | 持有 USER_ADMIN / SUPER_ADMIN |
| 查看审计日志 | `audit:read` | 持有 AUDITOR / SUPER_ADMIN |
权限主轴说明:
- namespace role 是权限主轴namespace ADMIN 对空间内所有 skill 有完整管理权,不受 owner 限制
- `owner_id` 语义为"主要维护人"owner 作为 MEMBER 时仅可管理自己创建的 skill
- 企业场景人员流动频繁owner 离职后 namespace ADMIN 仍能完整管理所有技能
### 6.1 审核与提升 API 路径适用范围
| API 路径 | 适用范围 | 权限要求 |
|----------|---------|---------|
| `POST /api/v1/reviews/{id}/approve` | 技能发布审核 | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` |
| `POST /api/v1/promotions/{id}/approve` | 提升到全局审核 | `SKILL_ADMIN` / `SUPER_ADMIN` |
| `GET /api/v1/admin/audit-logs` | 审计日志查询 | AUDITOR / SUPER_ADMIN |
| `PUT /api/v1/admin/users/{id}/roles` | 用户角色管理 | USER_ADMIN / SUPER_ADMIN |
| `POST /api/v1/admin/users/{id}/approve` | 用户准入审批 | USER_ADMIN / SUPER_ADMIN |
当前实现中,审核与提升都走统一 portal API是否允许操作由服务层根据 namespace role 与 platform role 联合判定,而不是靠分叉路由表达。
## 7. Session 设计
- 存储Spring Session + Redis必须多 Pod 环境刚需)
- 序列化JSON
- 过期:默认 8 小时Redis TTL 自动清理
### 7.1 Session 内容
Session 中存储以下字段:
- `userId`:平台用户 ID
- `displayName`:展示名
- `oauthProvider`:登录使用的 OAuth Provider
- `currentNamespaceId`:当前选中的命名空间(可选)
- `platformRoles`:平台角色列表(如 `["SKILL_ADMIN", "AUDITOR"]`),登录时从 `user_role_binding``role` 查询写入
- `roleVersion`:角色版本号,用于缓存一致性
### 7.2 角色缓存一致性机制
平台角色变更需要即时生效(如撤销审核权限),不能等 Session 过期:
1. 每次请求时从 Session 读取 `roleVersion`
2. 与 Redis 中的 `user:{userId}:roleVersion` 比对
3. 版本一致 → 直接使用 Session 中的 `platformRoles`
4. 版本不一致 → 从数据库重新加载角色,更新 Session
管理员修改用户角色时,递增 Redis 中该用户的 `roleVersion`
## 8. CSRF 防护
采用 Cookie-to-Header 模式:
- 后端设置 `XSRF-TOKEN` Cookie`HttpOnly=false`
- 前端从 Cookie 读取 Token放入请求 Header `X-XSRF-TOKEN`
- 后端校验 Header 与 Cookie 是否一致
- CLI API`/api/v1/**`)与兼容层(`/api/v1/**`)豁免 CSRF使用 Bearer Token无 Cookie
## 9. 前端权限控制
### 9.1 `/api/v1/auth/me` 响应结构
```json
{
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```
前端权限判定基于 `platformRoles` + `namespaces[].role`,后端通过 `role_permission` 表查询权限码。
统一约束:
- `/api/v1/auth/me``/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
- `/api/v1/auth/session/bootstrap` 也必须遵守同一统一响应结构。
- `msg` 必须走 Spring Boot 标准 `MessageSource` i18n 机制。
- locale 必须通过请求上下文自动获取,不在 controller 中显式传递。
- 认证失败返回 `401`,但 JSON 外层结构仍保持一致,例如 `{"code":401,"msg":"需要先登录","data":null,...}`
### 9.2 usePermission() Hook
```typescript
function usePermission() {
const { data: me } = useQuery({ queryKey: ['auth', 'me'], queryFn: fetchMe })
const hasRole = (role: string) => me?.platformRoles.includes(role) ?? false
const isSuperAdmin = () => hasRole('SUPER_ADMIN')
const isSkillAdmin = () => hasRole('SKILL_ADMIN') || isSuperAdmin()
const isUserAdmin = () => hasRole('USER_ADMIN') || isSuperAdmin()
const isAuditor = () => hasRole('AUDITOR') || isSuperAdmin()
return {
isLoggedIn: !!me,
isSuperAdmin,
isSkillAdmin,
isUserAdmin,
isAuditor,
// 命名空间角色判定
getNamespaceRole: (slug: string) =>
me?.namespaces.find(n => n.slug === slug)?.role,
isNamespaceAdmin: (slug: string) =>
['OWNER', 'ADMIN'].includes(me?.namespaces.find(n => n.slug === slug)?.role ?? ''),
isNamespaceMember: (slug: string) =>
['OWNER', 'ADMIN', 'MEMBER'].includes(me?.namespaces.find(n => n.slug === slug)?.role ?? ''),
}
}
```
### 9.3 路由级守卫
在 TanStack Router `beforeLoad` 中判定:
| 路由 | 条件 |
|------|------|
| `/dashboard/*` | 已登录 |
| `/dashboard/namespaces/{slug}/reviews` | 已登录 + 该 namespace 的 ADMIN 以上 |
| `/admin/*` | 已登录 + 持有任一平台角色SUPER_ADMIN / SKILL_ADMIN / USER_ADMIN / AUDITOR |
不满足条件时:未登录 → 重定向登录;已登录但无权限 → 显示 403 页面。
### 9.4 操作级控制
| 场景 | 判定逻辑 | UI 行为 |
|------|---------|---------|
| 技能详情页"提交发布"按钮 | `isNamespaceMember(namespace)` | 非成员不显示 |
| 审核列表"通过/拒绝"按钮 | 团队空间:`isNamespaceAdmin(namespace)`;全局空间:`isSkillAdmin()` | 无权限不显示 |
| 用户管理页 | `isUserAdmin()` | 无权限不显示 |
| 用户管理页"设为 SUPER_ADMIN" | `isSuperAdmin()` | 仅超管可见 |
| 审计日志页 | `isAuditor()` | 无权限不显示 |
| 技能详情页"归档"按钮 | `isNamespaceAdmin(namespace)` 或当前用户是 owner | 否则不显示 |
| 命名空间"添加成员"按钮 | `isNamespaceAdmin(namespace)` | 非管理员不显示 |
| 收藏/评分按钮 | `isLoggedIn` | 未登录时点击提示登录 |
### 9.5 登录交互
```
前端登录按钮
window.location.href = '/oauth2/authorization/github'
(后端 OAuth2 流程,用户无感)
回调后重定向到前端 (如 /?login=success)
前端检测 URL 参数 → 调用 /api/v1/auth/me → 更新登录态
```
前端无需引入额外 OAuth 库,登录流程完全由后端 Spring Security 处理。前端只需:
- 调用 `/api/v1/auth/providers` 获取可用 Provider 列表,动态渲染登录按钮
- 处理登录后的重定向
- 通过 `/api/v1/auth/me` 检测登录状态
### 9.6 安全边界原则
- 前端权限控制是 UX 优化,不是安全边界
- 后端每个写操作接口独立校验权限,不信任前端判定
- 前端隐藏按钮 ≠ 安全,用户可以直接调 API后端必须拦截
## 10. 权限矩阵(完整)
以下矩阵列出每个 API 接口的权限判定来源,作为后端实现的唯一参考。
### 10.1 Public API匿名可访问
| 接口 | 匿名 | 已登录 | 判定逻辑 |
|------|------|--------|---------|
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY成员空间+ PRIVATEowner/admin` | `SearchVisibilityScope` + 搜索索引状态 |
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须是 `PUBLISHED` | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status |
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
### 10.2 Authenticated API
| 接口 | 所需权限 | 判定来源 |
|------|---------|---------|
| `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token |
| `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token |
| `POST /api/v1/reviews` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` | `skill.owner_id` / `namespace_member.role` / platform roles |
| `POST .../versions/{ver}/withdraw-review` | 提交人本人 | `review_task.submitted_by` |
| `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role``skill.owner_id` |
| `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role``skill.owner_id` |
| `POST .../versions/{ver}/rerelease` | namespace ADMIN 以上 或 owner源版本必须 `PUBLISHED` | `namespace_member.role``skill.owner_id` + `skill_version.status` |
| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner`DRAFT` / `REJECTED` | `namespace_member.role``skill.owner_id` + `skill_version.status` |
### 10.3 CLI API
| 接口 | 所需凭证 | 额外判定 |
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 |
### 10.4 Admin API
| 接口 | 所需平台角色 | 判定来源 |
|------|------------|---------|
| `POST /api/v1/admin/skills/{id}/hide` | SUPER_ADMIN | `user_role_binding``role_permission` |
| `POST /api/v1/admin/skills/{id}/unhide` | SUPER_ADMIN | 同上 |
| `POST /api/v1/admin/skills/versions/{versionId}/yank` | SKILL_ADMIN / SUPER_ADMIN | 同上 |
| `PUT /api/v1/admin/users/{id}/roles` | USER_ADMIN / SUPER_ADMIN | 同上,且 USER_ADMIN 不可分配 SUPER_ADMIN |
| `POST /api/v1/admin/users/{id}/approve` | USER_ADMIN / SUPER_ADMIN | 同上 |
| `POST /api/v1/admin/users/{id}/ban` | USER_ADMIN / SUPER_ADMIN | 同上 |
| `GET /api/v1/admin/audit-logs` | AUDITOR / SUPER_ADMIN | 同上 |
### 10.5 Namespace API
| 接口 | 所需 namespace 角色 | 判定来源 |
|------|-------------------|---------|
| `POST /api/v1/namespaces/{slug}/members` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `DELETE /api/v1/namespaces/{slug}/members/{userId}` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `POST /api/v1/promotions` | 该空间 ADMIN 以上 或 owner | `namespace_member.role``skill.owner_id` |
### 10.6 Compatibility APIBearer Token 认证)
| 接口 | 所需凭证 | 额外判定 |
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `GET /api/v1/search` | 可选(匿名限 PUBLIC | `SearchVisibilityScope` |
| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过namespace 由 canonical slug 解析) |