skillhub/docs/03-authentication-design.md
ylhu16 14da7d30ac fix(auth): isolate unsafe account merge flow
Keep the legacy routes fail-closed, remove the unsafe orchestration service, replace the UI controls with a security notice, and define the acceptance contract for the future safe merge flow.

Closes #634

Parent: #628
Signed-off-by: ylhu16 <ylhu16@iflytek.com>
2026-08-04 18:37:01 +08:00

33 KiB
Raw Blame History

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 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。

// 基于 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 一期支持的策略(通过配置切换)

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_accountstatus=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

  • providerSpring OAuth2 client registration id例如 oktakeycloakoidc
  • subjectOIDC sub
  • email / emailVerifiedemailemail_verified
  • providerLogin:优先 preferred_username,其次 nameemailsub
  • 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 和审计复杂度

扩展接口如下:

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 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象:

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 配置要点

@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但架构支持后续扩展

# 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. 核心接口设计

// 自定义 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

当前策略:不自动合并,旧的手动合并流程也已临时隔离。旧流程把次账号 verification token 直接返回给主账号会话,不能分别证明两个账号的控制权,因此不能继续作为管理员或 用户合并入口。

  • 一期 GitHub-only不需要自动合并每个 Provider 登录独立创建用户
  • 多 Provider 上线时,再引入显式 Identity Link 和安全 Account Merge
  • email、username、display name 或主账号会话拿到的 token 均不能证明次账号所有权
  • 安全 Account Merge 必须要求主、次账号分别完成 fresh reauthentication
  • 在安全流程上线前,/api/v1/account/merge/initiateverifyconfirm 对已认证请求 统一返回 503 Service Unavailable

合并操作规则:

  • 合并操作写入审计日志
  • 合并后原 user_account 标记为 MERGED,保留记录不物理删除
  • 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权 的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
  • account_merge_request 记录保留用于审计和未来迁移,但不得通过 SQL 手工改为 VERIFIED/COMPLETED也不得手工迁移身份、角色、membership、凭据或 Token
  • 回滚到仍包含旧合并实现的镜像会重新暴露该安全问题;如必须回滚,应先在网关阻断 /api/v1/account/merge/*

未来安全流程的完整验收条件见 22-secure-account-merge-acceptance-design.md

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> 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
  • 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401即使存在有效 Session 也不得回退。缺少 Authorization 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session若无 Session公共读接口按匿名访问whoami 返回 401
  • 作用域: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 的绑定。

6. RBAC 授权判定

权限判定 = 平台角色权限role → permission 查询)  命名空间角色namespace_member.role

一期即上线完整 RBAC平台角色按最小权限拆分

平台角色 职责
SUPER_ADMIN 全部权限,硬判定短路
SKILL_ADMIN 全局空间审核、提升审核、隐藏/恢复技能、撤回已发布版本
USER_ADMIN 准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN
AUDITOR 审计日志只读
  • 命名空间权限仍由 namespace_member.roleOWNER / ADMIN / MEMBER决定
  • 一个用户可持有多个平台角色
  • 普通用户无平台角色,仅通过 namespace 成员关系获得操作权限

判定逻辑:

  1. 从 SecurityContext 获取当前用户
  2. 检查用户状态(DISABLED → 拒绝所有操作)
  3. 查询用户的平台角色(user_role_bindingrolerole_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_bindingrole 查询写入
  • 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 CookieHttpOnly=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 响应结构

{
  "code": 0,
  "msg": "获取成功",
  "data": {
    "userId": "usr_42",
    "displayName": "zhangsan",
    "email": "zhangsan@company.com",
    "avatarUrl": "https://...",
    "oauthProvider": "local",
    "canChangePassword": true,
    "platformRoles": ["SKILL_ADMIN", "AUDITOR"]
  },
  "timestamp": "2026-03-12T06:00:00Z",
  "requestId": "req-123"
}

前端平台级权限判定基于 platformRoles;是否展示修改密码入口和表单基于后端返回的 canChangePassword。后端通过 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

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 PUBLICACTIVE、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 已登录后按 visibility 判定;下载目标版本必须可安装 visibility + namespace status + SkillInstallability
GET /api/v1/skills/{ns}/{slug}/resolve PUBLICACTIVE、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 同上 visibility + namespace status + SkillInstallability
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.roleskill.owner_id
POST /api/v1/skills/{ns}/{slug}/archive namespace ADMIN 以上 或 owner namespace_member.roleskill.owner_id
POST .../versions/{ver}/rerelease namespace ADMIN 以上 或 owner源版本必须 PUBLISHED namespace_member.roleskill.owner_id + skill_version.status
DELETE .../versions/{ver} namespace ADMIN 以上 或 ownerDRAFT / REJECTED namespace_member.roleskill.owner_id + skill_version.status

10.3 CLI API

接口 凭证规则 授权与错误语义
GET /api/cli/v1/auth/whoami 有效 Web Session 或有效 Bearer Token 无有效身份返回 401坏 Bearer 即使存在 Session 也返回 401
GET /api/cli/v1/skills/search Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 匿名仅返回公开可安装 skill有效 Bearer 覆盖 Session坏 Bearer 返回 401不得降级
GET /api/cli/v1/skills/{namespace}/{slug}/resolve Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403
GET /api/cli/v1/skills/{namespace}/{slug}/download Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403
GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403

Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session确保请求使用 token 的用户、角色与 scopeBearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401不能回退到 Session 或匿名身份。完全缺少 Authorization 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session如果 Session 也不存在,公共读接口按匿名身份执行,而 whoami 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403服务端不向客户端区分 token 不存在、过期或已撤销。whoami.email 字段始终存在,但没有可用邮箱时值为 null

10.4 Admin API

接口 所需平台角色 判定来源
POST /api/v1/admin/skills/{id}/hide SUPER_ADMIN user_role_bindingrole_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.roleskill.owner_id

10.6 Compatibility APIBearer Token 认证)

接口 所需凭证 额外判定
GET /api/v1/whoami 任意有效 Bearer Token
GET /api/v1/search 可选(匿名限 PUBLIC SearchVisibilityScope
GET /api/v1/resolve 可选(匿名仅限 PUBLICACTIVE、非 hidden、命名空间未归档且目标版本可安装 visibility + namespace status + SkillInstallability
GET /api/v1/download/{slug}/{version} 可选(匿名仅限 PUBLICACTIVE、非 hidden、命名空间未归档且目标版本可安装 visibility + namespace status + SkillInstallability
POST /api/v1/publish Bearer Token + skill:publish 普通用户要求目标 namespace 成员;SUPER_ADMIN 可绕过namespace 由 canonical slug 解析)