# 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 Provider（Google、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（成员空间）+ PRIVATE（owner/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 API（Bearer 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 解析） |
