skillhub/docs/03-authentication-design.md
XiaoSeS 09a74cad1e fix(auth): harden provider adapter boundaries
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-07-31 02:06:58 +08:00

750 lines
37 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 认证与授权设计
> 外部身份架构说明:LDAP、DingTalk、CAS、SAML、可信代理及其他新外部身份接入,
> 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。GitHub、
> GitLab 和标准 OIDC 已迁入统一身份核心;Credential、Passive 和 Browser Adapter
> 已由 Provider Registry 统一发现和路由。旧名称 `DirectAuthProvider` 和
> `PassiveSessionAuthenticator` 仅是待删除的源码迁移别名,已经不能返回
> `PlatformPrincipal`。新 Provider 只能返回协议验证结果,由统一核心归一化为内部
> `IdentityAssertion`。
## 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: Identity Core │ 受信 descriptor + Authority Lock
│ │ Assertion Factory + 账号状态守卫
└─────────────┬───────────────┘
│ IdentityAssertion
▼
┌─────────────────────────────┐
│ Layer 3: Policy + Mapping │ Login / Provisioning Policy
│ │ Binding V2 + 字段来源与资料同步
└─────────────┬───────────────┘
│ PlatformPrincipal
▼
┌─────────────────────────────┐
│ Layer 4: Session / Token │ Web: Spring Session (Redis)
│ │ CLI: Device Flow + Bearer Token
└─────────────┬───────────────┘
│ SecurityContext
▼
┌─────────────────────────────┐
│ Layer 5: Authorization │ RBAC + 资源级判定
└─────────────────────────────┘
```
## 2. 准入策略(Access Policy)
OAuth 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。
```java
// 基于统一身份上下文的准入策略,与底层协议无关
public interface AccessPolicy {
AccessDecision evaluate(IdentityAccessContext context);
}
public record IdentityAccessContext(
String providerCode,
String subjectType,
String subject,
Optional<String> email,
EmailAssurance emailAssurance,
IdentityLoginContext requestContext,
IdentityAccessKind accessKind,
Optional<UserStatus> existingAccountStatus
) {}
public enum AccessDecision {
ALLOW, // 本次登录准入
DENY // 拒绝,不建立 Session,重定向到拒绝页
}
```
### 2.1 一期支持的策略(通过配置切换)
```yaml
skillhub:
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` | `context.providerCode` | 仅允许指定 Provider 登录 |
| `EMAIL_DOMAIN` | `context.email` + `context.emailAssurance` | 仅允许 `VERIFIED` / `AUTHORITATIVE` 邮箱且域名匹配 |
| `SUBJECT_WHITELIST` | `context.providerCode` + `context.subject` | 按 `provider:subject` 白名单,管理员预添加 |
### 2.2 准入失败处理
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得
业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。
### 2.3 首次建号策略(Provisioning Policy)
Login Policy 每次登录执行;Provisioning Policy 只在外部身份尚未绑定时执行。二者不能
再通过 `AccessDecision` 混合表达。
| 模式 | 未绑定身份的行为 |
|------|------------------|
| `AUTO` | 创建 `ACTIVE` Account、Binding、typed Subjects 和 `@global MEMBER` |
| `APPROVAL` | 创建 `PENDING` Account、Binding 和 typed Subjects,不建立 Session |
| `EXISTING_BINDING_ONLY` | 不创建任何记录,返回 `ACCESS_DENIED` |
`APPROVAL` 下,相同身份重复登录继续命中原 Binding 并返回 `ACCOUNT_PENDING`,不会重复
建号。管理员批准时在同一事务把账号改为 `ACTIVE` 并补齐 `@global MEMBER`;拒绝时改为
`DISABLED` 并保留 Binding,防止反复创建 PENDING 账号。
配置是受信 descriptor 的一部分,按 Provider Instance 生效:
```yaml
skillhub:
auth:
identity:
providers:
corp-oidc:
provisioning-mode: APPROVAL
profile-sync:
display-name: PRESERVE_LOCAL
email: FILL_IF_EMPTY
avatar-url: INITIAL_ONLY
```
### 2.4 资料同步策略
`user_profile_field_source` 记录 `displayName`、`email`、`avatarUrl` 当前值来自 Provider、
用户、管理员还是历史本地数据。升级迁移把已有非空值标记为 `LEGACY_LOCAL`,避免升级后
第一次外部登录覆盖历史资料。
每个字段支持 `NEVER`、`INITIAL_ONLY`、`FILL_IF_EMPTY`、`PRESERVE_LOCAL` 和
`PROVIDER_AUTHORITATIVE`。默认 displayName/avatarUrl 使用 `PRESERVE_LOCAL`,email 使用
`FILL_IF_EMPTY`,且 email 只有 `VERIFIED` / `AUTHORITATIVE` 才能写入。显式设置
`PROVIDER_AUTHORITATIVE` 后,登录 Provider 可以覆盖本地值;这项例外必须配置在具体
Provider 和具体字段上。
### 2.5 Email 碰撞
未绑定身份携带可信 email,而平台已有相同 email 时,核心只返回
`LinkRequired("EMAIL_COLLISION")`:
- 不按 email 自动绑定账号;
- 不返回目标 userId、账号资料或可直接完成绑定的 token;
- 不创建 Account、Binding 或 Subject;
- PR 5 的显式 Identity Link 完成前只展示安全提示和已有账号登录入口。
### 2.6 扩展性
后续新增 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:
① Adapter 从已验证响应提取 ProviderAuthenticationResult
② 服务端路由解析 ResolvedProviderHandle
③ 统一身份核心读取受信 descriptor,执行 Authority pin/复核
④ Assertion Factory 固定 provider/authority/subject/属性映射
⑤ 解析全部 Subject,锁定已有 Binding / Account
⑥ Account Guard + AccessPolicy.evaluate(IdentityAccessContext)
│
├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied(不建立 Session)
└── ALLOW ↓
│
⑦ 已绑定 → 按字段来源和 Profile Sync Policy 同步允许字段
└── 未绑定 → Provisioning Policy + email collision 检查
├── AUTO → 创建 ACTIVE Account + Binding + Subjects + membership
├── APPROVAL → 创建 PENDING Account + Binding + Subjects
└── EXISTING_BINDING_ONLY → 拒绝且不写入
│
▼
AuthenticationSuccessHandler:
① 创建 Spring Session (Redis)
② 重定向到前端页面 (可配置的 redirect_uri)
```
OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService`
分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为
`ProviderAuthenticationResult`:
- Subject candidate:类型固定为 `oidc_sub`,值为大小写敏感的 OIDC `sub`
- 属性事实:`email`、`email_verified`、`preferred_username`、`name`、`picture`
- 协议证据:只包含 `oidc`、认证时间和认证方法,不包含 token 或原始响应
- Provider code、issuer Authority 和最终属性映射由服务端受信 descriptor 固定
`identity_binding(provider_code, subject)` 保留兼容 primary 值,
`identity_binding_subject` 保存 typed primary/alias,并通过数据库约束保证一个 ACTIVE
Binding 恰有一个 ACTIVE primary。`identity_provider_state` 只保存 Provider code、
protocol、canonical Authority、SHA-256 fingerprint 和状态,不保存 client secret 或 token。
同一 registration id 切换 issuer 时进入粘性的 `AUTHORITY_MISMATCH`,不展示登录方式,
也不接受回调;恢复旧 Authority 后仍需显式恢复操作。
### 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”的流程
- 默认状态:关闭,开源版不提供任何 `PassiveAuthenticationAdapter` 实现
- 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度
扩展接口如下:
```java
public interface PassiveAuthenticationAdapter {
ProviderInstanceDefinition provider();
Optional<ProviderAuthenticationResult> authenticate(
PassiveAuthenticationRequest request
);
}
```
约束如下:
- Registry 先确认 Provider `READY`,再调用 `authenticate()`
- App 层把 Servlet 请求转换成不可变 `PassiveAuthenticationRequest`;Adapter 不能访问
`HttpSession`、Servlet API 或 `SecurityContext`
- `authenticate()` 只负责验证外部被动会话并返回非敏感协议事实
- 统一身份核心负责账号、Binding、审批和 `PlatformPrincipal`
- 断言存在但无效、重放或上游不可用时,Adapter 抛出只含稳定失败码的
`ProviderAuthenticationException`
- 是否允许启用该入口由 `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 CredentialAuthenticationAdapter {
ProviderInstanceDefinition provider();
ProviderAuthenticationResult authenticate(
CredentialAuthenticationRequest request
);
}
```
对应公共协议:
- `POST /api/v1/auth/direct/login`
约束如下:
- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制
- 关闭时返回 `403`
- provider 不受支持时返回 `400`
- provider 认证失败时使用 `ProviderAuthenticationFailureCode` 的稳定分类
- Provider 非 `READY` 时不会把凭证发送给 Adapter
- Adapter 不创建账号、Binding、角色或 Session
- 凭证无效、TLS、超时、配置或响应错误不会把上游详情写入用户响应
- 成功时建立标准 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 Provider 配置、启动协调和扩展边界
当前静态 descriptor source 只接受已配置且可唯一解析的 GitHub、GitLab 和标准 OIDC:
```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: ...
```
应用启动时固定执行以下顺序:
1. 从唯一的受信 descriptor source 读取已启用配置。
2. 对每个 Provider 在 PostgreSQL 中执行 Authority compare-and-set pin。
3. 再次读取持久化状态。
4. 登录目录每次读取时再次以持久化状态过滤;只有状态为 `READY` 且 fingerprint 与
当前 descriptor 一致的 Provider 才进入 `/api/v1/auth/providers` 和
`/api/v1/auth/methods`。
配置缺失、placeholder、未知/歧义协议、Authority 无法唯一确定、未 pin 或 mismatch
都 fail closed。授权入口和 callback 在 Spring Security 发起上游重定向、Token 交换或
userinfo 请求前执行相同的持久化 readiness 检查;登录目录不会直接读取
`OAuth2ClientProperties`,其他 Pod 写入的 mismatch 也不会被旧的内存投影继续展示。
运维把全部 Pod 恢复为已 pin 的相同 Authority 后,由 `SUPER_ADMIN` 调用:
```text
POST /api/v1/admin/identity-providers/{providerCode}/authority/recover
```
该操作不接受新 Authority 或 fingerprint,只能在数据库仍为 `AUTHORITY_MISMATCH` 且当前
受信 descriptor 的 fingerprint 等于已 pin 值时 compare-and-set 回 `READY`。成功变更
写入同一事务的 `PROVIDER_AUTHORITY_RECOVERED` 审计;重复调用返回
`recovered=false, state=READY`,不会伪造第二条恢复审计。配置仍指向新 Authority 时返回
冲突,必须等待后续独立的 Authority 迁移设计,不能用此接口改写 pin。
新增协议不能只添加 Spring registration 或在 OAuth user service 中加分支。必须按照
[统一身份联邦设计](./21-unified-identity-federation-design.md) 实现受信 descriptor、
协议 Adapter 和 conformance 测试;LDAP、DingTalk、CAS、SAML、SCIM 及动态 Provider
Registry 不属于当前阶段。
## 4. 核心接口设计
```java
public interface ExternalIdentityLoginService {
IdentityLoginOutcome authenticate(
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context
);
}
```
`ResolvedProviderHandle` 只能由服务端 `ClientRegistration` 路由解析产生。
`ProviderAuthenticationResult` 不含 Provider code、Authority、平台 userId、角色、
Principal、Session、token、ticket、Cookie 或原始响应。核心内部按固定顺序执行:
```text
Trusted descriptor
→ Authority Lock
→ IdentityAssertionFactory
→ Binding / Subject resolution
→ AccountLoginGuard
→ AccessPolicy
→ ProvisioningPolicy / email collision
→ ProfileSyncPolicy
→ PlatformPrincipalFactory
→ IdentityLoginOutcome
```
只有 `IdentityLoginOutcome.Authenticated` 可以到达既有 `PlatformSessionService`。当前
`PlatformPrincipal` 结构保持不变;Binding V2 和 profile source 使用 additive migration
及兼容列支持升级和回滚。
### 4.1 多 Provider 账号合并策略
同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。
当前策略:不自动合并,旧的手动合并流程也已临时隔离。旧流程把次账号 verification
token 直接返回给主账号会话,不能分别证明两个账号的控制权,因此不能继续作为管理员或
用户合并入口。
- 当前阶段:不自动合并,每个 Provider 登录独立创建用户
- 多 Provider 上线时,再引入显式 Identity Link 和安全 Account Merge
- email、username、display name 或主账号会话拿到的 token 均不能证明次账号所有权
- 安全 Account Merge 必须要求主、次账号分别完成 fresh reauthentication
- 在安全流程上线前,`/api/v1/account/merge/initiate`、`verify`、`confirm` 对已认证请求
统一返回 `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`](./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.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": "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
```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` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC`、`ACTIVE`、非 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.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/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 的用户、角色与 scope;Bearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 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_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` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) |